Skip to content

Commit 7dc251a

Browse files
authored
fix(billing): let operators resolve billing dead letters and stop re-paging old ones (#8869)
A billing Stripe sync that exhausted its retries stayed dead-lettered forever: requeue only sends it back through the same failure, retention never prunes a dead letter, and the hourly billing reconcile cron logged every one at ERROR on every run. - POST /api/v1/admin/outbox/[id]/resolve closes a dead-lettered billing Stripe event (dead_letter -> completed). last_error keeps who resolved it, why, and the last failure; the action is audited. Other domains count completed rows as delivered, so only billing Stripe events can be resolved. - The reconcile cron logs dead letters from the last two hourly runs at ERROR and older unresolved ones at WARN with the same identifiers; the dead-letter scan returns newest first so its cap never hides a new one. - The admin dashboard reports a resolved cancellation as resolved, not applied.
1 parent d645927 commit 7dc251a

11 files changed

Lines changed: 391 additions & 30 deletions

File tree

‎apps/sim/app/api/cron/reconcile-billing-seats/route.ts‎

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { createLogger } from '@sim/logger'
2+
import { toStringOrNull } from '@sim/utils/coerce'
23
import { toError } from '@sim/utils/errors'
4+
import { toRecord } from '@sim/utils/object'
35
import { type NextRequest, NextResponse } from 'next/server'
46
import { verifyCronAuth } from '@/lib/auth/internal'
57
import { reconcileTeamSeatDrift } from '@/lib/billing/organizations/seat-drift'
@@ -17,6 +19,22 @@ const BILLING_SYNC_EVENT_TYPES = [
1719
OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END,
1820
]
1921

22+
/**
23+
* Dead letters newer than two hourly runs log at ERROR, so one missed run cannot hide one; older
24+
* ones repeat at WARN until requeued or resolved through the admin outbox API.
25+
*/
26+
const NEW_DEAD_LETTER_WINDOW_MS = 2 * 60 * 60 * 1000
27+
28+
function describeDeadLetter(event: Awaited<ReturnType<typeof findDeadLetteredEvents>>[number]) {
29+
return {
30+
id: event.id,
31+
eventType: event.eventType,
32+
subscriptionId: toStringOrNull(toRecord(event.payload).subscriptionId),
33+
deadLetteredAt: event.processedAt?.toISOString() ?? null,
34+
lastError: event.lastError,
35+
}
36+
}
37+
2038
/**
2139
* Periodic billing-seat reconciliation. Self-heals Team organizations whose
2240
* stored seat count drifted from their member count, and reports any
@@ -38,18 +56,28 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
3856
const drift = await reconcileTeamSeatDrift()
3957

4058
const deadLettered = await findDeadLetteredEvents(BILLING_SYNC_EVENT_TYPES)
41-
if (deadLettered.length > 0) {
59+
const newSince = Date.now() - NEW_DEAD_LETTER_WINDOW_MS
60+
const isNew = (event: (typeof deadLettered)[number]) =>
61+
event.processedAt !== null && event.processedAt.getTime() >= newSince
62+
const newlyDeadLettered = deadLettered.filter(isNew)
63+
const previouslyReported = deadLettered.filter((event) => !isNew(event))
64+
if (newlyDeadLettered.length > 0) {
4265
logger.error(
4366
'Dead-lettered billing sync events require manual remediation — a billing state change (seat charge or cancellation) never reached Stripe',
4467
{
4568
requestId,
46-
count: deadLettered.length,
47-
events: deadLettered.map((event) => ({
48-
id: event.id,
49-
eventType: event.eventType,
50-
subscriptionId: (event.payload as { subscriptionId?: string } | null)?.subscriptionId,
51-
lastError: event.lastError,
52-
})),
69+
count: newlyDeadLettered.length,
70+
events: newlyDeadLettered.map(describeDeadLetter),
71+
}
72+
)
73+
}
74+
if (previouslyReported.length > 0) {
75+
logger.warn(
76+
'Previously reported billing sync dead letters are still unresolved — requeue or resolve them through the admin outbox API',
77+
{
78+
requestId,
79+
count: previouslyReported.length,
80+
events: previouslyReported.map(describeDeadLetter),
5381
}
5482
)
5583
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { createLogger } from '@sim/logger'
2+
import { toError } from '@sim/utils/errors'
3+
import { NextResponse } from 'next/server'
4+
import { resolveBillingSyncDeadLetter } from '@/lib/admin/billing-sync-resolution'
5+
import {
6+
type AdminV1ResolveOutboxEventResponse,
7+
adminV1ResolveOutboxEventContract,
8+
} from '@/lib/api/contracts/v1/admin'
9+
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
10+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11+
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
12+
13+
const logger = createLogger('AdminOutboxResolveAPI')
14+
15+
export const dynamic = 'force-dynamic'
16+
17+
const invalidOutboxEventResponse = (message: string) =>
18+
NextResponse.json({ success: false, error: message }, { status: 400 })
19+
20+
/**
21+
* POST /api/v1/admin/outbox/[id]/resolve
22+
*
23+
* Close a dead-lettered billing Stripe event an operator has remediated by hand or decided needs
24+
* nothing (see `resolveBillingSyncDeadLetter`). Use `/requeue` to retry one instead.
25+
*
26+
* Body: `{ "reason": string, "resolvedBy": string }`
27+
*/
28+
export const POST = withRouteHandler(
29+
withAdminAuthParams<{ id: string }>(async (request, context) => {
30+
const parsed = await parseRequest(adminV1ResolveOutboxEventContract, request, context, {
31+
validationErrorResponse: (error) =>
32+
invalidOutboxEventResponse(getValidationErrorMessage(error, 'Invalid resolve request')),
33+
invalidJsonResponse: () => invalidOutboxEventResponse('Request body must be valid JSON'),
34+
})
35+
if (!parsed.success) return parsed.response
36+
37+
const { id } = parsed.data.params
38+
39+
try {
40+
const resolved = await resolveBillingSyncDeadLetter(id, parsed.data.body, request)
41+
if (!resolved) {
42+
return NextResponse.json(
43+
{
44+
success: false,
45+
error:
46+
'Event not found, not in dead_letter status, or not a billing Stripe event. Only dead-lettered billing Stripe events can be resolved.',
47+
},
48+
{ status: 404 }
49+
)
50+
}
51+
52+
return NextResponse.json<AdminV1ResolveOutboxEventResponse>({ success: true, resolved })
53+
} catch (error) {
54+
logger.error('Failed to resolve outbox event', { eventId: id, error: toError(error).message })
55+
return NextResponse.json(
56+
{ success: false, error: 'Failed to resolve outbox event' },
57+
{ status: 500 }
58+
)
59+
}
60+
})
61+
)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { AuditAction, type AuditLogParams, AuditResourceType, recordAudit } from '@sim/audit'
2+
import type { AdminV1ResolveOutboxEventBody } from '@/lib/api/contracts/v1/admin'
3+
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-events'
4+
import { resolveDeadLetteredOutboxEvent } from '@/lib/core/outbox/service'
5+
6+
/**
7+
* Billing Stripe events only: their readers tell a resolved row from delivered work (the admin
8+
* dashboard reports a completed cancellation with a `lastError` as `resolved`). Other domains
9+
* count every `completed` row as delivered and retry through their own admin actions.
10+
*/
11+
const RESOLVABLE_EVENT_TYPES = Object.values(OUTBOX_EVENT_TYPES)
12+
13+
/**
14+
* Closes a dead-lettered billing Stripe event for the Admin API and audits who resolved it and
15+
* why. Null when the event does not exist, is not dead-lettered, or is not a billing Stripe event.
16+
*/
17+
export async function resolveBillingSyncDeadLetter(
18+
eventId: string,
19+
resolution: AdminV1ResolveOutboxEventBody,
20+
request: AuditLogParams['request']
21+
) {
22+
const resolved = await resolveDeadLetteredOutboxEvent(eventId, RESOLVABLE_EVENT_TYPES, resolution)
23+
if (!resolved) return null
24+
recordAudit({
25+
workspaceId: null,
26+
actorId: null,
27+
actorName: `Admin API (${resolution.resolvedBy})`,
28+
action: AuditAction.BILLING_SYNC_RESOLVED,
29+
resourceType: AuditResourceType.BILLING,
30+
resourceId: resolved.id,
31+
description: `Admin API resolved a dead-lettered ${resolved.eventType} event`,
32+
metadata: { eventType: resolved.eventType, ...resolution },
33+
request,
34+
})
35+
return resolved
36+
}

‎apps/sim/lib/admin/subscription-lifecycle.ts‎

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,24 @@ async function listRecentSubscriptionPayments(stripe: Stripe, stripeSubscription
177177
}
178178
}
179179

