mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 09:21:24 +08:00
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:
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user