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:
Manohar Gupta 2026-06-06 15:00:29 +05:30
parent e989e6c558
commit a69546977c
2 changed files with 16 additions and 10 deletions

View file

@ -67,12 +67,13 @@ export async function POST(request: Request) {
const entity = ((event.payload as Record<string, unknown>)?.payload as Record<string, unknown>)
?.subscription as Record<string, unknown> | undefined;
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);
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 currentEnd = toDate(ent?.current_end);
const currentStart = toISO(ent?.current_start);
const currentEnd = toISO(ent?.current_end);
const customerId = (ent?.customer_id as string) ?? null;
if (grant) {

View file

@ -36,9 +36,12 @@ const REVOKE_EVENTS: Record<string, string> = {
"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);
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) {
@ -124,8 +127,8 @@ export async function POST(req: Request) {
return new NextResponse("ok (unknown subscription)", { status: 200 });
}
const currentStart = unixToDate(sub?.current_start);
const currentEnd = unixToDate(sub?.current_end);
const currentStart = unixToISO(sub?.current_start);
const currentEnd = unixToISO(sub?.current_end);
const customerId = (sub?.customer_id as string) ?? null;
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.
await grantPremium(row.family_id, grantStatus);
} 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`
UPDATE family_subscriptions SET
status = ${revokeStatus}::subscription_status_enum,
cancelled_at = ${revokeStatus === "cancelled" ? new Date() : null},
cancelled_at = ${cancelledAt},
ended_at = COALESCE(${endedAt}, ended_at),
updated_at = NOW()
WHERE id = ${row.id}