Skip to content

Commit f067a1e

Browse files
cursoragentkubo6472
andcommitted
fix(payments): Comgate first checkout activation and failure policy
Persist pending payment_checkout_sessions at Comgate checkout creation so webhooks can resolve userId/planType on first purchase. Align payment.failed to past_due (matching GoPay). Document Comgate secrets in AGENTS.md and update payments README to reflect the real provider. Co-authored-by: Jakub Doboš <kubo6472@users.noreply.github.com>
1 parent f553907 commit f067a1e

3 files changed

Lines changed: 79 additions & 7 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,9 @@ TOTP_ENCRYPTION_KEY — AES-256-GCM encryption key for TOTP secrets
304304
VAPID_PRIVATE_KEY — generated with web-push generate-vapid-keys
305305
GOPAY_CLIENT_ID / GOPAY_CLIENT_SECRET / GOPAY_GOID — GoPay OAuth + merchant goId (draft; see `packages/payments/README.md`)
306306
GOPAY_API_BASE — optional; default sandbox `https://gw.sandbox.gopay.com/api` (prod `https://gate.gopay.cz/api`)
307+
COMGATE_MERCHANT / COMGATE_SECRET — Comgate merchant credentials (draft; see `packages/payments/README.md`)
308+
COMGATE_API_BASE — optional; default `https://payments.comgate.cz`
309+
COMGATE_COUNTRY — optional; default `CZ` (Comgate hosted gateway country)
307310
RSS_SECRET — 32+ random chars used only to sign/tokenize personal account RSS URLs (`/api/feed/:userId/:token` and `/api/account/rss`); not required for the public feed endpoint (`/api/feed/public`)
308311
VMP_API_PIPELINE_SECRET — shared with media-pipeline for `POST /api/admin/videos/:id/pipeline-status` HLS availability callbacks
309312
REPLICATION_TARGET_URL — full URL to Deno ingest (`/api/internal/replication/ingest` on api-node)

packages/api/src/paymentProcessor.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1203,6 +1203,21 @@ export async function handleCheckout(request: any, env: any, corsHeaders: any) {
12031203
);
12041204
}
12051205
if (session.checkoutUrl) {
1206+
if (apiProvider === 'comgate') {
1207+
const refId = String(session.metadata?.refId ?? '').trim();
1208+
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();
1219+
}
1220+
}
12061221
return jsonResponse(
12071222
{
12081223
checkoutUrl: session.checkoutUrl,
@@ -1654,7 +1669,6 @@ export async function handleComgateWebhook(request: any, env: any, corsHeaders:
16541669
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...corsHeaders },
16551670
});
16561671
}
1657-
// Look for existing subscription by transId or refId
16581672
const existing = await db
16591673
.prepare(
16601674
`SELECT user_id, plan_type FROM subscriptions
@@ -1663,8 +1677,28 @@ export async function handleComgateWebhook(request: any, env: any, corsHeaders:
16631677
)
16641678
.bind(subscriptionId, purchaseId || subscriptionId)
16651679
.first();
1666-
const userId = String(existing?.user_id ?? '').trim();
1667-
const planType = normalizePlanType(String(existing?.plan_type ?? 'monthly'));
1680+
1681+
let userId = String(existing?.user_id ?? '').trim();
1682+
let planType = normalizePlanType(String(existing?.plan_type ?? 'monthly'));
1683+
let pendingSessionId: string | null = null;
1684+
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+
}
1701+
16681702
if (userId) {
16691703
await upsertSubscriptionRow(db, {
16701704
userId,
@@ -1675,6 +1709,19 @@ export async function handleComgateWebhook(request: any, env: any, corsHeaders:
16751709
providerCustomerId: userId,
16761710
currentPeriodEnd: periodEndIsoForPlan(planType),
16771711
});
1712+
if (pendingSessionId) {
1713+
await db
1714+
.prepare(
1715+
`UPDATE payment_checkout_sessions
1716+
SET status = 'completed',
1717+
provider_subscription_id = ?,
1718+
completed_at = CURRENT_TIMESTAMP,
1719+
updated_at = CURRENT_TIMESTAMP
1720+
WHERE id = ? AND status = 'pending'`,
1721+
)
1722+
.bind(subscriptionId, pendingSessionId)
1723+
.run();
1724+
}
16781725
try {
16791726
await syncSubscriptionNewsletter(db, userId, 'active', env);
16801727
} catch (brevoErr) {
@@ -1695,7 +1742,7 @@ export async function handleComgateWebhook(request: any, env: any, corsHeaders:
16951742
await db
16961743
.prepare(
16971744
`UPDATE subscriptions
1698-
SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP
1745+
SET status = 'past_due', updated_at = CURRENT_TIMESTAMP
16991746
WHERE provider = 'comgate' AND provider_subscription_id = ?`,
17001747
)
17011748
.bind(subscriptionId)
@@ -1710,7 +1757,7 @@ export async function handleComgateWebhook(request: any, env: any, corsHeaders:
17101757
});
17111758
}
17121759
try {
1713-
await revokeOfflineLicensesForUser(db, existing.user_id, 'subscription_cancelled');
1760+
await revokeOfflineLicensesForUser(db, existing.user_id, 'subscription_past_due');
17141761
} catch (offlineErr) {
17151762
console.error('[comgate webhook] offline revoke failed', {
17161763
userId: existing.user_id,

packages/payments/README.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,33 @@ From [GoPay docs](https://doc.gopay.cz/#android-a-ios) (also tracked in [#442](h
6666

6767
This draft therefore never promises native Apple/Google Pay; checkout always redirects to `gw_url`.
6868

69+
## Comgate draft behaviour
70+
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.
72+
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).
73+
3. **Cancel**`POST /v1.0/cancel` on the stored `provider_subscription_id` (Comgate `transId`).
74+
4. **Failed renewal** — maps to `past_due` (same grace-period policy as GoPay), not immediate cancellation.
75+
76+
### Comgate admin_settings (no hardcoded prices)
77+
78+
| Key | Purpose |
79+
|---|---|
80+
| `comgate_monthly_price` / `comgate_yearly_price` / `comgate_club_price` | Plan amounts in **major** units (e.g. `199` = 199 CZK) |
81+
| `comgate_currency` | ISO currency, default `CZK` |
82+
83+
Worker secrets / vars:
84+
85+
| Name | Purpose |
86+
|---|---|
87+
| `COMGATE_MERCHANT` / `COMGATE_SECRET` | Merchant credentials |
88+
| `COMGATE_API_BASE` | Optional; default `https://payments.comgate.cz` |
89+
| `COMGATE_COUNTRY` | Optional; default `CZ` |
90+
| `API_URL` | Used to build webhook URL → `{API_URL}/api/payments/webhook/comgate` |
91+
6992
## Adding a provider
7093

7194
1. Add `src/providers/<id>/index.ts` exporting `createXProvider(config): PaymentProvider`.
7295
2. Register in `src/registry.ts` `PROVIDER_FACTORIES`.
7396
3. Wire config in the API composition root (`packages/api/src/paymentProviders.ts`).
7497
4. Add webhook route `/api/payments/webhook/<id>` or dispatch by path.
75-
76-
Comgate remains a registered stub — enable in settings only after a real implementation lands.
98+
5. For redirect providers without embeddable user metadata (Comgate), persist a pending `payment_checkout_sessions` row at checkout creation so webhooks can resolve the paying user.

0 commit comments

Comments
 (0)