From 01c164a78a4ef505fe9960be48aae7e31a14a3a1 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Mon, 20 Oct 2025 17:20:41 +0800 Subject: [PATCH] fix(server): rcat event parse (#13781) --- .../plugins/payment/revenuecat/controller.ts | 55 ++++++++++++++----- .../src/plugins/payment/revenuecat/service.ts | 31 ++++++----- 2 files changed, 58 insertions(+), 28 deletions(-) diff --git a/packages/backend/server/src/plugins/payment/revenuecat/controller.ts b/packages/backend/server/src/plugins/payment/revenuecat/controller.ts index 6c60b84d56..8ce344bd64 100644 --- a/packages/backend/server/src/plugins/payment/revenuecat/controller.ts +++ b/packages/backend/server/src/plugins/payment/revenuecat/controller.ts @@ -3,6 +3,8 @@ import { z } from 'zod'; import { Config, EventBus } from '../../../base'; import { Public } from '../../../core/auth'; +import { FeatureService } from '../../../core/features'; +import { Models } from '../../../models'; const RcEventSchema = z .object({ @@ -52,7 +54,9 @@ export class RevenueCatWebhookController { constructor( private readonly config: Config, - private readonly event: EventBus + private readonly event: EventBus, + private readonly models: Models, + private readonly feature: FeatureService ) {} @Public() @@ -70,28 +74,49 @@ export class RevenueCatWebhookController { if (parsed.success) { const event = parsed.data.event; const { id, app_user_id: appUserId, type } = event; + if ( event.environment.toLowerCase() === environment?.toLowerCase() ) { + const logParams = { + appUserId, + familyShare: event.is_family_share, + environment: event.environment, + }; this.logger.log( `[${id}] RevenueCat Webhook {${type}} received for appUserId=${appUserId}.` ); - - if ( - appUserId && - (typeof event.is_family_share !== 'boolean' || - !event.is_family_share) - ) { - // immediately ack and process asynchronously - this.event - .emitAsync('revenuecat.webhook', { appUserId, event }) - .catch((e: Error) => { - this.logger.error( - 'Failed to handle RevenueCat Webhook event.', - e + if (appUserId) { + const user = await this.models.user.get(appUserId); + if (user) { + if ( + (typeof event.is_family_share !== 'boolean' || + !event.is_family_share) && + (environment.toLowerCase() === 'production' || + this.feature.isStaff(user.email)) + ) { + // immediately ack and process asynchronously + this.event + .emitAsync('revenuecat.webhook', { appUserId, event }) + .catch((e: Error) => { + this.logger.error( + 'Failed to handle RevenueCat Webhook event.', + e + ); + }); + return; + } else { + this.logger.warn( + `[${id}] RevenueCat Webhook received for non-acceptable params.`, + logParams ); - }); + } + } } + this.logger.warn( + `RevenueCat Webhook received for unknown user`, + logParams + ); } } else { this.logger.warn( diff --git a/packages/backend/server/src/plugins/payment/revenuecat/service.ts b/packages/backend/server/src/plugins/payment/revenuecat/service.ts index e3da60fdd4..1b282e22f8 100644 --- a/packages/backend/server/src/plugins/payment/revenuecat/service.ts +++ b/packages/backend/server/src/plugins/payment/revenuecat/service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { z } from 'zod'; import { Config } from '../../../base'; @@ -34,7 +34,7 @@ const zRcV2RawEntitlementItem = z products: z .object({ items: z.array(zRcV2RawProduct).default([]) }) .partial() - .nullable(), + .nullish(), }) .passthrough(); @@ -75,7 +75,7 @@ const zRcV2RawEnvelope = z .object({ app_user_id: z.string().optional(), id: z.string().optional(), - subscriptions: z.array(zRcV2RawSubscription).default([]), + items: z.array(zRcV2RawSubscription).default([]), }) .passthrough(); @@ -96,6 +96,8 @@ export type Subscription = z.infer; @Injectable() export class RevenueCatService { + private readonly logger = new Logger(RevenueCatService.name); + constructor(private readonly config: Config) {} private get apiKey(): string { @@ -132,17 +134,15 @@ export class RevenueCatService { ); } - const envParsed = zRcV2RawEnvelope.safeParse(await res.json()); + const json = await res.json(); + const envParsed = zRcV2RawEnvelope.safeParse(json); if (envParsed.success) { - return envParsed.data.subscriptions + return envParsed.data.items .flatMap(sub => { const items = sub.entitlements.items ?? []; return items.map(ent => { const product = ent.products?.items?.[0]; - if (!product) { - return null; - } return { identifier: ent.lookup_key, isTrial: sub.status === 'trialing', @@ -151,20 +151,25 @@ export class RevenueCatService { sub.status === 'active' || sub.status === 'trialing', latestPurchaseDate: sub.starts_at - ? new Date(sub.starts_at * 1000) + ? new Date(sub.starts_at) : null, expirationDate: sub.current_period_ends_at - ? new Date(sub.current_period_ends_at * 1000) + ? new Date(sub.current_period_ends_at) : null, - productId: product.store_identifier, - store: sub.store ?? product.app.type, + productId: product?.store_identifier, + store: sub.store ?? product?.app.type, willRenew: sub.auto_renewal_status === 'will_renew', - duration: product.subscription?.duration ?? null, + duration: product?.subscription?.duration ?? null, }; }); }) .filter((s): s is Subscription => s !== null); } + this.logger.error( + `RevenueCat subscription parse failed: ${JSON.stringify( + envParsed.error.format() + )}` + ); return null; } }