Skip to content

Refund handler uses wrong amount and runs repeatedly #28398

@PanAchy

Description

@PanAchy

Description

The charge.refunded webhook handler in the Console has two bugs that can silently destroy a workspace's Zen credit balance:

Bug 1 — No idempotency check (duplicate deductions):
The handler sets PaymentTable.timeRefunded but never checks it before running. Stripe guarantees at-least-once delivery and retries webhooks on any 5xx or timeout response. Each retry re-runs the balance deduction in full, with no guard against having already processed the same refund.

Bug 2 — Deducts original charge amount instead of refund amount:
The balance deduction uses payment.amount — the original charge amount stored in PaymentTable — instead of body.data.object.amount_refunded, which is the actual amount Stripe refunded. For partial refunds, this over-deducts the balance by the difference between the original charge and the refund.

// packages/console/app/src/routes/stripe/webhook.ts — charge.refunded handler

// Bug 1: timeRefunded is never checked before proceeding
if (!payment) throw new Error("Payment not found")
// missing: if (payment.timeRefunded) return

await Database.transaction(async (tx) => {
  await tx.update(PaymentTable).set({ timeRefunded: new Date(...) })...

  // Bug 2: uses original charge amount, not refund amount
  balance: sql`${BillingTable.balance} - ${payment.amount}`
  // should be: centsToMicroCents(body.data.object.amount_refunded)
})

Combined impact: A single partial refund event, re-delivered once by Stripe, deducts twice the original charge amount from the workspace balance instead of the actual refund amount. This can drive the Zen balance deeply negative, blocking the workspace from using PAYG top-up credit with no self-service recovery path.

Plugins

No response

OpenCode version

No response

Steps to reproduce

  1. Have a workspace with a Zen PAYG balance from a top-up (e.g. $10 top-up → $10 credit stored).
  2. Issue a partial refund on that charge from the Stripe dashboard (e.g. refund $3 of the $10).
  3. Stripe fires charge.refunded. If the Console server returns a 5xx or times out (e.g. under load), Stripe retries.
  4. Observe: the workspace balance has been deducted by the full original charge amount ($10) instead of the refund amount ($3), and the deduction may have run twice.

Screenshot and/or share link

No response

Operating System

No response

Terminal

No response

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions