@@ -2,6 +2,7 @@ import { db } from '@sim/db'
22import { outboxEvent } from '@sim/db/schema'
33import { createLogger } from '@sim/logger'
44import { toError } from '@sim/utils/errors'
5+ import { toRecord } from '@sim/utils/object'
56import { and , eq , sql } from 'drizzle-orm'
67import { NextResponse } from 'next/server'
78import { adminV1RequeueOutboxEventContract } from '@/lib/api/contracts/v1/admin'
@@ -11,6 +12,10 @@ import {
1112 ENTERPRISE_METADATA_SYNC_EVENT_TYPE ,
1213 ENTERPRISE_PROVISION_EVENT_TYPE ,
1314} from '@/lib/billing/enterprise-outbox-events'
15+ import {
16+ isSubscriptionSyncEventType ,
17+ recommitSubscriptionSync ,
18+ } from '@/lib/billing/webhooks/subscription-sync'
1419import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1520import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
1621
@@ -28,7 +33,9 @@ const invalidOutboxEventResponse = (message: string) =>
2833 * will retry it. Resets `attempts`, `lastError`, and `availableAt` so
2934 * the next poll picks it up. Only dead-lettered events can be
3035 * requeued — completed/pending/processing rows are rejected to avoid
31- * operator errors.
36+ * operator errors. A Stripe subscription sync is re-committed with the
37+ * subscription's current value, so the retry never revives the value it
38+ * failed with.
3239 */
3340export const POST = withRouteHandler (
3441 withAdminAuthParams < { id : string } > ( async ( request , context ) => {
@@ -61,23 +68,34 @@ export const POST = withRouteHandler(
6168 const deliveryRevision = metadataIntent ?. success
6269 ? metadataIntent . data . deliveryRevision + 1
6370 : null
64- const result = await db
65- . update ( outboxEvent )
66- . set ( {
67- status : 'pending' ,
68- attempts : 0 ,
69- lastError : null ,
70- availableAt : new Date ( ) ,
71- lockedAt : null ,
72- processedAt : null ,
73- ...( deliveryRevision === null
74- ? { }
75- : {
76- payload : sql `(${ outboxEvent . payload } ::jsonb || ${ JSON . stringify ( { deliveryRevision } ) } ::jsonb)::json` ,
77- } ) ,
78- } )
79- . where ( and ( eq ( outboxEvent . id , id ) , eq ( outboxEvent . status , 'dead_letter' ) ) )
80- . returning ( { id : outboxEvent . id , eventType : outboxEvent . eventType } )
71+ const result = await db . transaction ( async ( tx ) => {
72+ const requeued = await tx
73+ . update ( outboxEvent )
74+ . set ( {
75+ status : 'pending' ,
76+ attempts : 0 ,
77+ lastError : null ,
78+ availableAt : new Date ( ) ,
79+ lockedAt : null ,
80+ processedAt : null ,
81+ ...( deliveryRevision === null
82+ ? { }
83+ : {
84+ payload : sql `(${ outboxEvent . payload } ::jsonb || ${ JSON . stringify ( { deliveryRevision } ) } ::jsonb)::json` ,
85+ } ) ,
86+ } )
87+ . where ( and ( eq ( outboxEvent . id , id ) , eq ( outboxEvent . status , 'dead_letter' ) ) )
88+ . returning ( { id : outboxEvent . id , eventType : outboxEvent . eventType } )
89+ const subscriptionId = toRecord ( existing ?. payload ) . subscriptionId
90+ if (
91+ requeued . length > 0 &&
92+ isSubscriptionSyncEventType ( requeued [ 0 ] . eventType ) &&
93+ typeof subscriptionId === 'string'
94+ ) {
95+ await recommitSubscriptionSync ( tx , requeued [ 0 ] . eventType , subscriptionId )
96+ }
97+ return requeued
98+ } )
8199
82100 if ( result . length === 0 ) {
83101 return NextResponse . json (
0 commit comments