Skip to content

feat(payments): scaffold invoice billing for enterprise accounts - #1429

Open
sejori wants to merge 3 commits into
mainfrom
feat/invoice-billing-scaffold
Open

feat(payments): scaffold invoice billing for enterprise accounts#1429
sejori wants to merge 3 commits into
mainfrom
feat/invoice-billing-scaffold

Conversation

@sejori

@sejori sejori commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Draft — groundwork for billing enterprise accounts by monthly invoice instead of charging a card. The branching and the provider call are here; the Stripe-side billing cycle that actually raises the invoice is not.

Why accrual, not "create and send an invoice"

My first pass raised one invoice per top-up and emailed it immediately. That's wrong for this feature: auto top-up can fire many times a month, so it would put an invoice per firing through the customer's AP department — the exact thing "monthly invoicing for PO processing" exists to avoid.

So accrue_invoice_item creates a pending Stripe invoice item — no invoice field — which Stripe parks on the customer and sweeps onto their next invoice. One invoice per period, covering everything accrued in it, raised and emailed by Stripe.

This is post-pay, and that matters

Credits are granted at accrual, before any money arrives.

The tempting alternative — grant on invoice.paid — is self-defeating: an invoice-billed account would sit at a zero balance all month with auto top-up unable to do anything about it, because the top-up it just performed hasn't been paid for yet.

So the account is genuinely being extended credit on terms. That's why invoicing_enabled is set by us after approval rather than self-served.

What's here

Migration 129 users.invoicing_enabled, default false. Orgs are rows in users, so billing an org works the same way.
accrue_invoice_item on PaymentProvider, implemented for Stripe and the dummy provider
Auto top-up branch accrues + credits, then returns
Manual top-up branch refuses with 409

The auto top-up branch sits before the payment-method lookup deliberately. These accounts may have no card at all, and everything below it — soft/hard decline backoff, "auto top-up disabled" emails — describes a failure mode that cannot happen here. Non-payment of an invoice is collections on Stripe's dunning schedule, not a card decline.

Manual top-up refuses rather than accruing. Sending an invoice-billed account to Checkout would either fail for want of a card, or take a payment for credits their monthly invoice is also going to cover — billing them twice for one top-up.

Not done — needed before this is a feature

  • The Stripe subscription (or equivalent) whose billing cycle raises, finalises and emails the monthly invoice. Stripe won't do this for a bare customer: pending items sit indefinitely until something creates an invoice. With auto_advance: true on the generated invoice, Stripe then handles delivery, reminders and receipts with no scheduler on our side.
  • Suspension / collections policy on non-payment. Right now an unpaid account keeps accruing.
  • A way to set the flag (currently SQL only — deliberate, given it extends credit).
  • Whether the ledger should record the invoice item ID for reconciliation.

