Repository navigation
Fix renewal periods, pending-item sweep, repeating coupons and canceled_at (#12–#15) - #17
Merged
Merged
Conversation
Fixes #15. Setting cancel_at_period_end: true left canceled_at null until the subscription ended, then stamped the period end. Real Stripe stamps the request's instant (cassette 12 step 12) and clears it on reactivation (step 13). - apply_update: a request that schedules a cancel (cancel_at_period_end: true or a cancel_at instant) stamps canceled_at = now and reason cancellation_requested; one that clears it (cancel_at_period_end: false or cancel_at: "") nulls canceled_at, cancel_at, the flag and the reason. Updates that name neither field leave the stamp alone (unprobed). - create_subscription: cancel_at_period_end sets cancel_at to the first period end and stamps canceled_at and the reason, as on update. - advance_cycle: the deferred cancel keeps the request's canceled_at and sets only ended_at to the boundary. - Schedule release clears a pending cancel's canceled_at and reason via the new subscription_lifecycle.clear_pending_cancel. - Conformance: the **.*_at allow-list entry now permits only two instants or two nulls, so cassette 12 checks canceled_at; the one other exposed pair (01_proration_half_cent marked_uncollectible_at) gets a scoped entry beside the invoice_now collapse declaration. - Fixtures: small_co and big_co rebuilt through the world (canceled_at changes only); guide rows rewritten; #15 removed from the known gaps. - Specs: billing_engine cancel rows, conformance allow-list table, and the company_fixtures catch-up note updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4HoEqzZETUSNyGMXAiJRe
Fixes #12. advance_cycle minted the renewal invoice before rolling the item periods, so every renewal line repeated the period the previous invoice had billed and the current period sat on no line. Licensed prices bill in advance. - advance_cycle: roll the item periods to the boundary first, then mint the renewal, so its lines carry [boundary, next boundary). The invoice's own period stays [previous start, boundary]. Draft-renewal rebuilds, proration back-links and the paid hook's update event now see item periods that match the lines (unprobed: no renewal body is recorded). - trial_end_now (natural boundary and trial_end=now): the invoice's own period looks back over the trial, [item period start, moment], instead of the forward-stamped new period. resume_subscription likewise looks back to [current item period start, now]; its line periods are as before. - Cancel credit back-links: the cancel now scopes each plain credit's lookup to the subscription item it credits, so the final invoice's line can no longer name another subscription's line ending on the same boundary. An item no plain line billed still names its subscription and item. A credit swept by a later invoice keeps the customer-wide match (documented as a known gap). - Fixture checks: "no overlapping cycle periods" loses its trial-end exception. A new "no overlapping item line periods" check fails two non-proration, non-zero lines of one item whose periods overlap; a $0 trial line bills nothing, so an early trial end passes. - Tests: renewal line and invoice periods, a draft renewal keeping its line id through a rebuild, credits linking to the renewal line, the two-subscription cancel case (first period and after a renewal), later sweeps, the trial-end and resume look-back periods, and the new check. - Fixtures: small_co and big_co rebuilt through the world (line periods, trial-end invoice periods, credited_items and events only; no ids or amounts changed); guide rows unchanged. - Docs and specs: the #12 known gap removed from docs/fixtures.md, the catch-up note marked fixed, billing_engine rows updated, and the new invariant added to the company_fixtures functional spec and replay.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4HoEqzZETUSNyGMXAiJRe
Fixes #14. A pending invoice item (POST /v1/invoiceitems with no invoice) was never pulled into a subscription invoice; only the always_invoice proration path swept, and that sweep was customer-wide, so one subscription's proration invoice took another subscription's prorations. - One sweep rule (invoicing.subscription_pending_items): the customer's pending items in the invoice's currency that are untagged or tagged to this subscription. Items tagged to another subscription stay pending (recorded, cassette 01 and probe_proration); other-currency items stay pending (unprobed). - Applied on subscription create (paid, trial, send_invoice), both trial-end paths, renewal, resume, conversion and the always_invoice proration invoice. The cancel invoice_now final invoice and the manual POST /v1/invoices are unchanged. Swept items become their own lines, newest first, ahead of the plan lines (the include order; unprobed), attached by create_invoice(attach=...) before invoice.created fires. Plan lines are never rebuilt, so trial lines stay $0. - Trial create that sweeps an item: the trial's first invoice bills it with the create's coupons applied as on a paid create (a once-coupon is consumed there instead of parked), is charged or left open for send_invoice, and the subscription stays trialing (unprobed). With nothing swept, the recorded $0 invoice and parked once-coupon stand. - Discounts: compute_totals honours each line's discountable, so a subscription coupon reaches a discountable swept item but never a proration. Negative invoice items (and add_lines lines) now default to discountable: false. always_invoice and conversion invoices still apply no coupon to swept items (declared). - Prorate-only cancel credits keep their subscription and item in the stored parent while the wire answers the recorded parent: null, so no other subscription's invoice sweeps them and a later include sweep links the right item. The credit_items plumbing is gone. - Line ids: rebuild_invoice_lines keys every invoice-item-backed line by its ii_, so a proration credit/debit pair no longer shares one il_. New fixture check "line ids are unique". - _final_invoice_on_cancel's docstring no longer claims a create swept customer-wide. - Tests: the sweep on every path, tagged items staying pending through another subscription's create, renewal and always_invoice and riding their own renewal, coupons and discountable, trial-create coupons, negative-item defaults, cancel credits, line-id stability through rebuilds, and the new check. - Fixtures rebuilt: small_co drops the BillPending beat and its invoice; Anika's setup item is created before she subscribes and rides her first invoice (LAUNCH takes half off it). Elena's item stays pending for her 09-10 renewal. big_co sweeps 274 pending prorations (272 at renewals, 2 on always_invoice invoices; 42 remain, all due after NOW); totals, charges, ledger and payouts change, and later simulated draws shift (some statuses, refunds and disputes). Guide rows regenerated. - Docs and specs: the #14 known gap removed from docs/fixtures.md, the catch-up note marked fixed, billing_engine updated to the tag-aware rule, the new invariant in the company_fixtures spec and replay.md, and the unprobed rulings declared in allowed_differences.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4HoEqzZETUSNyGMXAiJRe
Fixes #13. A `duration: repeating` discount never ended (its stored object had no `end`, and `_persisted_discount_specs` never read `duration_in_months` despite its docstring), and applying a coupon never incremented `times_redeemed`; `max_redemptions` and `valid` were never honoured. - Discount windows: DiscountSpec carries the discount's own start/end; a repeating discount ends duration_in_months calendar months after it starts (add_interval, day clamped; trial time included), once/forever have no end. The stored object gains `end` (Unix, null otherwise), and invoice-side objects keep the discount's real start/end. - A subscription invoice applies a persisted discount only while its billing instant is before `end`: the boundary for renewals and the walker's trial end, the request time for update-driven trial end, conversion and resume. A 3-month coupon on a monthly plan discounts the create invoice and two renewals; the renewal exactly at `end` bills full price (unprobed). The invoice prunes the ended discount from subscription.discounts before minting; invoices keep theirs, a draft rebuild included. - Review round: an ended discount is hidden at read time (subscription.discounts omits it once `end` passes, by the reading call's clock) and a new fixture finishing step, prune_ended_discounts, drops it from the stored row at NOW. Stripe removes it at `end` with customer.discount.deleted (unprobed; this world emits no discount events). - Redemptions: each discount minted from a coupon (create, or an update passing `discounts`) counts one redemption; an update that omits `discounts` keeps the row's own uncounted. A coupon at max_redemptions refuses with coupon_expired (unprobed message). - coupon.valid is computed at read time (not deleted, before redeem_by, under max_redemptions); the stored column is that rule as of the last write, and a new finishing step, refresh_coupon_validity, stores it at NOW. The schema comment now says so, which changes the schema hash. - Declared edge case: renewals chain each period from the previous boundary rather than from billing_cycle_anchor, so on a day 29-31 anchor a 3-month window can cover a fourth renewal (anchoring left for a follow-up). - Fixture checks: "coupon valid agrees with is_valid at NOW", "no subscription carries a discount past its end at NOW" and "invoice discounts are inside their windows", each with a planted violation. - Tests: the 3-month monthly window with swept items, annual plans, trial end via the walker and via update, conversion and resume after the window, draft rebuilds and reads past the window, deleted coupons, redemption counting on create/update, max_redemptions, the read-time valid, the finishing steps, and small_co's LAUNCH facts. - Fixtures rebuilt (empty for the schema hash): LAUNCH is 50% off for 3 months again and SWITCH25 25% off for 3 months. small_co: LAUNCH redeemed 10 times and valid: false at NOW, TEACHWELL25 5; monthly LAUNCH subscribers pay full price from the June renewal. big_co: SWITCH25 redeemed 15 times (37 invoices carry it); status, invoice, charge, dispute, refund and payout counts unchanged. Guide gains coupon and LAUNCH-subscriber landmarks; rows regenerated. - Docs and specs: the #13 known gap removed from docs/fixtures.md, the catch-up note marked fixed, billing_engine, data_model, replay.md and the company_fixtures functional spec updated, and the unprobed rulings declared in allowed_differences.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4HoEqzZETUSNyGMXAiJRe
Live probes of the Stripe test-mode sandbox (Stripe MCP, 2026-10-16/17) contradicted several coupon and trial-end rulings from #12-#15. This aligns the world with them and records what they confirmed. - Refusal messages: a coupon past its redeem_by is refused "Coupon X is expired and cannot be applied."; one at max_redemptions "Coupon X is used up and cannot be applied." (code coupon_expired kept; the MCP answers only the message, so type, status, param and code are unprobed). - Re-send rule: an update's discounts entry for a coupon the subscription already carries a live discount from keeps that discount unchanged (same di_, start and end), counts no redemption and is not refused, even when the coupon is used up. Only coupons it does not carry mint, are checked and count; omitted coupons are dropped; a coupon listed twice keeps its discount and mints a second (unprobed). Create is unchanged. - Invoice period: an update-driven trial end (trial_end: "now") stamps [now, now], as a create does; resume does too (unprobed, by analogy). The natural trial end keeps [trial start, trial end]. - billing_cycle_anchor moves to the update instant on an update-driven trial end: charged (recorded, cassette 12 seq 29; probed), send_invoice (unprobed) and pause (recorded, cassette 12 seq 39). The resume-unchanged test now pauses at the natural end to keep proving the stored anchor. - Zero discount entries on $0 trial lines: a trial create that sweeps nothing now lists [{0, di}] in each line's discount_amounts and pretax_credit_amounts. This is the one fixture change: 1 small_co invoice and 14 big_co invoices (plus their event snapshots), rebuilt through the world; docs/fixtures.md is unchanged. - Rulings now marked probed in code comments, allowed_differences.py and billing_engine.md: the #14 sweep order on a paid create and a trial create, coupons on discountable vs discountable: false swept items, the negative-item default, the trial-create sweep with its coupon and once-coupon consumption; the #12/#14 natural trial end; the #15 canceled_at stamps (cancel_at instant, flag, metadata-only update, cancel_at "" alone, create with the flag) and the cancel firing; the #13 repeating end, times_redeemed/valid and both messages. Rulings still unprobed stay marked so. Refs #12, #13, #14, #15 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4HoEqzZETUSNyGMXAiJRe
This was referenced Oct 17, 2026
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.
Fixes #12. Fixes #13. Fixes #14. Fixes #15.
Four fidelity fixes to the billing engine, one commit each. A fifth commit aligns them with live probes of a Stripe test-mode sandbox. Each commit was implemented, code-reviewed and re-reviewed until clean. Every commit that changes fixture output rebuilds the company fixtures through the world's code and regenerates the guide, so each one stands alone.
Commits
ce487af— Stampcanceled_atwhen a cancellation is requested (#15)cancel_at_period_end: trueor acancel_atinstant stampscanceled_at= request time andcancellation_details.reason = cancellation_requested(cassette 12 step 12).canceled_atand sets onlyended_at.cancel_at_period_end. Schedule release clears a pending cancel's stamp.**.*_atallowed difference now permits only two instants or two nulls, so cassette 12 actually checkscanceled_at. The one collapse in 01 that this exposed gets its own scoped entry.canceled_atvalues change.3376247— Bill a renewal's lines for the period that starts at its boundary (#12)advance_cyclerolls item periods before minting, so renewal lines bill[boundary, next boundary). The invoice-level period stays the look-back[previous start, boundary].credited_itemsand events change; no ids or amounts.9ba384e— Sweep pending invoice items into a subscription's own invoices (#14)send_invoice), both trial-end paths, renewal, resume, conversion, andalways_invoice. Thealways_invoicesweep no longer takes other subscriptions' prorations.compute_totalshonoursdiscountable. Negative items default to non-discountable, and prorations are never discounted.parent, while the wire still answers the recordedparent: null.ii_inrebuild_invoice_lines, so a credit/debit pair no longer shares anil_. A new check, "line ids are unique", enforces it.BillPendingbeat. Anika's setup fee rides her first invoice, andLAUNCHhalves it.5f1f82e— End repeating coupons and count coupon redemptions (#13)start/end; a repeating coupon'send= start +duration_in_months.end.times_redeemedcounts each minted discount.max_redemptionsis enforced withcoupon_expired, andvalidis computed at read time.LAUNCHandSWITCH25are restored to repeating, 3 months, per the spec.LAUNCHis redeemed 10 times andTEACHWELL255.emptyis rebuilt for the schema-comment hash only.validat NOW; no discount past its end; invoice discounts inside their windows.4086573— Align coupons and trial-end anchors with live Stripe probesProbes of the Stripe test-mode sandbox through the Stripe MCP (2026-10-16/17) confirmed most rulings and contradicted a few. This commit fixes the contradictions:
redeem_by→ "Coupon X is expired and cannot be applied."; a used-up coupon → "Coupon X is used up and cannot be applied."di_, start and end). It counts no redemption and is accepted even when used up. Only new coupons mint and redeem.trial_end: "now"): the invoice period is[now, now], as a create's is. Resume does the same, by analogy (unprobed). The natural trial end keeps[trial start, trial end].billing_cycle_anchormoves to the update instant on an update-driven trial end. This is recorded in cassette 12 seq 29 (charged) and seq 39 (pause), and probed for the charged case.allowed_differences.pyandbilling_engine.md.Live probe results (Stripe MCP, test mode)
discountable: falseone; negative items defaultdiscountable: falsetrialing; coupons apply there; a once-coupon is consumed[trial start, trial end],subscription_cycle, lines on the new period, pending item swept[trial start, now], now[now, now]canceled_at: flag andcancel_atstamp the request time; a metadata update doesn't re-stamp it;cancel_at: ""clears everything; create with the flag;cancel_atfiring keepscanceled_atend= start + N calendar months;times_redeemed/validat max and pastredeem_byStill unprobed:
end. The MCP can't advance test clocks.send_invoiceand failed-charge trial creates.end.cancelend's anchor.Known gaps left in place (declared, not fixed)
always_invoiceand conversion invoices apply no subscription coupon to swept items.payment_behavior. A trial create with nothing to discount still uses up a once-coupon.cancel_at:cancel_atbefore the period end doesn't prorate or truncate the item period; the probes showed Stripe does both.cancel_atinstant (without the flag) never fires.billing_cycle_anchor, so day 29–31 anchors drift. A follow-up is suggested.total_discount_amounts.customer.discount.*events, andexpand[]=discountsreturns ids.**.billing_cycle_anchorallowed difference accepts any integer pair, so replay wouldn't catch an anchor regression.Checks
uv run ruff format --check && uv run ruff check,uv run ty check,uv run pytest(full suite, including all cassette replays and fixture byte-identity rebuilds) anduv run seahaven checkall pass on the final tree.🤖 Generated with Claude Code
https://claude.ai/code/session_01U4HoEqzZETUSNyGMXAiJRe