fix(server): rcat event sync (#13648)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- New Features
- Subscriptions now include an explicit "trial" flag so trialing users
are identified and treated correctly.

- Bug Fixes
  - More robust handling when webhook fields are missing or null.
- Improved family-sharing detection to avoid incorrect async processing.

- Refactor
- Status determination and store resolution simplified to rely on
subscription data rather than event payloads.

- Tests
- Test fixtures updated to include trial and store details for accuracy.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2025-09-26 03:00:48 +08:00
committed by GitHub
parent 7a90e1551c
commit 3f9d9fef63
4 changed files with 58 additions and 60 deletions
@@ -179,6 +179,7 @@ test('should standardize RC subscriber response and upsert subscription with obs
const subscriber = mockSub([ const subscriber = mockSub([
{ {
identifier: 'Pro', identifier: 'Pro',
isTrial: false,
isActive: true, isActive: true,
latestPurchaseDate: new Date('2025-01-01T00:00:00.000Z'), latestPurchaseDate: new Date('2025-01-01T00:00:00.000Z'),
expirationDate: new Date('2026-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([ const subscriber = mockSub([
{ {
identifier: 'Pro', identifier: 'Pro',
isTrial: false,
isActive: false, isActive: false,
latestPurchaseDate: new Date('2024-01-01T00:00:00.000Z'), latestPurchaseDate: new Date('2024-01-01T00:00:00.000Z'),
expirationDate: new Date('2024-02-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: [ stub: [
{ {
identifier: 'Pro', identifier: 'Pro',
isTrial: false,
isActive: true, isActive: true,
latestPurchaseDate: new Date('2025-01-10T00:00:00.000Z'), latestPurchaseDate: new Date('2025-01-10T00:00:00.000Z'),
expirationDate: new Date('2025-02-10T00:00:00.000Z'), expirationDate: new Date('2025-02-10T00:00:00.000Z'),
productId: 'app.affine.pro.Monthly', productId: 'app.affine.pro.Monthly',
store: 'app_store', store: 'app_store' as const,
willRenew: true, willRenew: true,
duration: null, duration: null,
}, },
@@ -363,11 +366,12 @@ test('should activate subscriptions via webhook for whitelisted products across
stub: [ stub: [
{ {
identifier: 'AI', identifier: 'AI',
isTrial: false,
isActive: true, isActive: true,
latestPurchaseDate: new Date('2025-03-01T00:00:00.000Z'), latestPurchaseDate: new Date('2025-03-01T00:00:00.000Z'),
expirationDate: new Date('2026-03-01T00:00:00.000Z'), expirationDate: new Date('2026-03-01T00:00:00.000Z'),
productId: 'app.affine.pro.ai.Annual', productId: 'app.affine.pro.ai.Annual',
store: 'play_store', store: 'play_store' as const,
willRenew: true, willRenew: true,
duration: null, duration: null,
}, },
@@ -419,6 +423,7 @@ test('should keep active and advance period dates when a trialing subscription r
[ [
{ {
identifier: 'Pro', identifier: 'Pro',
isTrial: false,
isActive: true, isActive: true,
latestPurchaseDate: new Date('2025-04-01T00:00:00.000Z'), latestPurchaseDate: new Date('2025-04-01T00:00:00.000Z'),
expirationDate: new Date('2025-04-08T00: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', identifier: 'Pro',
isTrial: false,
isActive: true, isActive: true,
latestPurchaseDate: new Date('2025-04-08T00:00:00.000Z'), latestPurchaseDate: new Date('2025-04-08T00:00:00.000Z'),
expirationDate: new Date('2026-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', identifier: 'Pro',
isTrial: false,
isActive: true, isActive: true,
latestPurchaseDate: new Date('2025-04-01T00:00:00.000Z'), latestPurchaseDate: new Date('2025-04-01T00:00:00.000Z'),
expirationDate: new Date('2025-04-08T00: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', identifier: 'Pro',
isTrial: false,
isActive: false, isActive: false,
latestPurchaseDate: new Date('2025-04-01T00:00:00.000Z'), latestPurchaseDate: new Date('2025-04-01T00:00:00.000Z'),
expirationDate: new Date('2024-01-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([ mockSub([
{ {
identifier: 'Pro', identifier: 'Pro',
isTrial: false,
isActive: true, isActive: true,
latestPurchaseDate: new Date('2025-05-01T00:00:00.000Z'), latestPurchaseDate: new Date('2025-05-01T00:00:00.000Z'),
expirationDate: new Date('2025-06-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([ mockSub([
{ {
identifier: 'Pro', identifier: 'Pro',
isTrial: false,
isActive: false, isActive: false,
latestPurchaseDate: new Date('2025-05-01T00:00:00.000Z'), latestPurchaseDate: new Date('2025-05-01T00:00:00.000Z'),
expirationDate: new Date('2999-01-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([ mockSub([
{ {
identifier: 'Pro', identifier: 'Pro',
isTrial: false,
isActive: true, isActive: true,
latestPurchaseDate: new Date('2025-06-01T00:00:00.000Z'), latestPurchaseDate: new Date('2025-06-01T00:00:00.000Z'),
expirationDate: new Date('2025-07-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([ const subscriber = mockSub([
{ {
identifier: 'Pro', identifier: 'Pro',
isTrial: false,
isActive: true, isActive: true,
latestPurchaseDate: new Date('2025-03-01T00:00:00.000Z'), latestPurchaseDate: new Date('2025-03-01T00:00:00.000Z'),
expirationDate: new Date('2026-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([ mockSub([
{ {
identifier: 'Pro', identifier: 'Pro',
isTrial: false,
isActive: false, isActive: false,
latestPurchaseDate: new Date('2025-01-01T00:00:00.000Z'), latestPurchaseDate: new Date('2025-01-01T00:00:00.000Z'),
expirationDate: new Date('2025-01-15T00: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([ mockSub([
{ {
identifier: 'Weird', identifier: 'Weird',
isTrial: false,
isActive: true, isActive: true,
latestPurchaseDate: new Date('2025-07-01T00:00:00.000Z'), latestPurchaseDate: new Date('2025-07-01T00:00:00.000Z'),
expirationDate: new Date('2026-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', identifier: 'Pro',
isTrial: false,
isActive: true, isActive: true,
latestPurchaseDate: new Date('2025-08-01T00:00:00.000Z'), latestPurchaseDate: new Date('2025-08-01T00:00:00.000Z'),
expirationDate: new Date('2025-09-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', identifier: 'AI',
isTrial: false,
isActive: true, isActive: true,
latestPurchaseDate: new Date('2025-10-01T00:00:00.000Z'), latestPurchaseDate: new Date('2025-10-01T00:00:00.000Z'),
expirationDate: new Date('2026-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', identifier: 'Pro',
isTrial: false,
isActive: true, isActive: true,
latestPurchaseDate: new Date('2025-11-01T00:00:00.000Z'), latestPurchaseDate: new Date('2025-11-01T00:00:00.000Z'),
expirationDate: new Date('2026-02-01T00:00:00.000Z'), expirationDate: new Date('2026-02-01T00:00:00.000Z'),
@@ -30,11 +30,14 @@ const RcEventSchema = z
app_user_id: z.string().optional(), app_user_id: z.string().optional(),
store: z.string().optional(), store: z.string().optional(),
is_family_share: z.boolean().optional(), is_family_share: z.boolean().nullable().optional(),
period_type: z.string().optional(), period_type: z
original_transaction_id: z.string().optional(), .enum(['TRIAL', 'INTRO', 'NORMAL', 'PROMOTIONAL', 'PREPAID'])
transaction_id: z.string().optional(), .nullable()
purchase_token: z.string().optional(), .optional(),
original_transaction_id: z.string().nullable().optional(),
transaction_id: z.string().nullable().optional(),
purchase_token: z.string().nullable().optional(),
}) })
.passthrough(); .passthrough();
@@ -74,7 +77,11 @@ export class RevenueCatWebhookController {
`[${id}] RevenueCat Webhook {${type}} received for appUserId=${appUserId}.` `[${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 // immediately ack and process asynchronously
this.event this.event
.emitAsync('revenuecat.webhook', { appUserId, event }) .emitAsync('revenuecat.webhook', { appUserId, event })
@@ -3,6 +3,18 @@ import { z } from 'zod';
import { Config } from '../../../base'; 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 const zRcV2RawProduct = z
.object({ .object({
id: z.string().nonempty(), id: z.string().nonempty(),
@@ -11,20 +23,7 @@ const zRcV2RawProduct = z
.object({ duration: z.string().nullable() }) .object({ duration: z.string().nullable() })
.partial() .partial()
.nullable(), .nullable(),
app: z app: z.object({ type: Store }).partial(),
.object({
type: z.enum([
'amazon',
'app_store',
'mac_app_store',
'play_store',
'stripe',
'rc_billing',
'roku',
'paddle',
]),
})
.partial(),
}) })
.passthrough(); .passthrough();
@@ -49,7 +48,7 @@ const zRcV2RawSubscription = z
entitlements: zRcV2RawEntitlements, entitlements: zRcV2RawEntitlements,
starts_at: z.number(), starts_at: z.number(),
current_period_ends_at: z.number().nullable(), current_period_ends_at: z.number().nullable(),
store: z.string(), store: Store,
auto_renewal_status: z.enum([ auto_renewal_status: z.enum([
'will_renew', 'will_renew',
'will_not_renew', 'will_not_renew',
@@ -83,11 +82,12 @@ const zRcV2RawEnvelope = z
// v2 minimal, simplified structure exposed to callers // v2 minimal, simplified structure exposed to callers
export const Subscription = z.object({ export const Subscription = z.object({
identifier: z.string(), identifier: z.string(),
isTrial: z.boolean(),
isActive: z.boolean(), isActive: z.boolean(),
latestPurchaseDate: z.date().nullable(), latestPurchaseDate: z.date().nullable(),
expirationDate: z.date().nullable(), expirationDate: z.date().nullable(),
productId: z.string(), productId: z.string(),
store: z.string(), store: Store,
willRenew: z.boolean(), willRenew: z.boolean(),
duration: z.string().nullable(), duration: z.string().nullable(),
}); });
@@ -145,6 +145,7 @@ export class RevenueCatService {
} }
return { return {
identifier: ent.lookup_key, identifier: ent.lookup_key,
isTrial: sub.status === 'trialing',
isActive: isActive:
sub.gives_access === true || sub.gives_access === true ||
sub.status === 'active' || sub.status === 'active' ||
@@ -51,10 +51,8 @@ export class RevenueCatWebhookHandler {
// ignore non-whitelisted and non-fallbackable products // ignore non-whitelisted and non-fallbackable products
if (!mapping) continue; if (!mapping) continue;
const { status, deleteInstead, canceledAt, iapStore } = this.mapStatus( const { status, deleteInstead, canceledAt, iapStore } =
sub, this.mapStatus(sub);
event
);
const rcExternalRef = this.pickExternalRef(event); const rcExternalRef = this.pickExternalRef(event);
@@ -174,10 +172,7 @@ export class RevenueCatWebhookHandler {
); );
} }
private mapStatus( private mapStatus(sub: Subscription): {
sub: Subscription,
event?: RcEvent
): {
status: SubscriptionStatus; status: SubscriptionStatus;
iapStore: IapStore | null; iapStore: IapStore | null;
deleteInstead: boolean; deleteInstead: boolean;
@@ -185,29 +180,16 @@ export class RevenueCatWebhookHandler {
} { } {
const now = Date.now(); const now = Date.now();
const exp = sub.expirationDate?.getTime(); 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 // Determine iap store and external reference for observability
const iapStore = this.mapIapStore(sub.store, event); const iapStore = ['app_store', 'mac_app_store'].includes(sub.store)
? IapStore.app_store
// Refund/chargeback/revocation should be treated as immediate expiration : ['play_store'].includes(sub.store)
// Prioritize these event types regardless of current sub.isActive flag ? IapStore.play_store
if ( : null;
eventType.includes('refund') ||
eventType.includes('chargeback') ||
eventType.includes('revocation') ||
eventType.includes('revoke')
) {
return {
iapStore,
status: SubscriptionStatus.Canceled,
deleteInstead: true,
};
}
if (sub.isActive) { if (sub.isActive) {
if (periodType === 'trial') { if (sub.isTrial) {
return { return {
iapStore, iapStore,
status: SubscriptionStatus.Trialing, status: SubscriptionStatus.Trialing,
@@ -241,13 +223,4 @@ export class RevenueCatWebhookHandler {
deleteInstead: true, 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;
}
} }