From fa614d0fb7287c7b473e1d56871f7dc79dbebd4a Mon Sep 17 00:00:00 2001 From: Christian Galo Date: Wed, 28 Oct 2026 02:18:02 -0500 Subject: [PATCH] Ask for a subscription reconcile from every act that changes who holds a ladder Fourth section of #269's change. Every act that can change who holds a ladder now writes one reconcile_subscription outbox entry per live subscription of the organization (active, trialing, past_due or unpaid, in id order), in the act's own transaction, so each subscription's collection pause is decided again after the act commits. The entry is an insert and takes no lock. The acts: issuing a grant (entitlements.IssueGrant, which the issue handler now calls in place of ConferGrant), managing, revoking and expiring one, the legacy grant a default change confers, and removing a tier from a ladder. ConferGrantTx itself requests nothing, because the conferral and provisioning paths that call it run before the subscription exists or reconcile it themselves. In the reconcile, the ended branch and the surplus end request the organization's other subscriptions, excepting the one being reconciled. The screens utility waits until the application database holds no pending or failed outbox row before its first capture, because the demo seed's revocation and expiry now leave two entries the running app's relay completes on its own schedule. docs/stripe.md rewrites the grant over a paid plan: the console pauses the subscription's collection while the grant holds, and the costs that pause accepts are listed. docs/models/plan-ladders-transitions.md, docs/plan-management.md, docs/models/payments-billing.md and docs/database-locks.md say the same. Tests: TestActsRequestSubscriptionReconciles (six acts, two live subscriptions and a canceled one, ids and creation order differing), TestConferGrantTxRequestsNothing, TestReconcileEndedRequestsTheOtherSubscriptions, TestSurplusEndRequestsTheOtherSubscriptions, TestIssueGrantHandlerRequestsAReconcile, and the demo seed's entries. Seven trace goldens gain the ListLiveSubscriptionsOfPool read. Review: no blocker or major; six minors and six nits applied (a step name for the tier removal's request error, the fixture's id order, doc comments and the docs that still said a grant over a paid plan keeps billing). Breaks: 17 of 17 caught. Ignoring the exception is caught only through the surplus end: in the ended branch the excepted subscription is already canceled, so the live read never lists it. Full suite on reset databases: 4768 pass, 14 skip. make screens: no change beyond the three captures every run since section 2 redraws. --- docs/database-locks.md | 24 ++- docs/models/payments-billing.md | 2 +- docs/models/plan-ladders-transitions.md | 2 +- docs/plan-management.md | 2 +- docs/stripe.md | 2 +- internal/demoseed/seed_db_test.go | 26 +++ internal/entitlements/conferral.go | 29 +++ internal/entitlements/grant_acts.go | 60 ++++-- internal/entitlements/ladder_acts.go | 51 ++--- internal/entitlements/outbox_requests.go | 25 +++ .../entitlements/outbox_requests_db_test.go | 182 ++++++++++++++++++ internal/entitlements/plan_act_move.go | 9 + .../pool_provision_transitions.sql.go | 39 ++++ internal/entitlements/querier.go | 7 + .../queries/pool_provision_transitions.sql | 15 ++ .../displaced_subscription_test.go | 47 +++++ internal/fulfillment/reconcile.go | 28 ++- .../reconcile_trace/addon_removed.golden | 1 + .../canceled_resumes_grant.golden | 1 + .../canceled_to_default.golden | 1 + .../sweep_trace/already_canceled.golden | 1 + .../testdata/sweep_trace/already_ended.golden | 1 + .../sweep_trace/due_cancellation.golden | 1 + .../testdata/sweep_trace/lost_claim.golden | 1 + internal/server/operator_enrollment.go | 2 +- .../operator_enrollment_issue_db_test.go | 62 ++++++ .../design.md | 2 +- .../tasks.md | 20 +- test/e2e/screens/screens_test.go | 27 +++ 29 files changed, 604 insertions(+), 66 deletions(-) create mode 100644 internal/entitlements/outbox_requests_db_test.go create mode 100644 internal/server/operator_enrollment_issue_db_test.go diff --git a/docs/database-locks.md b/docs/database-locks.md index 47677d34..5df67d79 100644 --- a/docs/database-locks.md +++ b/docs/database-locks.md @@ -114,7 +114,7 @@ transactions run at READ COMMITTED. All of these run in a transaction from `BeginMaterializing`: the operator handlers, `ExpireGrantActivity` and the demo seed. -- **Issue or confer a grant** (`ConferGrantTx`, which `ConferGrant`, +- **Issue or confer a grant** (`ConferGrantTx`, which `ConferGrant`, `IssueGrant`, provisioning and `ReapplyDefaultsForPool` call): the rendezvous, the grant insert, then the pool row inside `core.confer`, then materialization. - **Revoke** (`RevokeGrantTx`): the rendezvous, the organization's default @@ -131,6 +131,15 @@ handlers, `ExpireGrantActivity` and the demo seed. lineage head row, then either `ExtendGrantTx` (inside it `core.confer` and `core.end_conferral`, whose pool lock is already held) or `ExtendWaitingGrantTx` (the same pool row again). +- **The reconcile requests.** Issue (`IssueGrant`), Manage's extension, + Revoke, Expire, a default change's grandfathered legacy grant, a tier + removal and a subscription reconcile that ended one of its own provisions + each write one `reconcile_subscription` row to `core.outbox` per live + subscription of the organization + (`RequestPoolSubscriptionReconciles`), after the act's materialization + (the reconcile's active branch: before it). An outbox request is an insert + in the act's transaction and takes no lock. `ConferGrantTx` requests + nothing. - **Resume replaced grants** (`ResumeReplacedGrantsTx`): called inside the transactions above and in the subscription reconcile. It takes the pool row first and reads the resumable grants only after the lock is held, so an @@ -159,12 +168,15 @@ advisory lock, then the rest by branch. `supersedeReleasedDowngrades` opens a second transaction, takes the subscription advisory lock and supersedes those rows; a released schedule that held none opens no second transaction. It takes no rendezvous and no - pool row: it writes only scheduled-change rows. + pool row: it writes only scheduled-change rows. When a surplus provision + ended, the reconcile requests (inserts, no lock) follow `convergeConferrals`, + before the materialization. - **Ended:** `core.end_conferral` by subscription (the pool row of each provision it ends), then, only when something ended, resumption (the pool row), then the pool row again (`LockPools`, which is the first take when nothing ended), then the floor restoration (`ReapplyDefaultsIfVacant`, - which confers through `core.confer`), then materialization. + which confers through `core.confer`), then materialization, then the + reconcile requests (inserts, no lock) when something ended. - **Suspended:** `reconcileSuspended` takes the pool row (`LockPools`) before `core.sync_source_status('suspended')`, then materializes when it suspended a provision. For a `past_due` or `unpaid` subscription it then makes the @@ -226,7 +238,8 @@ transaction timeout before the rendezvous statement. pool in the same order, the incumbents end (`core.end_conferral`), the remaining positions align (`core.align_conferral_shape`), the floor is restored when the disposition is migrate, with the act's id on the grants it - creates, and the pool materializes. It runs through `runPlanAct`. + creates, and the pool materializes, then the reconcile requests (inserts, no + lock). It runs through `runPlanAct`. - **Tier delete without holders** (`entitlements.DeleteTier`): the exclusive rendezvous, then the reads that decide the delete, all inside the transaction: the count of the tier's live holders (positions that are @@ -278,7 +291,8 @@ transaction timeout before the rendezvous statement. bucket's work (`ReapplyDefaultsIfVacant`, `ConferGrantTx` for a grandfathered incumbent, or `core.end_conferral`, `ReapplyDefaultsIfVacant` and materialization for a migration), which take the same pool row again, - and `core.settle_plan_act_obligation`. A failure on one organization leaves + then, after a grandfathered incumbent's legacy grant, the reconcile requests + (inserts, no lock), and `core.settle_plan_act_obligation`. A failure on one organization leaves the others alone. - **Recording a failed move** (`core.record_plan_act_obligation_failure`): a plain transaction that updates the one obligation row, and only while it is diff --git a/docs/models/payments-billing.md b/docs/models/payments-billing.md index 13af3e2b..d064e4ef 100644 --- a/docs/models/payments-billing.md +++ b/docs/models/payments-billing.md @@ -37,7 +37,7 @@ Database objects sit in the `core` schema (defined in `internal/db/migrations/00 | Invoice reconcile | `ReconcileInvoice`: reads the invoice, its lines and the charges of its payment intent back from the Stripe API at the SDK's version, takes a per-invoice lock, and converges the invoice (created and numbered by the first run that finds it issued, then only moved forward), its lines (inserted once), and one payment per settled charge | `internal/integrations/stripe/workflows/invoice_reconcile.go` | | Invoice sweep | The hourly Temporal schedule (`stripe-invoice-sweep`), also triggered at every boot, that reads back invoice mappings never read back, open invoices last read more than a day ago, and invoices named by dead-lettered invoice events, then fills payment methods and subscription links mapped since; it never renumbers, deletes, or reconciles a subscription. The Stripe provider page states when it last finished | `internal/integrations/stripe/workflows/invoice_sweep.go` | | Outbox drainer | The recurring worker that executes outbox rows (create Stripe product / price / customer), retrying with backoff until a cap parks the row as `dead_letter` for operator-driven retry | `internal/integrations/stripe/workflows/outbox.go` | -| Reconciler | `ReconcileSubscription`: refetches the subscription from the Stripe API and converges console records and provisions to match. It runs on `customer.subscription.created`, `.updated` and `.deleted`, on `checkout.session.completed` for a subscription, on the checkout return (for the customer's most recent subscription), in the scheduled-change sweeper when it reaches a period-end downgrade or fires a term-boundary switch or cancellation, after each member plan change, and from an invoice event's reconcile when the invoice's subscription or one of its line items has no mapping yet. The reconcile writes the collection pause Stripe reports onto the subscription row on every run. A desired product whose ladder another plan took from the subscription and holds is left alone and logged, while the subscription keeps billing; a subscription that pays for two tiers of one ladder gets the higher-ranked one, the other logged as a warning and not conferred (a subscription that alternated between the two before the reconcile chose by rank moves to the higher-ranked tier at its next reconcile and stays there); the reconcile writes in product id order under the pool row | `internal/fulfillment/reconcile.go` | +| Reconciler | `ReconcileSubscription`: refetches the subscription from the Stripe API and converges console records and provisions to match. It runs on `customer.subscription.created`, `.updated` and `.deleted`, on `checkout.session.completed` for a subscription, on the checkout return (for the customer's most recent subscription), in the scheduled-change sweeper when it reaches a period-end downgrade or fires a term-boundary switch or cancellation, after each member plan change, and from an invoice event's reconcile when the invoice's subscription or one of its line items has no mapping yet. The reconcile writes the collection pause Stripe reports onto the subscription row on every run. A desired product whose ladder another plan took from the subscription and holds is left alone and logged; when grants hold every ladder the subscription's conferring products are left alone on, the reconcile asks for its collection to be paused; a subscription that pays for two tiers of one ladder gets the higher-ranked one, the other logged as a warning and not conferred (a subscription that alternated between the two before the reconcile chose by rank moves to the higher-ranked tier at its next reconcile and stays there); the reconcile writes in product id order under the pool row | `internal/fulfillment/reconcile.go` | | Plan-change mechanics | Member-initiated switch and cancel, a preview of the price difference before switching, the commitment gating, and the scheduled-change bookkeeping | `internal/fulfillment/plan_change.go` | | Scheduled-change sweeper | The hourly backstop that fires due scheduled changes Stripe itself cannot fire | `internal/fulfillment/sweep.go`; `internal/workflows/billing/` | | Checkout | Runs the guard checks and starts the Stripe Checkout session; also creates the Stripe customer synchronously if the organization has none yet | `internal/server/billing.go` | diff --git a/docs/models/plan-ladders-transitions.md b/docs/models/plan-ladders-transitions.md index 35198163..2d358ff9 100644 --- a/docs/models/plan-ladders-transitions.md +++ b/docs/models/plan-ladders-transitions.md @@ -77,7 +77,7 @@ An invariant is a rule the model guarantees everywhere; code that would break on 17. **[app]** Every plan act's transition reason comes from one function, `PlanActReason` (`internal/entitlements/plan_act_runner.go`), which builds it from the act's detail row so the wording lives in one place. The three tier acts that write transitions (add, removal, reorder) name the ladder: `tier added to ladder `, `tier removal ( from )` and `plan-ladder tier reorder ()`. The default change names the org type's key: `org-type default change ()`. `DeleteTier` records its act (cause `tier_removed`) but writes no transition, because no one holds the tier, so it builds no reason; a Retry moves no position and has no reason. A move that a drain settles later builds the reason from the original act's detail row, so it reads as the act's own, and a migrated or grandfathered position appends what was done to it (`MigratedReason`, `GrandfatheredReason`). The reason is stored on the transition rows, so it is a write-time snapshot: it records the name as it read then, and a later rename does not rewrite it. An act that cannot read the ladder's row refuses at the read of names and records nothing. 18. **[app]** When a subscription's end, a grant's revocation or a grant's expiry ends at least one provision of that plan, every grant `core.resumable_grants(now())` names with that plan as holder re-confers from its own row, with the reason `grant-resumption:subscription:`, `grant-resumption:revocation:` or `grant-resumption:expiration:` followed by the ending plan's id. The chain rule: a marked grant waits on the nearest plan above it in its chain of replacements that has not ended, and resumes when that plan ends; a plan between them that ends first (it expires, is revoked, or is unmarked while it waits) is passed over. A grant and its extensions are one plan, named by the lineage's head. `core.grant_waits` is the one statement of the walk and the conditions (marked, active, valid at the instant, delivering nothing, its product on at least one ladder whose rungs no source but the holder holds, and first in resumption order among grants sharing a ladder), and the operator's delivery states and the member's held rung read it through the two functions. No other ending path resumes a grant: not a suspension, not a subscription dropping an item, not an extension, not an org-type migration and not a tier removal. 19. **[app]** On a ladder of the product it buys or switches to, a member's own move never takes a pool below a rung a grant delivers: from a grant-held rung the only move offered is up, through Checkout. Checkout never supersedes a position on another ladder of the product unless that ladder offers Checkout too: Checkout of a product refuses unless every ladder it is a tier of offers it. Two exceptions are open (#88), because the decision reads a position only on the ladders it is asked about. The plan switch: a switch to a product that is a tier of several ladders can supersede a position on another of them, a grant's included, because deciding it needs the subscription that holds each ladder. And a rung on a ladder the product is not a tier of: supersession is whole-bundle (invariant 7), so Checkout that supersedes a grant's position on a ladder of the product also ends that grant's rungs on ladders the product is not a tier of, and no default is restored there while the pool holds the product (invariant 11). `plans.Offer` and `plans.Across` state the rule; the Products page, Checkout and the switch read it. -20. **[db-code]** and **[app]** A subscription's reconcile does not take back a ladder another plan took from it. While a position of a non-default source other than the subscription holds a ladder on which the subscription's latest episode ended in a supersession by another source, the reconcile confers none of the subscription's products whose shape names that ladder; the subscription keeps billing and delivers nothing there, and its next reconcile after no such plan holds the ladder confers it again. A default-sourced position never holds a ladder against a subscription (invariant 11). `core.subscription_displacements` is the one statement of which ladders those are; the reconcile, the organization page and the operator's subscriptions list read it. +20. **[db-code]** and **[app]** A subscription's reconcile does not take back a ladder another plan took from it. While a position of a non-default source other than the subscription holds a ladder on which the subscription's latest episode ended in a supersession by another source, the reconcile confers none of the subscription's products whose shape names that ladder; the subscription delivers nothing there, and its next reconcile after no such plan holds the ladder confers it again. While every conferring product the subscription pays for is left alone and every plan holding those ladders is a grant, the subscription's collection is paused at Stripe. A default-sourced position never holds a ladder against a subscription (invariant 11). `core.subscription_displacements` is the one statement of which ladders those are; the reconcile, the organization page and the operator's subscriptions list read it. ## Dimensions diff --git a/docs/plan-management.md b/docs/plan-management.md index e999fbc9..4078a01c 100644 --- a/docs/plan-management.md +++ b/docs/plan-management.md @@ -161,7 +161,7 @@ An operator can issue a grant to move a pool to a specific tier: The system: - Creates a grant with the chosen reason and the resumption mark, then calls the conferral boundary (`internal/entitlements/conferral.go`) through the operator handler in `internal/server/operator_enrollment.go`: it writes the grant decree, confers the position onto the organization's default pool, then materializes the organization's effective entitlements - Registers a **Temporal workflow** to expire the grant at `valid_until` when one is set -- Keeps the ladder for a grant that supersedes a tier a subscription pays for: the subscription keeps billing and delivers nothing there while the grant lasts, and the organization page's Billing card and the subscription's row in the operator's subscriptions list carry a **Not delivering** badge whose title reads "Billing continues while a grant holds {ladder}."; the subscription delivers again at its next reconcile after the grant ends +- Keeps the ladder for a grant that supersedes a tier a subscription pays for: the subscription delivers nothing there while the grant lasts, and the console pauses its collection at Stripe, and the organization page's Billing card and the subscription's row in the operator's subscriptions list carry a **Not delivering** badge whose title reads "Billing continues while a grant holds {ladder}."; the subscription delivers again at its next reconcile after the grant ends While Temporal is not connected, which on a deployment without `temporal-host` is always, a grant with **Valid until** is refused before diff --git a/docs/stripe.md b/docs/stripe.md index 8793da24..3d23dfbc 100644 --- a/docs/stripe.md +++ b/docs/stripe.md @@ -211,7 +211,7 @@ A member moves between the paid tiers of a plan ladder on the Products page, and - **Cancellation.** Every paid plan offers **Cancel plan** on its card. Access continues until the end of the paid period (`cancel_at_period_end`), after which the organization moves to its type's default plan, when one is configured, or leaves the ladder. - **Taking a scheduled move back.** While a downgrade or a cancellation is scheduled, the plan's card offers **Keep plan**, which releases the schedule or clears `cancel_at_period_end` at no charge. While a downgrade is scheduled, **Cancel plan** follows it and replaces the downgrade; while a cancellation is scheduled, **Keep plan** is the only control. - **A plan whose payment is due.** A subscription Stripe holds as `past_due` or `unpaid` keeps its card on the Products page, shown as the current plan with its state, while its entitlements are suspended. No switch is offered in either direction and a posted one is refused before any Stripe call, because a switch would invoice while the failed invoice is still open. **Keep plan** and **Cancel plan** at the period end work as on an active plan. A posted immediate cancellation is refused, because Stripe's immediate cancellation stops automatic collection of the customer's finalized invoices, and a **Keep plan** over a downgrade that Stripe has already applied is refused, because there is nothing left to take back. The unpaid invoice stays due after a cancellation: once the cancellation takes effect, Stripe stops retrying that subscription's open invoice and leaves the customer's other subscriptions collecting, so the member pays it through the invoice's **Pay invoice**. A paused subscription shows its state and is refused every act; only a subscription made outside the console reaches `paused`, which follows a trial that ends without a payment method, because the console's Checkout creates no trial. -- **A grant over a paid plan.** An operator's grant over a tier a subscription pays for keeps the ladder until it ends or is revoked. The subscription keeps billing and delivers nothing there, and the console changes nothing at Stripe. While the grant holds the ladder, the organization page's Billing card and the subscription's row in the operator's subscriptions list carry a **Not delivering** badge, whose title reads "Billing continues while a grant holds {ladder}." Once only the organization type's default holds the ladder, or nothing does, the subscription's next reconcile delivers its tier again. A new purchase over a grant still replaces the grant. +- **A grant over a paid plan.** An operator's grant over a tier a subscription pays for keeps the ladder until it ends or is revoked, and the subscription delivers nothing there. The console pauses the subscription's collection at Stripe (`pause_collection` with `behavior=void`, and the marker `member_console_collection=paused_for_grant` in the subscription's metadata) within seconds, through the outbox, and sets no `resumes_at`. Each renewal Stripe makes while the grant holds is a draft for about an hour and is then voided, so nothing is charged. When the grant ends, the console clears the pause and the marker, and the tier is delivered again; no money moves at either edge. While the grant holds the ladder, the organization page's Billing card and the subscription's row in the operator's subscriptions list carry a **Not delivering** badge, whose title reads "Billing continues while a grant holds {ladder}." Once only the organization type's default holds the ladder, or nothing does, the subscription's next reconcile delivers its tier again. A new purchase over a grant still replaces the grant. What this accepts until the exact money rule (#280): the renewal date decides which way a grant that is not whole periods falls, because a period's renewal is voided or charged whole; a pause landing after a renewal's finalization lets that renewal be charged, and a clear landing before the finalization of a renewal made while paused lets it be charged; a pause or clear that lands late moves a whole period one way or the other, and a void cannot be undone; the first charge after a grant can come without Stripe's upcoming-renewal reminder. A subscription item with no entitlement set, such as a donation line added in the Dashboard, stops billing with the tier beside it while the pause holds. An invoice open before the grant stays due: Stripe keeps retrying it, and the account's dunning setting can cancel the paused subscription. - **Pay invoice.** The card of a plan whose payment is due, and the detail of an open invoice, link to Stripe's hosted invoice page. The console reads the page's URL from Stripe when the member opens the link and redirects there; it never stores or logs the URL, which is a bearer link, and it needs no key permission beyond the reads of subscriptions and invoices that the reconciles already make. Paying there makes the new card the customer's default for invoices but leaves the subscription's own default on the card that failed, which Stripe charges first, so the next renewal charges the failed card again. Stripe's page still takes payment for an invoice Stripe has marked uncollectible, which the console shows as open until the invoice sweep reads it again. A restricted key needs permission for the calls these moves make, beyond those the console already uses: `POST /v1/subscription_schedules`, `POST /v1/subscription_schedules/{id}`, `POST /v1/subscription_schedules/{id}/release` and `POST /v1/invoices/{id}/void`. The console also updates a subscription with `POST /v1/subscriptions/{id}` to set `pause_collection` while a grant holds its ladder and to clear it when the grant no longer does, which is the Subscriptions write the plan moves already need. Stripe names the missing permission in the error a key without it receives. diff --git a/internal/demoseed/seed_db_test.go b/internal/demoseed/seed_db_test.go index bcfd0627..2d58f70c 100644 --- a/internal/demoseed/seed_db_test.go +++ b/internal/demoseed/seed_db_test.go @@ -369,3 +369,29 @@ func TestSeedTwiceLeavesTheSameRows(t *testing.T) { } golden.Compare(t, "testdata/seed/twice.txt", golden.Ordinals(out.String())) } + +// The ledger demo's revocation and expiry each ask for a reconcile of Carlos's +// canceling subscription, the organization's one live subscription (openspec +// change grant-pauses-subscription-billing, design D4). The seed maps no +// Stripe subscription, so each entry completes with no Stripe call. +func TestSeedLedgerDemoRequestsCarlosSubscriptionsReconcile(t *testing.T) { + ctx := context.Background() + _, plain := scratchSeed(t) + logger := slog.New(slog.DiscardHandler) + if _, err := SeedOperator(ctx, plain, logger); err != nil { + t.Fatalf("seed the operator: %v", err) + } + if _, err := Seed(ctx, plain, logger); err != nil { + t.Fatalf("seed: %v", err) + } + q := dbtest.On(t, plain) + carlos := q.String(`SELECT s.subscription_id::text FROM core.subscriptions s + JOIN core.accounts a ON a.billing_account_id = s.billing_account_id + WHERE a.org_id = `+personalOrgSQL, demoPersons[1].Email) + want := []string{carlos, carlos} + got := q.Strings(`SELECT payload->>'subscription_id' FROM core.outbox + WHERE action_type = 'reconcile_subscription' ORDER BY id`) + if !slices.Equal(got, want) { + t.Errorf("the demo seed's entries = %v, want %v", got, want) + } +} diff --git a/internal/entitlements/conferral.go b/internal/entitlements/conferral.go index 9bcad62c..52f8fee2 100644 --- a/internal/entitlements/conferral.go +++ b/internal/entitlements/conferral.go @@ -363,3 +363,32 @@ func ConferGrant(ctx context.Context, db *sql.DB, in ConferGrantInput) (*ConferG } return result, nil } + +// IssueGrant is the operator's issuance: ConferGrantTx and +// RequestPoolSubscriptionReconciles (no exception) in one materializing +// transaction, so every live subscription of the organization is reconciled +// within seconds of a grant that may take a ladder from it +// (grant-pauses-subscription-billing D4). ConferGrantTx itself does not +// request. Provisioning and the default floor confer defaults, which never +// hold a ladder against a subscription (invariant 11); the demo seed's +// subscription holds no position; and the test kit's tests run the reconciles +// they test. +func IssueGrant(ctx context.Context, db *sql.DB, in ConferGrantInput) (*ConferGrantResult, error) { + tx, err := BeginMaterializing(ctx, db) + if err != nil { + return nil, fmt.Errorf("begin transaction: %w", err) + } + defer tx.Rollback() + + result, err := ConferGrantTx(ctx, tx, in) + if err != nil { + return nil, err + } + if err := RequestPoolSubscriptionReconciles(ctx, tx, result.PoolID, ""); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit transaction: %w", err) + } + return result, nil +} diff --git a/internal/entitlements/grant_acts.go b/internal/entitlements/grant_acts.go index 008bf729..a1d63951 100644 --- a/internal/entitlements/grant_acts.go +++ b/internal/entitlements/grant_acts.go @@ -234,7 +234,9 @@ type RevokeGrantResult struct { // grant's lineage (the named grant unless an extension replaced it), ends its // conferral, resumes the marked grants it held when that ended something, // restores the org-type default if the pool was left vacant, and -// re-materializes, within the caller's transaction. A grant of another +// re-materializes, then writes a reconcile request for each live subscription +// of the organization (`RequestPoolSubscriptionReconciles`), within the +// caller's transaction. A grant of another // organization raises ErrGrantNotFound before anything is written. // end_conferral succeeds quietly against nothing live, so orphan grants and // off-ladder grants need no special branch, and a grant that delivered @@ -316,6 +318,9 @@ func RevokeGrantTx(ctx context.Context, tx *sql.Tx, in RevokeGrantInput) (*Revok if err := MaterializePoolEntitlements(ctx, q, pool.PoolID); err != nil { return nil, fmt.Errorf("materialize entitlements: %w", err) } + if err := RequestPoolSubscriptionReconciles(ctx, tx, pool.PoolID, ""); err != nil { + return nil, err + } return &RevokeGrantResult{Grant: grant, PoolID: pool.PoolID}, nil } @@ -330,7 +335,8 @@ type ExpireGrantResult struct { // ExpireGrantTx decrees the grant expired (status active -> expired), ends // every position conferred by this grant source, resumes the marked grants // it held when that ended something, restores the org-type default if the -// pool was left vacant, and re-materializes, within the caller's +// pool was left vacant, and re-materializes, then writes a reconcile request for each live subscription +// of the organization (`RequestPoolSubscriptionReconciles`), within the caller's // transaction. It reads the grant and locks its pool before it writes the // grant row, the order revocation and resumption use (grant-resumption D4). // A grant that is no longer active raises ErrGrantNotActive and writes @@ -394,6 +400,9 @@ func ExpireGrantTx(ctx context.Context, tx *sql.Tx, grantID string) (*ExpireGran if err := MaterializePoolEntitlements(ctx, q, poolID); err != nil { return nil, fmt.Errorf("materialize pool %s: %w", poolID, err) } + if err := RequestPoolSubscriptionReconciles(ctx, tx, poolID, ""); err != nil { + return nil, err + } } return &ExpireGrantResult{Grant: grant, Ended: ended}, nil @@ -554,7 +563,9 @@ type ManageGrantResult struct { // nothing is written. A head with a non-ended provision goes through // ExtendGrantTx, which continues its position as a transfer; any other // head gets a successor through ExtendWaitingGrantTx. Both refuse a -// retired product (ErrProductRetired) before writing anything. +// retired product (ErrProductRetired) before writing anything. On an +// extension it then writes a reconcile request for each live +// subscription of the organization (`RequestPoolSubscriptionReconciles`). // - Valid until unchanged: the mark and the note are written to the head in // place, and nothing else: no grant row, no conferral, no transition, no // materialization. A head an extension written after the page rendered @@ -566,7 +577,7 @@ type ManageGrantResult struct { // No refusal depends on what the grant delivered when the page rendered. func ManageGrantTx(ctx context.Context, tx *sql.Tx, in ManageGrantInput) (*ManageGrantResult, error) { q := New(tx) - named, head, err := lockManagedGrant(ctx, q, in.OrgID, in.GrantID) + poolID, named, head, err := lockManagedGrant(ctx, q, in.OrgID, in.GrantID) if err != nil { return nil, err } @@ -575,10 +586,19 @@ func ManageGrantTx(ctx context.Context, tx *sql.Tx, in ManageGrantInput) (*Manag if err != nil { return nil, err } - if changed { - return extendHead(ctx, tx, q, in, head, bound) + if !changed { + return saveInPlace(ctx, q, in, head) } - return saveInPlace(ctx, q, in, head) + result, err := extendHead(ctx, tx, q, in, head, bound) + if err != nil { + return nil, err + } + // An extension replaces the head's provision with its own, which can + // change who holds a ladder; a save in place changes no holder. + if err := RequestPoolSubscriptionReconciles(ctx, tx, poolID, ""); err != nil { + return nil, err + } + return result, nil } // lockManagedGrant takes what a Manage save acts on, in this order: the @@ -587,41 +607,41 @@ func ManageGrantTx(ctx context.Context, tx *sql.Tx, in ManageGrantInput) (*Manag // (ErrOrgHasSeveralPools), the named grant of that organization // (ErrGrantNotFound), and the head of its lineage under its row lock, which // must be active (ErrGrantNotActive) and not a default grant -// (ErrGrantIsDefault). The pool lock comes before the head lock, the order -// docs/database-locks.md states. -func lockManagedGrant(ctx context.Context, q *Queries, orgID, grantID string) (named, head Grant, err error) { +// (ErrGrantIsDefault). It returns the pool's id. The pool lock comes before the +// head lock, the order docs/database-locks.md states. +func lockManagedGrant(ctx context.Context, q *Queries, orgID, grantID string) (poolID string, named, head Grant, err error) { pool, err := q.GetDefaultPoolByOrgID(ctx, orgID) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return Grant{}, Grant{}, fmt.Errorf("%w: %s", ErrGrantPoolMissing, orgID) + return "", Grant{}, Grant{}, fmt.Errorf("%w: %s", ErrGrantPoolMissing, orgID) } - return Grant{}, Grant{}, fmt.Errorf("resolve default pool for org %s: %w", orgID, err) + return "", Grant{}, Grant{}, fmt.Errorf("resolve default pool for org %s: %w", orgID, err) } if _, err := q.LockPools(ctx, pool.PoolID); err != nil { - return Grant{}, Grant{}, fmt.Errorf("lock pool %s: %w", pool.PoolID, err) + return "", Grant{}, Grant{}, fmt.Errorf("lock pool %s: %w", pool.PoolID, err) } pools, err := q.GetResourcePoolsByOrgID(ctx, orgID) if err != nil { - return Grant{}, Grant{}, fmt.Errorf("list pools for org %s: %w", orgID, err) + return "", Grant{}, Grant{}, fmt.Errorf("list pools for org %s: %w", orgID, err) } if len(pools) > 1 { - return Grant{}, Grant{}, fmt.Errorf("%w: %s", ErrOrgHasSeveralPools, orgID) + return "", Grant{}, Grant{}, fmt.Errorf("%w: %s", ErrOrgHasSeveralPools, orgID) } named, err = grantOfOrg(ctx, q, grantID, orgID) if err != nil { - return Grant{}, Grant{}, err + return "", Grant{}, Grant{}, err } head, err = q.GetGrantLineageHeadForUpdate(ctx, grantID) if err != nil { - return Grant{}, Grant{}, fmt.Errorf("read the head of grant %s's lineage: %w", grantID, err) + return "", Grant{}, Grant{}, fmt.Errorf("read the head of grant %s's lineage: %w", grantID, err) } switch { case head.Status != "active": - return Grant{}, Grant{}, fmt.Errorf("%w: %s", ErrGrantNotActive, head.GrantID) + return "", Grant{}, Grant{}, fmt.Errorf("%w: %s", ErrGrantNotActive, head.GrantID) case head.GrantReason == "default": - return Grant{}, Grant{}, fmt.Errorf("%w: %s", ErrGrantIsDefault, head.GrantID) + return "", Grant{}, Grant{}, fmt.Errorf("%w: %s", ErrGrantIsDefault, head.GrantID) } - return named, head, nil + return pool.PoolID, named, head, nil } // grantOfOrg reads the named grant and refuses it as ErrGrantNotFound when it diff --git a/internal/entitlements/ladder_acts.go b/internal/entitlements/ladder_acts.go index 2102ddb4..09fcea4e 100644 --- a/internal/entitlements/ladder_acts.go +++ b/internal/entitlements/ladder_acts.go @@ -48,28 +48,29 @@ type PlanActStep string // The steps of the plan-wide acts, in the order the acts run them. const ( - StepBegin PlanActStep = "begin" - StepReadProduct PlanActStep = "read the product" - StepCountHolders PlanActStep = "count the product's holders" - StepCreateTier PlanActStep = "create the tier" - StepReadProvisions PlanActStep = "read the product's live provisions" - StepRereadProvisions PlanActStep = "read the product's live provisions under the pool locks" - StepReadCollisions PlanActStep = "read the pools that cannot take the tier" - StepReadHolders PlanActStep = "read the tier's holders" - StepReadTiers PlanActStep = "read the ladder's tiers" - StepReadOrgTypes PlanActStep = "read the organization types" - StepReadNames PlanActStep = "read the ladder's and the product's names" - StepRecordAct PlanActStep = "record the act" - StepLockPools PlanActStep = "lock the holders' pools" - StepRereadHolders PlanActStep = "read the tier's holders under the pool locks" - StepDeleteTier PlanActStep = "delete the tier" - StepListTiers PlanActStep = "list the remaining tiers" - StepRenumber PlanActStep = "renumber the tiers" - StepEndIncumbents PlanActStep = "end the default-sourced positions" - StepAlign PlanActStep = "align a position to its product's shape" - StepRestore PlanActStep = "restore the organization type's default" - StepMaterialize PlanActStep = "materialize the pool" - StepCommit PlanActStep = "commit" + StepBegin PlanActStep = "begin" + StepReadProduct PlanActStep = "read the product" + StepCountHolders PlanActStep = "count the product's holders" + StepCreateTier PlanActStep = "create the tier" + StepReadProvisions PlanActStep = "read the product's live provisions" + StepRereadProvisions PlanActStep = "read the product's live provisions under the pool locks" + StepReadCollisions PlanActStep = "read the pools that cannot take the tier" + StepReadHolders PlanActStep = "read the tier's holders" + StepReadTiers PlanActStep = "read the ladder's tiers" + StepReadOrgTypes PlanActStep = "read the organization types" + StepReadNames PlanActStep = "read the ladder's and the product's names" + StepRecordAct PlanActStep = "record the act" + StepLockPools PlanActStep = "lock the holders' pools" + StepRereadHolders PlanActStep = "read the tier's holders under the pool locks" + StepDeleteTier PlanActStep = "delete the tier" + StepListTiers PlanActStep = "list the remaining tiers" + StepRenumber PlanActStep = "renumber the tiers" + StepEndIncumbents PlanActStep = "end the default-sourced positions" + StepAlign PlanActStep = "align a position to its product's shape" + StepRestore PlanActStep = "restore the organization type's default" + StepMaterialize PlanActStep = "materialize the pool" + StepRequestReconciles PlanActStep = "request the subscriptions' reconciles" + StepCommit PlanActStep = "commit" ) // PlanActError is a plan-wide act's failure: the step that failed, the pool, @@ -417,7 +418,8 @@ func deleteTier(ctx context.Context, bq billing.Querier, ladderID, productID str // migrate it ends the default-sourced positions first, then aligns every // remaining position of the product, then restores the default, so a pool // that still holds another live position after alignment is not re-floored; -// then it materializes the pool. +// then it materializes the pool and writes a reconcile request for each live +// subscription of the organization (`RequestPoolSubscriptionReconciles`). func removeFromPool(ctx context.Context, q planActQueriers, poolID string, pw *tierHolders, disposition string, actor Actor, res *RemoveTierResult) error { migrate := len(pw.incumbents) > 0 && disposition == "migrate" aligns := pw.aligns @@ -450,6 +452,9 @@ func removeFromPool(ctx context.Context, q planActQueriers, poolID string, pw *t if err := MaterializePoolEntitlements(ctx, q.Conferral, poolID); err != nil { return &PlanActError{Step: StepMaterialize, PoolID: poolID, Err: err} } + if err := RequestPoolSubscriptionReconciles(ctx, q.Tx, poolID, ""); err != nil { + return &PlanActError{Step: StepRequestReconciles, PoolID: poolID, Err: err} + } return nil } diff --git a/internal/entitlements/outbox_requests.go b/internal/entitlements/outbox_requests.go index f49ea3ac..008eb255 100644 --- a/internal/entitlements/outbox_requests.go +++ b/internal/entitlements/outbox_requests.go @@ -5,6 +5,7 @@ package entitlements import ( "context" + "fmt" "git.coopcloud.tech/wiki-cafe/member-console/internal/integration" ) @@ -21,3 +22,27 @@ func RequestSubscriptionReconcile(ctx context.Context, tx integration.DBTX, subs return integration.Enqueue(ctx, tx, "stripe", ReconcileSubscriptionAction, map[string]string{"subscription_id": subscriptionID}) } + +// RequestPoolSubscriptionReconciles writes one reconcile_subscription entry +// into tx for each live subscription of the pool's organization (active, +// trialing, past_due or unpaid), in subscription id order, except the +// subscription named by exceptSubscriptionID (empty: none). Every act that can +// change who holds a ladder calls it in its own transaction, so a subscription +// whose displacement the act changed is reconciled within seconds. The +// requests are inserts and take no lock; the act's pool lock, if it holds one, +// is untouched. +func RequestPoolSubscriptionReconciles(ctx context.Context, tx DBTX, poolID, exceptSubscriptionID string) error { + ids, err := New(tx).ListLiveSubscriptionsOfPool(ctx, poolID) + if err != nil { + return fmt.Errorf("list the live subscriptions of pool %s: %w", poolID, err) + } + for _, id := range ids { + if id == exceptSubscriptionID { + continue + } + if err := RequestSubscriptionReconcile(ctx, tx, id); err != nil { + return err + } + } + return nil +} diff --git a/internal/entitlements/outbox_requests_db_test.go b/internal/entitlements/outbox_requests_db_test.go new file mode 100644 index 00000000..a0f2a822 --- /dev/null +++ b/internal/entitlements/outbox_requests_db_test.go @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial +// SPDX-FileCopyrightText: 2025-2026 Christian Galo + +package entitlements_test + +// Every act that can change who holds a ladder asks for a reconcile of each +// live subscription of the organization, and the conferral underneath them +// asks for none (openspec change grant-pauses-subscription-billing, design D4, +// tasks 4.1 to 4.4 and 4.7). Each row builds its own organization type and +// organization, so the rows read only entries for their own subscriptions. + +import ( + "database/sql" + "slices" + "testing" + "time" + + "github.com/google/uuid" + + "git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements" + "git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest" + "git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world" +) + +// requestFixture is an organization whose type defaults to Main (Basic, Plus), +// floored on it, with two live subscriptions and one canceled one on its +// billing account. +type requestFixture struct { + t *testing.T + w *world.World + q *dbtest.Q + operator world.Person + org world.Org + orgType string + basic world.Product + plus world.Product + gold world.Product + main world.Ladder + next world.Ladder + live []string // the live subscriptions' ids, in id order + canceled string +} + +func newRequestFixture(t *testing.T) *requestFixture { + t.Helper() + w := world.New(t, dbtest.Open(t)) + f := &requestFixture{t: t, w: w, q: dbtest.On(t, w.DB), operator: w.Person("Operator")} + f.basic, f.plus, f.gold = w.Product("Basic"), w.Product("Plus"), w.Product("Gold") + f.main, f.next = w.Ladder("Main", f.basic, f.plus), w.Ladder("Next", f.gold) + f.orgType = w.OrgType("req") + w.DefaultLadder(f.orgType, f.main) + f.org = w.Org("Holder", f.orgType, f.operator) + w.Floor(f.org) + + account := w.BillingAccount(f.org, "Account") + now := time.Now() + for range 3 { + f.live = append(f.live, w.BareSubscription(account, now.Add(-24*time.Hour), now.Add(24*time.Hour)).ID) + } + f.canceled = f.live[2] + f.live = f.live[:2] + // The column default made the ids ascend with creation; a test of an + // order feeds more than one order, so the first-created live + // subscription takes the higher id and the second the lower. + lo, hi := uuid.Must(uuid.NewV7()).String(), uuid.Must(uuid.NewV7()).String() + f.q.Exec(`UPDATE core.subscriptions SET subscription_id = $2 WHERE subscription_id = $1`, f.live[0], hi) + f.q.Exec(`UPDATE core.subscriptions SET subscription_id = $2 WHERE subscription_id = $1`, f.live[1], lo) + f.live = []string{lo, hi} + f.q.Exec(`UPDATE core.subscriptions SET status = 'canceled', canceled_at = now(), ended_at = now() WHERE subscription_id = $1`, f.canceled) + return f +} + +// entries are the subscription ids the fixture's three subscriptions have +// reconcile_subscription entries for, in the order the entries were written. +func (f *requestFixture) entries() []string { + f.t.Helper() + return f.q.Strings(`SELECT payload->>'subscription_id' FROM core.outbox + WHERE action_type = 'reconcile_subscription' + AND payload->>'subscription_id' = ANY($1::text[]) + ORDER BY id`, "{"+f.live[0]+","+f.live[1]+","+f.canceled+"}") +} + +// actRequestRow is one act and the entries it must leave. +type actRequestRow struct { + name string + act func(f *requestFixture) +} + +// checkActRequests runs the row's act on a fresh fixture and compares the +// entries it wrote with one per live subscription, in id order. +func checkActRequests(t *testing.T, row actRequestRow) { + f := newRequestFixture(t) + row.act(f) + if got := f.entries(); !slices.Equal(got, f.live) { + t.Errorf("%s wrote entries for %v, want %v", row.name, got, f.live) + } +} + +func (f *requestFixture) issue(p world.Product) world.Grant { + f.t.Helper() + return f.w.Issue(f.org, p, world.IssueOpts{}) +} + +func (f *requestFixture) beginAct() *sql.Tx { + f.t.Helper() + tx, err := entitlements.BeginMaterializing(f.w.Ctx, f.w.DB) + if err != nil { + f.t.Fatalf("begin the act: %v", err) + } + f.t.Cleanup(func() { _ = tx.Rollback() }) + return tx +} + +func TestActsRequestSubscriptionReconciles(t *testing.T) { + rows := []actRequestRow{ + {"issue", func(f *requestFixture) { + person := uuid.MustParse(f.operator.ID) + if _, err := entitlements.IssueGrant(f.w.Ctx, f.w.DB, entitlements.ConferGrantInput{ + ProductID: f.plus.ID, OrgID: f.org.ID, GrantedByPersonID: f.operator.ID, GrantReason: "complimentary", + Quantity: 1, ActorType: "operator", ActorID: uuid.NullUUID{UUID: person, Valid: true}, + }); err != nil { + f.t.Fatalf("IssueGrant: %v", err) + } + }}, + {"manage", func(f *requestFixture) { + g := f.issue(f.plus) + f.w.Extend(g, time.Now().Add(48*time.Hour)) + }}, + {"revoke", func(f *requestFixture) { + f.w.Revoke(f.issue(f.plus)) + }}, + {"expire", func(f *requestFixture) { + g := f.issue(f.plus) + tx := f.beginAct() + if _, err := entitlements.ExpireGrantTx(f.w.Ctx, tx, g.ID); err != nil { + f.t.Fatalf("ExpireGrantTx: %v", err) + } + if err := tx.Commit(); err != nil { + f.t.Fatalf("commit the expiry: %v", err) + } + }}, + {"legacy grant of a default change", func(f *requestFixture) { + if _, err := entitlements.ChangeOrgTypeDefault(f.w.Ctx, f.w.DB, entitlements.DefaultChangeInput{ + OrgType: f.orgType, Ladder: uuid.NullUUID{UUID: uuid.MustParse(f.next.ID), Valid: true}, + Disposition: "grandfather", OperatorPersonID: f.operator.ID, + }); err != nil { + f.t.Fatalf("ChangeOrgTypeDefault: %v", err) + } + }}, + {"tier removal", func(f *requestFixture) { + if _, err := entitlements.RemoveTier(f.w.Ctx, f.w.DB, entitlements.RemoveTierInput{ + LadderID: f.main.ID, ProductID: f.basic.ID, Disposition: "migrate", OperatorPersonID: f.operator.ID, + }); err != nil { + f.t.Fatalf("RemoveTier: %v", err) + } + }}, + } + for _, row := range rows { + t.Run(row.name, func(t *testing.T) { checkActRequests(t, row) }) + } +} + +// ConferGrantTx alone requests nothing. Provisioning and the default floor +// confer defaults, which never hold a ladder against a subscription +// (invariant 11); the demo seed's subscription holds no position; and the test +// kit's tests run the reconciles they test. +func TestConferGrantTxRequestsNothing(t *testing.T) { + f := newRequestFixture(t) + tx := f.beginAct() + if _, err := entitlements.ConferGrantTx(f.w.Ctx, tx, entitlements.ConferGrantInput{ + ProductID: f.plus.ID, OrgID: f.org.ID, GrantedByPersonID: f.operator.ID, GrantReason: "complimentary", + Quantity: 1, ActorType: "operator", ActorID: uuid.NullUUID{UUID: uuid.MustParse(f.operator.ID), Valid: true}, + }); err != nil { + t.Fatalf("ConferGrantTx: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("commit: %v", err) + } + if n := len(f.entries()); n != 0 { + t.Errorf("ConferGrantTx wrote %d outbox entries, want 0", n) + } +} diff --git a/internal/entitlements/plan_act_move.go b/internal/entitlements/plan_act_move.go index 4af36a30..c6cb5995 100644 --- a/internal/entitlements/plan_act_move.go +++ b/internal/entitlements/plan_act_move.go @@ -202,6 +202,15 @@ func (m *obligationEnactment) enactOutgoing(ctx context.Context, q planActQuerie return failAt(ObligationGrandfatherOutcome, fmt.Errorf("grandfather conferral reported %q, expected the lineage supersession", res.Outcome)) } } + // A legacy grant replaces a default position, and unlike a default it + // holds the ladder against a subscription waiting on it + // (core.subscription_displacements), so the organization's + // subscriptions are reconciled (grant-pauses-subscription-billing D4). + if len(incumbents) > 0 { + if err := RequestPoolSubscriptionReconciles(ctx, q.Tx, m.poolID, ""); err != nil { + return failAt(ObligationGrandfather, err) + } + } m.move.Outcome = OutcomeGrandfathered default: if err := EndDefaultIncumbents(ctx, q.Conferral, incumbents, actor); err != nil { diff --git a/internal/entitlements/pool_provision_transitions.sql.go b/internal/entitlements/pool_provision_transitions.sql.go index 9196b383..cfca6929 100644 --- a/internal/entitlements/pool_provision_transitions.sql.go +++ b/internal/entitlements/pool_provision_transitions.sql.go @@ -84,6 +84,45 @@ func (q *Queries) ListAccountSubscriptionDisplacements(ctx context.Context, arg return items, nil } +const listLiveSubscriptionsOfPool = `-- name: ListLiveSubscriptionsOfPool :many +SELECT s.subscription_id::uuid AS subscription_id +FROM core.resource_pools rp +JOIN core.accounts a ON a.org_id = rp.org_id +JOIN core.subscriptions s ON s.billing_account_id = a.billing_account_id +WHERE rp.pool_id = $1::uuid + AND s.status IN ('active', 'trialing', 'past_due', 'unpaid') +ORDER BY s.subscription_id +` + +// The subscriptions of the pool's organization, through its accounts' +// billing accounts, whose Stripe status is live (active, trialing, past_due +// or unpaid), by subscription id. Every act that can change who holds a +// ladder writes one reconcile request per row +// (grant-pauses-subscription-billing D4); a Stripe-paused subscription is +// not listed (design D4). +func (q *Queries) ListLiveSubscriptionsOfPool(ctx context.Context, poolID string) ([]string, error) { + rows, err := q.db.QueryContext(ctx, listLiveSubscriptionsOfPool, poolID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []string{} + for rows.Next() { + var subscription_id string + if err := rows.Scan(&subscription_id); err != nil { + return nil, err + } + items = append(items, subscription_id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listRecentTransitions = `-- name: ListRecentTransitions :many SELECT t.transition_id, diff --git a/internal/entitlements/querier.go b/internal/entitlements/querier.go index 86196b2d..f2d8f7d9 100644 --- a/internal/entitlements/querier.go +++ b/internal/entitlements/querier.go @@ -434,6 +434,13 @@ type Querier interface { // the preview's other-source lines: the preview names at most 50 such pools, // and reads their sources here in one statement. ListLivePlanSourcesByPools(ctx context.Context, poolIds []string) ([]ListLivePlanSourcesByPoolsRow, error) + // The subscriptions of the pool's organization, through its accounts' + // billing accounts, whose Stripe status is live (active, trialing, past_due + // or unpaid), by subscription id. Every act that can change who holds a + // ladder writes one reconcile request per row + // (grant-pauses-subscription-billing D4); a Stripe-paused subscription is + // not listed (design D4). + ListLiveSubscriptionsOfPool(ctx context.Context, poolID string) ([]string, error) // The tier-removal preview/commit population in one round trip: every live // (active|suspended) junction on this ladder whose provision delivers this // product, with the org name and grant provenance so holders classify diff --git a/internal/entitlements/queries/pool_provision_transitions.sql b/internal/entitlements/queries/pool_provision_transitions.sql index 925fd367..c63d60b6 100644 --- a/internal/entitlements/queries/pool_provision_transitions.sql +++ b/internal/entitlements/queries/pool_provision_transitions.sql @@ -127,3 +127,18 @@ CROSS JOIN LATERAL core.subscription_displacements(s.subscription_id, rp.pool_id JOIN core.plan_ladders pl ON pl.plan_ladder_id = d.plan_ladder_id::uuid WHERE s.subscription_id = ANY(sqlc.arg(subscription_ids)::uuid[]) ORDER BY s.subscription_id, pl.name, d.plan_ladder_id::uuid, d.holder_provision_id::uuid; + +-- name: ListLiveSubscriptionsOfPool :many +-- The subscriptions of the pool's organization, through its accounts' +-- billing accounts, whose Stripe status is live (active, trialing, past_due +-- or unpaid), by subscription id. Every act that can change who holds a +-- ladder writes one reconcile request per row +-- (grant-pauses-subscription-billing D4); a Stripe-paused subscription is +-- not listed (design D4). +SELECT s.subscription_id::uuid AS subscription_id +FROM core.resource_pools rp +JOIN core.accounts a ON a.org_id = rp.org_id +JOIN core.subscriptions s ON s.billing_account_id = a.billing_account_id +WHERE rp.pool_id = sqlc.arg(pool_id)::uuid + AND s.status IN ('active', 'trialing', 'past_due', 'unpaid') +ORDER BY s.subscription_id; diff --git a/internal/fulfillment/displaced_subscription_test.go b/internal/fulfillment/displaced_subscription_test.go index 8f0d879f..64fe707a 100644 --- a/internal/fulfillment/displaced_subscription_test.go +++ b/internal/fulfillment/displaced_subscription_test.go @@ -536,3 +536,50 @@ func TestReconcile_TwoTiersOfOneLadderConferTheHigher(t *testing.T) { checkTwoTiers(t, row) } } + +// twoOnOneLadder is the fixture's subscription S1 with a second subscription +// S2 on the second tier of the same ladder reconciled over it, so S2 holds the +// ladder S1 pays for. It returns S1's and S2's core ids. +func twoOnOneLadder(t *testing.T) (database *sql.DB, mock *stripetest.MockBackend, s2 *stripe.Subscription, core1, core2 string) { + t.Helper() + database, fx, mock := subscribed(t) + _, _, stripePrice2 := secondTier(t, database, fx) + core1 = coreSubID(t, database, fx.stripeSubID) + s2 = secondSubscription(fx, stripePrice2) + mock.Sub = s2 + reconcile(t, database, s2.ID, "stripe:customer.subscription.updated") + return database, mock, s2, core1, coreSubID(t, database, s2.ID) +} + +// A subscription that held a ladder and ends asks for the organization's other +// subscriptions to be reconciled, never itself (openspec change +// grant-pauses-subscription-billing, design D4, task 4.7). +func TestReconcileEndedRequestsTheOtherSubscriptions(t *testing.T) { + database, mock, s2, core1, core2 := twoOnOneLadder(t) + before1, before2 := reconcileEntries(t, database, core1), reconcileEntries(t, database, core2) + + mock.Sub = withStatus(s2, stripe.SubscriptionStatusCanceled) + reconcile(t, database, s2.ID, "stripe:subscription.deleted") + + got1, got2 := reconcileEntries(t, database, core1)-before1, reconcileEntries(t, database, core2)-before2 + if got1 != 1 || got2 != 0 { + t.Errorf("entries after S2's end = S1 %d, S2 %d, want S1's alone", got1, got2) + } +} + +// A live subscription that drops the item that held a ladder asks for the +// organization's other subscriptions to be reconciled, never itself. +func TestSurplusEndRequestsTheOtherSubscriptions(t *testing.T) { + database, mock, s2, core1, core2 := twoOnOneLadder(t) + before1, before2 := reconcileEntries(t, database, core1), reconcileEntries(t, database, core2) + + dropped := *s2 + dropped.Items = &stripe.SubscriptionItemList{} + mock.Sub = &dropped + reconcile(t, database, s2.ID, "stripe:customer.subscription.updated") + + got1, got2 := reconcileEntries(t, database, core1)-before1, reconcileEntries(t, database, core2)-before2 + if got1 != 1 || got2 != 0 { + t.Errorf("entries after S2 dropped its item = S1 %d, S2 %d, want S1's alone", got1, got2) + } +} diff --git a/internal/fulfillment/reconcile.go b/internal/fulfillment/reconcile.go index f52791c4..ae368f85 100644 --- a/internal/fulfillment/reconcile.go +++ b/internal/fulfillment/reconcile.go @@ -160,7 +160,7 @@ func reconcileInTx(ctx context.Context, db *sql.DB, logger *slog.Logger, sub *st case statusActive: // 1.6–1.9 Converge items and entitlements for an entitlement-bearing sub. - out, err := reconcileItems(ctx, logger, billingQ, stripeQ, entQ, sub, subscriptionID, subUUID, poolID, actor) + out, err := reconcileItems(ctx, tx, logger, billingQ, stripeQ, entQ, sub, subscriptionID, subUUID, poolID, actor) if err != nil { return plan, err } @@ -273,7 +273,9 @@ func resolveAccountPool( // reconcileEnded is the terminal branch (1.10): it ends every live provision // for this subscription via end_conferral (idempotent — nothing live is a quiet success), resumes // the grants it replaced, restores the floor, materializes, closes the -// scheduled changes, marks the mapping deleted and commits. It takes the +// scheduled changes, marks the mapping deleted and commits. Only when it ended +// a provision, it then writes a reconcile request for each live subscription of +// the organization but this one (`RequestPoolSubscriptionReconciles`). It takes the // pool row before the floor restoration, so the vacancy read and the // materialization run under it even when nothing ended. func reconcileEnded( @@ -327,6 +329,14 @@ func reconcileEnded( if err := entitlements.MaterializePoolEntitlements(ctx, entQ, poolID); err != nil { return fmt.Errorf("reconcile: rematerialize after restoration: %w", err) } + // A provision this subscription held is gone, which can free a ladder + // another of the organization's subscriptions is displaced on + // (grant-pauses-subscription-billing D4). + if len(ended) > 0 { + if err := entitlements.RequestPoolSubscriptionReconciles(ctx, tx, poolID, subscriptionID); err != nil { + return fmt.Errorf("reconcile: request the other subscriptions' reconciles: %w", err) + } + } // Webhook-primary closeout: a period-end cancellation lands here when // Stripe fires customer.subscription.deleted at period end. Mark any // still-pending scheduled change for this subscription applied so the @@ -584,8 +594,13 @@ func recordCollectionMirror(ctx context.Context, billingQ *billing.Queries, subs // so the resume, the reads and every write run under it and a conferral that // committed while the reconcile waited is visible (docs/database-locks.md, // rule 2). +// +// Only when it ended a provision, it then writes a reconcile request for each +// live subscription of the organization but this one +// (`RequestPoolSubscriptionReconciles`). func reconcileItems( ctx context.Context, + tx *sql.Tx, logger *slog.Logger, billingQ *billing.Queries, stripeQ *internalstripe.Queries, @@ -633,6 +648,15 @@ func reconcileItems( if err != nil { return conferralOutcome{}, err } + // A provision ended because its product left the subscription can free a + // ladder another of the organization's subscriptions is displaced on; ask + // for those to be reconciled before the materialization, in the order that + // design's Locks section states (grant-pauses-subscription-billing D4). + if len(out.ended) > 0 { + if err := entitlements.RequestPoolSubscriptionReconciles(ctx, tx, poolID, subscriptionID); err != nil { + return conferralOutcome{}, fmt.Errorf("reconcile: request the other subscriptions' reconciles: %w", err) + } + } if len(resumed) > 0 || out.changed { if err := entitlements.MaterializePoolEntitlements(ctx, entQ, poolID); err != nil { return conferralOutcome{}, fmt.Errorf("reconcile: materialize entitlements: %w", err) diff --git a/internal/fulfillment/testdata/reconcile_trace/addon_removed.golden b/internal/fulfillment/testdata/reconcile_trace/addon_removed.golden index 2d70d1f3..d679c30c 100644 --- a/internal/fulfillment/testdata/reconcile_trace/addon_removed.golden +++ b/internal/fulfillment/testdata/reconcile_trace/addon_removed.golden @@ -28,6 +28,7 @@ ListConferralShapes ListSubscriptionDisplacements GetLivePoolProvisionsBySubscriptionID SELECT core.end_conferral($1, $2, $3, $4, $5, $6, $7, $8) +ListLiveSubscriptionsOfPool ListActiveScheduledChangesBySubscription GetPriceMappingByStripeID diff --git a/internal/fulfillment/testdata/reconcile_trace/canceled_resumes_grant.golden b/internal/fulfillment/testdata/reconcile_trace/canceled_resumes_grant.golden index d57da634..861e04b0 100644 --- a/internal/fulfillment/testdata/reconcile_trace/canceled_resumes_grant.golden +++ b/internal/fulfillment/testdata/reconcile_trace/canceled_resumes_grant.golden @@ -24,6 +24,7 @@ RELEASE SAVEPOINT grant_resumption LockResourcePool SELECT EXISTS ( +ListLiveSubscriptionsOfPool ApplyScheduledChangesForSubscription UpdateSubscriptionMappingSyncStatus commit diff --git a/internal/fulfillment/testdata/reconcile_trace/canceled_to_default.golden b/internal/fulfillment/testdata/reconcile_trace/canceled_to_default.golden index 0ee3b04e..9c5049ac 100644 --- a/internal/fulfillment/testdata/reconcile_trace/canceled_to_default.golden +++ b/internal/fulfillment/testdata/reconcile_trace/canceled_to_default.golden @@ -27,6 +27,7 @@ CreateGrant GetDefaultPoolByOrgID SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) +ListLiveSubscriptionsOfPool ApplyScheduledChangesForSubscription UpdateSubscriptionMappingSyncStatus commit diff --git a/internal/fulfillment/testdata/sweep_trace/already_canceled.golden b/internal/fulfillment/testdata/sweep_trace/already_canceled.golden index d1b3ccfe..9feded26 100644 --- a/internal/fulfillment/testdata/sweep_trace/already_canceled.golden +++ b/internal/fulfillment/testdata/sweep_trace/already_canceled.golden @@ -26,6 +26,7 @@ SELECT EXISTS ( GetResourcePoolByID SELECT t.default_plan_ladder_id::text +ListLiveSubscriptionsOfPool ApplyScheduledChangesForSubscription UpdateSubscriptionMappingSyncStatus commit diff --git a/internal/fulfillment/testdata/sweep_trace/already_ended.golden b/internal/fulfillment/testdata/sweep_trace/already_ended.golden index 30e2dfe0..87b2178d 100644 --- a/internal/fulfillment/testdata/sweep_trace/already_ended.golden +++ b/internal/fulfillment/testdata/sweep_trace/already_ended.golden @@ -27,6 +27,7 @@ SELECT EXISTS ( GetResourcePoolByID SELECT t.default_plan_ladder_id::text +ListLiveSubscriptionsOfPool ApplyScheduledChangesForSubscription UpdateSubscriptionMappingSyncStatus commit diff --git a/internal/fulfillment/testdata/sweep_trace/due_cancellation.golden b/internal/fulfillment/testdata/sweep_trace/due_cancellation.golden index 1991d70a..ee84f3c1 100644 --- a/internal/fulfillment/testdata/sweep_trace/due_cancellation.golden +++ b/internal/fulfillment/testdata/sweep_trace/due_cancellation.golden @@ -28,6 +28,7 @@ SELECT EXISTS ( GetResourcePoolByID SELECT t.default_plan_ladder_id::text +ListLiveSubscriptionsOfPool ApplyScheduledChangesForSubscription UpdateSubscriptionMappingSyncStatus commit diff --git a/internal/fulfillment/testdata/sweep_trace/lost_claim.golden b/internal/fulfillment/testdata/sweep_trace/lost_claim.golden index 9abf9ece..278e5ef0 100644 --- a/internal/fulfillment/testdata/sweep_trace/lost_claim.golden +++ b/internal/fulfillment/testdata/sweep_trace/lost_claim.golden @@ -28,6 +28,7 @@ SELECT EXISTS ( GetResourcePoolByID SELECT t.default_plan_ladder_id::text +ListLiveSubscriptionsOfPool ApplyScheduledChangesForSubscription UpdateSubscriptionMappingSyncStatus commit diff --git a/internal/server/operator_enrollment.go b/internal/server/operator_enrollment.go index 9f304c3c..3ff9645a 100644 --- a/internal/server/operator_enrollment.go +++ b/internal/server/operator_enrollment.go @@ -824,7 +824,7 @@ func (h *OperatorPartialsHandler) IssueGrant(w http.ResponseWriter, r *http.Requ return } - result, err := entitlements.ConferGrant(r.Context(), h.Database, entitlements.ConferGrantInput{ + result, err := entitlements.IssueGrant(r.Context(), h.Database, entitlements.ConferGrantInput{ ProductID: values.String("product_id"), OrgID: orgID, GrantedByPersonID: session.PersonID, diff --git a/internal/server/operator_enrollment_issue_db_test.go b/internal/server/operator_enrollment_issue_db_test.go new file mode 100644 index 00000000..0e6978d3 --- /dev/null +++ b/internal/server/operator_enrollment_issue_db_test.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial +// SPDX-FileCopyrightText: 2025-2026 Christian Galo + +package server + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/url" + "testing" + "time" + + "git.coopcloud.tech/wiki-cafe/member-console/internal/billing" + "git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest" +) + +// A grant posted through the Issue grant handler asks for a reconcile of the +// organization's live subscription, so a subscription whose tier the grant +// takes is paused within seconds (openspec change +// grant-pauses-subscription-billing, design D4, task 4.7). +func TestIssueGrantHandlerRequestsAReconcile(t *testing.T) { + database := dbtest.Open(t) + f := newGLFixture(t, database) + h := newGLHarness(t, database, f.operatorID) + org := glOrg(t, database, f, "Issue request "+f.operatorID[:8]) + + ctx := context.Background() + account, err := billing.New(database).CreateBillingAccount(ctx, billing.CreateBillingAccountParams{ + OrgID: org, Name: "Account " + org, Status: "active", Metadata: json.RawMessage("{}"), + }) + if err != nil { + t.Fatalf("create the billing account: %v", err) + } + sub, err := billing.New(database).CreateSubscription(ctx, billing.CreateSubscriptionParams{ + BillingAccountID: account.BillingAccountID, + Status: "active", + CurrentPeriodStart: sql.NullTime{Time: time.Now().Add(-24 * time.Hour), Valid: true}, + CurrentPeriodEnd: sql.NullTime{Time: time.Now().Add(24 * time.Hour), Valid: true}, + }) + if err != nil { + t.Fatalf("create the subscription: %v", err) + } + + form := url.Values{"product_id": {f.prodA}, "reason": {"manual"}, "quantity": {"1"}} + if code, body, _ := h.postOrg(h.handler.IssueGrant, org, form); code != http.StatusOK { + t.Fatalf("the issue handler answered %d: %s", code, body) + } + + var n int + if err := database.QueryRowContext(ctx, + `SELECT count(*) FROM core.outbox WHERE action_type = 'reconcile_subscription' AND payload->>'subscription_id' = $1`, + sub.SubscriptionID).Scan(&n); err != nil { + t.Fatalf("count the entries: %v", err) + } + if n == 0 { + t.Error("the issue handler wrote no reconcile_subscription entry") + } else if n != 1 { + t.Errorf("the issue handler wrote %d reconcile_subscription entries for %s, want 1", n, sub.SubscriptionID) + } +} diff --git a/openspec/changes/grant-pauses-subscription-billing/design.md b/openspec/changes/grant-pauses-subscription-billing/design.md index 8531dde9..a459cdd6 100644 --- a/openspec/changes/grant-pauses-subscription-billing/design.md +++ b/openspec/changes/grant-pauses-subscription-billing/design.md @@ -130,7 +130,7 @@ Reason: an organization has a handful of subscriptions at most, the reconcile is | A reconcile that ended one of its own provisions | `reconcileEnded` when it ended something, and the active branch when `endSurplusConferrals` ended something, for the organization's other live subscriptions (`exceptSubscriptionID` the reconciled one) | | Resume plan (D20) | `entitlements.ResumeSubscription`, after its conferral, with no exception | -`ConferGrantTx` itself does not request: provisioning, the default floor, the demo seed and the test kit call it, and none of their conferrals can take a ladder from a subscription. The demo seed's revocation and expiry (`internal/demoseed/demoseed.go:1208`, `:1227`) do request, for Carlos's canceling subscription; the seed maps no Stripe subscription, so each entry completes with L3 and no Stripe call. +`ConferGrantTx` itself does not request. Provisioning and the default floor confer defaults, which never hold a ladder against a subscription (invariant 11); the demo seed's subscription holds no position; and the test kit's tests run the reconciles they test. The demo seed's revocation and expiry (`internal/demoseed/demoseed.go:1208`, `:1227`) do request, for Carlos's canceling subscription; the seed maps no Stripe subscription, so each entry completes with L3 and no Stripe call. A reconcile's own disagreement writes one entry for its own subscription (D1). Acts left to the drift sweep, which finds each within the hour: - a tier reorder, a tier add or a product shape edit, because D8's exception reads ranks and can flip whether a subscription yields (F44); diff --git a/openspec/changes/grant-pauses-subscription-billing/tasks.md b/openspec/changes/grant-pauses-subscription-billing/tasks.md index d77c0dc7..88945511 100644 --- a/openspec/changes/grant-pauses-subscription-billing/tasks.md +++ b/openspec/changes/grant-pauses-subscription-billing/tasks.md @@ -78,27 +78,27 @@ Sections 1 to 12 are one commit each, and their items are that commit's parts; s ## 4. Every act that can change who holds a ladder asks for a reconcile -- [ ] 4.1 `internal/entitlements/queries/pool_provision_transitions.sql` (or the package's subscription query file): `ListLiveSubscriptionsOfPool` returns the subscription ids of the pool's organization (its accounts' billing accounts) whose status is `active`, `trialing`, `past_due` or `unpaid`, ordered by subscription id. `RequestPoolSubscriptionReconciles(ctx, tx, poolID, exceptSubscriptionID string) error` in `outbox_requests.go` writes one entry per row but the excepted one, in that order. `make sqlc-generate`. -- [ ] 4.2 `entitlements.IssueGrant(ctx, db, in ConferGrantInput) (ConferGrantResult, error)` runs `ConferGrantTx` and `RequestPoolSubscriptionReconciles` (no exception) in one `BeginMaterializing` transaction, as `ConferGrant` does; its doc comment says why `ConferGrantTx` itself does not request (design D4). `internal/server/operator_enrollment.go:828` calls it in place of `ConferGrant`. -- [ ] 4.3 `ManageGrantTx`, `RevokeGrantTx` and `ExpireGrantTx` (`internal/entitlements/grant_acts.go`) call `RequestPoolSubscriptionReconciles` after their materialization (an expiry only when it has a pool), with no exception. -- [ ] 4.4 `obligationEnactment.enactOutgoing` (`internal/entitlements/plan_act_move.go:174-217`) calls it after the grandfathered `ConferGrantTx`; `removeFromPool` (`internal/entitlements/ladder_acts.go:421-455`) after its materialization, per pool. -- [ ] 4.5 `internal/fulfillment/reconcile.go`: `reconcileEnded`, when it ended a provision, calls `entitlements.RequestPoolSubscriptionReconciles(ctx, tx, poolID, subscriptionID)` after its materialization, and the active branch, when `endSurplusConferrals` ended one, calls it after `endSurplusConferrals` and before its materialization, as the design's Locks section orders them. -- [ ] 4.6 Docs (design D14): `docs/stripe.md`, the bullet **A grant over a paid plan.** is rewritten: the grant keeps the ladder; the console pauses the subscription's collection at Stripe (`pause_collection` with `behavior=void` and the marker) within seconds, through the outbox; each renewal made while the grant holds is a draft for about an hour and is then voided; when the grant ends, collection resumes and the tier is delivered again; no money moves at either edge, and the costs this accepts are listed as design D14 lists them (the renewal date decides which way a grant that is not whole periods falls; a pause landing after a renewal's finalization lets it be charged and a clear landing before it lets a paused renewal be charged; a late call moves a whole period; the first charge after a grant can come without Stripe's upcoming-renewal reminder; the switch's cost is documented with the switch in 11.5, and Resume's with Resume in 12.9); an item with no entitlement set, such as a donation line added in the Dashboard, stops billing with the tier beside it while the pause holds (design D2); an invoice open before the grant stays due, Stripe keeps retrying it, and the account's dunning setting can cancel the paused subscription. `docs/models/plan-ladders-transitions.md:80` (invariant 20) says that the subscription's collection is paused while a grant holds every ladder it pays for; invariant 12 of `docs/models/payments-billing.md`, which is about Checkout, is left to section 9. `docs/database-locks.md` gains the sentence that an outbox request is an insert in the act's transaction and takes no lock. -- [ ] 4.7 Tests: +- [x] 4.1 `internal/entitlements/queries/pool_provision_transitions.sql` (or the package's subscription query file): `ListLiveSubscriptionsOfPool` returns the subscription ids of the pool's organization (its accounts' billing accounts) whose status is `active`, `trialing`, `past_due` or `unpaid`, ordered by subscription id. `RequestPoolSubscriptionReconciles(ctx, tx, poolID, exceptSubscriptionID string) error` in `outbox_requests.go` writes one entry per row but the excepted one, in that order. `make sqlc-generate`. +- [x] 4.2 `entitlements.IssueGrant(ctx, db, in ConferGrantInput) (ConferGrantResult, error)` runs `ConferGrantTx` and `RequestPoolSubscriptionReconciles` (no exception) in one `BeginMaterializing` transaction, as `ConferGrant` does; its doc comment says why `ConferGrantTx` itself does not request (design D4). `internal/server/operator_enrollment.go:828` calls it in place of `ConferGrant`. +- [x] 4.3 `ManageGrantTx`, `RevokeGrantTx` and `ExpireGrantTx` (`internal/entitlements/grant_acts.go`) call `RequestPoolSubscriptionReconciles` after their materialization (an expiry only when it has a pool), with no exception. +- [x] 4.4 `obligationEnactment.enactOutgoing` (`internal/entitlements/plan_act_move.go:174-217`) calls it after the grandfathered `ConferGrantTx`; `removeFromPool` (`internal/entitlements/ladder_acts.go:421-455`) after its materialization, per pool. +- [x] 4.5 `internal/fulfillment/reconcile.go`: `reconcileEnded`, when it ended a provision, calls `entitlements.RequestPoolSubscriptionReconciles(ctx, tx, poolID, subscriptionID)` after its materialization, and the active branch, when `endSurplusConferrals` ended one, calls it after `endSurplusConferrals` and before its materialization, as the design's Locks section orders them. +- [x] 4.6 Docs (design D14): `docs/stripe.md`, the bullet **A grant over a paid plan.** is rewritten: the grant keeps the ladder; the console pauses the subscription's collection at Stripe (`pause_collection` with `behavior=void` and the marker) within seconds, through the outbox; each renewal made while the grant holds is a draft for about an hour and is then voided; when the grant ends, collection resumes and the tier is delivered again; no money moves at either edge, and the costs this accepts are listed as design D14 lists them (the renewal date decides which way a grant that is not whole periods falls; a pause landing after a renewal's finalization lets it be charged and a clear landing before it lets a paused renewal be charged; a late call moves a whole period; the first charge after a grant can come without Stripe's upcoming-renewal reminder; the switch's cost is documented with the switch in 11.5, and Resume's with Resume in 12.9); an item with no entitlement set, such as a donation line added in the Dashboard, stops billing with the tier beside it while the pause holds (design D2); an invoice open before the grant stays due, Stripe keeps retrying it, and the account's dunning setting can cancel the paused subscription. `docs/models/plan-ladders-transitions.md:80` (invariant 20) says that the subscription's collection is paused while a grant holds every ladder it pays for; invariant 12 of `docs/models/payments-billing.md`, which is about Checkout, is left to section 9. `docs/database-locks.md` gains the sentence that an outbox request is an insert in the act's transaction and takes no lock. +- [x] 4.7 Tests: - `TestActsRequestSubscriptionReconciles` (`internal/entitlements/outbox_requests_db_test.go`), rows through `checkActRequests`, each on an organization with two live subscriptions and one canceled one: `issue` (through `IssueGrant`), `manage`, `revoke`, `expire`, `legacy grant of a default change`, `tier removal`; each act writes one `reconcile_subscription` entry per live subscription, in id order, and none for the canceled one. Failure text: `%s wrote entries for %v, want %v`. - `TestConferGrantTxRequestsNothing`: `ConferGrantTx` alone writes no entry. Failure text: `ConferGrantTx wrote %d outbox entries, want 0`. - `TestReconcileEndedRequestsTheOtherSubscriptions` (`internal/fulfillment/displaced_subscription_test.go`): S2 displaced S1, S2 is canceled and reconciled; one entry for S1 and none for S2. Failure text: `entries after S2's end = %v, want S1's alone`. - `TestSurplusEndRequestsTheOtherSubscriptions` (`internal/fulfillment/displaced_subscription_test.go`): S2 is live, holds the ladder over S1, and drops the item; one entry for S1 and none for S2. Failure text: `entries after S2 dropped its item = %v, want S1's alone`. - `TestIssueGrantHandlerRequestsAReconcile` (`internal/server/operator_enrollment_issue_db_test.go` or the issue handler's test file): a posted grant over a subscription's tier writes the entry. Failure text: `the issue handler wrote no reconcile_subscription entry`. - `internal/demoseed/seed_db_test.go`: the seed's revocation and expiry write entries for Carlos's canceling subscription; the existing assertions pass, and a new one asserts each entry names that subscription. Failure text: `the demo seed's entries = %v`. -- [ ] 4.8 Trace goldens, rewritten with `-update` and read, each gaining the one statement `ListLiveSubscriptionsOfPool` and no outbox insert, because each fixture holds one subscription, the excepted one. In the ended branch, the line goes directly after `` and before `ApplyScheduledChangesForSubscription` (which `UpdateSubscriptionMappingSyncStatus` and `commit` follow): the reconcile traces `internal/fulfillment/testdata/reconcile_trace/canceled_to_default.golden` and `canceled_resumes_grant.golden`, and the sweep traces `internal/fulfillment/testdata/sweep_trace/already_ended.golden`, `already_canceled.golden`, `due_cancellation.golden` and `lost_claim.golden`, which run `reconcileEnded` after a fired cancellation and end a provision (`core.end_conferral`, then `ListResumableGrantsHeldBy`). In the active branch, the line goes directly after `core.end_conferral` and before ``: the reconcile trace `reconcile_trace/addon_removed.golden`, the surplus end. `reconcile_trace/canceled_replay.golden` calls `core.end_conferral`, ends nothing and does not change. The tier removal's and the grandfathered grant's requests (4.4) change no plan-act golden (`internal/server/testdata/plan_acts/`): `actStatements` leaves out every `Get`, `List` and `Count` statement (`internal/server/plan_acts_golden_db_test.go:456-477`), and no plan-act fixture that reaches a request holds a subscription, so no outbox insert prints. Any other change is a stop. -- [ ] 4.9 Breaks: +- [x] 4.8 Trace goldens, rewritten with `-update` and read, each gaining the one statement `ListLiveSubscriptionsOfPool` and no outbox insert, because each fixture holds one subscription, the excepted one. In the ended branch, the line goes directly after `` and before `ApplyScheduledChangesForSubscription` (which `UpdateSubscriptionMappingSyncStatus` and `commit` follow): the reconcile traces `internal/fulfillment/testdata/reconcile_trace/canceled_to_default.golden` and `canceled_resumes_grant.golden`, and the sweep traces `internal/fulfillment/testdata/sweep_trace/already_ended.golden`, `already_canceled.golden`, `due_cancellation.golden` and `lost_claim.golden`, which run `reconcileEnded` after a fired cancellation and end a provision (`core.end_conferral`, then `ListResumableGrantsHeldBy`). In the active branch, the line goes directly after `core.end_conferral` and before ``: the reconcile trace `reconcile_trace/addon_removed.golden`, the surplus end. `reconcile_trace/canceled_replay.golden` calls `core.end_conferral`, ends nothing and does not change. The tier removal's and the grandfathered grant's requests (4.4) change no plan-act golden (`internal/server/testdata/plan_acts/`): `actStatements` leaves out every `Get`, `List` and `Count` statement (`internal/server/plan_acts_golden_db_test.go:456-477`), and no plan-act fixture that reaches a request holds a subscription, so no outbox insert prints. Any other change is a stop. +- [x] 4.9 Breaks: - `IssueGrant` without its request: caught by `TestActsRequestSubscriptionReconciles`, `want_new` `issue wrote entries for []`, and by `TestIssueGrantHandlerRequestsAReconcile`; - each of Manage, Revoke, Expire, the legacy grant and the tier removal without its request: one record each, `want_new` ` wrote entries for []`; - the status filter widened to every status (the generated `ListLiveSubscriptionsOfPool` constant without its `status IN`): caught by the same test, `want_new` `wrote entries for`; - the exception ignored in `RequestPoolSubscriptionReconciles`: caught by `TestSurplusEndRequestsTheOtherSubscriptions`, `want_new` `entries after S2 dropped its item =` (in the ended test the excepted subscription is canceled and never listed, so ignoring the exception changes nothing there); - `ConferGrantTx` requesting (the request moved into it): caught by `TestConferGrantTxRequestsNothing`, `want_new` `ConferGrantTx wrote`. -- [ ] 4.10 `make screens`: no change. 4.7 edits `internal/demoseed/seed_db_test.go`, which the demo snapshot's fingerprint covers (`test/reset-app-db.sh:120`), so this section rebuilds the snapshot, and the snapshot then holds the two `pending` entries the seed's revocation and expiry write; every later reset restores them `pending`. They complete as skips, with L3, once the running app's relay (a 15-second poll) reaches them, and `operator-overview`, the first capture, draws the queue's count ("{n} queued", `internal/embeds/templates/operator.html:277`), as the Stripe provider page does, so whether a capture shows them depends on timing. `test/e2e/screens` therefore gains a wait: before the first capture, the utility waits until the application database holds no `pending` row in `core.outbox`, through the admin connection it already opens for the timestamp pin (`browsertest.AppAdminDB`), with a timeout that fails the run. +- [x] 4.10 `make screens`: no change. 4.7 edits `internal/demoseed/seed_db_test.go`, which the demo snapshot's fingerprint covers (`test/reset-app-db.sh:120`), so this section rebuilds the snapshot, and the snapshot then holds the two `pending` entries the seed's revocation and expiry write; every later reset restores them `pending`. They complete as skips, with L3, once the running app's relay (a 15-second poll) reaches them, and `operator-overview`, the first capture, draws the queue's count ("{n} queued", `internal/embeds/templates/operator.html:277`), as the Stripe provider page does, so whether a capture shows them depends on timing. `test/e2e/screens` therefore gains a wait: before the first capture, the utility waits until the application database holds no `pending` row in `core.outbox`, through the admin connection it already opens for the timestamp pin (`browsertest.AppAdminDB`), with a timeout that fails the run. ## 5. The operator reads the confirmed pause diff --git a/test/e2e/screens/screens_test.go b/test/e2e/screens/screens_test.go index 3f0b4455..315c92be 100644 --- a/test/e2e/screens/screens_test.go +++ b/test/e2e/screens/screens_test.go @@ -241,6 +241,7 @@ func TestScreens(t *testing.T) { roles.page(t, "operator") roles.page(t, "member") pinTimestamps(t) + waitForEmptyOutbox(t) var captures []capture for _, s := range Manifest { @@ -845,6 +846,32 @@ func pinTimestamps(t *testing.T) { } } +// outboxDrainTimeout bounds waitForEmptyOutbox: the relay polls every 15 +// seconds and a skipped entry completes in the poll that reaches it. +const outboxDrainTimeout = 2 * time.Minute + +// waitForEmptyOutbox waits until the application database holds no pending, +// processing or failed row in core.outbox, through an admin connection, and +// fails the run at the timeout. The demo snapshot holds the pending entries +// the seed's revocation and expiry write; they complete as skips once the +// running app's relay reaches them, and the Overview and the Stripe provider +// page draw the queue's count (pending plus failed), so a capture taken before +// that depends on timing. +func waitForEmptyOutbox(t *testing.T) { + t.Helper() + db := browsertest.AppAdminDB(t) + if !browsertest.Eventually(outboxDrainTimeout, func() bool { + var n int + if err := db.QueryRowContext(context.Background(), + `SELECT count(*) FROM core.outbox WHERE status IN ('pending', 'processing', 'failed')`).Scan(&n); err != nil { + t.Fatalf("count the queued outbox rows: %v", err) + } + return n == 0 + }) { + t.Fatalf("outbox rows are still queued after %s", outboxDrainTimeout) + } +} + // resolveInstance opens the list page and returns the first link whose // href starts with the prefix, or "" when the seeded state has none. //