fix(billing): webhook crashed binding Date to postgres.js — use ISO strings
THE actual root cause of "charged but still free". reconcile surfaced it: TypeError: The "string" argument must be of type string... Received Date postgres.js in this repo binds timestamp params via a custom string serializer — passing a raw JS Date object throws. The webhook built currentStart/ currentEnd/endedAt/cancelledAt as Date objects and bound them into the UPDATE family_subscriptions query, so EVERY charged/activated/authenticated event crashed in processing → 500 → (with the now-fixed idempotency) retries also failed → entitlement never applied. Fix: all timestamp params are now ISO strings (.toISOString()): - webhook: unixToDate -> unixToISO; revoke branch ended_at/cancelled_at as ISO - reconcile endpoint: same toISO conversion Combined with the previous idempotency fix, live charges now grant correctly and a transient failure can be retried successfully. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e989e6c558
commit
a69546977c
2 changed files with 16 additions and 10 deletions
|
|
@ -67,12 +67,13 @@ export async function POST(request: Request) {
|
||||||
const entity = ((event.payload as Record<string, unknown>)?.payload as Record<string, unknown>)
|
const entity = ((event.payload as Record<string, unknown>)?.payload as Record<string, unknown>)
|
||||||
?.subscription as Record<string, unknown> | undefined;
|
?.subscription as Record<string, unknown> | undefined;
|
||||||
const ent = (entity as { entity?: Record<string, unknown> })?.entity;
|
const ent = (entity as { entity?: Record<string, unknown> })?.entity;
|
||||||
const toDate = (v: unknown) => {
|
// ISO strings — postgres.js binds timestamps as strings, not Date objects.
|
||||||
|
const toISO = (v: unknown) => {
|
||||||
const n = Number(v);
|
const n = Number(v);
|
||||||
return Number.isFinite(n) && n > 0 ? new Date(n * 1000) : null;
|
return Number.isFinite(n) && n > 0 ? new Date(n * 1000).toISOString() : null;
|
||||||
};
|
};
|
||||||
const currentStart = toDate(ent?.current_start);
|
const currentStart = toISO(ent?.current_start);
|
||||||
const currentEnd = toDate(ent?.current_end);
|
const currentEnd = toISO(ent?.current_end);
|
||||||
const customerId = (ent?.customer_id as string) ?? null;
|
const customerId = (ent?.customer_id as string) ?? null;
|
||||||
|
|
||||||
if (grant) {
|
if (grant) {
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,12 @@ const REVOKE_EVENTS: Record<string, string> = {
|
||||||
"subscription.paused": "paused",
|
"subscription.paused": "paused",
|
||||||
};
|
};
|
||||||
|
|
||||||
function unixToDate(v: unknown): Date | null {
|
// Razorpay sends unix seconds. Return an ISO STRING (not a Date) — postgres.js
|
||||||
|
// in this repo binds timestamps as strings via the custom serializer; passing a
|
||||||
|
// raw Date object throws ERR_INVALID_ARG_TYPE.
|
||||||
|
function unixToISO(v: unknown): string | null {
|
||||||
const n = typeof v === "number" ? v : Number(v);
|
const n = typeof v === "number" ? v : Number(v);
|
||||||
return Number.isFinite(n) && n > 0 ? new Date(n * 1000) : null;
|
return Number.isFinite(n) && n > 0 ? new Date(n * 1000).toISOString() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
|
@ -124,8 +127,8 @@ export async function POST(req: Request) {
|
||||||
return new NextResponse("ok (unknown subscription)", { status: 200 });
|
return new NextResponse("ok (unknown subscription)", { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentStart = unixToDate(sub?.current_start);
|
const currentStart = unixToISO(sub?.current_start);
|
||||||
const currentEnd = unixToDate(sub?.current_end);
|
const currentEnd = unixToISO(sub?.current_end);
|
||||||
const customerId = (sub?.customer_id as string) ?? null;
|
const customerId = (sub?.customer_id as string) ?? null;
|
||||||
|
|
||||||
const grantStatus = GRANT_EVENTS[eventType];
|
const grantStatus = GRANT_EVENTS[eventType];
|
||||||
|
|
@ -144,11 +147,13 @@ export async function POST(req: Request) {
|
||||||
// paused is a GRANT_EVENTS key? No — paused is in REVOKE. resumed→active.
|
// paused is a GRANT_EVENTS key? No — paused is in REVOKE. resumed→active.
|
||||||
await grantPremium(row.family_id, grantStatus);
|
await grantPremium(row.family_id, grantStatus);
|
||||||
} else if (revokeStatus) {
|
} else if (revokeStatus) {
|
||||||
const endedAt = revokeStatus === "paused" ? null : new Date();
|
const nowIso = new Date().toISOString();
|
||||||
|
const endedAt = revokeStatus === "paused" ? null : nowIso;
|
||||||
|
const cancelledAt = revokeStatus === "cancelled" ? nowIso : null;
|
||||||
await sql`
|
await sql`
|
||||||
UPDATE family_subscriptions SET
|
UPDATE family_subscriptions SET
|
||||||
status = ${revokeStatus}::subscription_status_enum,
|
status = ${revokeStatus}::subscription_status_enum,
|
||||||
cancelled_at = ${revokeStatus === "cancelled" ? new Date() : null},
|
cancelled_at = ${cancelledAt},
|
||||||
ended_at = COALESCE(${endedAt}, ended_at),
|
ended_at = COALESCE(${endedAt}, ended_at),
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = ${row.id}
|
WHERE id = ${row.id}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue