Skip to content

Commit 436759a

Browse files
cursoragentkubo6472
andcommitted
fix(payments): address remaining GoPay/Comgate review findings
Retry Comgate webhooks on unresolved users and processing errors, keep offline licenses through past_due, implement merchant-driven Comgate renewals via /v1.0/recurring plus Worker cron, reject GoPay club recurring checkout, localize checkout button copy, and source admin configured flags from payment settings. Co-authored-by: Jakub Doboš <kubo6472@users.noreply.github.com>
1 parent c0f286d commit 436759a

15 files changed

Lines changed: 374 additions & 108 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- Store each Comgate renewal transId separately from the original checkout
2+
-- transId kept on provider_subscription_id (initRecurringId).
3+
ALTER TABLE subscriptions ADD COLUMN last_provider_payment_id TEXT;

packages/api/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ import {
144144
handlePortal,
145145
handleSessionStatus,
146146
handleWebhook,
147+
runComgateRenewalJobs,
147148
} from './payments.js';
148149
import { handleVideoPipelineStatus } from './pipelineStatus.js';
149150
import {
@@ -1187,6 +1188,11 @@ const workerHandler = {
11871188
const runReplication = cron === '*/15 * * * *';
11881189

11891190
if (!runReplication) {
1191+
try {
1192+
await runComgateRenewalJobs(env);
1193+
} catch (err) {
1194+
console.error('Comgate renewal sweep failed:', err);
1195+
}
11901196
try {
11911197
await runScheduledPublishJobs(env);
11921198
await syncScheduledPublishHint(env);

packages/api/src/paymentProcessor.ts

Lines changed: 153 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1492,10 +1492,6 @@ export async function handleWebhook(
14921492
}
14931493
}
14941494

1495-
/**
1496-
* GET /api/account/subscription — protected
1497-
* Returns the most recent subscription row for the authenticated user.
1498-
*/
14991495
/**
15001496
* GET /api/payments/webhook/gopay?id=&parent_id= — NO auth (GoPay calls this)
15011497
* Notifications are unsigned; we re-fetch payment status with merchant credentials.
@@ -1621,15 +1617,6 @@ export async function handleGoPayWebhook(request: any, env: any, corsHeaders: an
16211617
err: brevoErr,
16221618
});
16231619
}
1624-
try {
1625-
await revokeOfflineLicensesForUser(db, existing.user_id, 'subscription_past_due');
1626-
} catch (offlineErr) {
1627-
console.error('[gopay webhook] offline revoke failed', {
1628-
userId: existing.user_id,
1629-
err: offlineErr,
1630-
});
1631-
throw offlineErr;
1632-
}
16331620
}
16341621
}
16351622
return jsonResponse({ ok: true }, 200, corsHeaders);
@@ -1682,10 +1669,25 @@ export async function handleGoPayWebhook(request: any, env: any, corsHeaders: an
16821669
}
16831670
}
16841671

1672+
function comgateNotifyOk(corsHeaders: any) {
1673+
return new Response('code=0&message=OK', {
1674+
status: 200,
1675+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...corsHeaders },
1676+
});
1677+
}
1678+
1679+
function comgateNotifyRetry(corsHeaders: any, message = 'processing error') {
1680+
return new Response(`code=1500&message=${encodeURIComponent(message)}`, {
1681+
status: 500,
1682+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...corsHeaders },
1683+
});
1684+
}
1685+
16851686
/**
16861687
* POST /api/payments/webhook/comgate — NO auth (Comgate calls this)
16871688
* Notifications include `secret` for verification; status is re-verified via API.
1688-
* Must reply `code=0&message=OK`.
1689+
* Reply `code=0&message=OK` only for successfully processed or intentionally ignored
1690+
* notifications so Comgate retries unresolved identities and internal errors.
16891691
*/
16901692
export async function handleComgateWebhook(request: any, env: any, corsHeaders: any) {
16911693
const rawBody = await request.text();
@@ -1694,19 +1696,13 @@ export async function handleComgateWebhook(request: any, env: any, corsHeaders:
16941696
if (!comgateProvider) {
16951697
const config = buildPaymentsConfig(env);
16961698
if (!config.comgate) {
1697-
return new Response('code=0&message=OK', {
1698-
status: 200,
1699-
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...corsHeaders },
1700-
});
1699+
return comgateNotifyOk(corsHeaders);
17011700
}
17021701
const forced = createEnabledProviders(['comgate'], config);
17031702
comgateProvider = forced.get('comgate');
17041703
}
17051704
if (!comgateProvider || !comgateProvider.isConfigured()) {
1706-
return new Response('code=0&message=OK', {
1707-
status: 200,
1708-
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...corsHeaders },
1709-
});
1705+
return comgateNotifyOk(corsHeaders);
17101706
}
17111707

17121708
const valid = await comgateProvider.verifyWebhookSignature(rawBody, '');
@@ -1722,61 +1718,70 @@ export async function handleComgateWebhook(request: any, env: any, corsHeaders:
17221718
const db = getDb(env);
17231719
const subscriptionId = String(event.subscriptionId ?? '').trim();
17241720
const purchaseId = String(event.purchaseId ?? '').trim();
1721+
const renewalTransId = String(event.providerOrderId ?? '').trim();
17251722

17261723
if (event.type === 'checkout.completed' || event.type === 'invoice.paid') {
17271724
if (!subscriptionId) {
1728-
return new Response('code=0&message=OK', {
1729-
status: 200,
1730-
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...corsHeaders },
1731-
});
1725+
return comgateNotifyOk(corsHeaders);
17321726
}
17331727

17341728
const identity = await resolveComgateCheckoutIdentity(db, {
17351729
subscriptionId,
17361730
purchaseId,
17371731
});
17381732

1739-
if (identity?.userId) {
1740-
await upsertSubscriptionRow(db, {
1741-
userId: identity.userId,
1742-
planType: identity.planType,
1743-
status: 'active',
1744-
provider: 'comgate',
1745-
providerSubscriptionId: subscriptionId,
1746-
providerCustomerId: identity.userId,
1747-
currentPeriodEnd: periodEndIsoForPlan(identity.planType),
1748-
});
1749-
if (identity.fromPendingSession && identity.pendingSessionId) {
1750-
await db
1751-
.prepare(
1752-
`UPDATE payment_checkout_sessions
1753-
SET status = 'completed',
1754-
provider_subscription_id = ?,
1755-
completed_at = CURRENT_TIMESTAMP,
1756-
updated_at = CURRENT_TIMESTAMP
1757-
WHERE id = ? AND status = 'pending'`,
1758-
)
1759-
.bind(subscriptionId, identity.pendingSessionId)
1760-
.run();
1761-
}
1762-
try {
1763-
await syncSubscriptionNewsletter(db, identity.userId, 'active', env);
1764-
} catch (brevoErr) {
1765-
console.error('[comgate webhook] newsletter sync failed', {
1766-
userId: identity.userId,
1767-
err: brevoErr,
1768-
});
1769-
}
1770-
} else {
1733+
if (!identity?.userId) {
17711734
console.warn('[comgate webhook] checkout completed without resolvable user', {
17721735
subscriptionId,
17731736
purchaseId,
17741737
});
1738+
return comgateNotifyRetry(corsHeaders, 'unresolved user');
1739+
}
1740+
1741+
await upsertSubscriptionRow(db, {
1742+
userId: identity.userId,
1743+
planType: identity.planType,
1744+
status: 'active',
1745+
provider: 'comgate',
1746+
providerSubscriptionId: subscriptionId,
1747+
providerCustomerId: identity.userId,
1748+
currentPeriodEnd: periodEndIsoForPlan(identity.planType),
1749+
});
1750+
if (renewalTransId && renewalTransId !== subscriptionId) {
1751+
await db
1752+
.prepare(
1753+
`UPDATE subscriptions
1754+
SET last_provider_payment_id = ?, updated_at = CURRENT_TIMESTAMP
1755+
WHERE provider = 'comgate' AND provider_subscription_id = ?`,
1756+
)
1757+
.bind(renewalTransId, subscriptionId)
1758+
.run();
1759+
}
1760+
if (identity.fromPendingSession && identity.pendingSessionId) {
1761+
await db
1762+
.prepare(
1763+
`UPDATE payment_checkout_sessions
1764+
SET status = 'completed',
1765+
provider_subscription_id = ?,
1766+
completed_at = CURRENT_TIMESTAMP,
1767+
updated_at = CURRENT_TIMESTAMP
1768+
WHERE id = ? AND status = 'pending'`,
1769+
)
1770+
.bind(subscriptionId, identity.pendingSessionId)
1771+
.run();
1772+
}
1773+
try {
1774+
await syncSubscriptionNewsletter(db, identity.userId, 'active', env);
1775+
} catch (brevoErr) {
1776+
console.error('[comgate webhook] newsletter sync failed', {
1777+
userId: identity.userId,
1778+
err: brevoErr,
1779+
});
17751780
}
17761781
}
17771782

17781783
// Match GoPay / Stripe: failed renewals enter a grace period (past_due), not
1779-
// immediate cancellation.
1784+
// immediate cancellation. Offline licenses stay valid until actual cancellation.
17801785
if (event.type === 'payment.failed') {
17811786
if (subscriptionId) {
17821787
const existing = await db
@@ -1803,30 +1808,103 @@ export async function handleComgateWebhook(request: any, env: any, corsHeaders:
18031808
err: brevoErr,
18041809
});
18051810
}
1806-
try {
1807-
await revokeOfflineLicensesForUser(db, existing.user_id, 'subscription_past_due');
1808-
} catch (offlineErr) {
1809-
console.error('[comgate webhook] offline revoke failed', {
1810-
userId: existing.user_id,
1811-
err: offlineErr,
1812-
});
1813-
throw offlineErr;
1814-
}
18151811
}
18161812
}
18171813
}
18181814

1819-
return new Response('code=0&message=OK', {
1820-
status: 200,
1821-
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...corsHeaders },
1822-
});
1815+
return comgateNotifyOk(corsHeaders);
18231816
} catch (err) {
18241817
console.error('handleComgateWebhook error:', err);
1825-
return new Response('code=0&message=OK', {
1826-
status: 200,
1827-
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...corsHeaders },
1818+
return comgateNotifyRetry(corsHeaders);
1819+
}
1820+
}
1821+
1822+
/**
1823+
* Merchant-driven Comgate renewals: charge `/v1.0/recurring` with the original
1824+
* checkout transId (`provider_subscription_id`) and store each new transId in
1825+
* `last_provider_payment_id` without overwriting the initRecurringId.
1826+
*/
1827+
export async function renewDueComgateSubscriptions(
1828+
db: {
1829+
prepare: (sql: string) => {
1830+
bind: (...args: unknown[]) => {
1831+
all?: () => Promise<{ results?: Array<Record<string, unknown>> }>;
1832+
run?: () => Promise<unknown>;
1833+
first?: () => Promise<Record<string, unknown> | null>;
1834+
};
1835+
all?: () => Promise<{ results?: Array<Record<string, unknown>> }>;
1836+
};
1837+
},
1838+
provider: {
1839+
createSubscription: (input: {
1840+
userId: string;
1841+
planType: PlanType;
1842+
initRecurringId?: string;
1843+
customerId?: string;
1844+
email?: string;
1845+
}) => Promise<{ lastPaymentId?: string | null }>;
1846+
},
1847+
): Promise<{ attempted: number; renewed: number }> {
1848+
const due = await db
1849+
.prepare(
1850+
`SELECT s.id, s.user_id, s.plan_type, s.provider_subscription_id, u.email
1851+
FROM subscriptions s
1852+
LEFT JOIN users u ON u.id = s.user_id
1853+
WHERE s.provider = 'comgate'
1854+
AND s.status IN ('active', 'past_due')
1855+
AND IFNULL(s.cancel_at_period_end, 0) = 0
1856+
AND s.provider_subscription_id IS NOT NULL
1857+
AND datetime(s.current_period_end) <= datetime('now')
1858+
LIMIT 25`,
1859+
)
1860+
.all?.();
1861+
const rows = due?.results ?? [];
1862+
let renewed = 0;
1863+
for (const row of rows) {
1864+
const initRecurringId = String(row.provider_subscription_id ?? '').trim();
1865+
const userId = String(row.user_id ?? '').trim();
1866+
const email = String(row.email ?? '').trim();
1867+
if (!initRecurringId || !userId) continue;
1868+
const planType = normalizePlanType(String(row.plan_type ?? 'monthly'));
1869+
const created = await provider.createSubscription({
1870+
userId,
1871+
planType,
1872+
initRecurringId,
1873+
customerId: userId,
1874+
...(email ? { email } : {}),
18281875
});
1876+
const lastPaymentId = String(created.lastPaymentId ?? '').trim();
1877+
await db
1878+
.prepare(
1879+
`UPDATE subscriptions
1880+
SET last_provider_payment_id = COALESCE(?, last_provider_payment_id),
1881+
current_period_end = ?,
1882+
status = 'active',
1883+
updated_at = CURRENT_TIMESTAMP
1884+
WHERE id = ? AND provider = 'comgate'`,
1885+
)
1886+
.bind(lastPaymentId || null, periodEndIsoForPlan(planType), row.id)
1887+
.run?.();
1888+
renewed += 1;
1889+
}
1890+
return { attempted: rows.length, renewed };
1891+
}
1892+
1893+
export async function runComgateRenewalJobs(
1894+
env: any,
1895+
): Promise<{ attempted: number; renewed: number }> {
1896+
const db = getDb(env);
1897+
const { providers } = await getPaymentProviders(env);
1898+
let comgateProvider = providers.get('comgate');
1899+
if (!comgateProvider) {
1900+
const config = buildPaymentsConfig(env);
1901+
if (!config.comgate) return { attempted: 0, renewed: 0 };
1902+
comgateProvider = createEnabledProviders(['comgate'], config).get('comgate');
1903+
}
1904+
if (!comgateProvider || !comgateProvider.isConfigured()) {
1905+
return { attempted: 0, renewed: 0 };
18291906
}
1907+
return renewDueComgateSubscriptions(db, comgateProvider);
18301908
}
18311909

18321910
/**

packages/api/src/payments.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@ export {
1616
handleGoPayWebhook,
1717
handlePortal,
1818
handleSessionStatus,
19+
runComgateRenewalJobs,
1920
} from './paymentProcessor.js';
2021
export { handleWebhook, normalizeStripeStatus } from './stripe.js';

0 commit comments

Comments
 (0)