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
- Have a workspace with a Zen PAYG balance from a top-up (e.g. $10 top-up → $10 credit stored).
- Issue a partial refund on that charge from the Stripe dashboard (e.g. refund $3 of the $10).
- Stripe fires
charge.refunded. If the Console server returns a 5xx or times out (e.g. under load), Stripe retries.
- 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
Description
The
charge.refundedwebhook 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.timeRefundedbut 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 inPaymentTable— instead ofbody.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.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
charge.refunded. If the Console server returns a 5xx or times out (e.g. under load), Stripe retries.Screenshot and/or share link
No response
Operating System
No response
Terminal
No response