180+
/**
181+
* Where a dashboard cancellation event stands. A completed event with a `lastError` is a dead
182+
* letter an operator resolved without the cancellation reaching Stripe, so it reads as `resolved`
183+
* with that note, never as `applied`.
184+
*/
185+
function cancellationSyncOutcome(status: string, lastError: string | null) {
186+
if (status === 'completed') {
187+
return lastError
188+
? { status: 'resolved' as const, error: lastError }
189+
: { status: 'applied' as const, error: null }
190+
}
191+
if (status === 'dead_letter') return { status: 'failed' as const, error: lastError }
192+
return {
193+
status: status === 'processing' ? ('processing' as const) : ('pending' as const),
194+
error: null,
195+
}
196+
}
197+
180198
async function getDashboardCancellationSync(organizationId: string, subscriptionId: string) {
181199
const [row] = await db
182200
.select({
@@ -205,15 +223,7 @@ async function getDashboardCancellationSync(organizationId: string, subscription
205223
row.eventType === OUTBOX_EVENT_TYPES.STRIPE_CANCEL_SUBSCRIPTION_IMMEDIATELY
206224
? ('immediate' as const)
207225
: ('period_end' as const),
208-
status:
209-
row.status === 'completed'
210-
? ('applied' as const)
211-
: row.status === 'dead_letter'
212-
? ('failed' as const)
213-
: row.status === 'processing'
214-
? ('processing' as const)
215-
: ('pending' as const),
216-
error: row.status === 'dead_letter' ? row.error : null,
226+
...cancellationSyncOutcome(row.status, row.error),
217227
}
218228
}
219229

@@ -286,6 +296,7 @@ export async function requestDashboardSubscriptionCancellation({
286296
id: outboxEvent.id,
287297
eventType: outboxEvent.eventType,
288298
status: outboxEvent.status,
299+
lastError: outboxEvent.lastError,
289300
subscriptionId: sql<string>`${outboxEvent.payload} ->> 'subscriptionId'`,
290301
reason: sql<string | null>`${outboxEvent.payload} ->> 'reason'`,
291302
})
@@ -351,12 +362,8 @@ export async function requestDashboardSubscriptionCancellation({
351362
operationId,
352363
outboxEventId: existingOperation.id,
353364
subscriptionId: existingOperation.subscriptionId,
354-
status:
355-
existingOperation.status === 'completed'
356-
? ('applied' as const)
357-
: existingOperation.status === 'processing'
358-
? ('processing' as const)
359-
: ('pending' as const),
365+
status: cancellationSyncOutcome(existingOperation.status, existingOperation.lastError)
366+
.status,
360367
}
361368
}
362369

‎apps/sim/lib/api/contracts/v1/admin/dashboard.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -663,7 +663,7 @@ const adminDashboardBillingActionsSchema = z.object({
663663
.object({
664664
operationId: z.string().uuid(),
665665
timing: z.enum(['period_end', 'immediate']),
666-
status: z.enum(['pending', 'processing', 'applied', 'failed']),
666+
status: z.enum(['pending', 'processing', 'applied', 'resolved', 'failed']),
667667
error: z.string().nullable(),
668668
})
669669
.nullable(),
@@ -684,7 +684,7 @@ const adminDashboardBillingActionsSchema = z.object({
684684
const adminDashboardCancellationResultSchema = z.object({
685685
success: z.literal(true),
686686
operationId: z.string(),
687-
status: z.enum(['pending', 'processing', 'applied', 'failed']),
687+
status: z.enum(['pending', 'processing', 'applied', 'resolved', 'failed']),
688688
})
689689
const adminDashboardRefundResultSchema = z.object({
690690
success: z.literal(true),

‎apps/sim/lib/api/contracts/v1/admin/outbox.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z } from 'zod'
22
import {
3+
type ContractBody,
34
type ContractJsonResponse,
45
type ContractParams,
56
type ContractQuery,
@@ -62,6 +63,29 @@ const adminV1OutboxRequeueResultSchema = z.object({
6263
}),
6364
})
6465

66+
/** Bounded so the 500-character `last_error` keeps at least 100 characters of the last failure. */
67+
const adminV1ResolveOutboxEventBodySchema = z.object({
68+
reason: z
69+
.string({ error: 'reason is required' })
70+
.trim()
71+
.min(1, 'reason is required')
72+
.max(300, 'reason must be at most 300 characters'),
73+
resolvedBy: z
74+
.string({ error: 'resolvedBy is required' })
75+
.trim()
76+
.min(1, 'resolvedBy is required')
77+
.max(60, 'resolvedBy must be at most 60 characters'),
78+
})
79+
80+
const adminV1OutboxResolveResultSchema = z.object({
81+
success: z.literal(true),
82+
resolved: z.object({
83+
id: z.string(),
84+
eventType: z.string(),
85+
lastError: z.string().nullable(),
86+
}),
87+
})
88+
6589
export const adminV1ListOutboxContract = defineRouteContract({
6690
method: 'GET',
6791
path: '/api/v1/admin/outbox',
@@ -82,6 +106,17 @@ export const adminV1RequeueOutboxEventContract = defineRouteContract({
82106
},
83107
})
84108

109+
export const adminV1ResolveOutboxEventContract = defineRouteContract({
110+
method: 'POST',
111+
path: '/api/v1/admin/outbox/[id]/resolve',
112+
params: adminV1IdParamsSchema,
113+
body: adminV1ResolveOutboxEventBodySchema,
114+
response: {
115+
mode: 'json',
116+
schema: adminV1OutboxResolveResultSchema,
117+
},
118+
})
119+
85120
export type AdminV1ListOutboxQueryInput = ContractQueryInput<typeof adminV1ListOutboxContract>
86121
export type AdminV1ListOutboxQuery = ContractQuery<typeof adminV1ListOutboxContract>
87122
export type AdminV1RequeueOutboxEventParams = ContractParams<
@@ -91,3 +126,7 @@ export type AdminV1ListOutboxResponse = ContractJsonResponse<typeof adminV1ListO
91126
export type AdminV1RequeueOutboxEventResponse = ContractJsonResponse<
92127
typeof adminV1RequeueOutboxEventContract
93128
>
129+
export type AdminV1ResolveOutboxEventBody = ContractBody<typeof adminV1ResolveOutboxEventContract>
130+
export type AdminV1ResolveOutboxEventResponse = ContractJsonResponse<
131+
typeof adminV1ResolveOutboxEventContract
132+
>

0 commit comments

Comments
 (0)