diff --git a/packages/backend/server/src/__tests__/payment/revenuecat.spec.ts b/packages/backend/server/src/__tests__/payment/revenuecat.spec.ts index 35fc095cb..21f37e9c7 100644 --- a/packages/backend/server/src/__tests__/payment/revenuecat.spec.ts +++ b/packages/backend/server/src/__tests__/payment/revenuecat.spec.ts @@ -179,6 +179,7 @@ test('should standardize RC subscriber response and upsert subscription with obs const subscriber = mockSub([ { identifier: 'Pro', + isTrial: false, isActive: true, latestPurchaseDate: new Date('2025-01-01T00:00:00.000Z'), expirationDate: new Date('2026-01-01T00:00:00.000Z'), @@ -245,6 +246,7 @@ test('should process expiration/refund by deleting subscription and emitting can const subscriber = mockSub([ { identifier: 'Pro', + isTrial: false, isActive: false, latestPurchaseDate: new Date('2024-01-01T00:00:00.000Z'), expirationDate: new Date('2024-02-01T00:00:00.000Z'), @@ -341,11 +343,12 @@ test('should activate subscriptions via webhook for whitelisted products across stub: [ { identifier: 'Pro', + isTrial: false, isActive: true, latestPurchaseDate: new Date('2025-01-10T00:00:00.000Z'), expirationDate: new Date('2025-02-10T00:00:00.000Z'), productId: 'app.affine.pro.Monthly', - store: 'app_store', + store: 'app_store' as const, willRenew: true, duration: null, }, @@ -363,11 +366,12 @@ test('should activate subscriptions via webhook for whitelisted products across stub: [ { identifier: 'AI', + isTrial: false, isActive: true, latestPurchaseDate: new Date('2025-03-01T00:00:00.000Z'), expirationDate: new Date('2026-03-01T00:00:00.000Z'), productId: 'app.affine.pro.ai.Annual', - store: 'play_store', + store: 'play_store' as const, willRenew: true, duration: null, }, @@ -419,6 +423,7 @@ test('should keep active and advance period dates when a trialing subscription r [ { identifier: 'Pro', + isTrial: false, isActive: true, latestPurchaseDate: new Date('2025-04-01T00:00:00.000Z'), expirationDate: new Date('2025-04-08T00:00:00.000Z'), @@ -431,6 +436,7 @@ test('should keep active and advance period dates when a trialing subscription r [ { identifier: 'Pro', + isTrial: false, isActive: true, latestPurchaseDate: new Date('2025-04-08T00:00:00.000Z'), expirationDate: new Date('2026-04-08T00:00:00.000Z'), @@ -471,6 +477,7 @@ test('should remove or cancel the record and revoke entitlement when a trialing [ { identifier: 'Pro', + isTrial: false, isActive: true, latestPurchaseDate: new Date('2025-04-01T00:00:00.000Z'), expirationDate: new Date('2025-04-08T00:00:00.000Z'), @@ -483,6 +490,7 @@ test('should remove or cancel the record and revoke entitlement when a trialing [ { identifier: 'Pro', + isTrial: false, isActive: false, latestPurchaseDate: new Date('2025-04-01T00:00:00.000Z'), expirationDate: new Date('2024-01-01T00:00:00.000Z'), @@ -518,6 +526,7 @@ test('should set canceledAt and keep active until expiration when will_renew is mockSub([ { identifier: 'Pro', + isTrial: false, isActive: true, latestPurchaseDate: new Date('2025-05-01T00:00:00.000Z'), expirationDate: new Date('2025-06-01T00:00:00.000Z'), @@ -554,6 +563,7 @@ test('should retain record as past_due (inactive but not expired) and NOT emit c mockSub([ { identifier: 'Pro', + isTrial: false, isActive: false, latestPurchaseDate: new Date('2025-05-01T00:00:00.000Z'), expirationDate: new Date('2999-01-01T00:00:00.000Z'), @@ -657,6 +667,7 @@ test('should skip RC upsert when Stripe active already exists for same plan', as mockSub([ { identifier: 'Pro', + isTrial: false, isActive: true, latestPurchaseDate: new Date('2025-06-01T00:00:00.000Z'), expirationDate: new Date('2025-07-01T00:00:00.000Z'), @@ -722,6 +733,7 @@ test('should reconcile and fix missing or out-of-order states for revenuecat Act const subscriber = mockSub([ { identifier: 'Pro', + isTrial: false, isActive: true, latestPurchaseDate: new Date('2025-03-01T00:00:00.000Z'), expirationDate: new Date('2026-03-01T00:00:00.000Z'), @@ -759,6 +771,7 @@ test('should treat refund as early expiration and revoke immediately', async t = mockSub([ { identifier: 'Pro', + isTrial: false, isActive: false, latestPurchaseDate: new Date('2025-01-01T00:00:00.000Z'), expirationDate: new Date('2025-01-15T00:00:00.000Z'), @@ -790,6 +803,7 @@ test('should ignore non-whitelisted productId and not write to DB', async t => { mockSub([ { identifier: 'Weird', + isTrial: false, isActive: true, latestPurchaseDate: new Date('2025-07-01T00:00:00.000Z'), expirationDate: new Date('2026-07-01T00:00:00.000Z'), @@ -819,6 +833,7 @@ test('should map via entitlement+duration when productId not whitelisted (P1M/P1 [ { identifier: 'Pro', + isTrial: false, isActive: true, latestPurchaseDate: new Date('2025-08-01T00:00:00.000Z'), expirationDate: new Date('2025-09-01T00:00:00.000Z'), @@ -831,6 +846,7 @@ test('should map via entitlement+duration when productId not whitelisted (P1M/P1 [ { identifier: 'AI', + isTrial: false, isActive: true, latestPurchaseDate: new Date('2025-10-01T00:00:00.000Z'), expirationDate: new Date('2026-10-01T00:00:00.000Z'), @@ -843,6 +859,7 @@ test('should map via entitlement+duration when productId not whitelisted (P1M/P1 [ { identifier: 'Pro', + isTrial: false, isActive: true, latestPurchaseDate: new Date('2025-11-01T00:00:00.000Z'), expirationDate: new Date('2026-02-01T00:00:00.000Z'), diff --git a/packages/backend/server/src/plugins/payment/revenuecat/controller.ts b/packages/backend/server/src/plugins/payment/revenuecat/controller.ts index 27246e67f..6c60b84d5 100644 --- a/packages/backend/server/src/plugins/payment/revenuecat/controller.ts +++ b/packages/backend/server/src/plugins/payment/revenuecat/controller.ts @@ -30,11 +30,14 @@ const RcEventSchema = z app_user_id: z.string().optional(), store: z.string().optional(), - is_family_share: z.boolean().optional(), - period_type: z.string().optional(), - original_transaction_id: z.string().optional(), - transaction_id: z.string().optional(), - purchase_token: z.string().optional(), + is_family_share: z.boolean().nullable().optional(), + period_type: z + .enum(['TRIAL', 'INTRO', 'NORMAL', 'PROMOTIONAL', 'PREPAID']) + .nullable() + .optional(), + original_transaction_id: z.string().nullable().optional(), + transaction_id: z.string().nullable().optional(), + purchase_token: z.string().nullable().optional(), }) .passthrough(); @@ -74,7 +77,11 @@ export class RevenueCatWebhookController { `[${id}] RevenueCat Webhook {${type}} received for appUserId=${appUserId}.` ); - if (appUserId && !event.is_family_share) { + if ( + appUserId && + (typeof event.is_family_share !== 'boolean' || + !event.is_family_share) + ) { // immediately ack and process asynchronously this.event .emitAsync('revenuecat.webhook', { appUserId, event }) diff --git a/packages/backend/server/src/plugins/payment/revenuecat/service.ts b/packages/backend/server/src/plugins/payment/revenuecat/service.ts index e3450eb29..e3da60fdd 100644 --- a/packages/backend/server/src/plugins/payment/revenuecat/service.ts +++ b/packages/backend/server/src/plugins/payment/revenuecat/service.ts @@ -3,6 +3,18 @@ import { z } from 'zod'; import { Config } from '../../../base'; +const Store = z.enum([ + 'amazon', + 'app_store', + 'mac_app_store', + 'play_store', + 'promotional', + 'stripe', + 'rc_billing', + 'roku', + 'paddle', +]); + const zRcV2RawProduct = z .object({ id: z.string().nonempty(), @@ -11,20 +23,7 @@ const zRcV2RawProduct = z .object({ duration: z.string().nullable() }) .partial() .nullable(), - app: z - .object({ - type: z.enum([ - 'amazon', - 'app_store', - 'mac_app_store', - 'play_store', - 'stripe', - 'rc_billing', - 'roku', - 'paddle', - ]), - }) - .partial(), + app: z.object({ type: Store }).partial(), }) .passthrough(); @@ -49,7 +48,7 @@ const zRcV2RawSubscription = z entitlements: zRcV2RawEntitlements, starts_at: z.number(), current_period_ends_at: z.number().nullable(), - store: z.string(), + store: Store, auto_renewal_status: z.enum([ 'will_renew', 'will_not_renew', @@ -83,11 +82,12 @@ const zRcV2RawEnvelope = z // v2 minimal, simplified structure exposed to callers export const Subscription = z.object({ identifier: z.string(), + isTrial: z.boolean(), isActive: z.boolean(), latestPurchaseDate: z.date().nullable(), expirationDate: z.date().nullable(), productId: z.string(), - store: z.string(), + store: Store, willRenew: z.boolean(), duration: z.string().nullable(), }); @@ -145,6 +145,7 @@ export class RevenueCatService { } return { identifier: ent.lookup_key, + isTrial: sub.status === 'trialing', isActive: sub.gives_access === true || sub.status === 'active' || diff --git a/packages/backend/server/src/plugins/payment/revenuecat/webhook.ts b/packages/backend/server/src/plugins/payment/revenuecat/webhook.ts index f98bcd67a..0aa066a71 100644 --- a/packages/backend/server/src/plugins/payment/revenuecat/webhook.ts +++ b/packages/backend/server/src/plugins/payment/revenuecat/webhook.ts @@ -51,10 +51,8 @@ export class RevenueCatWebhookHandler { // ignore non-whitelisted and non-fallbackable products if (!mapping) continue; - const { status, deleteInstead, canceledAt, iapStore } = this.mapStatus( - sub, - event - ); + const { status, deleteInstead, canceledAt, iapStore } = + this.mapStatus(sub); const rcExternalRef = this.pickExternalRef(event); @@ -174,10 +172,7 @@ export class RevenueCatWebhookHandler { ); } - private mapStatus( - sub: Subscription, - event?: RcEvent - ): { + private mapStatus(sub: Subscription): { status: SubscriptionStatus; iapStore: IapStore | null; deleteInstead: boolean; @@ -185,29 +180,16 @@ export class RevenueCatWebhookHandler { } { const now = Date.now(); const exp = sub.expirationDate?.getTime(); - const periodType = (event?.period_type || '').toLowerCase(); - const eventType = (event?.type || '').toString().toLowerCase(); // Determine iap store and external reference for observability - const iapStore = this.mapIapStore(sub.store, event); - - // Refund/chargeback/revocation should be treated as immediate expiration - // Prioritize these event types regardless of current sub.isActive flag - if ( - eventType.includes('refund') || - eventType.includes('chargeback') || - eventType.includes('revocation') || - eventType.includes('revoke') - ) { - return { - iapStore, - status: SubscriptionStatus.Canceled, - deleteInstead: true, - }; - } + const iapStore = ['app_store', 'mac_app_store'].includes(sub.store) + ? IapStore.app_store + : ['play_store'].includes(sub.store) + ? IapStore.play_store + : null; if (sub.isActive) { - if (periodType === 'trial') { + if (sub.isTrial) { return { iapStore, status: SubscriptionStatus.Trialing, @@ -241,13 +223,4 @@ export class RevenueCatWebhookHandler { deleteInstead: true, }; } - - private mapIapStore(store?: string, event?: RcEvent): IapStore | null { - const s = (store || event?.store || '').toString().toLowerCase(); - if (!s) return null; - if (s.includes('app') || s.includes('ios')) return IapStore.app_store; - if (s.includes('play') || s.includes('android') || s.includes('google')) - return IapStore.play_store; - return null; - } }