Test plan

  • cargo test -p dwctl1942 passed, 0 failed
  • just lint rust — clean
  • New: an invoice-billed account is credited without touching a card (the test uses the dummy provider's always-declines customer, so a leak into the card path would fail it); the flag defaults false so existing accounts keep the card path unchanged; manual checkout is refused with a reason

Inert until the column is set, so it's safe to land ahead of the remaining work.

Groundwork for billing enterprise accounts by monthly invoice instead of
charging a card. Draft: the branching and the provider call are here, the
Stripe-side billing cycle that actually raises the invoice is not.

Adds `users.invoicing_enabled` (migration 129, default false) and branches
both charge paths on it. Organizations are rows in `users`, so billing an
org works the same way.

Deliberately accrues rather than invoices. `accrue_invoice_item` creates a
*pending* Stripe invoice item with no `invoice` field, so Stripe parks it
on the customer and sweeps it onto their next invoice. Raising one invoice
per top-up would put an invoice per auto-top-up firing through the
customer's AP department, which is the exact thing monthly invoicing
exists to avoid.

This is post-pay, so credits are granted at accrual, before any money
arrives. Waiting for payment would leave an invoice-billed account at a
zero balance until month end with auto top-up unable to do anything about
it. The account is being extended credit on terms - which is why the flag
is set by us after approval rather than self-served.

The auto top-up branch runs before the payment-method lookup: these
accounts may have no card, and the card-decline machinery below it
(soft/hard backoff, "auto top-up disabled" emails) describes a failure
mode that cannot happen. Non-payment is collections on Stripe's dunning
schedule, not a decline.

Manual top-up refuses with 409 rather than accruing. Sending an
invoice-billed account to Checkout would either fail for want of a card or
take payment for credits their invoice already covers.

Still to do before this is a feature: the Stripe subscription (or
equivalent) whose billing cycle raises, finalises and emails the monthly
invoice; suspension on non-payment; and a way to set the flag.

Tests: 1942 pass, just lint rust clean. New coverage asserts an
invoice-billed account is credited without touching a card, that the flag
defaults false so existing accounts keep the card path unchanged, and that
manual checkout is refused with a reason.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 4, 2026

Copy link
Copy Markdown

Deploying control-layer with  Cloudflare Pages  Cloudflare Pages

Latest commit: 918760a
Status: ✅  Deploy successful!
Preview URL: https://35d61460.control-layer.pages.dev
Branch Preview URL: https://feat-invoice-billing-scaffol.control-layer.pages.dev

View logs

sejori added 2 commits August 4, 2026 13:58
CI's clippy (1.97.0) flags unnecessary_to_owned on
IdempotencyKey::new(idempotency_key.to_string()) - it takes the &str
directly. My local clippy component was stale and missed it; re-running
rustup component add clippy reproduces the failure locally.

Verified: just lint rust clean, cargo test -p dwctl passes.
@sejori
sejori marked this pull request as ready for review August 5, 2026 14:43
Copilot AI review requested due to automatic review settings August 5, 2026 14:43
@sejori

sejori commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Marking ready so it picks up a review — this is still not for merging. It's the invoice-billing scaffold: the branching and provider call are here, the Stripe billing cycle that actually raises the monthly invoice is not (see the unchecked list in the description).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR scaffolds enterprise “invoice-billed” credit top-ups by adding an invoicing_enabled flag on users, introducing a provider API to accrue pending invoice items, and branching auto top-up + manual payment flows so invoice-billed accounts never touch the card/decline machinery.

Changes:

  • Add users.invoicing_enabled (default false) and plumb it through DB/repo + API models.
  • Extend PaymentProvider with accrue_invoice_item, implemented for Stripe and the dummy provider.
  • Update auto top-up processing to accrue invoice items + grant credits immediately, and refuse manual /payments checkout with 409 for invoice-billed accounts (with tests).

Reviewed changes

Copilot reviewed 12 out of 23 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
dwctl/src/test/utils.rs Include invoicing_enabled in test user/org responses.
dwctl/src/payment_providers/stripe.rs Implement Stripe accrue_invoice_item via Invoice Item creation with idempotency.
dwctl/src/payment_providers/mod.rs Add accrue_invoice_item to the PaymentProvider trait with detailed semantics.
dwctl/src/payment_providers/dummy.rs Implement deterministic dummy accrue_invoice_item for tests.
dwctl/src/notifications.rs Add invoice-billed auto top-up branch (accrue + credit grant) and add tests.
dwctl/src/db/models/users.rs Add invoicing_enabled to UserDBResponse.
dwctl/src/db/handlers/users.rs Plumb invoicing_enabled through user queries and AutoTopupUser.
dwctl/src/db/handlers/organizations.rs Include invoicing_enabled in org queries and responses.
dwctl/src/api/models/users.rs Expose invoicing_enabled on UserResponse (and map from DB).
dwctl/src/api/handlers/payments.rs Resolve invoicing_enabled for billing target and refuse manual checkout for invoice-billed accounts (+ test).
dwctl/migrations/129_add_invoicing_enabled.sql Add users.invoicing_enabled column with schema comments.
dwctl/Cargo.toml Enable async-stripe-billing invoice_item feature.
.sqlx/query-df59ccd8080d8eb0bd1e11ab6e5a2b8fcdfbb3ae2c95a05ccd9f76042320f41a.json Update SQLx metadata for modified query shape.
.sqlx/query-a0064aaaeb73454786a5d770d2065da9e1b586370213c0126e102ff522c7ca51.json Add SQLx metadata for updated single-user query with invoicing_enabled.
.sqlx/query-8222c47b243451ed39721bfa8cedd046d712bb443c1fd14b2690dc69dcac6879.json Add SQLx metadata for org lookup query including invoicing_enabled.
.sqlx/query-6b92359c98bbafbe74b541a87d7b503151da51c0b242599a3531c15efe999d23.json Add SQLx metadata for org insert returning invoicing_enabled.
.sqlx/query-65bf99c8e1eff69c58dbb62e8737abcb9ccd1a3feb5f3be6b4d69801c7e5d900.json Update SQLx metadata for modified query shape.
.sqlx/query-598de7b5ac212cc23bc35777ef69bde1c3e66e3c8dfed78d18533bc28273850f.json Add SQLx metadata for org update returning invoicing_enabled.
.sqlx/query-56c4c894f4dc2e484887fc4008e8942915d1b9f6d235205e99b89e59c48c8979.json Add SQLx metadata for SELECT invoicing_enabled FROM users WHERE id = $1.
.sqlx/query-3a4f75221e74290abdb289495f6d35a63e7b1d76b839f2ac9d4aa31d02143866.json Update SQLx metadata for modified query shape.
.sqlx/query-2e7a93e7d7eb046deff882e5e951f46bb5c5e38bf2d7dcfe97f352368393c1d7.json Add SQLx metadata for multi-user query including invoicing_enabled.
.sqlx/query-29dbbb71782841c85e29f1b9830629b5163848c8e2281e17926c22a89fce3428.json Update SQLx metadata for modified query shape.
.sqlx/query-1013312f96fb8b077e8d3d0948e9acee2c4188950e81dc314efcbb01d33aaee9.json Add SQLx metadata for auto-topup user selection query including invoicing_enabled.
Files not reviewed (11)
  • .sqlx/query-1013312f96fb8b077e8d3d0948e9acee2c4188950e81dc314efcbb01d33aaee9.json: Generated file
  • .sqlx/query-29dbbb71782841c85e29f1b9830629b5163848c8e2281e17926c22a89fce3428.json: Generated file
  • .sqlx/query-2e7a93e7d7eb046deff882e5e951f46bb5c5e38bf2d7dcfe97f352368393c1d7.json: Generated file
  • .sqlx/query-3a4f75221e74290abdb289495f6d35a63e7b1d76b839f2ac9d4aa31d02143866.json: Generated file
  • .sqlx/query-56c4c894f4dc2e484887fc4008e8942915d1b9f6d235205e99b89e59c48c8979.json: Generated file
  • .sqlx/query-598de7b5ac212cc23bc35777ef69bde1c3e66e3c8dfed78d18533bc28273850f.json: Generated file
  • .sqlx/query-65bf99c8e1eff69c58dbb62e8737abcb9ccd1a3feb5f3be6b4d69801c7e5d900.json: Generated file
  • .sqlx/query-6b92359c98bbafbe74b541a87d7b503151da51c0b242599a3531c15efe999d23.json: Generated file
  • .sqlx/query-8222c47b243451ed39721bfa8cedd046d712bb443c1fd14b2690dc69dcac6879.json: Generated file
  • .sqlx/query-a0064aaaeb73454786a5d770d2065da9e1b586370213c0126e102ff522c7ca51.json: Generated file
  • .sqlx/query-df59ccd8080d8eb0bd1e11ab6e5a2b8fcdfbb3ae2c95a05ccd9f76042320f41a.json: Generated file

Comment on lines +4 to +8
-- Enterprise customers with PO processing can't pay by card on demand. For
-- them, top-ups and auto top-ups create a Stripe invoice with
-- `collection_method = send_invoice`; Stripe emails it and handles the payment
-- link, reminders and receipts. Credits land when the invoice is paid, not
-- when it is issued.
Comment on lines +16 to +17
COMMENT ON COLUMN users.invoicing_enabled IS
'Bill this account by emailed Stripe invoice rather than an immediate card charge. Enabled manually after approval; see payment_providers::PaymentProvider::create_and_send_invoice.';
Comment on lines +913 to +923
if let Err(e) = Credits::new(&mut *conn).create_transaction(&request).await {
// The accrual landed at the provider, so the customer will be
// invoiced for credits they never received. Needs manual repair,
// hence Critical rather than a quiet warning.
crate::background_error!(
AUTO_TOPUP, "invoice_credit_grant", Critical,
user_id = %user.id,
error = %e,
"Accrued an invoice item but failed to grant the credits"
);
}
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