Skip to content

Commit 518987a

Browse files
cursoragentkubo6472
andcommitted
fix(payments): harden Comgate first-checkout and renumber migration
Extract resolveComgateCheckoutIdentity so first-purchase webhooks resolve the user from payment_checkout_sessions (migration 0010). Fail closed if the pending session cannot be persisted at checkout. Align docs/tests for past_due on payment.failed and renumber settings migration to 0056 to avoid colliding with 0045_native_clients. Co-authored-by: Jakub Doboš <kubo6472@users.noreply.github.com>
1 parent f067a1e commit 518987a

5 files changed

Lines changed: 295 additions & 53 deletions

File tree

packages/api/migrations/0045_gopay_payment_settings.sql renamed to packages/api/migrations/0056_gopay_payment_settings.sql

File renamed without changes.

packages/api/src/paymentProcessor.ts

Lines changed: 107 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,69 @@ function normalizePlanType(planType: string): PlanType {
206206
return 'monthly';
207207
}
208208

209+
/**
210+
* Resolve the paying user for a Comgate webhook when no subscription row exists yet
211+
* (first checkout). Relies on `payment_checkout_sessions` from migration
212+
* `0010_gocardless_payments.sql` — written at checkout creation with
213+
* `checkout_token = refId` and `provider_checkout_id = transId`.
214+
*/
215+
export async function resolveComgateCheckoutIdentity(
216+
db: {
217+
prepare: (sql: string) => {
218+
bind: (...args: unknown[]) => {
219+
first: () => Promise<{ id?: unknown; user_id?: unknown; plan_type?: unknown } | null>;
220+
};
221+
};
222+
},
223+
opts: { subscriptionId: string; purchaseId: string },
224+
): Promise<{
225+
userId: string;
226+
planType: PlanType;
227+
pendingSessionId: string | null;
228+
fromPendingSession: boolean;
229+
} | null> {
230+
const subscriptionId = String(opts.subscriptionId ?? '').trim();
231+
const purchaseId = String(opts.purchaseId ?? '').trim();
232+
if (!subscriptionId && !purchaseId) return null;
233+
234+
const existing = await db
235+
.prepare(
236+
`SELECT user_id, plan_type FROM subscriptions
237+
WHERE provider = 'comgate' AND (provider_subscription_id = ? OR purchase_id = ?)
238+
LIMIT 1`,
239+
)
240+
.bind(subscriptionId, purchaseId || subscriptionId)
241+
.first();
242+
243+
if (existing?.user_id) {
244+
return {
245+
userId: String(existing.user_id).trim(),
246+
planType: normalizePlanType(String(existing.plan_type ?? 'monthly')),
247+
pendingSessionId: null,
248+
fromPendingSession: false,
249+
};
250+
}
251+
252+
const pending = await db
253+
.prepare(
254+
`SELECT id, user_id, plan_type FROM payment_checkout_sessions
255+
WHERE provider = 'comgate' AND status = 'pending'
256+
AND (checkout_token = ? OR provider_checkout_id = ?)
257+
LIMIT 1`,
258+
)
259+
.bind(purchaseId || subscriptionId, subscriptionId || purchaseId)
260+
.first();
261+
262+
if (!pending?.user_id) return null;
263+
264+
return {
265+
userId: String(pending.user_id).trim(),
266+
planType: normalizePlanType(String(pending.plan_type ?? 'monthly')),
267+
pendingSessionId: String(pending.id ?? '').trim() || null,
268+
fromPendingSession: true,
269+
};
270+
}
271+
209272
async function upsertSubscriptionRow(
210273
db: any,
211274
params: {
@@ -1204,19 +1267,30 @@ export async function handleCheckout(request: any, env: any, corsHeaders: any) {
12041267
}
12051268
if (session.checkoutUrl) {
12061269
if (apiProvider === 'comgate') {
1270+
// Comgate cannot carry free-form metadata on the payment object. Persist a
1271+
// pending payment_checkout_sessions row (table from migration 0010) so the
1272+
// webhook can resolve userId/planType on first purchase.
12071273
const refId = String(session.metadata?.refId ?? '').trim();
12081274
const orderId = String(session.orderId ?? '').trim();
1209-
if (refId && orderId) {
1210-
await db
1211-
.prepare(
1212-
`INSERT INTO payment_checkout_sessions (
1213-
id, user_id, provider, plan_type, checkout_token, provider_checkout_id, status,
1214-
created_at, updated_at
1215-
) VALUES (?, ?, 'comgate', ?, ?, ?, 'pending', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
1216-
)
1217-
.bind(crypto.randomUUID(), user.sub, planType, refId, orderId)
1218-
.run();
1275+
if (!refId || !orderId) {
1276+
return jsonResponse(
1277+
{
1278+
error: 'Failed to create checkout session',
1279+
code: 'checkout_session_persist_failed',
1280+
},
1281+
502,
1282+
corsHeaders,
1283+
);
12191284
}
1285+
await db
1286+
.prepare(
1287+
`INSERT INTO payment_checkout_sessions (
1288+
id, user_id, provider, plan_type, checkout_token, provider_checkout_id, status,
1289+
created_at, updated_at
1290+
) VALUES (?, ?, 'comgate', ?, ?, ?, 'pending', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
1291+
)
1292+
.bind(crypto.randomUUID(), user.sub, planType, refId, orderId)
1293+
.run();
12201294
}
12211295
return jsonResponse(
12221296
{
@@ -1616,11 +1690,6 @@ export async function handleGoPayWebhook(request: any, env: any, corsHeaders: an
16161690
}
16171691
}
16181692

1619-
/**
1620-
* POST /api/payments/webhook/comgate — NO auth (Comgate calls this)
1621-
* Notifications include `secret` for verification; status is re-verified via API.
1622-
* Must reply `code=0&message=OK`.
1623-
*/
16241693
/**
16251694
* POST /api/payments/webhook/comgate — NO auth (Comgate calls this)
16261695
* Notifications include `secret` for verification; status is re-verified via API.
@@ -1669,47 +1738,23 @@ export async function handleComgateWebhook(request: any, env: any, corsHeaders:
16691738
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...corsHeaders },
16701739
});
16711740
}
1672-
const existing = await db
1673-
.prepare(
1674-
`SELECT user_id, plan_type FROM subscriptions
1675-
WHERE provider = 'comgate' AND (provider_subscription_id = ? OR purchase_id = ?)
1676-
LIMIT 1`,
1677-
)
1678-
.bind(subscriptionId, purchaseId || subscriptionId)
1679-
.first();
1680-
1681-
let userId = String(existing?.user_id ?? '').trim();
1682-
let planType = normalizePlanType(String(existing?.plan_type ?? 'monthly'));
1683-
let pendingSessionId: string | null = null;
16841741

1685-
if (!userId) {
1686-
const pending = await db
1687-
.prepare(
1688-
`SELECT id, user_id, plan_type FROM payment_checkout_sessions
1689-
WHERE provider = 'comgate' AND status = 'pending'
1690-
AND (checkout_token = ? OR provider_checkout_id = ?)
1691-
LIMIT 1`,
1692-
)
1693-
.bind(purchaseId || subscriptionId, subscriptionId)
1694-
.first();
1695-
if (pending?.user_id) {
1696-
userId = String(pending.user_id).trim();
1697-
planType = normalizePlanType(String(pending.plan_type ?? planType));
1698-
pendingSessionId = String(pending.id ?? '').trim() || null;
1699-
}
1700-
}
1742+
const identity = await resolveComgateCheckoutIdentity(db, {
1743+
subscriptionId,
1744+
purchaseId,
1745+
});
17011746

1702-
if (userId) {
1747+
if (identity?.userId) {
17031748
await upsertSubscriptionRow(db, {
1704-
userId,
1705-
planType,
1749+
userId: identity.userId,
1750+
planType: identity.planType,
17061751
status: 'active',
17071752
provider: 'comgate',
17081753
providerSubscriptionId: subscriptionId,
1709-
providerCustomerId: userId,
1710-
currentPeriodEnd: periodEndIsoForPlan(planType),
1754+
providerCustomerId: identity.userId,
1755+
currentPeriodEnd: periodEndIsoForPlan(identity.planType),
17111756
});
1712-
if (pendingSessionId) {
1757+
if (identity.fromPendingSession && identity.pendingSessionId) {
17131758
await db
17141759
.prepare(
17151760
`UPDATE payment_checkout_sessions
@@ -1719,17 +1764,27 @@ export async function handleComgateWebhook(request: any, env: any, corsHeaders:
17191764
updated_at = CURRENT_TIMESTAMP
17201765
WHERE id = ? AND status = 'pending'`,
17211766
)
1722-
.bind(subscriptionId, pendingSessionId)
1767+
.bind(subscriptionId, identity.pendingSessionId)
17231768
.run();
17241769
}
17251770
try {
1726-
await syncSubscriptionNewsletter(db, userId, 'active', env);
1771+
await syncSubscriptionNewsletter(db, identity.userId, 'active', env);
17271772
} catch (brevoErr) {
1728-
console.error('[comgate webhook] newsletter sync failed', { userId, err: brevoErr });
1773+
console.error('[comgate webhook] newsletter sync failed', {
1774+
userId: identity.userId,
1775+
err: brevoErr,
1776+
});
17291777
}
1778+
} else {
1779+
console.warn('[comgate webhook] checkout completed without resolvable user', {
1780+
subscriptionId,
1781+
purchaseId,
1782+
});
17301783
}
17311784
}
17321785

1786+
// Match GoPay / Stripe: failed renewals enter a grace period (past_due), not
1787+
// immediate cancellation.
17331788
if (event.type === 'payment.failed') {
17341789
if (subscriptionId) {
17351790
const existing = await db
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import assert from 'node:assert/strict';
2+
import { describe, it } from 'node:test';
3+
import { resolveComgateCheckoutIdentity } from '../src/paymentProcessor.js';
4+
5+
type Row = Record<string, unknown>;
6+
7+
class FakeDb {
8+
subscriptions: Row[];
9+
sessions: Row[];
10+
lastSql = '';
11+
lastArgs: unknown[] = [];
12+
13+
constructor(opts?: { subscriptions?: Row[]; sessions?: Row[] }) {
14+
this.subscriptions = opts?.subscriptions ?? [];
15+
this.sessions = opts?.sessions ?? [];
16+
}
17+
18+
prepare(sql: string) {
19+
const db = this;
20+
return {
21+
bind(...args: unknown[]) {
22+
db.lastSql = sql;
23+
db.lastArgs = args;
24+
return this;
25+
},
26+
async first(): Promise<Row | null> {
27+
if (sql.includes('FROM subscriptions') && sql.includes("provider = 'comgate'")) {
28+
const [subscriptionId, purchaseId] = db.lastArgs as [string, string];
29+
return (
30+
db.subscriptions.find(
31+
(row) =>
32+
row.provider_subscription_id === subscriptionId ||
33+
row.purchase_id === purchaseId ||
34+
row.purchase_id === subscriptionId,
35+
) ?? null
36+
);
37+
}
38+
if (sql.includes('FROM payment_checkout_sessions')) {
39+
const [tokenOrPurchase, checkoutId] = db.lastArgs as [string, string];
40+
return (
41+
db.sessions.find(
42+
(row) =>
43+
row.provider === 'comgate' &&
44+
row.status === 'pending' &&
45+
(row.checkout_token === tokenOrPurchase ||
46+
row.provider_checkout_id === checkoutId ||
47+
row.provider_checkout_id === tokenOrPurchase),
48+
) ?? null
49+
);
50+
}
51+
return null;
52+
},
53+
};
54+
}
55+
}
56+
57+
describe('resolveComgateCheckoutIdentity', () => {
58+
it('returns null when neither subscription nor pending session exists (first-checkout bug regression)', async () => {
59+
const db = new FakeDb();
60+
const result = await resolveComgateCheckoutIdentity(db, {
61+
subscriptionId: 'AB12-CD34-EF56',
62+
purchaseId: 'vmp-user1-1',
63+
});
64+
assert.equal(result, null);
65+
});
66+
67+
it('resolves first-time subscriber from pending payment_checkout_sessions by refId', async () => {
68+
const db = new FakeDb({
69+
sessions: [
70+
{
71+
id: 'sess-1',
72+
provider: 'comgate',
73+
status: 'pending',
74+
checkout_token: 'vmp-user1-1',
75+
provider_checkout_id: 'AB12-CD34-EF56',
76+
user_id: 'user-1',
77+
plan_type: 'yearly',
78+
},
79+
],
80+
});
81+
const result = await resolveComgateCheckoutIdentity(db, {
82+
subscriptionId: 'AB12-CD34-EF56',
83+
purchaseId: 'vmp-user1-1',
84+
});
85+
assert.deepEqual(result, {
86+
userId: 'user-1',
87+
planType: 'yearly',
88+
pendingSessionId: 'sess-1',
89+
fromPendingSession: true,
90+
});
91+
});
92+
93+
it('resolves first-time subscriber from pending session by transId when refId missing', async () => {
94+
const db = new FakeDb({
95+
sessions: [
96+
{
97+
id: 'sess-2',
98+
provider: 'comgate',
99+
status: 'pending',
100+
checkout_token: 'vmp-user2-9',
101+
provider_checkout_id: 'ZZ99-YY88-XX77',
102+
user_id: 'user-2',
103+
plan_type: 'monthly',
104+
},
105+
],
106+
});
107+
const result = await resolveComgateCheckoutIdentity(db, {
108+
subscriptionId: 'ZZ99-YY88-XX77',
109+
purchaseId: '',
110+
});
111+
assert.deepEqual(result, {
112+
userId: 'user-2',
113+
planType: 'monthly',
114+
pendingSessionId: 'sess-2',
115+
fromPendingSession: true,
116+
});
117+
});
118+
119+
it('prefers existing subscription row over pending session on renewals', async () => {
120+
const db = new FakeDb({
121+
subscriptions: [
122+
{
123+
user_id: 'user-renew',
124+
plan_type: 'club',
125+
provider_subscription_id: 'AB12-CD34-EF56',
126+
purchase_id: 'vmp-user1-1',
127+
},
128+
],
129+
sessions: [
130+
{
131+
id: 'sess-stale',
132+
provider: 'comgate',
133+
status: 'pending',
134+
checkout_token: 'vmp-user1-1',
135+
provider_checkout_id: 'AB12-CD34-EF56',
136+
user_id: 'should-not-win',
137+
plan_type: 'monthly',
138+
},
139+
],
140+
});
141+
const result = await resolveComgateCheckoutIdentity(db, {
142+
subscriptionId: 'AB12-CD34-EF56',
143+
purchaseId: 'vmp-user1-1',
144+
});
145+
assert.deepEqual(result, {
146+
userId: 'user-renew',
147+
planType: 'club',
148+
pendingSessionId: null,
149+
fromPendingSession: false,
150+
});
151+
});
152+
153+
it('ignores non-pending checkout sessions', async () => {
154+
const db = new FakeDb({
155+
sessions: [
156+
{
157+
id: 'sess-done',
158+
provider: 'comgate',
159+
status: 'completed',
160+
checkout_token: 'vmp-user1-1',
161+
provider_checkout_id: 'AB12-CD34-EF56',
162+
user_id: 'user-1',
163+
plan_type: 'monthly',
164+
},
165+
],
166+
});
167+
const result = await resolveComgateCheckoutIdentity(db, {
168+
subscriptionId: 'AB12-CD34-EF56',
169+
purchaseId: 'vmp-user1-1',
170+
});
171+
assert.equal(result, null);
172+
});
173+
});

packages/payments/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ This draft therefore never promises native Apple/Google Pay; checkout always red
6868

6969
## Comgate draft behaviour
7070

71-
1. **Checkout**`POST /api/payments/payment` creates a Comgate payment with `initRecurring=true` and returns a `redirect` URL. A pending row in `payment_checkout_sessions` (keyed by `refId` / `transId`) stores the user and plan until the webhook fires.
71+
1. **Checkout**`POST /api/payments/payment` creates a Comgate payment with `initRecurring=true` and returns a `redirect` URL. A pending row in `payment_checkout_sessions` (table from migration `0010_gocardless_payments.sql`; keyed by `refId` / `transId`) stores the user and plan until the webhook fires. Checkout fails closed if that row cannot be written.
7272
2. **Webhook** — Comgate sends **POST** callbacks with a `secret` field. The Worker verifies the secret, re-fetches status via `/v1.0/status`, and resolves the paying user from the pending checkout session (first purchase) or an existing subscription row (renewals).
7373
3. **Cancel**`POST /v1.0/cancel` on the stored `provider_subscription_id` (Comgate `transId`).
7474
4. **Failed renewal** — maps to `past_due` (same grace-period policy as GoPay), not immediate cancellation.

0 commit comments

Comments
 (0)