Skip to content

feat: bill contracts from billing segments - #6448

Closed
brunomiguelpinto wants to merge 1 commit into
mainfrom
billing-segments-consumer
Closed

brunomiguelpinto wants to merge 1 commit into
mainfrom
billing-segments-consumer

Conversation

@brunomiguelpinto

Copy link
Copy Markdown
Contributor

Context

#6348 produces billing segments, but nothing consumes them. BillingSegments::ProcessService has 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. /cycles and /bill only ever existed for subscriptions, on the miguel-billing-date-engine branch, 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::ProcessBillingSegmentsJob fans out one BillingSegments::ProcessJob per 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 by billing_at, so a segment cannot sit unread because two queries disagree on what "ready" means.

GET /api/v2/contracts/segments previews what the calendar would produce over a window without writing anything, per contract or for several through external_ids. It answers from the schedule alone: a contract that has already billed previews exactly like one that has not. That is what the new Schedule#segments_overlapping is for — unlike segments_due_by it 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, not cycles: each one carries cycle_index inside 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 not billing_segments, which is reserved for the stored rows if we ever expose them.

POST /api/v2/contracts/bill runs the producer and the consumer in sequence up to end_on and 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_billing keeps its behaviour but now sits on a new .schedulable scope — 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::FeeSerializer reads 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 advance card 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 in Schedule and 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

@lago-claude-ai-agent

Copy link
Copy Markdown
Contributor

Automated pre-review (advisory, not a required check) — verdict: HOLD · CI green

HOLD — three correctness gaps need fixing:

  • Contracts::BillService returns the stale invoice instances created before ProcessService re-queries and finalizes them, so the new endpoint can serialize status: "generating" for a finalized or closed invoice. Reload the returned invoices and cover the response status.
  • ProcessBillingSegmentsJob selects advance-metered pending segments that ProcessService deliberately excludes; those rows remain pending and cause a useless job every hour. Align the fan-out scope with the consumer and add coverage.
  • PreviewService#cards uses schedulable(to), which drops a card ending during the requested window even though its earlier segments overlap the window. Select cards by window overlap and add an ended-card regression test.

Base automatically changed from billing-segments-scheduler to main September 21, 2026 15:27
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
brunomiguelpinto force-pushed the billing-segments-consumer branch from 05beeb2 to c502277 Compare September 21, 2026 16:12
@lago-claude-ai-agent

Copy link
Copy Markdown
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.

  • Clock::ProcessBillingSegmentsJob#pending_customer_ids must exclude advance-metered segments just as ProcessService#pending_segments does, and cover that case. Those rows intentionally remain pending, so the current hourly scan re-enqueues their customers forever without processing anything.
  • PreviewService#cards must select cards whose lifetimes overlap the full from...to window, with a regression test for an ended/replaced card. Calling schedulable(to) applies current_and_scheduled(to), which omits an ended card even when its segments overlap the requested window.
  • Validate start_on/end_on before calling to_date and reject malformed or reversed windows with an API error. The current controller raises on malformed public input, and these failure paths have no coverage.

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)
@brunomiguelpinto
brunomiguelpinto deleted the billing-segments-consumer branch September 22, 2026 15:08
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.

1 participant