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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user