Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,52 @@
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 () => {
Expand Down Expand Up @@ -419,5 +465,5 @@
// early-return; this turns that silent no-op into a hard CI failure so a
// money-path proof can never masquerade as a vacuous green.
test("pglite schema applied — never a silent skip", () => {
expect(pgliteReady).toBe(true);

Check failure on line 468 in packages/cloud/shared/src/lib/services/__tests__/app-credit-hold-concurrency.test.ts

View workflow job for this annotation

GitHub Actions / coverage on changed files

error: expect(received).toBe(expected)

Expected: true Received: false at <anonymous> (/home/runner/work/eliza/eliza/packages/cloud/shared/src/lib/services/__tests__/app-credit-hold-concurrency.test.ts:468:23)
});
18 changes: 18 additions & 0 deletions packages/cloud/shared/src/lib/services/app-credits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | undefined)?.idempotencyKey === "string"
? ((metadata as Record<string, unknown>).idempotencyKey as string)
: undefined;

const baseCostDifference = actualBaseCost - estimatedBaseCost;

// Resolve the user's organization once — every branch below charges or
Expand Down Expand Up @@ -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,
Expand Down
Loading