feat(payments): scaffold invoice billing for enterprise accounts - #1429
Open
sejori wants to merge 3 commits into
Open
feat(payments): scaffold invoice billing for enterprise accounts#1429sejori wants to merge 3 commits into
sejori wants to merge 3 commits into
Conversation
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.
Deploying control-layer with
|
| 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 |
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
marked this pull request as ready for review
August 5, 2026 14:43
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). |
Contributor
There was a problem hiding this comment.
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(defaultfalse) and plumb it through DB/repo + API models. - Extend
PaymentProviderwithaccrue_invoice_item, implemented for Stripe and the dummy provider. - Update auto top-up processing to accrue invoice items + grant credits immediately, and refuse manual
/paymentscheckout with409for 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" | ||
| ); | ||
| } |
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.
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_itemcreates a pending Stripe invoice item — noinvoicefield — 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_enabledis set by us after approval rather than self-served.What's here
users.invoicing_enabled, defaultfalse. Orgs are rows inusers, so billing an org works the same way.accrue_invoice_itemPaymentProvider, implemented for Stripe and the dummy provider409The 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
auto_advance: trueon the generated invoice, Stripe then handles delivery, reminders and receipts with no scheduler on our side.Test plan
cargo test -p dwctl— 1942 passed, 0 failedjust lint rust— cleanInert until the column is set, so it's safe to land ahead of the remaining work.