Repository navigation
fix: Cap Infra auto top-up as soon as the balance falls below the threshold - #4156
Merged
Merged
Conversation
A top-up now waits only after a decline, while a payment is still processing, or at ten automatic top-ups in any 24 hours. Each attempt is keyed by the account's count of settled top-ups and the exact request, read under the account lock, so a retried or racing trigger makes one payment. Adds scheduleAutoTopUp, which runs a top-up after the response is sent and never throws into the caller.
Transcript holds, reservations and settlements, and low-balance refusals at upload create, upload complete and transcript create or start, now ask for an auto top-up straight away instead of waiting for the next storage run.
After a purchase, while auto top-up is off, the Billing page says it can add credit as soon as the balance falls below a threshold. The card notes no longer say the card is charged at most once a day.
A hung Stripe call could use up the storage run's time. A timeout is logged like any other failure to reach Stripe, writes no ledger row, and the next attempt sends the same idempotency key.
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
|
Sandchest reviewed Sandchest reviews every change for vulnerabilities an attacker could use · 35 credits · Review details |
The Stripe key is now the account and its count of settled top-ups only, so a trigger that reads the same attempt after the amount or card changed gets Stripe's idempotency error instead of a second payment, and that error counts as an attempt in progress. A payment still processing blocks the next one however long it takes. Outside a request, scheduleAutoTopUp now finishes the attempt before it returns instead of leaving a detached promise.
Member
Author
|
hey @greptileai, please re-review the PR |
Member
Author
|
hey @greptileai, please re-review the PR |
A pending payment blocks the next auto top-up until it settles. So an account is never stuck behind a payment whose webhook never arrives, a run that finds one older than 72 hours reads it from Stripe once and records the outcome, or leaves it if Stripe still reports it as processing.
Member
Author
|
hey @greptileai, please re-review the PR |
The key was already counting declines alongside top-ups. It now names both counts, so a retry after a recorded decline visibly sends a new key while a settings change during an attempt keeps the old one.
Member
Author
|
hey @greptileai, please re-review the PR |
…-on-debit # Conflicts: # apps/web/lib/infra-ledger-store.ts
…-on-debit # Conflicts: # apps/web/lib/developer-transcript-store.ts
Member
Author
|
hey @greptileai, please re-review the PR |
Settled automatic top-ups count for 24 hours. A reservation or a payment still processing counts until it settles or is reconciled, because it may already have been charged.
…ck until it resolves
Member
Author
|
hey @greptileai, please re-review the PR |
…ven when deleted just before the lock
…s other app can top up
Member
Author
|
hey @greptileai, please re-review the PR |
This was referenced Nov 13, 2026
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Auto top-up now works the way people expect from a usage-billed API: the card is charged as soon as the available balance falls below the threshold, as often as that happens, with a safety limit.
What was wrong
runDueAutoTopUpsran only from the storage cron (41 */6 * * *). An app that spent its balance on transcription, or was refused an upload for low balance, got 402 for up to six hours with auto top-up on and a card saved.runAutoTopUprefused for 24 hours after any attempt (lastAttemptincluded successful top-ups), and the Stripe key wasinfra-auto-top-up:<account>:<UTC day>. A busy app that used up one top-up stayed stuck until the next day.What changed
flowchart LR A[Transcript hold, reserve or settle] --> S[scheduleAutoTopUp] B[402 at upload create or complete] --> S C[402 at transcript create or start] --> S S -->|in a request: after the response<br/>otherwise: awaited inline| R[runAutoTopUp] K[Storage cron, unchanged] --> R R -->|snapshot under account lock| D{below threshold?<br/>not disputed, not processing,<br/>no decline in 24h, under 10 in 24h} D -->|yes| P[PaymentIntent<br/>key = account : top-ups : declines]lib/infra-auto-top-up.ts):scheduleAutoTopUp({ accountId } | { appId })decides by whether there is a request.after()and returns at once, so the attempt is never on the request's critical path.after()throws, so it awaits the attempt inline. The attempt is bounded by the 15 s Stripe timeout and cannot be frozen half-way.lib/developer-transcript-billing.ts);lib/infra-video-upload.ts);lib/developer-transcript-store.ts).runDueAutoTopUpsstraight after charging, in the same cron run, so it needs no extra call.infra-auto-top-up:<account>:<recorded top-ups>:<recorded declines>, with nothing from the settings in it. A recorded decline therefore gives the retry 24 hours later a new key, so Stripe tries the card again instead of replaying the stored decline.idempotency_key_in_use(409).StripeIdempotencyError; this was measured against the real API. So a settings change cannot start a second payment.StripeConnectionError(measured). It is logged like any other failure to reach Stripe and writes no ledger row. The next attempt sends the same idempotency key, so a retry cannot charge twice.billing.mdxsays auto top-up runs as soon as the available balance falls below the threshold, up to 10 times in any 24 hours, and waits 24 hours after a decline.developer-billing-docs.test.tschecks both against the code.Two checks on the transcription workflow
1. The workflow bundle is unchanged. I ran the workflow builder the way
next buildruns it: thewithWorkflowconfig atphase-production-build, which writesapp/.well-known/workflow/v1/flow/route.jsandstep/route.js. I ran it on the base commit (802e2ac) and on this branch.flow/route.js(10,862,920 bytes), is byte-identical on both (cmpfinds no difference).infra-auto-top-up,scheduleAutoTopUp,developer-transcript-billing,api.stripe.com,infra-billing-emails,next/server,AUTO_TOP_UPorcreateHash. The builder already drops the step-only billing import from the workflow bundle.import()is needed.2.
after()inside a workflow step.@workflow/world-vercel's queue handler awaits the step within the route handler. Soafter()has a scope there and the attempt runs after the step answers.scheduleAutoTopUpnow awaits the attempt instead of leaving a detached promise. A unit test covers each path.settleTranscriptdirectly, outside a request, so it took the out-of-request path.developerTranscribeWorkflowitself: the real hold, reserve and settle steps on scratch MySQL with Stripe test mode, with only speech, storage and the media server faked.after()was called 3 times and threw each time, so each attempt ran inline.topup 500000 -> 1500050, thentranscription -83 -> 1499967).Owner ceiling: $1,000 a day of automatic top-ups across all apps
Each app has its own card and its own 10-a-day limit, and an owner can have 25 apps. Without an owner-level ceiling, one stolen card saved on every app could be charged far more than any single app's limit. So:
owner_daily_limit. Auto top-up stays on, no email is sent, and nothing is written to the ledger.topup_pendingledger row with amount 0, referenced by the attempt's idempotency key. Its metadata holds the amount, customer, card, receipt email and description. On a real database the lock is held for 6 to 47 ms.GET_LOCKbecause the codebase already relies onSELECT ... FOR UPDATEon PlanetScale, and named locks there need a reserved connection I could not verify without production.Cap Infra credits for <app name>, auto top-up. The name is trimmed with its whitespace collapsed, and the whole description is kept within Stripe's 1,000 characters.owner_idx, and the ledger sum usesaccount_type_created_idx.account_ref_dedup_idx.reforrangeonaccount_type_created_idx.owner_deleted_idx, thenapp_id_unique), and the only unindexed step is the 2-row derived held-credit table.One waiting rule for the guard and the sweep
autoTopUpWaits(now)inlib/infra-auto-top-up.tsis one SQL fragment with four conditions, correlated to the account row:It is used in two places:
runAutoTopUpreads all four under the owner lock and skips with the matching reason.autoTopUpReleased(now)(none of the four) in place of the old single retry cutoff.The two therefore cannot drift apart. Details:
accountId,typeand acreatedAtrange, whichaccount_type_created_idxserves. The "is this pending payment settled" check usesaccount_ref_dedup_idx.runAutoTopUpthen reconciles it from Stripe.`alias`.`column`. In a single-table select, drizzle drops table names from columns, and a correlated subquery would then compare its own columns; the real-database tests caught this.The #4143 merge keeps
withTranscriptSlotas it is. The only transcript trigger is on the 402 low-balance refusal instartGate; the 429too_many_transcriptsdoes not ask for a top-up.For #4145
The first-month charge can call
scheduleAutoTopUp({ accountId })after it commits.Proof
Stripe test mode, real scratch MySQL, rerun on the final code (reservations, owner ceiling, with #4142 and #4143). All seven live checks pass: the transcription and same-day top-ups, the declined card, the race, the settings change, the decline retry, the 72-hour reconcile and the full workflow. The scratch databases were dropped afterwards. The timings below come from the first run. On the merged code the top-up runs inline outside a request, so it has finished by the time
settleTranscriptreturns, and the full workflow including the top-up took 915 ms.First run. Accounts were set at a $10 threshold, $5 top-ups, with a real test card. The transcription debit ran through
settleTranscript, the same function the transcription workflow calls. These are reproduced timings:pm_card_chargeCustomerFail):recent_declinestraight away and still at 23.9 hours, with 1 Stripe attempt.succeededand five werein_progress. Stripe holds 1 PaymentIntent and the ledger 1 top-up row.StripeIdempotencyErrorand answeredin_progress. Stripe held one PaymentIntent ($5, attempt0:0).1:0. The new amount applies from the next attempt, and there is never a second payment for one attempt.awaiting_payment, and Stripe is not asked;reconciled, the $5 top-up is recorded, and it stays a single PaymentIntent;reconciled, a failure is recorded, and auto top-up switches off.0:0. The account waited at 23.9 hours. At 24.1 hours the retry sent attempt0:1, a new key, and Stripe made a second, separate PaymentIntent and declined the card afresh. The stored decline was not replayed.Committed tests
__tests__/integration/infra-auto-top-up-database.test.tsruns against a real MySQL with a Stripe fake that honours idempotency keys:FOR UPDATEon another app of the same owner (the lock that upload completion and transcript holds take) waited 1 to 2 ms during the call.owner_daily_limit(if the ceiling counts only settled top-ups, this fails);owner_daily_limitand stays switched on;owner_daily_limit; it goes through again once 24 hours have passed; and credit bought by hand is not counted.above_threshold.developer-transcript-concurrency,developer-transcript-console-ceiling,agent-developer-transcript-limitand the transcripts database suite (32 tests on a localcap_speech_*database).scheduleAutoTopUpreturning at once inside a request, finishing the attempt before returning outside one, and never throwing;homepage-seoandshare-stage-identity, failing on main too). No test changed status. The only removed tests are the two rewritten for the new rules: "keys by UTC day" and "one attempt per 24 hours".

Confidence Score: 5/5
The PR appears safe to merge; the deleted-app recovery issue is fixed.Summary
Auto top-up now responds to low balances after transcript charges and billing refusals, with repeat payments, saved attempts, and daily limits.
Reviews (13) · Last reviewed commit: "test: the sweep voids a deleted app's lo..." · Reviewed by Greptile