diff --git a/packages/cloud/shared/src/lib/services/__tests__/app-credit-hold-concurrency.test.ts b/packages/cloud/shared/src/lib/services/__tests__/app-credit-hold-concurrency.test.ts index fd435fb319fc5..0fbf10a83fb47 100644 --- a/packages/cloud/shared/src/lib/services/__tests__/app-credit-hold-concurrency.test.ts +++ b/packages/cloud/shared/src/lib/services/__tests__/app-credit-hold-concurrency.test.ts @@ -356,6 +356,52 @@ describe("reserveInferenceCredits — real row-locked upfront hold (#10857)", () PGLITE_TIMEOUT, ); + test( + "a repeated reconcile REFUND for the same reservation is idempotent — no double-refund mint (#11512)", + async () => { + if (!pgliteReady) return; + + const payerOrgId = await seedOrg("10.000000"); + const consumerId = await seedUser(payerOrgId); + const creatorOrgId = await seedOrg("0.000000"); + const creatorId = await seedUser(creatorOrgId); + const app = await seedApp({ + organizationId: creatorOrgId, + createdByUserId: creatorId, + inferenceMarkupPercentage: 10, + }); + + // $2 estimate + 10% markup = $2.20 debited → org 7.80. + const reservation = await appCreditsService.reserveInferenceCredits({ + appId: app.id, + userId: consumerId, + estimatedBaseCost: 2, + description: "reconcile-refund idempotency", + idempotencyKey: "req-11512", + metadata: { model: "test-model" }, + app, + }); + expect(await orgBalance(payerOrgId)).toBeCloseTo(7.8, 6); + + // First settle: actual $0.5 → refund (2 − 0.5) × 1.1 = $1.65 → org 9.45. + const first = await reservation.reconcile(0.5); + expect(first?.adjustmentType).toBe("refund"); + expect(await orgBalance(payerOrgId)).toBeCloseTo(9.45, 6); + + // Second settle of the SAME reservation (the #11512 shape: the settler's + // first-call-wins guard reset on a mid-settle throw, so the route's + // fallback settleReservation(0) re-invokes reconcile). WITHOUT the + // idempotency key this commits a SECOND, larger refund → org 11.65 + // (minted above its $10 start). WITH the key the refund dedupes on + // stripe_payment_intent_id (ON CONFLICT DO NOTHING) → balance unchanged. + await reservation.reconcile(0); + expect(await orgBalance(payerOrgId)).toBeCloseTo(9.45, 6); + // Hard invariant: never minted above the debited amount. + expect(await orgBalance(payerOrgId)).toBeLessThan(10); + }, + PGLITE_TIMEOUT, + ); + test( "a $0 estimate opens a MIN_RESERVATION floor hold instead of throwing 'Amount must be positive' (residual of #10892)", async () => { diff --git a/packages/cloud/shared/src/lib/services/app-credits.ts b/packages/cloud/shared/src/lib/services/app-credits.ts index 4a9a7f331d0ff..bc4665dc421eb 100644 --- a/packages/cloud/shared/src/lib/services/app-credits.ts +++ b/packages/cloud/shared/src/lib/services/app-credits.ts @@ -697,6 +697,18 @@ export class AppCreditsService { // Validate metadata size and depth const metadata = validateMetadata(rawMetadata, "reconcileCredits"); + // Request-stable idempotency key (threaded via withChargeIdempotencyKey). + // The reconcile refund is otherwise NON-idempotent, and the reservation + // settler resets its first-call-wins guard on throw — so if a refund + // commits then a post-refund write throws (DB blip), the route's fallback + // settle re-invokes reconcile and a SECOND full refund mints credit + // (#11512). Keying the refund on this stable id makes the re-invoke dedupe + // on the credit_transactions unique index instead of double-crediting. + const reconcileIdempotencyKey = + typeof (metadata as Record | undefined)?.idempotencyKey === "string" + ? ((metadata as Record).idempotencyKey as string) + : undefined; + const baseCostDifference = actualBaseCost - estimatedBaseCost; // Resolve the user's organization once — every branch below charges or @@ -762,6 +774,12 @@ export class AppCreditsService { organizationId, amount: refundAmount, description: `App reconciliation refund (${app.name ?? appId})`, + // Idempotent on the request-stable key: a second settle of the same + // reservation (e.g. after the settler's guard reset on a mid-settle + // throw) dedupes to the first refund instead of minting (#11512). + ...(reconcileIdempotencyKey && { + stripePaymentIntentId: `reconcile-refund:${reconcileIdempotencyKey}`, + }), metadata: { appId, userId,