Skip to content

Fix renewal periods, pending-item sweep, repeating coupons and canceled_at (#12–#15) - #17

Merged
scosman merged 5 commits into
mainfrom
claude/happy-ramanujan-u8vjnc
Oct 17, 2026
Merged

scosman merged 5 commits into
mainfrom
claude/happy-ramanujan-u8vjnc

Conversation

@scosman

@scosman scosman commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

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 — Stamp canceled_at when a cancellation is requested (#15)

  • cancel_at_period_end: true or a cancel_at instant stamps canceled_at = request time and cancellation_details.reason = cancellation_requested (cassette 12 step 12).
  • Reactivating clears both (step 13).
  • When the deferred cancel fires, it keeps the request's canceled_at and sets only ended_at.
  • Create honours cancel_at_period_end. Schedule release clears a pending cancel's stamp.
  • The **.*_at allowed difference now permits only two instants or two nulls, so cassette 12 actually checks canceled_at. The one collapse in 01 that this exposed gets its own scoped entry.
  • Fixtures: only canceled_at values change.

3376247 — Bill a renewal's lines for the period that starts at its boundary (#12)

  • advance_cycle rolls item periods before minting, so renewal lines bill [boundary, next boundary). The invoice-level period stays the look-back [previous start, boundary].
  • Trial-end and resume invoices stop stamping their own period forward. The probe commit below refines these.
  • Cancel credits now link to their own subscription item's line. The mislink predated this PR; this change made it reachable. Regression tests cover it.
  • Fixture checks:
    • The trial-end exception is dropped from "no overlapping cycle periods".
    • A new "no overlapping item line periods" check is added ($0 trial lines excluded).
  • Fixtures: periods, credited_items and events change; no ids or amounts.

9ba384e — Sweep pending invoice items into a subscription's own invoices (#14)

  • Sweep scope: subscription invoices sweep the customer's pending items in the invoice currency that are untagged or tagged to this subscription. Items tagged to another subscription stay pending, as cassette 01 records; a customer-wide sweep fails that replay.
  • Where it applies: create (paid, trial, send_invoice), both trial-end paths, renewal, resume, conversion, and always_invoice. The always_invoice sweep no longer takes other subscriptions' prorations.
  • Discounts: compute_totals honours discountable. Negative items default to non-discountable, and prorations are never discounted.
  • Trial creates: a trial create that sweeps an item bills it and applies the create's coupons.
  • Cancel credits: prorate-only cancel credits keep their subscription in the stored parent, while the wire still answers the recorded parent: null.
  • Line ids: lines backed by an invoice item are keyed by ii_ in rebuild_invoice_lines, so a credit/debit pair no longer shares an il_. A new check, "line ids are unique", enforces it.
  • Fixtures:
    • small_co drops the BillPending beat. Anika's setup fee rides her first invoice, and LAUNCH halves it.
    • big_co sweeps 274 pending prorations, which shifts later simulated draws. Review traced every change back to the sweeps.

5f1f82e — End repeating coupons and count coupon redemptions (#13)

  • Discount window:
    • Discounts carry start/end; a repeating coupon's end = start + duration_in_months.
    • A discount applies while the invoice's billing instant is before end.
    • An ended discount is hidden at read time, pruned at the next invoice, and pruned at fixture finish.
  • Redemptions: times_redeemed counts each minted discount. max_redemptions is enforced with coupon_expired, and valid is computed at read time.
  • Fixtures:
    • LAUNCH and SWITCH25 are restored to repeating, 3 months, per the spec. LAUNCH is redeemed 10 times and TEACHWELL25 5.
    • empty is rebuilt for the schema-comment hash only.
  • New checks: coupon valid at NOW; no discount past its end; invoice discounts inside their windows.

4086573 — Align coupons and trial-end anchors with live Stripe probes

Probes 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:

  • Refusal messages: a coupon past redeem_by → "Coupon X is expired and cannot be applied."; a used-up coupon → "Coupon X is used up and cannot be applied."
  • Re-sending a coupon on update: a coupon the subscription already carries keeps its discount (same di_, start and end). It counts no redemption and is accepted even when used up. Only new coupons mint and redeem.
  • Update-driven trial end (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_anchor moves 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.
  • $0 trial lines list a zero-amount entry per invoice discount. This is the only fixture change: 1 small_co invoice and 14 big_co invoices.
  • Annotations: confirmed rulings are now marked "probed via the Stripe MCP" in code, allowed_differences.py and billing_engine.md.

Live probe results (Stripe MCP, test mode)

Ruling Result
Paid create sweeps untagged pending items (newest item first, then the plan line) ✅ confirmed
Coupon discounts a discountable swept item, not a discountable: false one; negative items default discountable: false ✅ confirmed
Trial create sweeps a pending item, charges it and stays trialing; coupons apply there; a once-coupon is consumed ✅ confirmed
Natural trial end: invoice [trial start, trial end], subscription_cycle, lines on the new period, pending item swept ✅ confirmed
Update-driven trial end invoice period ❌ was [trial start, now], now [now, now]
canceled_at: flag and cancel_at stamp the request time; a metadata update doesn't re-stamp it; cancel_at: "" clears everything; create with the flag; cancel_at firing keeps canceled_at ✅ confirmed
Repeating end = start + N calendar months; times_redeemed/valid at max and past redeem_by ✅ confirmed
Refusal messages ❌ fixed (see above)
Re-sending an already-applied coupon ❌ fixed (see above)

Still unprobed:

  • The discount at exactly end. The MCP can't advance test clocks.
  • The send_invoice and failed-charge trial creates.
  • Line order beyond the cases above.
  • The day clamp on end.
  • A coupon listed twice in one request.
  • Resume's invoice period.
  • The update-driven cancel end's anchor.

Known gaps left in place (declared, not fixed)

  • Coupons: always_invoice and conversion invoices apply no subscription coupon to swept items.
  • Trial creates: a failed swept-item charge ignores payment_behavior. A trial create with nothing to discount still uses up a once-coupon.
  • Resume invoices don't record their discounts or tax rates. This predates the PR, and no fixture holds one.
  • cancel_at:
    • A cancel_at before the period end doesn't prorate or truncate the item period; the probes showed Stripe does both.
    • A bare cancel_at instant (without the flag) never fires.
  • Renewal anchor drift: renewals chain from the previous boundary rather than billing_cycle_anchor, so day 29–31 anchors drift. A follow-up is suggested.
  • Wire shape:
    • Plan lines don't copy the subscription's metadata.
    • Zero-amount discounts are left out of total_discount_amounts.
    • There are no customer.discount.* events, and expand[]=discounts returns ids.
  • Replay coverage: the **.billing_cycle_anchor allowed 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) and uv run seahaven check all pass on the final tree.

🤖 Generated with Claude Code

https://claude.ai/code/session_01U4HoEqzZETUSNyGMXAiJRe

scosman and others added 5 commits October 15, 2026 23:02
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant