From 359fdddcf4d44788326063872c11c7bf789627e6 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sun, 21 Jun 2026 22:48:17 +0800 Subject: [PATCH] feat(payment): handle App Store and Google Play subscription webhooks (#4701) Add server-push endpoints so store-side subscription changes (cancel, refund, expire, renew, grace period) are reflected in the database, not only the in-app verification flow. Previously only Stripe had a webhook. - POST /api/apple/notifications: verify and decode App Store Server Notifications V2, resolve the user by original_transaction_id, map the notification type to a status, and update the subscription and plan. A single endpoint serves Sandbox and Production. Refunded one-time purchases are marked refunded and storage is recomputed. - POST /api/google/notifications: verify the Pub/Sub shared-secret token, decode the RTDN, resolve the user by purchase_token, re-verify against the Play Developer API (overriding the status for terminal events such as REVOKED/EXPIRED and grace period), and handle voided purchases. - Add an isEntitledStatus helper and reuse the existing createOrUpdateSubscription and plan-update logic shared with Stripe. New configuration: GOOGLE_RTDN_VERIFICATION_TOKEN (shared secret in the Pub/Sub push URL) and the optional GOOGLE_IAP_PACKAGE_NAME; Apple reuses APPLE_IAP_BUNDLE_ID and the existing service-account credentials. Co-authored-by: Claude Opus 4.8 (1M context) --- .../app/api/iap-notifications-routes.test.ts | 93 ++++++ .../libs/payment/apple-notifications.test.ts | 277 ++++++++++++++++++ .../libs/payment/google-notifications.test.ts | 244 +++++++++++++++ .../src/app/api/apple/notifications/route.ts | 29 ++ .../src/app/api/google/notifications/route.ts | 42 +++ .../libs/payment/iap/apple/notifications.ts | 168 +++++++++++ .../src/libs/payment/iap/apple/server.ts | 6 +- .../libs/payment/iap/google/notifications.ts | 232 +++++++++++++++ .../src/libs/payment/iap/google/server.ts | 3 +- .../src/libs/payment/iap/payments.ts | 24 ++ .../readest-app/src/libs/payment/iap/utils.ts | 8 + 11 files changed, 1123 insertions(+), 3 deletions(-) create mode 100644 apps/readest-app/src/__tests__/app/api/iap-notifications-routes.test.ts create mode 100644 apps/readest-app/src/__tests__/libs/payment/apple-notifications.test.ts create mode 100644 apps/readest-app/src/__tests__/libs/payment/google-notifications.test.ts create mode 100644 apps/readest-app/src/app/api/apple/notifications/route.ts create mode 100644 apps/readest-app/src/app/api/google/notifications/route.ts create mode 100644 apps/readest-app/src/libs/payment/iap/apple/notifications.ts create mode 100644 apps/readest-app/src/libs/payment/iap/google/notifications.ts create mode 100644 apps/readest-app/src/libs/payment/iap/payments.ts diff --git a/apps/readest-app/src/__tests__/app/api/iap-notifications-routes.test.ts b/apps/readest-app/src/__tests__/app/api/iap-notifications-routes.test.ts new file mode 100644 index 00000000..79e6cedc --- /dev/null +++ b/apps/readest-app/src/__tests__/app/api/iap-notifications-routes.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Route-level concerns for the App Store / Google Play webhook endpoints: +// payload validation and the Google Pub/Sub shared-secret token. The status +// mapping itself is covered by the handler tests. + +const hooks = vi.hoisted(() => ({ + handleAppleNotification: vi.fn(), + handleGoogleNotification: vi.fn(), +})); + +vi.mock('@/libs/payment/iap/apple/notifications', () => ({ + handleAppleNotification: hooks.handleAppleNotification, +})); +vi.mock('@/libs/payment/iap/google/notifications', () => ({ + handleGoogleNotification: hooks.handleGoogleNotification, +})); + +import { POST as applePOST } from '@/app/api/apple/notifications/route'; +import { POST as googlePOST } from '@/app/api/google/notifications/route'; + +const jsonReq = (url: string, body: unknown) => + new Request(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + +beforeEach(() => { + hooks.handleAppleNotification.mockReset(); + hooks.handleGoogleNotification.mockReset(); + hooks.handleAppleNotification.mockResolvedValue({ handled: true, status: 'active' }); + hooks.handleGoogleNotification.mockResolvedValue({ handled: true, status: 'active' }); + delete process.env['GOOGLE_RTDN_VERIFICATION_TOKEN']; +}); + +describe('POST /api/apple/notifications', () => { + it('rejects a request without a signedPayload', async () => { + const res = await applePOST(jsonReq('https://web.readest.com/api/apple/notifications', {})); + expect(res.status).toBe(400); + expect(hooks.handleAppleNotification).not.toHaveBeenCalled(); + }); + + it('processes a signed payload', async () => { + const res = await applePOST( + jsonReq('https://web.readest.com/api/apple/notifications', { signedPayload: 'jws' }), + ); + expect(res.status).toBe(200); + expect(hooks.handleAppleNotification).toHaveBeenCalledWith('jws'); + }); + + it('returns 500 when processing throws so Apple retries', async () => { + hooks.handleAppleNotification.mockRejectedValue(new Error('db down')); + const res = await applePOST( + jsonReq('https://web.readest.com/api/apple/notifications', { signedPayload: 'jws' }), + ); + expect(res.status).toBe(500); + }); +}); + +describe('POST /api/google/notifications', () => { + it('rejects a request with an invalid verification token', async () => { + process.env['GOOGLE_RTDN_VERIFICATION_TOKEN'] = 'secret'; + const res = await googlePOST( + jsonReq('https://web.readest.com/api/google/notifications?token=wrong', { + message: { data: 'abc' }, + }), + ); + expect(res.status).toBe(401); + expect(hooks.handleGoogleNotification).not.toHaveBeenCalled(); + }); + + it('processes a message when the token matches', async () => { + process.env['GOOGLE_RTDN_VERIFICATION_TOKEN'] = 'secret'; + const res = await googlePOST( + jsonReq('https://web.readest.com/api/google/notifications?token=secret', { + message: { data: 'abc' }, + }), + ); + expect(res.status).toBe(200); + expect(hooks.handleGoogleNotification).toHaveBeenCalledWith('abc'); + }); + + it('acknowledges an empty Pub/Sub message without processing', async () => { + const res = await googlePOST( + jsonReq('https://web.readest.com/api/google/notifications', { message: {} }), + ); + expect(res.status).toBe(200); + expect(hooks.handleGoogleNotification).not.toHaveBeenCalled(); + const json = (await res.json()) as { reason?: string }; + expect(json.reason).toBe('empty_message'); + }); +}); diff --git a/apps/readest-app/src/__tests__/libs/payment/apple-notifications.test.ts b/apps/readest-app/src/__tests__/libs/payment/apple-notifications.test.ts new file mode 100644 index 00000000..a0f58cda --- /dev/null +++ b/apps/readest-app/src/__tests__/libs/payment/apple-notifications.test.ts @@ -0,0 +1,277 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Apple App Store Server Notifications V2 webhook handler. The notification is +// signed by Apple (decoded/verified by `app-store-server-api`), carries no +// user id, and must be resolved to a user via `original_transaction_id` before +// the subscription/plan tables are updated. + +const appleMocks = vi.hoisted(() => ({ + decodeNotificationPayload: vi.fn(), + decodeTransaction: vi.fn(), + decodeRenewalInfo: vi.fn(), +})); + +vi.mock('app-store-server-api', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + decodeNotificationPayload: appleMocks.decodeNotificationPayload, + decodeTransaction: appleMocks.decodeTransaction, + decodeRenewalInfo: appleMocks.decodeRenewalInfo, + }; +}); + +const h = vi.hoisted(() => ({ supabase: null as ReturnType | null })); + +vi.mock('@/utils/supabase', () => ({ + createSupabaseAdminClient: () => h.supabase!.client, +})); + +import { + NotificationType, + NotificationSubtype, + AutoRenewStatus, + TransactionType, +} from 'app-store-server-api'; +import { handleAppleNotification } from '@/libs/payment/iap/apple/notifications'; + +type Captures = { + appleSubUpserts: Array>; + planUpdates: Array>; + paymentUpdates: Array>; +}; + +function createSupabaseMock(state: { + appleSubRow?: unknown; + paymentRow?: unknown; + completedPayments?: Array<{ storage_gb: number }>; +}) { + const captures: Captures = { appleSubUpserts: [], planUpdates: [], paymentUpdates: [] }; + const client = { + from(table: string) { + switch (table) { + case 'apple_iap_subscriptions': + return { + select: () => ({ + eq: () => ({ single: () => Promise.resolve({ data: state.appleSubRow ?? null }) }), + }), + upsert: (obj: Record) => { + captures.appleSubUpserts.push(obj); + return Promise.resolve({ data: obj, error: null }); + }, + }; + case 'plans': + return { + update: (obj: Record) => ({ + eq: () => { + captures.planUpdates.push(obj); + return Promise.resolve({ data: null, error: null }); + }, + }), + }; + case 'payments': + return { + select: () => ({ + eq: () => ({ + single: () => Promise.resolve({ data: state.paymentRow ?? null }), + in: () => Promise.resolve({ data: state.completedPayments ?? [] }), + }), + }), + update: (obj: Record) => ({ + eq: () => { + captures.paymentUpdates.push(obj); + return Promise.resolve({ data: null, error: null }); + }, + }), + }; + default: + throw new Error(`unexpected table: ${table}`); + } + }, + }; + return { client, captures }; +} + +const PLUS_PRODUCT = 'com.bilingify.readest.plus.monthly'; +const STORAGE_PRODUCT = 'com.bilingify.readest.purchase.storage.5gb'; +const BUNDLE_ID = 'com.bilingify.readest'; +const ORIGINAL_TX = 'orig-tx-1'; + +const buildTransaction = (overrides: Record = {}) => ({ + bundleId: BUNDLE_ID, + productId: PLUS_PRODUCT, + transactionId: 'tx-1', + originalTransactionId: ORIGINAL_TX, + purchaseDate: 1_700_000_000_000, + expiresDate: Date.now() + 30 * 24 * 60 * 60 * 1000, + quantity: 1, + type: TransactionType.AutoRenewableSubscription, + webOrderLineItemId: 'wol-1', + subscriptionGroupIdentifier: 'group-1', + ...overrides, +}); + +const mockNotification = ( + notificationType: NotificationType, + subtype?: NotificationSubtype, + hasRenewal = true, +) => { + appleMocks.decodeNotificationPayload.mockResolvedValue({ + notificationType, + subtype, + data: { + bundleId: BUNDLE_ID, + environment: 'Production', + signedTransactionInfo: 'SIGNED_TX', + signedRenewalInfo: hasRenewal ? 'SIGNED_RENEWAL' : undefined, + }, + }); +}; + +beforeEach(() => { + appleMocks.decodeNotificationPayload.mockReset(); + appleMocks.decodeTransaction.mockReset(); + appleMocks.decodeRenewalInfo.mockReset(); + process.env['APPLE_IAP_BUNDLE_ID'] = BUNDLE_ID; + appleMocks.decodeTransaction.mockResolvedValue(buildTransaction()); + appleMocks.decodeRenewalInfo.mockResolvedValue({ autoRenewStatus: AutoRenewStatus.On }); +}); + +describe('handleAppleNotification — subscriptions', () => { + it('marks a renewed subscription active and keeps the paid plan', async () => { + mockNotification(NotificationType.DidRenew); + const sb = createSupabaseMock({ appleSubRow: { user_id: 'user-1' } }); + h.supabase = sb; + + const res = await handleAppleNotification('payload'); + + expect(res).toMatchObject({ handled: true, status: 'active' }); + expect(sb.captures.appleSubUpserts.at(-1)).toMatchObject({ status: 'active' }); + expect(sb.captures.planUpdates.at(-1)).toEqual({ plan: 'plus', status: 'active' }); + }); + + it('drops the user to free when the subscription expires', async () => { + mockNotification(NotificationType.Expired); + appleMocks.decodeTransaction.mockResolvedValue( + buildTransaction({ expiresDate: Date.now() - 1000 }), + ); + const sb = createSupabaseMock({ appleSubRow: { user_id: 'user-1' } }); + h.supabase = sb; + + const res = await handleAppleNotification('payload'); + + expect(res).toMatchObject({ handled: true, status: 'expired' }); + expect(sb.captures.appleSubUpserts.at(-1)).toMatchObject({ status: 'expired' }); + expect(sb.captures.planUpdates.at(-1)).toEqual({ plan: 'free', status: 'expired' }); + }); + + it('revokes access and drops to free on a subscription refund', async () => { + mockNotification(NotificationType.Refund); + const sb = createSupabaseMock({ appleSubRow: { user_id: 'user-1' } }); + h.supabase = sb; + + const res = await handleAppleNotification('payload'); + + expect(res).toMatchObject({ handled: true, status: 'revoked' }); + expect(sb.captures.appleSubUpserts.at(-1)).toMatchObject({ status: 'expired' }); + expect(sb.captures.planUpdates.at(-1)).toEqual({ plan: 'free', status: 'revoked' }); + }); + + it('keeps the plan active but records auto-renew off when renewal is disabled', async () => { + mockNotification( + NotificationType.DidChangeRenewalStatus, + NotificationSubtype.AutoRenewDisabled, + ); + appleMocks.decodeRenewalInfo.mockResolvedValue({ autoRenewStatus: AutoRenewStatus.Off }); + const sb = createSupabaseMock({ appleSubRow: { user_id: 'user-1' } }); + h.supabase = sb; + + const res = await handleAppleNotification('payload'); + + expect(res).toMatchObject({ handled: true, status: 'active' }); + expect(sb.captures.appleSubUpserts.at(-1)).toMatchObject({ + status: 'active', + auto_renew_status: false, + }); + expect(sb.captures.planUpdates.at(-1)).toEqual({ plan: 'plus', status: 'active' }); + }); + + it('keeps entitlement during the billing grace period', async () => { + mockNotification(NotificationType.DidFailToRenew, NotificationSubtype.GracePeriod); + const sb = createSupabaseMock({ appleSubRow: { user_id: 'user-1' } }); + h.supabase = sb; + + const res = await handleAppleNotification('payload'); + + expect(res).toMatchObject({ handled: true, status: 'in_grace_period' }); + expect(sb.captures.planUpdates.at(-1)).toEqual({ plan: 'plus', status: 'in_grace_period' }); + }); + + it('ignores notifications for an unknown subscription', async () => { + mockNotification(NotificationType.DidRenew); + const sb = createSupabaseMock({ appleSubRow: null }); + h.supabase = sb; + + const res = await handleAppleNotification('payload'); + + expect(res).toMatchObject({ handled: false, reason: 'subscription_not_found' }); + expect(sb.captures.planUpdates).toHaveLength(0); + }); +}); + +describe('handleAppleNotification — validation', () => { + it('skips summary notifications without transaction data', async () => { + appleMocks.decodeNotificationPayload.mockResolvedValue({ + notificationType: NotificationType.RenewalExtension, + subtype: NotificationSubtype.Summary, + summary: { requestIdentifier: 'r1' }, + }); + h.supabase = createSupabaseMock({}); + + const res = await handleAppleNotification('payload'); + + expect(res).toMatchObject({ handled: false, reason: 'summary_notification' }); + }); + + it('rejects a payload for a different bundle id', async () => { + mockNotification(NotificationType.DidRenew); + appleMocks.decodeNotificationPayload.mockResolvedValue({ + notificationType: NotificationType.DidRenew, + data: { + bundleId: 'com.evil.app', + environment: 'Production', + signedTransactionInfo: 'SIGNED_TX', + }, + }); + h.supabase = createSupabaseMock({}); + + await expect(handleAppleNotification('payload')).rejects.toThrow(); + }); +}); + +describe('handleAppleNotification — one-time purchases', () => { + it('marks a refunded one-time purchase and recomputes storage', async () => { + appleMocks.decodeNotificationPayload.mockResolvedValue({ + notificationType: NotificationType.Refund, + data: { + bundleId: BUNDLE_ID, + environment: 'Production', + signedTransactionInfo: 'SIGNED_TX', + }, + }); + appleMocks.decodeTransaction.mockResolvedValue( + buildTransaction({ productId: STORAGE_PRODUCT, type: TransactionType.NonConsumable }), + ); + const sb = createSupabaseMock({ + paymentRow: { user_id: 'user-1' }, + completedPayments: [], + }); + h.supabase = sb; + + const res = await handleAppleNotification('payload'); + + expect(res).toMatchObject({ handled: true }); + expect(sb.captures.paymentUpdates.at(-1)).toMatchObject({ status: 'refunded' }); + expect(sb.captures.planUpdates.at(-1)).toMatchObject({ storage_purchased_bytes: 0 }); + }); +}); diff --git a/apps/readest-app/src/__tests__/libs/payment/google-notifications.test.ts b/apps/readest-app/src/__tests__/libs/payment/google-notifications.test.ts new file mode 100644 index 00000000..09d2c50d --- /dev/null +++ b/apps/readest-app/src/__tests__/libs/payment/google-notifications.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Google Play Real-Time Developer Notifications (RTDN) webhook handler. The +// Pub/Sub message carries a base64-encoded DeveloperNotification with no user +// id and no trustworthy state, so the handler resolves the user via the stored +// `purchase_token` and re-verifies against the Play Developer API (overriding +// the status for terminal events such as REVOKED/EXPIRED). + +const googleMocks = vi.hoisted(() => ({ + verifyPurchase: vi.fn(), +})); + +vi.mock('@/libs/payment/iap/google/verifier', () => ({ + getGoogleIAPVerifier: () => ({ verifyPurchase: googleMocks.verifyPurchase }), +})); + +const h = vi.hoisted(() => ({ supabase: null as ReturnType | null })); + +vi.mock('@/utils/supabase', () => ({ + createSupabaseAdminClient: () => h.supabase!.client, +})); + +import { handleGoogleNotification } from '@/libs/payment/iap/google/notifications'; + +type Captures = { + googleSubUpserts: Array>; + planUpdates: Array>; + paymentUpdates: Array>; +}; + +function createSupabaseMock(state: { + googleSubRow?: unknown; + paymentRow?: unknown; + completedPayments?: Array<{ storage_gb: number }>; +}) { + const captures: Captures = { googleSubUpserts: [], planUpdates: [], paymentUpdates: [] }; + const client = { + from(table: string) { + switch (table) { + case 'google_iap_subscriptions': + return { + select: () => ({ + eq: () => ({ single: () => Promise.resolve({ data: state.googleSubRow ?? null }) }), + }), + upsert: (obj: Record) => { + captures.googleSubUpserts.push(obj); + return Promise.resolve({ data: obj, error: null }); + }, + }; + case 'plans': + return { + update: (obj: Record) => ({ + eq: () => { + captures.planUpdates.push(obj); + return Promise.resolve({ data: null, error: null }); + }, + }), + }; + case 'payments': + return { + select: () => ({ + eq: () => ({ + single: () => Promise.resolve({ data: state.paymentRow ?? null }), + in: () => Promise.resolve({ data: state.completedPayments ?? [] }), + }), + }), + update: (obj: Record) => ({ + eq: () => { + captures.paymentUpdates.push(obj); + return Promise.resolve({ data: null, error: null }); + }, + }), + }; + default: + throw new Error(`unexpected table: ${table}`); + } + }, + }; + return { client, captures }; +} + +const PLUS_PRODUCT = 'com.bilingify.readest.plus.monthly'; +const PACKAGE = 'com.bilingify.readest'; +const TOKEN = 'purchase-token-1'; + +const subRow = { + user_id: 'user-1', + product_id: PLUS_PRODUCT, + order_id: 'order-1', + package_name: PACKAGE, +}; + +const encode = (notification: Record) => + Buffer.from(JSON.stringify(notification)).toString('base64'); + +const subscriptionMessage = (notificationType: number) => + encode({ + version: '1.0', + packageName: PACKAGE, + eventTimeMillis: '1700000000000', + subscriptionNotification: { + version: '1.0', + notificationType, + purchaseToken: TOKEN, + subscriptionId: PLUS_PRODUCT, + }, + }); + +const activeVerification = () => ({ + success: true, + status: 'active', + purchaseDate: new Date(1_700_000_000_000), + expiresDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + purchaseType: 'subscription', + purchaseData: { + orderId: 'order-1', + priceAmountMicros: '4990000', + priceCurrencyCode: 'USD', + autoRenewing: true, + purchaseState: 0, + acknowledgementState: 1, + quantity: 1, + }, +}); + +beforeEach(() => { + googleMocks.verifyPurchase.mockReset(); + googleMocks.verifyPurchase.mockResolvedValue(activeVerification()); +}); + +describe('handleGoogleNotification — subscriptions', () => { + it('re-verifies and keeps the plan active on renewal', async () => { + const sb = createSupabaseMock({ googleSubRow: subRow }); + h.supabase = sb; + + const res = await handleGoogleNotification(subscriptionMessage(2)); // SUBSCRIPTION_RENEWED + + expect(res).toMatchObject({ handled: true, status: 'active' }); + expect(sb.captures.googleSubUpserts.at(-1)).toMatchObject({ status: 'active' }); + expect(sb.captures.planUpdates.at(-1)).toEqual({ plan: 'plus', status: 'active' }); + }); + + it('forces revocation on SUBSCRIPTION_REVOKED even if the API still reports active', async () => { + googleMocks.verifyPurchase.mockResolvedValue(activeVerification()); + const sb = createSupabaseMock({ googleSubRow: subRow }); + h.supabase = sb; + + const res = await handleGoogleNotification(subscriptionMessage(12)); // SUBSCRIPTION_REVOKED + + expect(res).toMatchObject({ handled: true, status: 'revoked' }); + expect(sb.captures.planUpdates.at(-1)).toEqual({ plan: 'free', status: 'revoked' }); + }); + + it('drops to free on SUBSCRIPTION_EXPIRED', async () => { + googleMocks.verifyPurchase.mockResolvedValue({ + ...activeVerification(), + status: 'expired', + expiresDate: new Date(Date.now() - 1000), + }); + const sb = createSupabaseMock({ googleSubRow: subRow }); + h.supabase = sb; + + const res = await handleGoogleNotification(subscriptionMessage(13)); // SUBSCRIPTION_EXPIRED + + expect(res).toMatchObject({ handled: true, status: 'expired' }); + expect(sb.captures.planUpdates.at(-1)).toEqual({ plan: 'free', status: 'expired' }); + }); + + it('keeps entitlement during the grace period', async () => { + const sb = createSupabaseMock({ googleSubRow: subRow }); + h.supabase = sb; + + const res = await handleGoogleNotification(subscriptionMessage(6)); // SUBSCRIPTION_IN_GRACE_PERIOD + + expect(res).toMatchObject({ handled: true, status: 'in_grace_period' }); + expect(sb.captures.planUpdates.at(-1)).toEqual({ plan: 'plus', status: 'in_grace_period' }); + }); + + it('downgrades to free when re-verification fails on a terminal event', async () => { + googleMocks.verifyPurchase.mockResolvedValue({ success: false, error: 'gone' }); + const sb = createSupabaseMock({ googleSubRow: subRow }); + h.supabase = sb; + + const res = await handleGoogleNotification(subscriptionMessage(13)); // SUBSCRIPTION_EXPIRED + + expect(res).toMatchObject({ handled: true, status: 'expired' }); + expect(sb.captures.planUpdates.at(-1)).toEqual({ plan: 'free', status: 'expired' }); + }); + + it('ignores notifications for an unknown purchase token', async () => { + const sb = createSupabaseMock({ googleSubRow: null }); + h.supabase = sb; + + const res = await handleGoogleNotification(subscriptionMessage(2)); + + expect(res).toMatchObject({ handled: false, reason: 'subscription_not_found' }); + expect(sb.captures.planUpdates).toHaveLength(0); + }); +}); + +describe('handleGoogleNotification — voided purchases', () => { + it('revokes a refunded subscription', async () => { + const sb = createSupabaseMock({ googleSubRow: subRow }); + h.supabase = sb; + + const res = await handleGoogleNotification( + encode({ + version: '1.0', + packageName: PACKAGE, + voidedPurchaseNotification: { purchaseToken: TOKEN, orderId: 'order-1', productType: 1 }, + }), + ); + + expect(res).toMatchObject({ handled: true, status: 'revoked' }); + expect(sb.captures.planUpdates.at(-1)).toEqual({ plan: 'free', status: 'revoked' }); + }); + + it('refunds a voided one-time purchase and recomputes storage', async () => { + const sb = createSupabaseMock({ paymentRow: { user_id: 'user-1' }, completedPayments: [] }); + h.supabase = sb; + + const res = await handleGoogleNotification( + encode({ + version: '1.0', + packageName: PACKAGE, + voidedPurchaseNotification: { purchaseToken: TOKEN, orderId: 'order-1', productType: 2 }, + }), + ); + + expect(res).toMatchObject({ handled: true }); + expect(sb.captures.paymentUpdates.at(-1)).toMatchObject({ status: 'refunded' }); + expect(sb.captures.planUpdates.at(-1)).toMatchObject({ storage_purchased_bytes: 0 }); + }); +}); + +describe('handleGoogleNotification — other', () => { + it('acknowledges test notifications without processing', async () => { + h.supabase = createSupabaseMock({}); + + const res = await handleGoogleNotification(encode({ testNotification: { version: '1.0' } })); + + expect(res).toMatchObject({ handled: false, reason: 'test_notification' }); + }); +}); diff --git a/apps/readest-app/src/app/api/apple/notifications/route.ts b/apps/readest-app/src/app/api/apple/notifications/route.ts new file mode 100644 index 00000000..3237b109 --- /dev/null +++ b/apps/readest-app/src/app/api/apple/notifications/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; +import { handleAppleNotification } from '@/libs/payment/iap/apple/notifications'; + +// App Store Server Notifications V2 endpoint. Configure this URL in App Store +// Connect (Production and Sandbox). The payload is signed by Apple and verified +// inside `handleAppleNotification`, so no separate authentication is needed. +export async function POST(request: Request) { + let signedPayload: unknown; + try { + const body = await request.json(); + signedPayload = body?.signedPayload; + } catch { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); + } + + if (typeof signedPayload !== 'string' || !signedPayload) { + return NextResponse.json({ error: 'Missing signedPayload' }, { status: 400 }); + } + + try { + const result = await handleAppleNotification(signedPayload); + return NextResponse.json({ received: true, ...result }); + } catch (error) { + // Respond 500 so Apple retries transient failures (e.g. a database hiccup). + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('Apple notification error:', message); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/readest-app/src/app/api/google/notifications/route.ts b/apps/readest-app/src/app/api/google/notifications/route.ts new file mode 100644 index 00000000..3599ebbd --- /dev/null +++ b/apps/readest-app/src/app/api/google/notifications/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from 'next/server'; +import { handleGoogleNotification } from '@/libs/payment/iap/google/notifications'; + +// Google Play Real-Time Developer Notifications (RTDN) endpoint, delivered via a +// Cloud Pub/Sub push subscription. The push URL must include the shared secret +// (`?token=...`) matching GOOGLE_RTDN_VERIFICATION_TOKEN; the notification state +// itself is re-verified against the Play Developer API in the handler. +export async function POST(request: Request) { + const expectedToken = process.env['GOOGLE_RTDN_VERIFICATION_TOKEN']; + if (expectedToken) { + const token = new URL(request.url).searchParams.get('token'); + if (token !== expectedToken) { + return NextResponse.json({ error: 'Invalid verification token' }, { status: 401 }); + } + } else { + console.warn('GOOGLE_RTDN_VERIFICATION_TOKEN is not set; skipping token verification'); + } + + let messageData: unknown; + try { + const body = await request.json(); + messageData = body?.message?.data; + } catch { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); + } + + // Pub/Sub may deliver an empty message (e.g. during endpoint validation). + // Acknowledge it so it is not retried. + if (typeof messageData !== 'string' || !messageData) { + return NextResponse.json({ received: true, handled: false, reason: 'empty_message' }); + } + + try { + const result = await handleGoogleNotification(messageData); + return NextResponse.json({ received: true, ...result }); + } catch (error) { + // Respond 500 so Pub/Sub retries transient failures. + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('Google notification error:', message); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/readest-app/src/libs/payment/iap/apple/notifications.ts b/apps/readest-app/src/libs/payment/iap/apple/notifications.ts new file mode 100644 index 00000000..17ef0bd0 --- /dev/null +++ b/apps/readest-app/src/libs/payment/iap/apple/notifications.ts @@ -0,0 +1,168 @@ +import { + AutoRenewStatus, + JWSRenewalInfoDecodedPayload, + JWSTransactionDecodedPayload, + NotificationSubtype, + NotificationType, + TransactionType, + decodeNotificationPayload, + decodeRenewalInfo, + decodeTransaction, + isDecodedNotificationDataPayload, +} from 'app-store-server-api'; +import { PlanType } from '@/types/quota'; +import { createSupabaseAdminClient } from '@/utils/supabase'; +import { IAPStatus } from '../types'; +import { mapProductIdToProductName } from '../utils'; +import { markPaymentRefunded } from '../payments'; +import { VerifiedPurchase, createOrUpdateSubscription } from './server'; + +export interface AppleNotificationResult { + handled: boolean; + reason?: string; + notificationType?: string; + status?: IAPStatus; +} + +const statusFromExpiry = (expiresDate?: number): IAPStatus => + expiresDate && expiresDate > Date.now() ? 'active' : 'expired'; + +/** + * Derive the effective entitlement status of a subscription from the App Store + * notification type. The signed transaction is the source of truth for dates, + * but the notification type tells us how to interpret the event (refund, + * revoke, grace period, ...) which the transaction alone does not. + */ +const deriveSubscriptionStatus = ( + type: NotificationType, + subtype: NotificationSubtype | undefined, + transaction: JWSTransactionDecodedPayload, +): IAPStatus => { + switch (type) { + case NotificationType.Subscribed: + case NotificationType.DidRenew: + case NotificationType.OfferRedeemed: + case NotificationType.RenewalExtended: + case NotificationType.RefundReversed: + case NotificationType.RefundDeclined: + return 'active'; + case NotificationType.Expired: + case NotificationType.GracePeriodExpired: + return 'expired'; + case NotificationType.Refund: + case NotificationType.Revoke: + return 'revoked'; + case NotificationType.DidFailToRenew: + return subtype === NotificationSubtype.GracePeriod + ? 'in_grace_period' + : statusFromExpiry(transaction.expiresDate); + // DID_CHANGE_RENEWAL_STATUS, DID_CHANGE_RENEWAL_PREF, PRICE_INCREASE, ...: + // entitlement is unchanged, the subscription stays active until it expires. + default: + return statusFromExpiry(transaction.expiresDate); + } +}; + +export async function handleAppleNotification( + signedPayload: string, +): Promise { + const payload = await decodeNotificationPayload(signedPayload); + + // Summary payloads (e.g. RENEWAL_EXTENSION SUMMARY) carry aggregate results + // for a request rather than a single transaction; nothing to update. + if (!isDecodedNotificationDataPayload(payload)) { + return { handled: false, reason: 'summary_notification' }; + } + + const expectedBundleId = process.env['APPLE_IAP_BUNDLE_ID']; + if (expectedBundleId && payload.data.bundleId !== expectedBundleId) { + throw new Error(`Unexpected bundle id: ${payload.data.bundleId}`); + } + + const transaction = await decodeTransaction(payload.data.signedTransactionInfo); + const renewalInfo: JWSRenewalInfoDecodedPayload | undefined = payload.data.signedRenewalInfo + ? await decodeRenewalInfo(payload.data.signedRenewalInfo) + : undefined; + + const planType: PlanType = + transaction.type === TransactionType.NonConsumable || + transaction.type === TransactionType.Consumable + ? 'purchase' + : 'subscription'; + + const supabase = createSupabaseAdminClient(); + const notificationType = payload.notificationType; + + if (planType === 'purchase') { + // One-time purchases are recorded by the client verification flow; the only + // webhook events that matter are refunds/revocations. + if ( + notificationType !== NotificationType.Refund && + notificationType !== NotificationType.Revoke + ) { + return { handled: false, reason: 'ignored_purchase_event', notificationType }; + } + + const { data: paymentRow } = await supabase + .from('payments') + .select('user_id') + .eq('apple_original_transaction_id', transaction.originalTransactionId) + .single(); + + if (!paymentRow?.user_id) { + return { handled: false, reason: 'payment_not_found', notificationType }; + } + + await markPaymentRefunded( + paymentRow.user_id, + 'apple_original_transaction_id', + transaction.originalTransactionId, + ); + return { handled: true, status: 'revoked', notificationType }; + } + + const { data: subRow } = await supabase + .from('apple_iap_subscriptions') + .select('user_id') + .eq('original_transaction_id', transaction.originalTransactionId) + .single(); + + if (!subRow?.user_id) { + return { handled: false, reason: 'subscription_not_found', notificationType }; + } + + const status = deriveSubscriptionStatus(notificationType, payload.subtype, transaction); + const autoRenewStatus = renewalInfo + ? renewalInfo.autoRenewStatus === AutoRenewStatus.On + : undefined; + + const purchase: VerifiedPurchase = { + platform: 'ios', + status, + customerEmail: '', + orderId: transaction.webOrderLineItemId || transaction.originalTransactionId, + subscriptionId: transaction.webOrderLineItemId || transaction.originalTransactionId, + planName: mapProductIdToProductName(transaction.productId), + planType: 'subscription', + productId: transaction.productId, + transactionId: transaction.transactionId, + originalTransactionId: transaction.originalTransactionId, + purchaseDate: new Date(transaction.purchaseDate).toISOString(), + expiresDate: transaction.expiresDate ? new Date(transaction.expiresDate).toISOString() : null, + quantity: transaction.quantity, + environment: String(payload.data.environment).toLowerCase(), + bundleId: payload.data.bundleId, + webOrderLineItemId: transaction.webOrderLineItemId, + subscriptionGroupIdentifier: transaction.subscriptionGroupIdentifier, + type: transaction.type, + revocationDate: transaction.revocationDate + ? new Date(transaction.revocationDate).toISOString() + : null, + revocationReason: transaction.revocationReason ?? null, + autoRenewStatus, + }; + + await createOrUpdateSubscription(subRow.user_id, purchase); + + return { handled: true, status, notificationType }; +} diff --git a/apps/readest-app/src/libs/payment/iap/apple/server.ts b/apps/readest-app/src/libs/payment/iap/apple/server.ts index 9939386a..ac70daa7 100644 --- a/apps/readest-app/src/libs/payment/iap/apple/server.ts +++ b/apps/readest-app/src/libs/payment/iap/apple/server.ts @@ -2,6 +2,7 @@ import { ApplePaymentData } from '@/types/payment'; import { createSupabaseAdminClient } from '@/utils/supabase'; import { updateUserStorage } from '@/libs/payment/storage'; import { + isEntitledStatus, isStoragePurchase, mapProductIdToProductName, mapProductIdToUserPlan, @@ -23,6 +24,7 @@ export type VerifiedPurchase = VerifiedIAP & { type?: string; revocationDate?: string | null; revocationReason?: number | null; + autoRenewStatus?: boolean; }; export async function createOrUpdateSubscription(userId: string, purchase: VerifiedPurchase) { @@ -51,7 +53,7 @@ export async function createOrUpdateSubscription(userId: string, purchase: Verif environment: purchase.environment, bundle_id: purchase.bundleId, quantity: purchase.quantity || 1, - auto_renew_status: true, + auto_renew_status: purchase.autoRenewStatus ?? true, web_order_line_item_id: purchase.webOrderLineItemId, subscription_group_identifier: purchase.subscriptionGroupIdentifier, created_at: new Date(), @@ -71,7 +73,7 @@ export async function createOrUpdateSubscription(userId: string, purchase: Verif await supabase .from('plans') .update({ - plan: ['active', 'trialing'].includes(purchase.status) ? plan : 'free', + plan: isEntitledStatus(purchase.status) ? plan : 'free', status: purchase.status, }) .eq('id', userId); diff --git a/apps/readest-app/src/libs/payment/iap/google/notifications.ts b/apps/readest-app/src/libs/payment/iap/google/notifications.ts new file mode 100644 index 00000000..03d5ce84 --- /dev/null +++ b/apps/readest-app/src/libs/payment/iap/google/notifications.ts @@ -0,0 +1,232 @@ +import { createSupabaseAdminClient } from '@/utils/supabase'; +import { IAPStatus } from '../types'; +import { mapProductIdToProductName } from '../utils'; +import { markPaymentRefunded } from '../payments'; +import { VerifyPurchaseParams, getGoogleIAPVerifier } from './verifier'; +import { VerifiedPurchase, createOrUpdateSubscription, processPurchaseData } from './server'; + +// Google Play subscription RTDN notification types. +// https://developer.android.com/google/play/billing/rtdn-reference#sub +const enum SubscriptionNotificationType { + IN_GRACE_PERIOD = 6, + ON_HOLD = 5, + PAUSED = 10, + REVOKED = 12, + EXPIRED = 13, +} + +// Google Play voided purchase product types. +const VOIDED_PRODUCT_TYPE_SUBSCRIPTION = 1; + +interface SubscriptionNotification { + version?: string; + notificationType: number; + purchaseToken: string; + subscriptionId: string; +} + +interface OneTimeProductNotification { + version?: string; + notificationType: number; + purchaseToken: string; + sku: string; +} + +interface VoidedPurchaseNotification { + purchaseToken: string; + orderId?: string; + productType?: number; + refundType?: number; +} + +export interface DeveloperNotification { + version?: string; + packageName?: string; + eventTimeMillis?: string; + subscriptionNotification?: SubscriptionNotification; + oneTimeProductNotification?: OneTimeProductNotification; + voidedPurchaseNotification?: VoidedPurchaseNotification; + testNotification?: { version?: string }; +} + +export interface GoogleNotificationResult { + handled: boolean; + reason?: string; + notificationType?: number; + status?: IAPStatus; +} + +interface GoogleSubscriptionRow { + user_id: string; + product_id: string; + order_id: string; + package_name: string; +} + +// The re-verified state is the source of truth for renewals, but terminal/grace +// events must override it: the Play API can still report a subscription as +// active immediately after a revocation, and grace periods surface as a pending +// payment state rather than an entitled one. +const overrideStatus = (status: IAPStatus, notificationType: number): IAPStatus => { + switch (notificationType) { + case SubscriptionNotificationType.REVOKED: + return 'revoked'; + case SubscriptionNotificationType.EXPIRED: + case SubscriptionNotificationType.ON_HOLD: + case SubscriptionNotificationType.PAUSED: + return 'expired'; + case SubscriptionNotificationType.IN_GRACE_PERIOD: + return 'in_grace_period'; + default: + return status; + } +}; + +const buildFallbackPurchase = ( + row: GoogleSubscriptionRow, + purchaseToken: string, + productId: string, + packageName: string, + status: IAPStatus, +): VerifiedPurchase => ({ + platform: 'android', + status, + customerEmail: '', + orderId: row.order_id, + subscriptionId: row.order_id, + planName: mapProductIdToProductName(productId), + planType: 'subscription', + productId, + purchaseToken, + expiresDate: null, + quantity: 1, + environment: 'production', + packageName, + autoRenewing: false, +}); + +async function handleSubscriptionNotification( + notification: SubscriptionNotification, + packageName: string | undefined, +): Promise { + const { notificationType, purchaseToken, subscriptionId } = notification; + const supabase = createSupabaseAdminClient(); + + const { data: row } = await supabase + .from('google_iap_subscriptions') + .select('user_id, product_id, order_id, package_name') + .eq('purchase_token', purchaseToken) + .single(); + + if (!row?.user_id) { + return { handled: false, reason: 'subscription_not_found', notificationType }; + } + + const subRow = row as GoogleSubscriptionRow; + const productId = subscriptionId || subRow.product_id; + const pkg = packageName || subRow.package_name; + const verifyParams: VerifyPurchaseParams = { + orderId: subRow.order_id, + purchaseToken, + productId, + packageName: pkg, + }; + + const verificationResult = await getGoogleIAPVerifier().verifyPurchase(verifyParams); + + if (verificationResult.success) { + const status = overrideStatus(verificationResult.status!, notificationType); + verificationResult.status = status; + await processPurchaseData({ id: subRow.user_id, email: '' }, verifyParams, verificationResult); + return { handled: true, status, notificationType }; + } + + // Re-verification failed (e.g. a revoked purchase is no longer queryable). + // For terminal events we still downgrade the user using the stored row. + const status = overrideStatus('expired', notificationType); + await createOrUpdateSubscription( + subRow.user_id, + buildFallbackPurchase(subRow, purchaseToken, productId, pkg, status), + ); + return { handled: true, status, notificationType }; +} + +async function handleVoidedPurchase( + notification: VoidedPurchaseNotification, +): Promise { + const { purchaseToken, productType } = notification; + const supabase = createSupabaseAdminClient(); + + if (productType === VOIDED_PRODUCT_TYPE_SUBSCRIPTION) { + const { data: row } = await supabase + .from('google_iap_subscriptions') + .select('user_id, product_id, order_id, package_name') + .eq('purchase_token', purchaseToken) + .single(); + + if (!row?.user_id) { + return { handled: false, reason: 'subscription_not_found' }; + } + + const subRow = row as GoogleSubscriptionRow; + await createOrUpdateSubscription( + subRow.user_id, + buildFallbackPurchase( + subRow, + purchaseToken, + subRow.product_id, + subRow.package_name, + 'revoked', + ), + ); + return { handled: true, status: 'revoked' }; + } + + const { data: paymentRow } = await supabase + .from('payments') + .select('user_id') + .eq('google_purchase_token', purchaseToken) + .single(); + + if (!paymentRow?.user_id) { + return { handled: false, reason: 'payment_not_found' }; + } + + await markPaymentRefunded(paymentRow.user_id, 'google_purchase_token', purchaseToken); + return { handled: true, status: 'revoked' }; +} + +export async function handleGoogleNotification( + messageData: string, +): Promise { + const decoded = Buffer.from(messageData, 'base64').toString('utf-8'); + const notification = JSON.parse(decoded) as DeveloperNotification; + + if (notification.testNotification) { + return { handled: false, reason: 'test_notification' }; + } + + const expectedPackage = process.env['GOOGLE_IAP_PACKAGE_NAME']; + if (expectedPackage && notification.packageName && notification.packageName !== expectedPackage) { + throw new Error(`Unexpected package name: ${notification.packageName}`); + } + + if (notification.voidedPurchaseNotification) { + return handleVoidedPurchase(notification.voidedPurchaseNotification); + } + + if (notification.subscriptionNotification) { + return handleSubscriptionNotification( + notification.subscriptionNotification, + notification.packageName, + ); + } + + // One-time product purchases are recorded by the client verification flow; + // their refunds arrive as voided purchase notifications instead. + if (notification.oneTimeProductNotification) { + return { handled: false, reason: 'ignored_one_time_event' }; + } + + return { handled: false, reason: 'unknown_notification' }; +} diff --git a/apps/readest-app/src/libs/payment/iap/google/server.ts b/apps/readest-app/src/libs/payment/iap/google/server.ts index 491d6d7e..dd2a1a0d 100644 --- a/apps/readest-app/src/libs/payment/iap/google/server.ts +++ b/apps/readest-app/src/libs/payment/iap/google/server.ts @@ -2,6 +2,7 @@ import { GooglePaymentData } from '@/types/payment'; import { createSupabaseAdminClient } from '@/utils/supabase'; import { updateUserStorage } from '@/libs/payment/storage'; import { + isEntitledStatus, isStoragePurchase, mapProductIdToProductName, mapProductIdToUserPlan, @@ -91,7 +92,7 @@ export async function createOrUpdateSubscription(userId: string, purchase: Verif await supabase .from('plans') .update({ - plan: ['active', 'trialing'].includes(purchase.status) ? plan : 'free', + plan: isEntitledStatus(purchase.status) ? plan : 'free', status: purchase.status, }) .eq('id', userId); diff --git a/apps/readest-app/src/libs/payment/iap/payments.ts b/apps/readest-app/src/libs/payment/iap/payments.ts new file mode 100644 index 00000000..c0571f51 --- /dev/null +++ b/apps/readest-app/src/libs/payment/iap/payments.ts @@ -0,0 +1,24 @@ +import { createSupabaseAdminClient } from '@/utils/supabase'; +import { updateUserStorage } from '@/libs/payment/storage'; + +type PaymentRefKey = 'apple_original_transaction_id' | 'google_purchase_token'; + +/** + * Mark a one-time purchase as refunded and recompute the user's purchased + * storage. Used by the App Store / Google Play webhooks when a non-subscription + * purchase (e.g. a storage add-on) is refunded or voided. + */ +export async function markPaymentRefunded(userId: string, column: PaymentRefKey, value: string) { + const supabase = createSupabaseAdminClient(); + + const { error } = await supabase + .from('payments') + .update({ status: 'refunded' }) + .eq(column, value); + + if (error) { + throw new Error(`Failed to mark payment refunded: ${error.message}`); + } + + await updateUserStorage(userId); +} diff --git a/apps/readest-app/src/libs/payment/iap/utils.ts b/apps/readest-app/src/libs/payment/iap/utils.ts index 176cac6f..0caa6782 100644 --- a/apps/readest-app/src/libs/payment/iap/utils.ts +++ b/apps/readest-app/src/libs/payment/iap/utils.ts @@ -1,4 +1,12 @@ import { PlanInterval, PlanType, UserPlan } from '@/types/quota'; +import { IAPStatus } from './types'; + +// Statuses that still grant the user their paid plan. Anything else (expired, +// cancelled past its period, revoked, pending, on hold) falls back to `free`. +export const ENTITLED_IAP_STATUSES: IAPStatus[] = ['active', 'in_grace_period']; + +export const isEntitledStatus = (status: IAPStatus): boolean => + ENTITLED_IAP_STATUSES.includes(status); export const mapProductIdToUserPlan = (productId: string, isSubscription = false): UserPlan => { if (productId.includes('.plus')) return 'plus';