diff --git a/apps/sim/app/api/cron/reconcile-billing-seats/route.ts b/apps/sim/app/api/cron/reconcile-billing-seats/route.ts index 64e1e957ee6..bba71634175 100644 --- a/apps/sim/app/api/cron/reconcile-billing-seats/route.ts +++ b/apps/sim/app/api/cron/reconcile-billing-seats/route.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' +import { toStringOrNull } from '@sim/utils/coerce' import { toError } from '@sim/utils/errors' +import { toRecord } from '@sim/utils/object' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' import { reconcileTeamSeatDrift } from '@/lib/billing/organizations/seat-drift' @@ -17,6 +19,22 @@ const BILLING_SYNC_EVENT_TYPES = [ OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, ] +/** + * Dead letters newer than two hourly runs log at ERROR, so one missed run cannot hide one; older + * ones repeat at WARN until requeued or resolved through the admin outbox API. + */ +const NEW_DEAD_LETTER_WINDOW_MS = 2 * 60 * 60 * 1000 + +function describeDeadLetter(event: Awaited>[number]) { + return { + id: event.id, + eventType: event.eventType, + subscriptionId: toStringOrNull(toRecord(event.payload).subscriptionId), + deadLetteredAt: event.processedAt?.toISOString() ?? null, + lastError: event.lastError, + } +} + /** * Periodic billing-seat reconciliation. Self-heals Team organizations whose * stored seat count drifted from their member count, and reports any @@ -38,18 +56,28 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const drift = await reconcileTeamSeatDrift() const deadLettered = await findDeadLetteredEvents(BILLING_SYNC_EVENT_TYPES) - if (deadLettered.length > 0) { + const newSince = Date.now() - NEW_DEAD_LETTER_WINDOW_MS + const isNew = (event: (typeof deadLettered)[number]) => + event.processedAt !== null && event.processedAt.getTime() >= newSince + const newlyDeadLettered = deadLettered.filter(isNew) + const previouslyReported = deadLettered.filter((event) => !isNew(event)) + if (newlyDeadLettered.length > 0) { logger.error( 'Dead-lettered billing sync events require manual remediation — a billing state change (seat charge or cancellation) never reached Stripe', { requestId, - count: deadLettered.length, - events: deadLettered.map((event) => ({ - id: event.id, - eventType: event.eventType, - subscriptionId: (event.payload as { subscriptionId?: string } | null)?.subscriptionId, - lastError: event.lastError, - })), + count: newlyDeadLettered.length, + events: newlyDeadLettered.map(describeDeadLetter), + } + ) + } + if (previouslyReported.length > 0) { + logger.warn( + 'Previously reported billing sync dead letters are still unresolved — requeue or resolve them through the admin outbox API', + { + requestId, + count: previouslyReported.length, + events: previouslyReported.map(describeDeadLetter), } ) } diff --git a/apps/sim/app/api/v1/admin/outbox/[id]/resolve/route.ts b/apps/sim/app/api/v1/admin/outbox/[id]/resolve/route.ts new file mode 100644 index 00000000000..fff8549713e --- /dev/null +++ b/apps/sim/app/api/v1/admin/outbox/[id]/resolve/route.ts @@ -0,0 +1,61 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { NextResponse } from 'next/server' +import { resolveBillingSyncDeadLetter } from '@/lib/admin/billing-sync-resolution' +import { + type AdminV1ResolveOutboxEventResponse, + adminV1ResolveOutboxEventContract, +} from '@/lib/api/contracts/v1/admin' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' + +const logger = createLogger('AdminOutboxResolveAPI') + +export const dynamic = 'force-dynamic' + +const invalidOutboxEventResponse = (message: string) => + NextResponse.json({ success: false, error: message }, { status: 400 }) + +/** + * POST /api/v1/admin/outbox/[id]/resolve + * + * Close a dead-lettered billing Stripe event an operator has remediated by hand or decided needs + * nothing (see `resolveBillingSyncDeadLetter`). Use `/requeue` to retry one instead. + * + * Body: `{ "reason": string, "resolvedBy": string }` + */ +export const POST = withRouteHandler( + withAdminAuthParams<{ id: string }>(async (request, context) => { + const parsed = await parseRequest(adminV1ResolveOutboxEventContract, request, context, { + validationErrorResponse: (error) => + invalidOutboxEventResponse(getValidationErrorMessage(error, 'Invalid resolve request')), + invalidJsonResponse: () => invalidOutboxEventResponse('Request body must be valid JSON'), + }) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + try { + const resolved = await resolveBillingSyncDeadLetter(id, parsed.data.body, request) + if (!resolved) { + return NextResponse.json( + { + success: false, + error: + 'Event not found, not in dead_letter status, or not a billing Stripe event. Only dead-lettered billing Stripe events can be resolved.', + }, + { status: 404 } + ) + } + + return NextResponse.json({ success: true, resolved }) + } catch (error) { + logger.error('Failed to resolve outbox event', { eventId: id, error: toError(error).message }) + return NextResponse.json( + { success: false, error: 'Failed to resolve outbox event' }, + { status: 500 } + ) + } + }) +) diff --git a/apps/sim/lib/admin/billing-sync-resolution.ts b/apps/sim/lib/admin/billing-sync-resolution.ts new file mode 100644 index 00000000000..131456c6f6b --- /dev/null +++ b/apps/sim/lib/admin/billing-sync-resolution.ts @@ -0,0 +1,36 @@ +import { AuditAction, type AuditLogParams, AuditResourceType, recordAudit } from '@sim/audit' +import type { AdminV1ResolveOutboxEventBody } from '@/lib/api/contracts/v1/admin' +import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-events' +import { resolveDeadLetteredOutboxEvent } from '@/lib/core/outbox/service' + +/** + * Billing Stripe events only: their readers tell a resolved row from delivered work (the admin + * dashboard reports a completed cancellation with a `lastError` as `resolved`). Other domains + * count every `completed` row as delivered and retry through their own admin actions. + */ +const RESOLVABLE_EVENT_TYPES = Object.values(OUTBOX_EVENT_TYPES) + +/** + * Closes a dead-lettered billing Stripe event for the Admin API and audits who resolved it and + * why. Null when the event does not exist, is not dead-lettered, or is not a billing Stripe event. + */ +export async function resolveBillingSyncDeadLetter( + eventId: string, + resolution: AdminV1ResolveOutboxEventBody, + request: AuditLogParams['request'] +) { + const resolved = await resolveDeadLetteredOutboxEvent(eventId, RESOLVABLE_EVENT_TYPES, resolution) + if (!resolved) return null + recordAudit({ + workspaceId: null, + actorId: null, + actorName: `Admin API (${resolution.resolvedBy})`, + action: AuditAction.BILLING_SYNC_RESOLVED, + resourceType: AuditResourceType.BILLING, + resourceId: resolved.id, + description: `Admin API resolved a dead-lettered ${resolved.eventType} event`, + metadata: { eventType: resolved.eventType, ...resolution }, + request, + }) + return resolved +} diff --git a/apps/sim/lib/admin/subscription-lifecycle.ts b/apps/sim/lib/admin/subscription-lifecycle.ts index 406eb5fadf0..6873dc3de5a 100644 --- a/apps/sim/lib/admin/subscription-lifecycle.ts +++ b/apps/sim/lib/admin/subscription-lifecycle.ts @@ -177,6 +177,24 @@ async function listRecentSubscriptionPayments(stripe: Stripe, stripeSubscription } } +/** + * Where a dashboard cancellation event stands. A completed event with a `lastError` is a dead + * letter an operator resolved without the cancellation reaching Stripe, so it reads as `resolved` + * with that note, never as `applied`. + */ +function cancellationSyncOutcome(status: string, lastError: string | null) { + if (status === 'completed') { + return lastError + ? { status: 'resolved' as const, error: lastError } + : { status: 'applied' as const, error: null } + } + if (status === 'dead_letter') return { status: 'failed' as const, error: lastError } + return { + status: status === 'processing' ? ('processing' as const) : ('pending' as const), + error: null, + } +} + async function getDashboardCancellationSync(organizationId: string, subscriptionId: string) { const [row] = await db .select({ @@ -205,15 +223,7 @@ async function getDashboardCancellationSync(organizationId: string, subscription row.eventType === OUTBOX_EVENT_TYPES.STRIPE_CANCEL_SUBSCRIPTION_IMMEDIATELY ? ('immediate' as const) : ('period_end' as const), - status: - row.status === 'completed' - ? ('applied' as const) - : row.status === 'dead_letter' - ? ('failed' as const) - : row.status === 'processing' - ? ('processing' as const) - : ('pending' as const), - error: row.status === 'dead_letter' ? row.error : null, + ...cancellationSyncOutcome(row.status, row.error), } } @@ -286,6 +296,7 @@ export async function requestDashboardSubscriptionCancellation({ id: outboxEvent.id, eventType: outboxEvent.eventType, status: outboxEvent.status, + lastError: outboxEvent.lastError, subscriptionId: sql`${outboxEvent.payload} ->> 'subscriptionId'`, reason: sql`${outboxEvent.payload} ->> 'reason'`, }) @@ -351,12 +362,8 @@ export async function requestDashboardSubscriptionCancellation({ operationId, outboxEventId: existingOperation.id, subscriptionId: existingOperation.subscriptionId, - status: - existingOperation.status === 'completed' - ? ('applied' as const) - : existingOperation.status === 'processing' - ? ('processing' as const) - : ('pending' as const), + status: cancellationSyncOutcome(existingOperation.status, existingOperation.lastError) + .status, } } diff --git a/apps/sim/lib/api/contracts/v1/admin/dashboard.ts b/apps/sim/lib/api/contracts/v1/admin/dashboard.ts index 9b6c2943ef4..4df4029cc7f 100644 --- a/apps/sim/lib/api/contracts/v1/admin/dashboard.ts +++ b/apps/sim/lib/api/contracts/v1/admin/dashboard.ts @@ -663,7 +663,7 @@ const adminDashboardBillingActionsSchema = z.object({ .object({ operationId: z.string().uuid(), timing: z.enum(['period_end', 'immediate']), - status: z.enum(['pending', 'processing', 'applied', 'failed']), + status: z.enum(['pending', 'processing', 'applied', 'resolved', 'failed']), error: z.string().nullable(), }) .nullable(), @@ -684,7 +684,7 @@ const adminDashboardBillingActionsSchema = z.object({ const adminDashboardCancellationResultSchema = z.object({ success: z.literal(true), operationId: z.string(), - status: z.enum(['pending', 'processing', 'applied', 'failed']), + status: z.enum(['pending', 'processing', 'applied', 'resolved', 'failed']), }) const adminDashboardRefundResultSchema = z.object({ success: z.literal(true), diff --git a/apps/sim/lib/api/contracts/v1/admin/outbox.ts b/apps/sim/lib/api/contracts/v1/admin/outbox.ts index 07c698f2045..cb53298c9f2 100644 --- a/apps/sim/lib/api/contracts/v1/admin/outbox.ts +++ b/apps/sim/lib/api/contracts/v1/admin/outbox.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { + type ContractBody, type ContractJsonResponse, type ContractParams, type ContractQuery, @@ -62,6 +63,29 @@ const adminV1OutboxRequeueResultSchema = z.object({ }), }) +/** Bounded so the 500-character `last_error` keeps at least 100 characters of the last failure. */ +const adminV1ResolveOutboxEventBodySchema = z.object({ + reason: z + .string({ error: 'reason is required' }) + .trim() + .min(1, 'reason is required') + .max(300, 'reason must be at most 300 characters'), + resolvedBy: z + .string({ error: 'resolvedBy is required' }) + .trim() + .min(1, 'resolvedBy is required') + .max(60, 'resolvedBy must be at most 60 characters'), +}) + +const adminV1OutboxResolveResultSchema = z.object({ + success: z.literal(true), + resolved: z.object({ + id: z.string(), + eventType: z.string(), + lastError: z.string().nullable(), + }), +}) + export const adminV1ListOutboxContract = defineRouteContract({ method: 'GET', path: '/api/v1/admin/outbox', @@ -82,6 +106,17 @@ export const adminV1RequeueOutboxEventContract = defineRouteContract({ }, }) +export const adminV1ResolveOutboxEventContract = defineRouteContract({ + method: 'POST', + path: '/api/v1/admin/outbox/[id]/resolve', + params: adminV1IdParamsSchema, + body: adminV1ResolveOutboxEventBodySchema, + response: { + mode: 'json', + schema: adminV1OutboxResolveResultSchema, + }, +}) + export type AdminV1ListOutboxQueryInput = ContractQueryInput export type AdminV1ListOutboxQuery = ContractQuery export type AdminV1RequeueOutboxEventParams = ContractParams< @@ -91,3 +126,7 @@ export type AdminV1ListOutboxResponse = ContractJsonResponse +export type AdminV1ResolveOutboxEventBody = ContractBody +export type AdminV1ResolveOutboxEventResponse = ContractJsonResponse< + typeof adminV1ResolveOutboxEventContract +> diff --git a/apps/sim/lib/billing/webhooks/stripe-sync-convergence.integration.ts b/apps/sim/lib/billing/webhooks/stripe-sync-convergence.integration.ts index 1a757734d4e..d2f082ea461 100644 --- a/apps/sim/lib/billing/webhooks/stripe-sync-convergence.integration.ts +++ b/apps/sim/lib/billing/webhooks/stripe-sync-convergence.integration.ts @@ -57,6 +57,7 @@ vi.mock('@sim/db', () => ({ vi.mock('@/lib/billing/stripe-client', () => stripeClientMock) vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) +import { ADMIN_MEMBER_OPERATION_EVENT_TYPE } from '@/lib/admin/member-operation-event' import { requestDashboardSubscriptionCancellation } from '@/lib/admin/subscription-lifecycle' import { createSimAuthAdapter } from '@/lib/auth/sim-auth-adapter' import { CREDIT_TIERS } from '@/lib/billing/constants' @@ -76,8 +77,13 @@ import { reconcileSubscriptionSyncFromStripe, recordCustomerRestoreAfterHook, } from '@/lib/billing/webhooks/subscription-sync' -import { enqueueOutboxEvent, processOutboxEventById } from '@/lib/core/outbox/service' +import { + enqueueOutboxEvent, + findDeadLetteredEvents, + processOutboxEventById, +} from '@/lib/core/outbox/service' import { POST as requeueOutboxEvent } from '@/app/api/v1/admin/outbox/[id]/requeue/route' +import { POST as resolveOutboxEvent } from '@/app/api/v1/admin/outbox/[id]/resolve/route' const schemaName = `stripe_sync_${generateId().replaceAll('-', '')}` const connection = postgres( @@ -1446,3 +1452,139 @@ describe('webhook reconcile cost', () => { } }) }) + +async function storedOutboxEvent(eventId: string) { + const [row] = await testDatabase + .select({ status: outboxEvent.status, lastError: outboxEvent.lastError }) + .from(outboxEvent) + .where(eq(outboxEvent.id, eventId)) + if (!row) throw new Error(`Outbox event ${eventId} not found`) + return row +} + +/** A Team organization whose seat sync (1 → 2 seats) is enqueued and not yet run. */ +async function createPendingSeatSync() { + const [owner, joiner] = await Promise.all([createUser('owner'), createUser('joiner')]) + const org = await createOrganizationWithPlan('team', 1) + await addMember(org.organizationId, owner.id, 'owner') + await addMember(org.organizationId, joiner.id) + await reconcileOrganizationSeats({ organizationId: org.organizationId, reason: 'member-added' }) + const seatSync = await latestOutboxEventId( + OUTBOX_EVENT_TYPES.STRIPE_SYNC_SUBSCRIPTION_SEATS, + org.subscriptionId + ) + return { org, seatSync } +} + +describe('operator resolve', () => { + beforeAll(() => setEnvFlags({ isBillingEnabled: true })) + + function resolveFromAdminApi( + eventId: string, + body: unknown, + headers: Record = { 'x-admin-key': ADMIN_API_KEY } + ) { + return resolveOutboxEvent( + new NextRequest(`http://localhost:3000/api/v1/admin/outbox/${eventId}/resolve`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id: eventId }) } + ) + } + + const resolution = { reason: 'Reconciled the subscription by hand', resolvedBy: 'billing-oncall' } + + it('closes a dead-lettered sync so it never runs again or reaches the reconcile report', async () => { + const { org, seatSync } = await createPendingSeatSync() + await deadLetter(seatSync) + + const response = await resolveFromAdminApi(seatSync, resolution) + + expect(response.status).toBe(200) + const event = await storedOutboxEvent(seatSync) + expect(event.status).toBe('completed') + expect(event.lastError).toBe( + 'Resolved by billing-oncall: Reconciled the subscription by hand | last failure: Stripe is unavailable' + ) + const reported = await findDeadLetteredEvents([ + OUTBOX_EVENT_TYPES.STRIPE_SYNC_SUBSCRIPTION_SEATS, + ]) + expect(reported.map(({ id }) => id)).not.toContain(seatSync) + await expect(processEvent(seatSync)).resolves.toBe('completed') + expect(stripe.subscription(org.stripeSubscriptionId).items.data[0].quantity).toBe(1) + }) + + it('reports a resolved dashboard cancellation as resolved, not applied', async () => { + const org = await createOrganizationWithPlan('team') + const operationId = generateId() + const request = { + organizationId: org.organizationId, + operationId, + timing: 'period_end' as const, + actor: { id: null, name: 'Admin', email: null }, + } + await requestDashboardSubscriptionCancellation(request) + const cancelSync = await latestOutboxEventId( + OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, + org.subscriptionId + ) + await deadLetter(cancelSync) + expect((await resolveFromAdminApi(cancelSync, resolution)).status).toBe(200) + + await expect(requestDashboardSubscriptionCancellation(request)).resolves.toMatchObject({ + status: 'resolved', + }) + }) + + it('refuses an event that is not dead-lettered and leaves it to run', async () => { + const { seatSync } = await createPendingSeatSync() + + const response = await resolveFromAdminApi(seatSync, resolution) + + expect(response.status).toBe(404) + expect(await storedOutboxEvent(seatSync)).toMatchObject({ status: 'pending', lastError: null }) + }) + + it('refuses a dead letter that is not a billing Stripe event', async () => { + const eventId = await testDatabase.transaction((tx) => + enqueueOutboxEvent(tx, ADMIN_MEMBER_OPERATION_EVENT_TYPE, { operationId: generateId() }) + ) + await testDatabase + .update(outboxEvent) + .set({ status: 'dead_letter', lastError: 'failed' }) + .where(eq(outboxEvent.id, eventId)) + + const response = await resolveFromAdminApi(eventId, resolution) + + expect(response.status).toBe(404) + expect((await storedOutboxEvent(eventId)).status).toBe('dead_letter') + }) + + it('rejects a resolve whose reason is only whitespace', async () => { + const { seatSync } = await createPendingSeatSync() + await deadLetter(seatSync) + + const response = await resolveFromAdminApi(seatSync, { + reason: ' ', + resolvedBy: 'billing-oncall', + }) + + expect(response.status).toBe(400) + expect((await storedOutboxEvent(seatSync)).status).toBe('dead_letter') + }) + + it.each([ + ['no admin key', {}], + ['a wrong admin key', { 'x-admin-key': 'not-the-admin-key' }], + ])('denies a resolve with %s', async (_case, headers) => { + const { seatSync } = await createPendingSeatSync() + await deadLetter(seatSync) + + const response = await resolveFromAdminApi(seatSync, resolution, headers) + + expect(response.status).toBe(401) + expect((await storedOutboxEvent(seatSync)).status).toBe('dead_letter') + }) +}) diff --git a/apps/sim/lib/core/outbox/service.ts b/apps/sim/lib/core/outbox/service.ts index eed0495eafa..d36870aa6bd 100644 --- a/apps/sim/lib/core/outbox/service.ts +++ b/apps/sim/lib/core/outbox/service.ts @@ -390,10 +390,10 @@ export function outboxPayloadHasSourceOperationId(payload: unknown, operationId: const DEAD_LETTER_SCAN_LIMIT = 100 /** - * Return events currently in `dead_letter` for the given event types (capped at - * `DEAD_LETTER_SCAN_LIMIT`). Used by periodic reconcilers to surface stuck work - * that exhausted its retries and now needs operator attention — the cap keeps a - * runaway backlog from materializing unboundedly into memory each run. + * Return events currently in `dead_letter` for the given event types, most recently + * dead-lettered first so the `DEAD_LETTER_SCAN_LIMIT` cap never hides a new one. Used by periodic + * reconcilers to surface stuck work that exhausted its retries and now needs operator attention; + * the cap keeps a runaway backlog from materializing unboundedly into memory each run. */ export async function findDeadLetteredEvents( eventTypes: string[] @@ -403,9 +403,53 @@ export async function findDeadLetteredEvents( .select() .from(outboxEvent) .where(and(eq(outboxEvent.status, 'dead_letter'), inArray(outboxEvent.eventType, eventTypes))) + .orderBy(sql`${outboxEvent.processedAt} desc nulls last`, desc(outboxEvent.id)) .limit(DEAD_LETTER_SCAN_LIMIT) } +/** + * Closes a dead-lettered event an operator has remediated by hand (or decided needs nothing): + * it moves to `completed` and never runs again. `last_error` keeps who resolved it, why, and as + * much of the failure it had as fits after them (callers bound the resolution so some always + * does), so the row stays an auditable record. Only a `dead_letter` row of one of `eventTypes` + * matches; anything else returns null untouched. + */ +export async function resolveDeadLetteredOutboxEvent( + eventId: string, + eventTypes: string[], + resolution: { reason: string; resolvedBy: string } +): Promise<{ id: string; eventType: string; lastError: string | null } | null> { + const note = `Resolved by ${resolution.resolvedBy}: ${resolution.reason}` + const [resolved] = await db + .update(outboxEvent) + .set({ + status: 'completed', + lastError: sql`left(${note} || coalesce(' | last failure: ' || ${outboxEvent.lastError}, ''), ${MAX_PERSISTED_ERROR_LENGTH})`, + processedAt: new Date(), + lockedAt: null, + }) + .where( + and( + eq(outboxEvent.id, eventId), + eq(outboxEvent.status, 'dead_letter'), + inArray(outboxEvent.eventType, eventTypes) + ) + ) + .returning({ + id: outboxEvent.id, + eventType: outboxEvent.eventType, + lastError: outboxEvent.lastError, + }) + if (!resolved) return null + logger.info('Resolved dead-lettered outbox event', { + eventId: resolved.id, + eventType: resolved.eventType, + resolvedBy: resolution.resolvedBy, + reason: resolution.reason, + }) + return resolved +} + /** The event's current payload, read fresh rather than from the copy its handler was claimed with. */ export async function readOutboxEventPayload(eventId: string): Promise { const [row] = await db diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index f914b9f7266..5faf0ed77eb 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -55,6 +55,7 @@ export const AuditAction = { INVOICE_PAYMENT_SUCCEEDED: 'invoice.payment_succeeded', INVOICE_PAYMENT_FAILED: 'invoice.payment_failed', OVERAGE_BILLED: 'billing.overage_billed', + BILLING_SYNC_RESOLVED: 'billing.sync_resolved', CHARGE_DISPUTE_OPENED: 'charge.dispute_opened', CHARGE_DISPUTE_CLOSED: 'charge.dispute_closed', diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index ecd491c798c..feea8829449 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -43,6 +43,7 @@ const AuditAction = { INVOICE_PAYMENT_SUCCEEDED: 'invoice.payment_succeeded', INVOICE_PAYMENT_FAILED: 'invoice.payment_failed', OVERAGE_BILLED: 'billing.overage_billed', + BILLING_SYNC_RESOLVED: 'billing.sync_resolved', CHARGE_DISPUTE_OPENED: 'charge.dispute_opened', CHARGE_DISPUTE_CLOSED: 'charge.dispute_closed', SUBSCRIPTION_CREATED: 'subscription.created', diff --git a/packages/testing/src/mocks/outbox-service.mock.ts b/packages/testing/src/mocks/outbox-service.mock.ts index 1744ecfb0bd..72a80fb8347 100644 --- a/packages/testing/src/mocks/outbox-service.mock.ts +++ b/packages/testing/src/mocks/outbox-service.mock.ts @@ -68,6 +68,7 @@ export const outboxServiceMockFns = { ) }), mockFindDeadLetteredEvents: vi.fn(), + mockResolveDeadLetteredOutboxEvent: vi.fn(), mockListInflightOutboxEvents: vi.fn(), mockReadOutboxEventPayload: vi.fn(), mockPatchRetryableOutboxEvents: vi.fn(), @@ -100,6 +101,7 @@ export const outboxServiceMock = { outboxEventHasSourceOperationId: outboxServiceMockFns.mockOutboxEventHasSourceOperationId, outboxPayloadHasSourceOperationId: outboxServiceMockFns.mockOutboxPayloadHasSourceOperationId, findDeadLetteredEvents: outboxServiceMockFns.mockFindDeadLetteredEvents, + resolveDeadLetteredOutboxEvent: outboxServiceMockFns.mockResolveDeadLetteredOutboxEvent, listInflightOutboxEvents: outboxServiceMockFns.mockListInflightOutboxEvents, readOutboxEventPayload: outboxServiceMockFns.mockReadOutboxEventPayload, patchRetryableOutboxEvents: outboxServiceMockFns.mockPatchRetryableOutboxEvents,