@@ -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 */
16901692export 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/**
0 commit comments