Skip to content

billing: add --dry-run mode and always send manual invoices#3099

Draft
jshearer wants to merge 6 commits into
masterfrom
jshearer/billing_dry_run
Draft

billing: add --dry-run mode and always send manual invoices#3099
jshearer wants to merge 6 commits into
masterfrom
jshearer/billing_dry_run

Conversation

@jshearer

@jshearer jshearer commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

--dry-run mode

The invoice generator's trial-run workflow ran against a testmode Stripe account, which lacked livemode invoice state, so manual bills that already had open or paid invoices showed up as phantom creates. --dry-run runs against the livemode API in read-only mode and reports what each invoice would do without creating or modifying anything in Stripe.

To make that safe, the per-invoice work is separated into two phases:

  • classify performs only reads (validation and Stripe lookups) and returns the action that would be taken.
  • execute performs all writes (customer and invoice creation, line items, amount verification).

--dry-run stops after classify and logs the would-be action; a normal run classifies and then executes, so both paths share the same decision logic.

A behavior change rides along with this: manual bills that already have an open, paid, void, or uncollectible invoice in Stripe are now classified as AlreadyProcessed and skipped instead of erroring. This is expected for date-range-overlapping manual bills invoiced in a previous run, and it applies to real runs too, not only dry runs.

Always send manual invoices

Stored payment methods cover monthly usage overages. Manual bills such as contracts and one-off charges should be sent as invoices so the customer decides how to pay, rather than being charged automatically. Manual invoices now use send_invoice regardless of --charge-type, both at creation and during the send phase, even when a payment method is on file.

jshearer added 2 commits July 1, 2026 16:46
* Add `--dry-run`: classify every invoice against the livemode Stripe API and report what would happen without creating or modifying anything. The previous testmode trial run misreported manual bills that already had invoices as phantom creates, because the sandbox lacked livemode state. Manual bills already invoiced in an earlier run now report as `AlreadyProcessed` instead of erroring.
* Manual invoices are always sent as invoices rather than charged to a stored payment method, even when one is on file. Stored payment methods cover monthly usage overages; manual bills such as contracts and one-off charges should let the customer choose how to pay.

Both changes were part of PR #2849, which was closed while the rest of the branch merged via #2883 and #2902, so they never landed. This reapplies them onto current master.
… from --recreate-finalized

The classify/execute split returns no Stripe customer for skips resolved in the read-only Phase 1, which silently disabled --clean_up for those tenants. Separately, --recreate-finalized still applied to manual invoices, conflicting with the new `AlreadyProcessed` handling that makes manual reruns idempotent.

* --clean_up falls back to a `find_customer` lookup when the `Skip` action carries no customer, so it again removes stale draft invoices for `FreeTier`, `FutureTrialStart`, and `LessThanMinimum` tenants. Phase 1 stays free of Stripe calls, so the lookup only runs under --clean_up.
* Manual invoices are excluded from --recreate-finalized so an already-sent open manual invoice is not voided and reissued. An open manual invoice reports `AlreadyProcessed`, and a manual draft is refreshed through the normal Update path.
@jshearer jshearer force-pushed the jshearer/billing_dry_run branch from 473d8e8 to 5810d41 Compare July 2, 2026 03:18
@jshearer jshearer self-assigned this Jul 2, 2026
claude added 4 commits July 8, 2026 17:48
The manual-invoice -> send_invoice override in execute() was only applied
on the create path. When an existing draft was updated, execute() reused
whatever collection method the invoice was originally created with, so a
manual invoice previously stored as charge_automatically was left to
auto-charge the customer's card instead of being sent for manual payment.

Hoist the collection method / due date derivation from `mode` and, on the
update path, POST the reconciled collection method (and due date) to Stripe
when it differs from what the invoice currently carries.
execute()'s delete/recreate path re-retrieves the invoice and re-checks its
status to guard against it changing between classification and execution, but
the update path did not: it retrieved the invoice and immediately cleared and
re-added line items. If a concurrent run or a human finalized/paid/voided the
draft in that window, the line-item mutations would fail with an opaque Stripe
error instead of a clear diagnostic.

Add the symmetric status re-check: bail with a 'changed state since
classification' message unless the invoice is still a draft.
The dry-run arm reported "Would publish"/"Would update" straight from
classify(), which never exercises the customer/email resolution that lives in
execute(). A tenant with no resolvable billing email (no customer email, no
tenants.billing_email, and no admin user) was previewed as a success but would
bail on a real run, so dry-run gave false confidence.

Add a read-only email-resolvability check mirroring ensure_customer_for_invoicing
and report such tenants under the Error bucket in the preview. Extract the admin
lookup into earliest_admin_email() and reuse it in both paths (the SQL text is
kept byte-identical so it hits the existing offline query cache). Document on the
flag that the invoice total cannot be reconciled in dry-run, since that requires
creating the invoice in Stripe.
Address the remaining publish.rs review feedback in one pass:

- Thread the customer found during classify() into the Create/Update actions so
  execute() reuses it via ensure_customer_for_invoicing instead of issuing a
  second identical Stripe customer search per non-skipped invoice (#8). This also
  collapses the ad-hoc Option<Option<Customer>> memo into a single lookup (#13).
- Replace the .expect() on a Final invoice's `extra` payload with a bail!, so a
  null payload fails that one tenant instead of panicking and aborting the run (#5).
- Preserve the manual-invoice annotation on the error path by recomputing it via
  a shared manual_annotation() helper (#7).
- Derive is_update from existing_invoice_id.is_some() rather than carrying it in
  the destructure tuple (#14).
- De-duplicate the dry-run/normal invoice-count log and the per-tenant result
  debug line (a shared log_result closure) (#11, #12).
- Drop comments that merely restate the following call and qualify the bare
  InvoiceStatus reference, per CLAUDE.md (#15).

Not changed: the skip-reason ordering shift is intentional (and avoids a Stripe
lookup for skipped tenants); the execute()-side invoice re-retrieve is now
required by the draft-status re-check; and the fresh customer balance re-fetch
is deliberate.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants