feat: bill contracts from billing segments - #6448
Closed
brunomiguelpinto wants to merge 1 commit into
Closed
brunomiguelpinto wants to merge 1 commit into
brunomiguelpinto wants to merge 1 commit into
Conversation
Contributor
|
Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green HOLD — three correctness gaps need fixing:
|
The billing-segment producer writes rows, but nothing consumes them: the consumer service has been on main for a while with no trigger, so a card that comes due writes its segment and stops there, and the only way to reach an invoice is a console call. Product-catalog QA also lost its two endpoints. The cycles preview and the billing run only ever existed for subscriptions and were never ported when subscriptions became contracts, so there is no way to see what a contract will bill, or to fast-forward one, from the API. The consumer now runs on its own hourly tick, fanning out one job per customer holding a pending segment. It is a tick of its own rather than a continuation of the producer's, because segments are also written outside the clock and the customers who owe an invoice are not the customers whose cards came due. Neither end filters by billing instant, so a segment cannot sit unread because two queries disagree on what ready means. The preview endpoint answers what the calendar would produce over a window, writing nothing. It reads the schedule alone, so a contract that has already billed previews exactly like one that has not. Its rows are segments rather than cycles: each carries its cycle index, and a rate change splits one cycle into two rows, which the old name hid. The billing endpoint runs the producer and the consumer in sequence up to a date. It takes no start date, because each card resumes from its own clock. Billing stays customer-grained, as the consumer groups a customer's segments into as few invoices as their contracts allow, so a customer's other contracts bill in the same run exactly as they would on the clock. The card selection splits in two: what the calendar can schedule at all, and what is additionally due. The preview asks only for the first.
brunomiguelpinto
force-pushed
the
billing-segments-consumer
branch
from
September 21, 2026 16:12
05beeb2 to
c502277
Compare
Contributor
|
Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green HOLD — the production fan-out and preview endpoint disagree with the lifecycle rules of the code they call.
|
brunomiguelpinto
added a commit
that referenced
this pull request
Sep 22, 2026
## Context The producer writes billing segments on its own hourly tick ([#6348](#6348)), but nothing turned them into invoices — `BillingSegments::ProcessService` was only ever reachable from a spec. Second slice of [#6448](#6448), on top of the index in [#6462](#6462). The QA endpoints follow separately. ## Description An hourly tick reads which customers are owed an invoice and fans out one job per customer, which invoices that customer's whole set. - **Runs at `*:17`, five minutes behind the producer,** so a card that comes due is invoiced in the same hour. - **Retries on `Sequenced::SequenceError`.** The service finalizes inline and numbering serialises per billing entity; without it a collision leaves an invoice stuck in `generating`. - **Skips deleted customers.** Deleting one leaves their segments unbilled and the job then raises `RecordNotFound`, once an hour, for good. Same hole the producer had. ### Why the model and the service are in here The scan and the consumer have to agree on what is owed, or the clock offers work the consumer will not do — a customer enqueued every hour for a run that does nothing. Two ways that happened, both caught in review: - the scan read `pending` while the segments are also owed in `processing` - the consumer skips metered usage billed in advance, which is priced per event and never leaves its state, while the scan did not So eligibility is now one scope on `BillingSegment`, read from both ends, instead of two predicates that can drift. The service loses its own copy. The scenario spec now invoices through the consumer's tick instead of calling the service by hand, so the whole pipe is covered. ## Follow-ups - Segments of a deleted customer stay unbilled. Clearing them belongs to `Customers::TerminateRelationsService` and needs a `canceled` status — separate PR. - Invoices left `generating` are recovered by the job's retry, not by the scan. - `billing_timing` and `product_type` could be denormalised onto `BillingSegment` to drop the joins the scope needs — [@tiagolupepic](https://github.com/tiagolupepic)'s suggestion, separate PR. - The fan-out scan is unpaged; `miguel-engine-v2` keyset-pages it if it ever hurts. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
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.
Context
#6348 produces billing segments, but nothing consumes them.
BillingSegments::ProcessServicehas been on main for a while with no trigger: a card that comes due writes its row and stops there, and the only way to turn that row into an invoice is a console call.Mike also lost the two endpoints he runs product-catalog QA with.
/cyclesand/billonly ever existed for subscriptions, on themiguel-billing-date-enginebranch, and were never ported when subscriptions became contracts — so there is no way to preview what a contract will bill, or to fast-forward one, from the API.Stacked on #6348.
Description
The consumer runs on the clock.
Clock::ProcessBillingSegmentsJobfans out oneBillingSegments::ProcessJobper customer holding a pending segment, mirroring the producer's shape. It is a tick of its own rather than a continuation of the producer's, because a segment is also written outside the clock and the customers who owe an invoice are not the customers whose cards came due. It runs five minutes behind the producer, so a card that comes due is invoiced in the same hour. Neither end filters bybilling_at, so a segment cannot sit unread because two queries disagree on what "ready" means.GET /api/v2/contracts/segmentspreviews what the calendar would produce over a window without writing anything, per contract or for several throughexternal_ids. It answers from the schedule alone: a contract that has already billed previews exactly like one that has not. That is what the newSchedule#segments_overlappingis for — unlikesegments_due_byit does not resume from the stored high-water mark, because a preview must answer for the window it was asked about.The rows are
segments, notcycles: each one carriescycle_indexinside it, and after a rate change a single cycle yields two rows. Naming them cycles would hide the distinction the engine is built on. It is also notbilling_segments, which is reserved for the stored rows if we ever expose them.POST /api/v2/contracts/billruns the producer and the consumer in sequence up toend_onand returns the invoices. There is no start date: each card resumes from its own clock, so the only question a caller can answer is how far forward to go.Billing is customer-grained, not contract-grained — the consumer groups a customer's segments into as few invoices as their contracts allow. The contracts asked for select the customers, so a customer's other contracts bill in the same run, exactly as they would on the clock. A spec pins that. Anything narrower would be a path production never takes.
ContractRateCard.due_for_billingkeeps its behaviour but now sits on a new.schedulablescope — live, priced, and with a window to bill in. Being due adds the clock and the contract's status on top; the preview asks only for the first part.Known gaps
Invoices come back without their fee lines.
V1::FeeSerializerreads the fee's subscription and a v2 fee hangs off a billing segment instead, so including them raises. That is BIL-457.A mid-cycle rate change on an
advancecard still splits the cycle and bills each half a whole period. The rule was settled this week — advance does not split, what is paid stays paid — but the fix is a separate change inScheduleand is not in here. This is the first PR that puts that behaviour in front of QA, so expect it to be found.🤖 Generated with Claude Code