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) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-21 22:48:17 +08:00
committed by GitHub
parent 96d65d9960
commit 359fdddcf4
11 changed files with 1123 additions and 3 deletions
@@ -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');
});
});
@@ -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<typeof import('app-store-server-api')>();
return {
...actual,
decodeNotificationPayload: appleMocks.decodeNotificationPayload,
decodeTransaction: appleMocks.decodeTransaction,
decodeRenewalInfo: appleMocks.decodeRenewalInfo,
};
});
const h = vi.hoisted(() => ({ supabase: null as ReturnType<typeof createSupabaseMock> | 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<Record<string, unknown>>;
planUpdates: Array<Record<string, unknown>>;
paymentUpdates: Array<Record<string, unknown>>;
};
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<string, unknown>) => {
captures.appleSubUpserts.push(obj);
return Promise.resolve({ data: obj, error: null });
},
};
case 'plans':
return {
update: (obj: Record<string, unknown>) => ({
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<string, unknown>) => ({
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<string, unknown> = {}) => ({
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 });
});
});
@@ -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<typeof createSupabaseMock> | null }));
vi.mock('@/utils/supabase', () => ({
createSupabaseAdminClient: () => h.supabase!.client,
}));
import { handleGoogleNotification } from '@/libs/payment/iap/google/notifications';
type Captures = {
googleSubUpserts: Array<Record<string, unknown>>;
planUpdates: Array<Record<string, unknown>>;
paymentUpdates: Array<Record<string, unknown>>;
};
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<string, unknown>) => {
captures.googleSubUpserts.push(obj);
return Promise.resolve({ data: obj, error: null });
},
};
case 'plans':
return {
update: (obj: Record<string, unknown>) => ({
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<string, unknown>) => ({
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<string, unknown>) =>
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' });
});
});
@@ -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 });
}
}
@@ -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 });
}
}
@@ -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<AppleNotificationResult> {
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 };
}
@@ -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);
@@ -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<GoogleNotificationResult> {
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<GoogleNotificationResult> {
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<GoogleNotificationResult> {
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' };
}
@@ -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);
@@ -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);
}
@@ -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';