feat(payment): observability for store subscription webhooks (#4704)

* feat(payment): observability for store subscription webhooks

Add monitoring for the App Store / Google Play webhooks so store-side
subscription changes are observable on Cloudflare, where stored Workers
Logs are head-sampled at 1% and would miss almost all low-volume webhook
events.

- Add iap/telemetry.ts: every webhook invocation emits a structured log
  line (streamed in full by `wrangler tail`) and a Cloudflare Analytics
  Engine data point (100% capture, independent of log sampling). Writes
  no-op off the Worker runtime, mirroring the getCloudflareContext guard
  in deepl/translate.ts.
- Instrument both webhook routes to record outcome (handled, skipped,
  rejected, error), notification type, status, reason, and latency on
  every return path.
- Add GET /api/cron/iap-reconcile: a CRON_SECRET-protected sweep that
  counts drift (rows still active while their store expiry has passed = a
  missed webhook) in both IAP tables and records a reconcile metric.
  Detection-only; never mutates state.
- Add the IAP_WEBHOOK_AE Analytics Engine binding to wrangler.toml.

New configuration: CRON_SECRET (reconcile auth) and an iap_webhooks
Analytics Engine dataset bound as IAP_WEBHOOK_AE. The reconcile route is
triggered on a schedule (a Cloudflare Cron Trigger worker that fetches
the URL, or any external scheduler) with an Authorization bearer header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(payment): move reconciliation to a dedicated cron Worker

Replace the public CRON_SECRET-protected /api/cron/iap-reconcile route
with a dedicated Cloudflare Cron Worker. A Cron Trigger invokes the
worker's scheduled() handler directly, so there is no public HTTP surface
and no shared request secret to manage - the strongest option on
Cloudflare (OpenNext's generated worker only exports `fetch`, so the main
worker cannot host a scheduled() handler).

- Add workers/iap-reconcile: a self-contained worker (own package.json,
  tsconfig, wrangler.toml) matching the existing workers/send-email
  convention, registered in pnpm-workspace.yaml. Hourly Cron Trigger;
  reads the IAP tables via the Supabase service role and records a drift
  metric to the shared iap_webhooks Analytics Engine dataset.
- Reconcile logic lives in workers/iap-reconcile/src/reconcile.ts and is
  unit-tested from the app suite.
- Remove the public route and its test; drop the now-unused
  recordIapReconcile from iap/telemetry.ts (webhook telemetry unchanged).

Configuration: set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY as secrets
on the worker and deploy it with `wrangler deploy` from its directory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-22 00:02:02 +08:00
committed by GitHub
parent 359fdddcf4
commit 4fa7f76bc1
15 changed files with 488 additions and 1 deletions
@@ -1,29 +1,58 @@
import { NextResponse } from 'next/server';
import { handleAppleNotification } from '@/libs/payment/iap/apple/notifications';
import { recordIapWebhook } from '@/libs/payment/iap/telemetry';
// 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) {
const startedAt = Date.now();
let signedPayload: unknown;
try {
const body = await request.json();
signedPayload = body?.signedPayload;
} catch {
recordIapWebhook({
provider: 'apple',
outcome: 'rejected',
reason: 'invalid_body',
durationMs: Date.now() - startedAt,
});
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
if (typeof signedPayload !== 'string' || !signedPayload) {
recordIapWebhook({
provider: 'apple',
outcome: 'rejected',
reason: 'missing_payload',
durationMs: Date.now() - startedAt,
});
return NextResponse.json({ error: 'Missing signedPayload' }, { status: 400 });
}
try {
const result = await handleAppleNotification(signedPayload);
recordIapWebhook({
provider: 'apple',
outcome: result.handled ? 'handled' : 'skipped',
notificationType: result.notificationType,
status: result.status,
reason: result.reason,
durationMs: Date.now() - startedAt,
});
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);
recordIapWebhook({
provider: 'apple',
outcome: 'error',
reason: message,
durationMs: Date.now() - startedAt,
});
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -1,15 +1,24 @@
import { NextResponse } from 'next/server';
import { handleGoogleNotification } from '@/libs/payment/iap/google/notifications';
import { recordIapWebhook } from '@/libs/payment/iap/telemetry';
// 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 startedAt = Date.now();
const expectedToken = process.env['GOOGLE_RTDN_VERIFICATION_TOKEN'];
if (expectedToken) {
const token = new URL(request.url).searchParams.get('token');
if (token !== expectedToken) {
recordIapWebhook({
provider: 'google',
outcome: 'rejected',
reason: 'invalid_token',
durationMs: Date.now() - startedAt,
});
return NextResponse.json({ error: 'Invalid verification token' }, { status: 401 });
}
} else {
@@ -21,22 +30,48 @@ export async function POST(request: Request) {
const body = await request.json();
messageData = body?.message?.data;
} catch {
recordIapWebhook({
provider: 'google',
outcome: 'rejected',
reason: 'invalid_body',
durationMs: Date.now() - startedAt,
});
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) {
recordIapWebhook({
provider: 'google',
outcome: 'skipped',
reason: 'empty_message',
durationMs: Date.now() - startedAt,
});
return NextResponse.json({ received: true, handled: false, reason: 'empty_message' });
}
try {
const result = await handleGoogleNotification(messageData);
recordIapWebhook({
provider: 'google',
outcome: result.handled ? 'handled' : 'skipped',
notificationType: result.notificationType,
status: result.status,
reason: result.reason,
durationMs: Date.now() - startedAt,
});
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);
recordIapWebhook({
provider: 'google',
outcome: 'error',
reason: message,
durationMs: Date.now() - startedAt,
});
return NextResponse.json({ error: message }, { status: 500 });
}
}