main
44
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
dec031e6dc |
Name every ladder a grant leaves empty when it supersedes a bundle
#271. A grant on one ladder of a product that is a tier of several ladders keeps ending the subscription's tier on all of them, and the ladders the grant is not on stay empty until it ends; what changes is that every place an operator or a member looks now says so. A new test pins the console's behaviour the strings rest on: the subscription holds nothing anywhere, the other ladder holds no position, the grant delivers, and a reconcile of the subscription is queued, which pauses its billing when it pays for nothing else. The Issue grant form: an option now carries its own hint (forms.Option.Hint, rendered as data-hint, and hint-on-choice.js shows the chosen option's hint or the declared one). A product that would supersede a tier of several ladders names them: "This grant would supersede a tier a subscription pays for on Duo and Wiki; its billing pauses until the grant ends." A product superseding a tier of one ladder keeps the existing warning. An option's hint is not held to the registry's 100-character cap, because the ladder names are the fact. The Not delivering badge on the organization page's Billing card and in the subscriptions list: the ladder's name rides the conferral shapes (ListConferralShapes selects it, Rung.LadderName), and the title adds "; nothing is delivered on {ladders}" for the ladders of the tier on which no live position exists when the page renders: "Billing paused while a grant holds Wiki; nothing is delivered on Duo." A ladder something else holds is named in neither clause. The organization page reads the default pool's positions it already holds; the list reads them with one statement per page (ListLiveLadderIDsBySubscriptionIDs), and a failed read shows "Failed to load deliveries." as the other reads do. Whether a sentence says paused or continues is decided from the deciding displacements only, as the reconcile decides the pause, so a ladder a later subscription holds cannot turn a recorded pause into "Billing continues"; when a named holder is not a grant the sentence reads "Billing paused while other plans hold Duo and Wiki." The member's Products page: on each ladder the grant does not hold, the paid tier's card reads "Paused while Pro is granted on Wiki", or "Billing continues while Pro is granted on Wiki" until Stripe confirms the pause, and draws no control; the controls stay on the card of the ladder the grant holds. docs/models/plan-ladders-transitions.md, docs/plan-management.md and docs/stripe.md say the same. Tests: TestReconcile_AGrantOnOneLadderOfABundlePausesIt, TestSupersededLadders, TestLadderNamesNeedsEveryName, TestRenderHintOnChoiceShowsTheChosenOptionsOwnHint, TestSubscriptionsListNamesEveryLadderOfABundle, TestOrgEnrollmentTwoLadderGolden (two states on their own World), TestGrantHeldLadder, TestPaidPlanCardOnAVacatedLadder, and new rows in TestDisplacedOnPage, TestDisplacementTitle, TestOrgEnrollmentIssueGrantSupersedeHint, TestOrgEnrollmentReadFailures and TestSubscriptionsListDisplacementReadFailures. TestMemberPlansGolden: the seven paid-plus-under-granted-pro states draw Duo's Plus card with the ladder line; the member and organization .json goldens gain the new fields. The grant-hint walkthrough's own-hint step is a subtest that skips, naming its precondition, because no demo state holds a subscription to a product of two ladders. Full suite on reset databases: 5143 passed, 15 skipped, none failed. make screens: no page changed; no demo state holds such a subscription. Hand captures cover the grant form, the badge and the member's cards. Unseen by any test: hint-on-choice.js not setting the line's text; only the browser walkthrough reads it, and its step skips. The hand captures show it. Breaks, all CAUGHT (44): - DisplacedProducts passes over a product of several rungs - displacedOnPage adds no emptied ladder (page rows) - displacedOnPage adds no emptied ladder (golden) - displacedOnPage names only the deciding ladder as held - groupDisplaced keeps an emptied ladder the group names as held - continuingSentence one subscription without the clause - displacedOnPage ignores live (page rows) - displacedOnPage ignores live (list) - pausedSentence uses pausedHolder whatever the holders - displacedOnPage takes grantsOnly over every holder the walk adds - organization page drops the positions read error from the badge - subscriptionDisplacementTitles ignores the live ladder read error - ListLiveLadderIDsBySubscriptionIDs answers no row - ListConferralShapes selects no name (list) - ListConferralShapes selects no name (golden) - RungsByProduct leaves LadderName empty - supersededLadders answers for a product whose superseded provisions each hold one ladder - supersededLadders keeps only the ladders the product is a tier of - ladderNames answers the names it read when one is missing - issuanceProductOptions leaves Option.Hint empty (page) - issuanceProductOptions leaves Option.Hint empty (golden) - issueGrantSupersedeHintOn answers for no ladders - selectOptions does not copy Hint - selectOptions copies Hint without ShowsHint - choiceHint answers the declared hint for a chosen option with its own - HintDefault left empty - HintDefault set when no option carries its own hint - ui_form_field.html without data-hint on the option - ui_form_field.html without data-hint-default on the line - grantHolds without its DefaultSourced test - grantHeldLadder walks rows in place of ladders - grantHeldLadder without its subscription test - setPaidUnderGrant draws the controls on a card with grantLadder - template plain paused branch without the ladder - template payment-due paused branch without the ladder - template moves-to paused branch without the ladder - template without the Billing continues branch - buildLadder without the empty-ladder case - buildLadder takes the heading's cancellation date from a card with grantLadder - pausedSentence one subscription without the clause - pausedSentence several subscriptions without the clause - continuingSentence a count of the account without the clause - continuingSentence several subscriptions without the clause - groupDisplaced leaves the emptied ladders unsorted |
||
|
|
688e36227b |
Let a member resume a paid plan a lower grant holds, and show a grant's end date
Twelfth section of #269's change. When the grant that holds a ladder ranks below the tier a paused subscription pays for, the paid plan's card now offers Resume plan before Cancel plan. Resume plan is a console act (entitlements.ResumeSubscription, run by fulfillment.ResumePlan from PostResume): in one transaction under the pool row it confers the subscription's own tier over the grant and writes the reconcile requests, and the outbox's reconcile then clears the pause at Stripe after the commit, so the member's request makes no Stripe call. It takes no subscription's advisory lock, so an ending act or a reconcile holding that lock goes first; a second submit confers once; over a bundle grant it ends the grant on its other ladder too. It is refused when its conditions no longer hold: a suspended subscription answers as a past-due or paused plan does, and a scheduled move, a rung the subscription or another one already holds, or a Stripe mapping the outbox cannot reach answers the unavailable-plan sentence. Its confirmation reads "Resume <paid>?" and "Your subscription replaces the grant. Nothing is charged now; then <price>." A grant marked to resume delivers again when the subscription ends. Rides along, by ruling: the current card a grant holds now states its cost like the others, reading "Granted until <date>" when the grant has an end date and "Granted" when it has none (the grant's valid_until, read with the positions in ListLadderPositionsByPool). Under a grant of the subscription's own tier, where the paid card is the current card, its paused line carries the date: "Paused while Plus is granted until Feb 20, 2099". The organization type's default plan's card, which states no cost either, is #289. Also by ruling, the switch on the granted tier's own card reads "Pay for this plan" and its confirmation "Pay for <tier>?" (Move.OwnTier); the tiers above keep "Upgrade". docs/stripe.md, docs/plan-management.md, docs/models/plan-ladders-transitions.md and docs/database-locks.md say the same. Tests: TestResumeSubscription_* (taking the ladder from the grant, refusals, an ending act first, a second submit, a bundle grant, a marked grant resuming), TestResumePlan_* (no Stripe call, the act's refusals mapped, an unreachable mapping, a failed clear keeping the ladder), TestPostResume, TestResumeConfirmation, TestRenderPlansAfterMoveAnswersResume, TestGrantedCurrentCard, TestSwitchLabelUnderAGrant, TestOffer rows, and TestPaidPlanCardUnderAGrant rows for the dated own-tier lines. TestMemberPlansGolden gains four states (paid-standard-under-granted-basic and three dated ones); nine states gain "Granted" on the granted current card, and paid-retired-plus-under-granted-pro's switch reads "Pay for this plan". The routes goldens gain the resume route. TestResumePlan_AFailedClearKeepsTheLadder runs the drift sweep where the task named collectionDrift; the sweep reaches the same decision. Full suite on reset databases: 5090 passed, 14 skipped, none failed. make screens: no page changed; no demo state reaches the paid card. Breaks, all CAUGHT (43): - Resume: the rank test dropped from rule 3a.1 - Resume: the console-pause test dropped from rule 3a.1 - Resume: the scheduled-move test dropped from rule 3a.1 - Resume: the bundle test dropped from rule 3a.1 - Resume: the suspension test dropped from rule 3a.1 - Resume: the GrantHolds test dropped from rule 3a.1 - ResumeSubscription: the status test dropped - ResumeSubscription: the materialization dropped - ResumePlan: the act's refusal mapping dropped - ResumeSubscription: the marker test dropped (a behavior alone passes) - ResumeSubscription: the rank test dropped - ResumeSubscription: the scheduled-change read dropped (cancel_at_period_end alone) - ResumeSubscription: the holds test dropped - ResumeSubscription: the reconcile requests dropped - ResumeSubscription: the reads moved above LockPools - ResumeSubscription: the reconcile's advisory lock on the subscription's Stripe id taken after LockPools - ResumePlan: the reachability test dropped - Resume plan: the trigger's declaration removed - Resume plan: the confirmation's first sentence dropped - Resume plan: the control not drawn (CanResume ignored by the template) - Resume plan: CanResume not counted in plansOfferPaidMove - Resume plan: the handler's decision gate dropped - Granted card: GrantedUntil never set (TestGrantedCurrentCard) - Granted card: GrantedUntil never set (TestPaidPlanCardUnderAGrant) - Granted card: GrantedUntil never set (TestMemberPlansGolden/granted-plus-until) - Granted card: the template's two branches removed - Granted card: setGranted called only when a subscription pays on the ladder - Granted card: the default_sourced test dropped - Granted card: pos.GrantID.Valid dropped from setGranted - Granted card: a clock read added (a passed end date drawn as Granted) - Granted card: setGranted called after the paid card's return - Granted card: setGranted also called on the paid card under a grant of another tier - Granted card: the date left out of the plain paused branch (TestPaidPlanCardUnderAGrant) - Granted card: the date left out of the plain paused branch (TestMemberPlansGolden/granted-plus-own-tier-until) - Granted card: the date left out of the payment-due paused branch - Granted card: the date left out of the downgrade's paused branch - Granted card: the date drawn on a line that does not read Paused (the renews branch) - Granted card: the query's column selected as NULL (the join left out) - Granted card: JOIN core.grants in place of the LEFT JOIN (the new test) - Granted card: JOIN core.grants in place of the LEFT JOIN (the live-positions test) - renderPlansAfterMove: ErrResumeUnavailable dropped from the unavailable-plan case - moveLabel: the own-tier case reverted to Upgrade - planMoveMessage: the own-tier heading reverted to Switch to |
||
|
|
23e5689df8 |
Let a member switch a plan paused under a grant to the granted tier or above
Eleventh section of #269's change, and #278. A member whose paid plan is paused while an operator's grant holds its ladder can now switch that plan to the granted tier, or to a tier above it, from that tier's card. The switch supersedes the grant, so the subscription takes the ladder; a grant marked to resume delivers again when the subscription ends. Stripe refuses an always_invoice update on a paused subscription and refuses clearing the pause beside pending_if_incomplete, which the approval path needs, so the paused form is two calls: the pause is cleared in a call of its own, then today's upgrade is sent unchanged. Before the clear, the switch writes a reconcile_subscription outbox entry due four minutes later, so a process that stops between the calls still has the subscription paused again; the outbox relay now takes an entry only once its next_attempt_at has passed (EnqueueAt, RequestSubscriptionReconcileAt), and every other writer leaves it due at once. A guard refuses the switch with "Try again in a moment." while a paused renewal draft is due to finalize within ten minutes, reading the later of next_payment_attempt and automatically_finalizes_at. When the update does not apply (a decline, an approval, a Stripe error from either call, a deadline, a closed request), a deferred restore runs the subscription's reconcile under context.WithoutCancel and pauses it again; the draft list, the voids and the clear carry 20 seconds, the charge 60, and the restore 60, through the reconcile's context (#285). An abandoned upgrade's open invoice is voided before the clear, and Cancel plan voids it too on a subscription that pays without holding its ladder. The offer: rule 3a item 2 offers the switch on the granted tier's current card and on each tier above it, and refuses it when the paying product or the target is a tier of another ladder, as Resume plan does, because the supersession ends the whole grant. A target priced in another currency is never offered. Stripe answers no preview for a paused subscription, so the confirmation reads "Your subscription replaces the grant." with the fallback charge sentence, and toward a price of another interval "Your card is charged <price> now, minus a credit for the unused part of your current plan, then <then> from today." The server's 8-second write timeout can lose a slow switch's answer while its state still converges; that is #290. docs/stripe.md, docs/database-locks.md and docs/models/provider-integration.md say the same. The change text also carries section 12's granted-card ride-along, built there. Tests: TestSwitchAPausedSubscription_* (seventeen: the two calls, the granted tier, a decline and an approval paused again, every other exit restoring, a closed request, a holding subscription, a stale marker, a foreign pause, an ordinary upgrade, the guard, the backstop before the clear, every call's deadline, the abandoned upgrade voided first, a failed restore, a marked grant resuming), TestPreviewSwitchOfAPausedSubscription, TestCancelAPausedSubscriptionVoidsAnAbandonedSwitch, TestCancelAHoldingSubscriptionVoidsNothing, TestOutboxPollSkipsAnEntryNotYetDue, the server's paused-switch post and preview tests, and new TestOffer, TestPlanMoveMessageStatesTheTiming and TestPayingByLadder rows. The golden paid-retired-plus-under-granted-pro gains the switch; the five paid-plus-under-granted-pro and granted-plus-own-tier states pin its refusal for a tier of two ladders and are unchanged. Deviations: the past-due and paused post rows set Stripe's status on the mock and reconcile it, since the kit's Suspend and Subscribe build no Stripe subscription for the mock; the break "the replacement not resumed when S ends" breaks the resumption's read in grant_resumption.go, because no Go write records a replacement (core.grant_replacements is a view). Sandbox 11.1 ran before the code: pending_if_incomplete refuses pause_collection, a paused subscription answers no proration preview, and the two-call form works, approval included; every non-applied outcome ended paused with nothing charged. Full suite on reset databases: 5018 passed, 14 skipped, none failed. make screens: no member page changed; the known flakes on the plan ladder and domains pages. Breaks, all CAUGHT (75): - the clear not sent - the clear folded into call 2 - call 2's form changed - the restore not run after a decline - the restore not run after an approval - the restore run only in the two pending branches, in place of the deferred step - the deferred step set up after call 1 answers, in place of before it - the restore called before each return, in place of a deferred step - WithoutCancel dropped from restorePause's context - WithoutCancel dropped from stripeCallContext - the applied branch run under the request's context - the restore made a blind set - the scope widened to a stale marker - the scope widened to a foreign pause - the scope narrowed to a subscription that does not hold its ladder - the paused form taken with no pause - the guard not called - the passed time not refused - the earlier of the two times taken - automatically_finalizes_at not read - next_payment_attempt not read - the billing-reason test dropped - the auto-advance test dropped - a failed list let through - a draft with both times null let through - the backstop written after the clear - the backstop's delay dropped - EnqueueAt leaving the column's default - a failed backstop insert ignored - the poll's next_attempt_at filter dropped - call 2 sent with no context - call 2 given pausedSwitchCallTimeout - the clear sent with no context - the draft list sent with no context - the restore given no deadline - the proration moment's check dropped - the preview calling Stripe for a paused subscription - the preview keyed on the console's pause alone - Cancel's void dropped - a refused void let through to the cancellation - Cancel's void placed after the commitment's deferral - Cancel's void widened to a holding subscription - the replacement not resumed when S ends - rule 3a applied after rule 3 - ReplacesGrant not set by rule 3a item 2 - the paid-tier rank test dropped - the suspension test dropped - replacesGrant ignored - section 8's exception removed for the switch's product - a paying refusal left to !pos.Subscribed (the not-suspended mapping deleted) - the suspended mapping deleted from switchOffer - the Paying.Paused test dropped from switchOffer - the !pos.Subscribed test dropped from the mappings' condition - the currency test dropped - the currency test applied ahead of the price rule - NewPeriod not set - NewPeriod set on a disabled move - NewPeriod set whatever the interval - payingFrom leaving Currency and Interval unset - postedPrice leaving Currency unset - defaultPrice leaving Currency unset - planMoveMessage ignoring newPeriodCharge - M12 chosen only when the preview has no figure - renderSwitchPreview passing no newPeriodCharge - renderSwitchPreview passing no ReplacesGrant - step 4 dropped from the paused form - the abandoned void sent with no context - step 4 moved after the clear - the failed restore not logged (L7) - the failed restore logged without stripe_subscription_id (L7) - the restore's failure changing the member's answer (F64) - a bundle's subscription offered the switch (rule 3a item 2 reading OtherLadders) - the server preview calling Stripe for a paused subscription - a bundle target offered the switch (rule 3a item 2 reading Target.OtherLadders) - a bundle target posted from a paused subscription |
||
|
|
1d0e2be9f0 |
Show the member a paid plan paused under a grant, with Cancel, Keep and Pay invoice
Tenth section of #269's change. When a grant holds the ladder a subscription pays for and Stripe has confirmed the console's pause, the member's Products page draws the paid plan's own card: "Paused while Pro is granted" (M0), with ", payment due" when the subscription is past_due or unpaid (M1, then Pay invoice and Cancel plan, and no suspension line), or ", moves to Basic on {date}" when a downgrade was scheduled before the grant (M8). The card offers Cancel plan, whose confirmation reads "Plus ends on {date}; Pro stays granted." (M2), or "Your subscription ends on {date}; Plus stays granted." under a grant of the subscription's own tier (M9), followed by "The unpaid invoice stays due." when payment is due (M7). A cancellation scheduled shows the ladder heading's "Cancels on {date}" badge and Keep plan; on a ladder a grant holds the badge carries no title, as on a suspended plan, because the grant keeps delivering past the date. A pause Stripe has not confirmed keeps the existing lines (M5). A paying row with no period end shows the existing "Billing details are still being processed." line and no control. Cancel plan cancels at the period end (decision 16), Keep plan clears only cancel_at_period_end, and Pay invoice opens the paying subscription's open invoice: the plan-change resolver falls back to the paying subscription when no subscription holds the ladder (resolveSubscriptionOnLadder), with its product and a suspended position for past_due, unpaid and paused, and the hosted-invoice route lists that subscription's open invoices. No move sets or clears the pause. The Stripe mock answers GET /v1/invoices. Sandbox check (task 10.1, on a test clock with the console's restricted key): Stripe accepts cancel_at_period_end=true and then false on a subscription paused with behavior void and the marker, creates no invoice, keeps the pause and the marker, and voids the next renewal. docs/stripe.md and docs/plan-management.md describe the card and its moves. Tests: TestMemberPlansGolden states paid-plus-under-granted-pro (with M0 and Cancel plan), its past-due, cancellation-scheduled, downgrade-scheduled, paused and unconfirmed forms, granted-plus-own-tier, and paid-retired-plus-under-granted-pro; the 22 earlier states' .json gain three zero-valued fields and no .html changes. TestPaidPlanCardUnderAGrant, TestCancelBodyOnThePaidCard (a tier with the same name included), TestCancelBadgeOnAPaidCardUnderAGrant, TestPlansOfferPaidMoveGate's paidDue, TestPlanUnpaidInvoiceForAPausedPlan, TestCancelAPausedSubscription and TestKeepAPausedSubscription (the pause stays), TestAPastDuePayingSubscriptionIsNotCancelledImmediately. Review: no blocker or major. Five minors applied: the own-tier case read from the card's relation instead of the tier's name, which is no identity; the cancel and keep tests given the console's pause; the paying row with no period end; two stale doc comments; and the badge's title, false on a grant-held ladder, dropped as on a suspended plan. Nine nits applied; payingByLadder, left test-only, is replaced by the functions it composed. Breaks: 22 of 22 caught. Full suite on reset databases: 4930 pass, 14 skip. make screens: no demo state reaches the paid card, so no capture of this section's changes; hand captures of the seven states and both confirmations sit beside the change's screens. |
||
|
|
ec6cf1e479 |
Refuse a second subscription on a ladder a paused subscription pays for
Ninth section of #269's change, and #277. A grant over a paid tier now pauses the subscription that pays for it, so that subscription pays on a ladder without holding it. The offer decision did not know such a subscription: on a grant-held rung it sold Checkout, and a member could buy a second subscription on a ladder the first still paid for. The offer now reads the paying subscription: on each ladder, the first live subscription by id (active, trialing, past_due, unpaid or paused) whose current item names a tier of the ladder and that does not hold the ladder's position. Rule 3a, ahead of rule 3, sells nothing through Checkout on a ladder a subscription pays for; every target answers none there, and section 10's paid card and section 11's switch give the moves that ladder offers. The Products page, the Billing page, Checkout, the switch and its preview all reach it through one plans.Offer decision. ListSubscriptionRungsByOrg reads the paying subscriptions in one statement per pass over the organization's ladders, cached on the loader; a switch's page after its move reads again. ListLadderPositionsByPool also returns default_sourced. Checkout's refusal logs the price, the ladder and the reason. docs/models/plan-ladders-transitions.md (invariant 19) and docs/models/payments-billing.md (invariant 12) state the rule. Tests: TestOffer rows and TestOfferSellsNothingOnAPayingLadder; TestPayingByLadder, each row in two orders (the holding subscription, the default, a Dashboard pause, a scheduled downgrade, unpaid, two tiers of one subscription); TestCheckoutRefusesALadderASubscriptionPaysFor over an active, past_due, unpaid, paused and trialing displaced subscription (422, the Copy sentence, no Stripe call); TestCheckoutAboveAGrantWithNoSubscription, two controls that sell and a ladder only an ended item names, which sells; TestProductsPageReadsPayingOnce; failed-read rows for the Products page, Checkout and the switch; the golden state paid-plus-under-granted-pro. Review: no blocker or major. Three minors (the new read's failure untested; status, ended-item and two-tier facts untested; a Checkout test that could not tell the offer's refusal from the price check's) and seven nits applied. The change's text now says one read per pass over the ladders, not per request, since a switch's page after its move must read again. Unseen by any test: the default_sourced column read as false, and switchOffer's paying refusals, which section 11's tests reach. Breaks: 15 of 15 caught. Full suite on reset databases: 4890 pass, 14 skip. make screens: no change of this section's; the two organization captures that differ are the grant form's hint, not yet in the accepted baseline. The change's text also states the grant form's two-line state: a chosen tier marked with the warning sign whose entitlement set has no active rule shows the rule-less sentence and the supersession hint, each a different cost of the same choice. |
||
|
|
f7363e98af |
Show the grant form's supersession hint only once a marked product is chosen
Task 5.10 of #269's change. The Issue grant form showed a yellow alert under its lead whenever its Product select offered a product marked with the warning sign, before anything was chosen, so it read as a standing warning. The sentence now reads "This grant would supersede a tier a subscription pays for; its billing pauses until the grant ends." and is the Product field's one hint line, hidden until the operator chooses a marked product and hidden again when an unmarked one is chosen. A refused submission that chose a marked product renders it shown. The form's message is the lead alone. The forms library gains the mechanism, for any Stacked form: a select declares HintOnChoice with its Hint, options carry ShowsHint, and the field part renders the hint only when an offered option shows it, hidden unless the chosen one does, and named by the select's aria-describedby only while it shows. One external script, hint-on-choice.js, loaded with defer by the operator shell, follows later choices and htmx swaps through data-hint-toggle and data-shows-hint. The invariants refuse HintOnChoice off a Stacked select, without a Hint or with a Notice, and the escape hatch refuses data-hint-toggle. The change's text gains a form-library requirement ("A select's hint can wait for the option that shows it") and modifies form-conventions' "Every control has a label, and its hint and error are described" so a hint is described while it shows. docs/design-system.md describes the mechanism in section 8 and adds the script to the progressive-enhancement table. Tests: TestOrgEnrollmentIssueGrantSupersedeHint (at rest, no marked product, a refused submission with a marked, an unmarked and no product, the message the lead alone), TestOperatorShellLoadsTheHintToggle, TestRenderHintOnChoiceFollowsTheChosenOption, TestInvariantsHintOnChoiceBelongsToAStackedSelectWithItsHint, the escape hatch's and the invariant messages' rows, and TestGrantSupersedeHintWalkthrough, which chooses a marked and then an unmarked product on the demo's Carlos Member organization and checks the hint and aria-describedby both ways. Three organization goldens change only in the message and the Product field. Review: round 1, three majors (the hint supplied per render, bypassing the registry's hint checks; no spec for the library's new contract, with form-conventions saying the opposite; the script's break unreachable while the app serves its embedded copy) and five minors, all fixed; round 2, no major. Breaks: 12 of 12 caught. 8 in code; the script's 4 run as a second list whose preparation rebuilds and restarts the app on the broken tree. Full suite on reset databases: 4843 pass, 14 skip. make screens: Carlos Member's organization, open and refused, at both widths, loses the alert. Hand captures of the form at rest, with a marked product chosen and with an unmarked one chosen after it sit beside the change's screens. |
||
|
|
de3d5102a7 |
Title a displaced subscription paused once Stripe confirms its collection pause
Fifth section of #269's change. The operator's Not delivering badge now reads the pause Stripe confirmed: a displaced subscription whose recorded pause_collection behavior is set, and whose every holder is a grant, is titled "Billing paused while a grant holds {ladders}." ("grants hold" when several grant provisions hold them). An organization with several subscriptions reads "Billing paused on {k} of {n} subscriptions while ...", followed, when some are not paused, by the existing "Billing continues on {m} while ..." sentence. The record decides "paused", never the rule: a subscription whose pause Stripe has not confirmed keeps the existing title. The organization page and the subscriptions list both read the column section 2 added. The Issue grant warning reads "Products marked ⚠ would supersede a tier a subscription pays for; its billing pauses until the grant ends." docs/stripe.md and docs/plan-management.md name the paused title and the existing one while the pause is unconfirmed. Tests: TestDisplacementTitle rows for the paused, mixed and holder forms, the mixed form in both input orders; TestDisplacedOnPage rows for a recorded pause, a Dashboard pause and a pause under a subscription holder; TestSubscriptionTitlesFor's paused row; TestSubscriptionsListMarksAPausedSubscription; the golden state grant-over-subscription-paused, which differs from grant-over-subscription only in the badge's title. The three goldens that show the warning change only in its sentence. The Dashboard-pause row records mark_uncollectible rather than the test kit's void, because displacedOnPage sees only the behavior and a void row would repeat the recorded-pause row. Review: no blocker or major. Three minors and five nits applied: five comments that still said a displaced subscription bills, a row that could not test a recorded pause under a purchase, the second input order, and a narrower failure text for the warning's break. Breaks: 9 of 9 caught. Full suite on reset databases: 4783 pass, 14 skip. make screens: no change beyond the three captures every run since section 2 redraws. The tasks expected the Issue grant captures to change; none shows the warning, because the captured organization's one subscription holds no tier, so item 5.9 now says so. |
||
|
|
fc68a6b78e |
Stop offering "None" for the entitlement set of a product that is a tier
Fourth section of #153's change. The product edit reads the ladders the product is a tier of. For a tier product the Entitlement set select no longer offers "None"; it opens with the disabled placeholder "Choose an entitlement set", and the field's one line reads "This product is a tier of <ladders>, and a tier needs an entitlement set." in place of the off-ladder sentence, which moves from the field's declared hint to the same line slot (the create page keeps it). A submitted "None" is refused with that sentence; so is a clear refused by section 2's trigger when the product became a tier after the page was read, after the ladders are read again. A failed read of the ladders is logged and saves nothing, instead of reading as "on no ladder". The edit's write now runs in its own READ COMMITTED transaction (db.InTx with db.ReadCommitted, item 4.6), so a tier add that commits first is seen by the clear whatever the server's default isolation; at REPEATABLE READ the edit's snapshot predated the tier and the clear committed. UpdateProduct is split into helpers, each answer and log line kept. Tests: TestTheEntitlementSetFieldFollowsTheProductsTiers, TestTheCreatePageCarriesTheOffLadderLine, TestTierNeedsSetSentence, TestProductEditOfATierProductOffersNoNone, TestProductEditRefusesNoneForAProductThatBecameATier, TestProductEditWithUnreadLaddersSavesNothing, TestProductEditClearingASetIsRefusedWhenATierAddCommitsFirstAtAnyDefaultIsolation (on a connection whose default is REPEATABLE READ). The forms golden operator.product.html moves the hint to the line. TestUpdateProductEntitlementSetSentinel builds its product on committed rows, since the edit's own transaction cannot see an uncommitted fixture; its assertions are unchanged. productTierOf takes no context: it logs through the handler's logger and unparam refuses the unused parameter. Breaks: 20 of 20 caught. The parse's error left in place; None offered whatever the tiers (T6, T6c, T6d); the placeholder left out (T6d, T6); the ladders out of name order; the line kept beside the error; the off-ladder line blanked beside an error; the create page's line lost; the failed read treated as no tier; the save going ahead when the read failed; the parse's field error left when the ladders are unread; L3 not logged; the second-read refusal not logged; the database's refusal unmapped; the refusal named over a failed second read; the join reduced to the first name; the write at the server's default isolation; the empty-value condition dropped. Review: round 1, no blocker or major; four minor and three nits, applied. #153's sequence (item 5.4), app rebuilt from this tree, from the demo snapshot, the whole walkthrough package three times without a reset: ok each run. The plan-ladders walkthrough's ladder now has tiers with sets, the org-types walkthrough committed a default change to it on runs 2 and 3 and put the default back, and the grant revoke passed on all three runs; the app log has no "has no entitlement set". Item 5.4 is reworded to what ran: the three-walkthrough filter it named finds no revoke subject on the demo snapshot, which another walkthrough's grant supplies. Full suite on reset databases: 4731 pass, 14 skip. make screens: no change beyond section 1's; the captured product is not a tier. |
||
|
|
2f1803d1e6 |
Mark a subscription that delivers nothing with a badge, on the organization page and in the subscriptions list
Section 8 of #212's change, after review: the Billing card's muted line under the Subscriptions row becomes an amber "Not delivering" badge on the row's value line, after the state badge, with a title that names the ladders and the holder ("Billing continues while a grant holds Demo Ladder."; with several subscriptions "Billing continues on <k> of <n> subscriptions while ..."). The operator's subscriptions list marks each displaced subscription's row with the same badge and title, read with one grouped statement per page and turned into titles by the same steps the card uses. A failed read on the list says "Failed to load deliveries." once above the table and marks no row. A paused subscription no longer counts: it generates no invoice, so "Billing continues" would be false; it is marked once it is active again. The grant form's warning now reads "Products marked ⚠ would supersede a tier a subscription pays for; the subscription keeps billing and delivers nothing on that ladder until the grant ends." The billing view it named before can change nothing. A Stripe-paused subscription's tier is still marked, an accepted edge until #269's change, which pauses a displaced subscription's billing and rewords the badge and the warning. Goldens: 16 change, each as item 8.9 lists: the organization page's JSON goldens rename DisplacementNote to DisplacementTitle, the two subscriptions-list goldens gain the title field, three HTML goldens carry the new warning, and grant-over-subscription.html the badge in place of the line. No other golden changed. Breaks (scripts/mutation-check, 31 of 31 CAUGHT): - paused restored - not_delivering removed from badgeMap - badge tone - list badge without title - card title never set - card template without badge - account form never chosen - n read as displaced count - subscription holder as grant - several holders as one - ladders joined with commas - sort removed - ladders not deduplicated - holders not deduplicated - status filter dropped (card) - status filter dropped (list) - list row in account form - list row in account form (list vs card) - list titles never set (the field set to strings.TrimPrefix of the title by itself, an empty string, because removing the line leaves titles unused and does not compile) - delivering row given the displaced title - list template without badge - list pool not default - list reads moved into the row loop (emulated: an inline closure calls the title read once per row; a helper only the break called would fail deadcode) - list DisplacementError assignment and L4 removed - O4 template line removed - O4 line moved under the table - rows dropped on a failed read - badge label - card badge without title - delivering row given an entry - old warning restored Hand capture on the Stripe sandbox (API 2025-02-24.acacia): a member subscribed to a $20/month tier through Checkout; an operator issued a complimentary grant of the tier above; an edit to the subscription at Stripe then reconciled it, and the reconcile logged "reconcile: product not conferred; another plan holds its ladder". The Billing card and the subscription's list row each showed Active and Not delivering at 1280 and at the 390 mobile width, with no sideways page scroll; the badge's title read by script: "Billing continues while a grant holds Demo Ladder." Captures under test/screens/displaced-subscription-waits/. The suite on reset databases passed on this section's tree, 4711 tests, 14 skipped; make screens changed no screen this section owns, and every registered form was captured. The review's last fixes (the list test's row checks now fail when a row is missing, two comment rewraps, task wording) came after it; the list test and its five breaks were rerun on them: pass, 5 of 5 CAUGHT. make lint passes; openspec validate --strict passes. |
||
|
|
8fdecf0e0e |
Stop tiering products with no entitlement set in tests, and leave them out of the tier picker
First section of the change that makes a product with no entitlement set unable to be a plan tier (#153). The change's proposal, design, tasks and spec deltas come in with it. Fixtures: seven tests that made a product with no set a ladder tier give it one (productWithSet in internal/db, mkTierProduct in internal/server, tierProduct in the merchant-disclosure sheet). The sheet is a capture make test does not run; its edit was checked by reading and go vet. The test kit's WithoutSet comment says that from migration 00035 on a tier's set cannot be cleared. The failure the plan-act goldens inject moves from a product with no set to a product made draft after it is laddered, which core.confer refuses at the same statement and step. Four goldens under internal/server/testdata/plan_acts change only there: the product reads Drafted instead of NoSet, and the driver text reads "is draft and not conferrable". TestResumption_ARefusedConferralIsRolledBackAlone drafts Plus the same way. The tier picker: tierCandidates keeps a product that is published, has an entitlement set and is not on the ladder, in the order given; availableTierProducts calls it. The Add tier panel's empty state keeps its first sentence and says "A tier needs a published product with an entitlement set that is not on this ladder yet." A posted product the list left out is refused with "Choose a product from the list." make screens: operator-plan-ladder-detail's open and invalid captures change at both widths. The demo's three products on no ladder have no set, so the demo ladder has nothing to add and the open panel shows the empty state. The other changed screens are the known data-only differences and the host's time zone (#274). Read-only counts on the test stack's app database before any reset: no default ladder starts on a product with no set, no plan act waits for one, and the two tiers with no set sat on a ladder the browser walkthroughs had left behind. Breaks (scripts/mutation-check, 11 of 11 CAUGHT): - default-initiate-fails without its draft - default-migrate-restore-fails without its draft - remove-second-pool-fails without its draft - reorder-an-organization-fails without its draft - the draft removed from the refused conferral test - the tier filter without the set - the tier candidates in reverse order - the empty state's old second sentence restored - the tier add accepts a product the list left out - the tier filter without the lifecycle - the tier filter keeps a product on the ladder The seven fixture edits and the WithoutSet comment carry no break: they remove a state section 2's triggers forbid, and section 2's run is their test. make lint passes; openspec validate --strict passes; the suite on reset databases passes, 4694 tests, 15 skipped. |
||
|
|
04862abe9c |
Say on the organization page which ladder a subscription delivers nothing on
An operator could not see that a subscription bills while another plan holds the ladder it pays for. The organization page's Billing card now says it under the subscriptions row, from the same rule the reconcile uses: the page reads the account's displacements, the subscriptions' current items and their rungs once per account, and calls fulfillment.RungsByProduct and DisplacedProducts over them, so a subscription whose item moved off the ladder reads no line. The line, for one subscription: "Delivers nothing on <ladders>, which a grant holds" (or "which another subscription holds", "which another plan holds", "which other plans hold"); for several: "<k> delivers nothing on ..." or "<k> deliver nothing on ...". Ladders are distinct, sorted by name, joined with joinWithAnd. A canceled or incomplete subscription reads no line. A failed read of the displacements, the items or their shapes logs and shows "Failed to load deliveries." in the line's place, styled as the page's other errors, never an absent line. Queries: ListAccountSubscriptionDisplacements (entitlements, one statement per account with ladder names by join) and ListCurrentSubscriptionItemsByBillingAccountID (billing). The page's read count stays fixed per account (a read-count row grows displaced subscriptions), and TestOrgEnrollmentReadFailures fails each new read. The template uses trim markers, so every existing HTML golden is unchanged; the new state grant-over-subscription shows the line, and eight JSON goldens gain the two empty fields. plan-management.md, stripe.md and plan-ladders-transitions.md now say the organization page reads the rule. Tests: TestDisplacementNote, TestDisplacedOnPage, TestSubscriptionMayBeDisplaced, TestDisplacementLineFor (a canceled subscription reads no line), the golden state, the read-count and read-failure rows. Breaks (scripts/mutation-check, 14 of 14 CAUGHT): - DisplacementNote never set - the template without the line - a subscription holder worded as a grant - several holders worded as one - the ladders joined with commas only - the plural form never chosen - the sort removed - the ladders not deduplicated - the holders not deduplicated - canceled admitted - displacedOnPage without the current products - the displacement read in a loop over the subscriptions - the DisplacementError assignment and the L3 log line removed - the status filter at the call site removed make screens: no screen shows the line (the demo seed holds no displaced subscription). Fourteen screens differ from the baseline, none from this change: the host's time zone moved from Eastern to Central, and the captures render pinned times in the host's zone, so every time column reads an hour earlier; the Stripe integration page's wall-clock "last read back" line and the five data-only differences seen on every run tonight are the rest. The suite on reset databases passes, 4686 tests, 15 skipped. make lint passes; openspec validate --strict passes. |
||
|
|
dc2fd46aa0 |
Show an operator a suspended delivery, and say a suspended plan's end
The organization page's Pools panel listed only active deliveries, so a plan whose payment is due vanished from it and the Issue grant form did not warn that a grant would supersede it. The panel now lists a suspended delivery with a Suspended badge, taken from the delivery's first row (the status trigger copies a provision's status to every rung that has not ended), and subscriptionHeldLadders holds its ladder, so the supersession warning shows. ExcludeSuspended is deleted: nothing reads it any more. The Billing card's subscription summary orders states unpaid, past_due, paused, canceling, active, trialing, so the worst state names the card. A suspending state drops "renews <date>", which names a period nobody has paid for; one set to cancel at the period end keeps "ends <date>", which is true, since Stripe cancels it then whatever its invoice says, and is the card's only sign that the member already cancelled. The copy table's row 14 says so. The subscription's Unpaid badge has its own key, subscription_unpaid, in the danger tone, used by the card's Subscriptions badge and the subscriptions list through one helper; the invoice's and the balance's Unpaid keep the warning tone. Rides along, listed in the proposal's quick fixes: a single incomplete_expired subscription said "renews <date>"; like a canceled one it now says nothing. The design's S10 row now says the hourly invoice sweep re-reads an invoice still open a day after its last read, so an uncollectible one reads open for up to a day, not until its next event. Tests: TestOrgEnrollmentGolden's suspended-subscription changes on purpose (Small2 delivering with its badge, the supersession warning, no renewal note), four JSON goldens gain "Suspended": false; TestSummarizeSubscriptions gains the precedence, no-note, ends and incomplete-expired rows; TestSubscriptionUnpaidBadgeIsDanger pins the label and the tones. Suite 4613 pass, 15 skip, 0 fail after the review's fixes. make screens: only the known data-only differences (regenerated ids, a domain's age). Breaks, all CAUGHT: pools panel loop filters suspended positions subscriptionHeldLadders skips suspended positions delivery Suspended never set precedence without unpaid ahead of past_due paused left out of the precedence subscriptions row unpaid badge through the shared key subscriptions list unpaid badge through the shared key note kept for a suspending state unpaid label through the Suspended word unpaid not a suspending state for the note paused not a suspending state for the note past due ranked below paused shared unpaid key drawn in danger past due drawn as unpaid suspending state says nothing even when canceling incomplete expired renews |
||
|
|
ddd57bf162 |
Show a suspended plan in the member's entitlements panel with its state
The "Your entitlements" panel listed only active plan positions, so a plan whose payment is due read as no plan at all. It now lists every live position, one line per ladder from the ladder's first row in query order, which is its active row when it has one, and badges a suspended line "Payment due" (danger) or "Paused" (secondary). An active row never carries a badge, whatever its subscription's status says. The badge comes from badgeMap through a new payment_due key, so the map stays the one place a state becomes a label. orgHasEntitlement counts a suspended plan too. Its only caller is the dashboard's empty state, which grants no access, so a member whose payment is due is no longer told they hold nothing. Tests: TestLadderRowsYieldOneLinePerLadder (one line per ladder from its active row, and an active row whose subscription is past_due carries no badge); TestMemberEntitlementsGolden's past-due-plan changes on purpose (the plan's line with Payment due, and Enrolled true, which the template's empty-state check reads) and gains paused-plan; three JSON goldens gain "State": "" and nothing else; TestOrgHasEntitlement's payment-due row now wants true. Suite 4605 pass, 15 skip, 0 fail after the review's fixes. make screens: only the known data-only differences (regenerated ids, a domain's age). Breaks, all CAUGHT: ladderRows keeps ExcludeSuspended State always payment_due the template without the badge orgHasEntitlement keeps ExcludeSuspended ladderRows without the one-line skip subscription status decides an active row |
||
|
|
a7bea2fadf |
Let a member pay a plan's unpaid invoice on Stripe's page
A plan whose payment is due shows Pay invoice as its card's first
control, and an open invoice's detail shows it too. Each opens a console
route in a new tab: GET /billing/plans/{ladderID}/unpaid-invoice and
GET /billing/invoices/{invoiceID}/hosted. The route checks that the plan
or invoice belongs to the member's organization, the way the invoice
detail does and through the same gate function, reads the invoice from
Stripe when the member opens it, and answers 303 to its hosted invoice
page. The URL is a bearer link: it is never stored, logged or written
into a body, the redirect has no body, only an absolute https URL is
followed, and every answer carries Cache-Control: no-store.
The by-plan route answers /billing without a Stripe call for a plan
that is not suspended, a mapping the key cannot reach or no Stripe
querier, and for a latest invoice that is already paid; the by-invoice
route redirects a paid invoice to its receipt. A failed Stripe read logs
"member hosted invoice: stripe read failed" and answers /billing. Both
routes answer a refusal with the styled error page, reusing "The page
you requested does not exist." and "An unexpected error occurred. Try
again.", which the Copy table now lists as reused; the invoice detail,
a partial, keeps its plain answers. Pay invoice counts as a paid control
for the Products page's billing line.
Tests: TestHostedInvoiceRoutes (18 rows, another organization's plan and
invoice among them), TestInvoiceDetailOffersPayInvoice,
TestPayInvoiceReferencesTheBillingLine, new rows in
TestPlansOfferPaidMoveGate. The test log capture now carries With
attributes and keeps a repeated key's values, so the check that no line
carries the URL cannot be bypassed. Goldens: seven payment-due
member-plans pages gain Pay invoice; routes.golden and
routes-not-ready.golden gain the two routes (anonymous: 302 to /login);
route_pages.golden gains them as styled 404s.
Not caught by a break of their own: the by-plan gate's pool scoping and
its billing-account check guard together, and the other-organization
row catches only their conjunction (no single edit removes the scoping,
and the test kit cannot back one organization's position with another's
subscription); that the detail and the by-invoice route call one gate is
read in review; pageRefused's 500 branch has no row, since no fixture
can fail a read on the real database.
Rides along: the change's proposal and design name #255 to #258, the
issues filed for its out-of-scope items, and the #150 note; the design
gains S11, a sandbox check of an abandoned upgrade's invoice after its
pending update expires.
Suite 4603 pass, 15 skip, 0 fail. make screens: only the known
data-only differences (regenerated ids, a domain's age).
Breaks, all CAUGHT:
by-invoice route without the ownership gate
by-invoice route without the environment check
by-invoice route redirecting only an open invoice
by-invoice route accepting a URL that is not https
by-plan route without its suspended check
by-plan route redirecting a paid latest invoice
by-plan route without its environment check
by-plan read without the expand of latest_invoice
by-plan route answering a missing position 303 to /billing
by-invoice handler without its own Cache-Control
by-plan handler without its own Cache-Control
the hosted URL logged on success
failed invoice read without its log line
failed subscription read without its log line
card without Pay invoice
Pay invoice on a paused plan
Pay invoice drawn without the LadderID check
plansOfferPaidMove not counting Pay invoice
Pay invoice without its aria-describedby
PayPath set for an invoice in the other environment
PayPath set for a paid invoice
by-plan route not registered
refusal of a Pay invoice route as bare text
the hosted URL logged through Logger.With
|
||
|
|
996d6eee34 |
Hold a plan whose payment is due on the Products page and in the plan moves
Third section of #148, which fixes the issue's two points. A plan whose subscription is past due, unpaid or paused is still the organization's: the Products page shows it as the current plan and offers no Checkout on its ladder, and the Checkout endpoint refuses one, so a second Stripe subscription can no longer start while the first is retried. The current card of such a plan reads "<price>/<interval>, payment due" (or ", paused") in place of its renewal line, with "Entitlements are suspended until the invoice is paid." (or "Entitlements are suspended."). A past-due subscription may be a failed card or a renewal waiting on the bank's 3-D Secure approval, so the words say what is owed, not why. The badge tooltip that promised access until the period end is dropped there, and the cancel confirmation names the end ("Pro ends on {date}.", or "..., then you move to {plan}.") followed by "The unpaid invoice stays due." The plan moves on that subscription: - A switch, in either direction, and its preview are refused with "Pay the unpaid invoice before changing this plan." before any Stripe call, in the decision and in fulfillment, so no second invoice interleaves with the open one. - Cancel plan at period end and Keep plan work as on an active plan. An immediate cancellation is refused: Stripe stops collecting every finalized invoice of the customer when a subscription is cancelled. - Keep plan is refused once the scheduled downgrade has landed at Stripe, which the failed renewal usually means, so it cannot undo a change Stripe already bills; the page then draws no "moves to" line and no Keep plan. A landed downgrade is recognised by the schedule Stripe reports or, once Stripe has released that schedule, by the subscription already carrying the lower price, the test the reconcile uses; the second part comes from this section's review and the change's design says so. - A paused plan offers no move at all and fulfillment refuses each one, with "A paused plan cannot be changed."; no console-made subscription reaches paused today. HasActiveSubscriptionOnLadder becomes SubscriptionBacksLadder for its wider meaning. The test kit gains ConferUnprojectedSuspended for a subscription not yet projected. The member-plans goldens gain seven states for these plans; the two characterization goldens of the previous section now show the corrected page, and every other golden gains only an empty Suspension field. make screens: no changed screen comes from this section (the demo has no suspended plan); the seven changed shots are the known data-only and timing differences. Full suite on reset databases: 4578 pass, 15 skip, none failed. make lint passes. Breaks, each caught: - resolveLadderSubscription keeps ExcludeSuspended (TestCancelASuspendedSubscriptionAtPeriodEnd) - resolveLadderSubscription keeps ExcludeSuspended (TestSubscriptionBacksASuspendedLadder) - SwitchPlan without its suspended refusal - SwitchPlan refuses only after a Stripe read (the refusal after the first Stripe call of the downgrade) - positionStatus never set - the suspended refusal placed in resolveLadderSubscription instead of SwitchPlan - KeepCurrentPlan without its landed-downgrade check - the landed check without its price comparison - the immediate branch without the check - the immediate check placed after the schedule release - SwitchPlan without the paused refusal - KeepCurrentPlan without the paused refusal - CancelSubscription without the paused refusal - ErrSubscriptionSuspended returned for a paused subscription in SwitchPlan - the suspended rule (15) removed (offer table) - the suspended rule (15) removed (the page) - the suspended rule applied before the held rung rule (8) - the suspended rule applied before the free rung rule (14) - the paused rule removed - the paused rule placed after the held rung rule (8) - onLadder without pos.Suspended - positions keeps ExcludeSuspended (TestMemberPlansGolden) - positions keeps ExcludeSuspended (TestCheckoutRefusesALadderWhosePlanIsSuspended) - switchOffer without the suspended case - the suspended case placed after the current-tier case - ErrSubscriptionPaused never returned by switchOffer - the refusal log line without subscription_status - renderPlansAfterMove without its four cases - renderPlansAfterMove without its row 17 case - renderPlansAfterMove without its row 18 case - KeepCurrentPlan without its paused refusal, seen through the page - Suspension always payment_due (the paused branch removed) - the payment-due price line in place of "ends" - setCancelBodies without the appended sentence - the sentence appended on the current card only (the loop narrowed to CanCancel) - Cancel plan drawn on a paused plan - Suspension payment_due only for past_due - onLadder keeping a downgrade whose date is not after now - onLadder dropping every downgrade of a suspended position - setCancelBodies keeping "You keep {current} until {date}" on a payment-due plan - setCancelBodies choosing the form of row 19 when another plan follows - setCancelBodies choosing the form of row 20 when nothing follows - the body of row 24 read as row 21 - the body of row 20 read as row 21 - CancelBadge keeps its Title on a suspended plan - KeepCurrentPlan without rule 1's downgradeLanded test - onLadder keeping a landed downgrade (the date check removed), seen through the refused Keep - ConferUnprojectedSuspended without the suspension - the paused rule removed (the page's move check) |
||
|
|
10057f8e3a |
Read suspended plan positions, and keep every reader's answer unchanged
Second section of #148. Goldens first pin what the member sees today for a plan whose payment is due: the Products page treats the plan as not held and offers Subscribe on every priced plan, the entitlements panel reads "No active plan", and a plan of boolean entitlements reads as none. Those are the wrong answers the later sections fix. ListLadderPositionsByPool now reads live positions, active and suspended, with the position's and the subscription's status, and puts an active row before a suspended one on the same ladder so a suspended row can never be chosen over an active one. Every reader filters suspended rows out through one helper, entitlements.ExcludeSuspended: the Products page's position loader, the entitlements panel, the dashboard's entitlement check, the plan moves' subscription lookup, and the operator's Pools panel and grant advisory. No existing answer, golden or test result changes; each later section removes the filter from the surfaces it releases. A test proves that a suspended position confers nothing, because entitlements come only from active provisions. The plan moves' lookup filters too, and no test of this section can see it; its break runs with the section that releases those moves. Also regenerated every sqlc package through make sqlc-generate, which restores the license headers a bare sqlc generate drops: BRIDGEFILES generated files that had lost their headers gain them back, with no other change. Full suite on reset databases before the review fixes: 4524 pass, 15 skip, none failed. The review fixes add a test arrangement and change comments and license headers; after them internal/entitlements, plans, fulfillment, server and the test kit pass. make lint passes. Breaks, each caught: - the position query keeps only active rows - the position query without its active-first order - the subscription status is NULL - ExcludeSuspended keeps every row - ExcludeSuspended keeps only active rows - positionLoader.positions without ExcludeSuspended - ladderRows without ExcludeSuspended - orgHasEntitlement without ExcludeSuspended - the Pools panel's loop without ExcludeSuspended - subscriptionHeldLadders without ExcludeSuspended - materialization reads suspended provisions - the position query orders oldest first |
||
|
|
979e38b985 |
Suspend a test subscription the way the reconcile does
First section of #148. The test kit's Suspend suspended the provisions a subscription confers and left the subscription itself active, a pair the app never writes: the reconcile sets the subscription's Stripe status (past_due, unpaid or paused) and suspends its provisions together. Suspend now takes that status, writes it to the subscription row with the row's own period, cancellation and end fields, and then suspends and settles as before; any other status fails the test. The three callers pass past_due. The organization page's golden for a suspended subscription changes accordingly: its Billing card read Active and now reads Past due. The other two callers read no subscription status and keep their results. This change also adds openspec/changes/suspended-plans-stay-held, the plan for #148: a suspended plan stays the member's on every surface, confers nothing, refuses a second Checkout and a switch, and offers a way to pay the unpaid invoice. Full suite on reset databases: 4517 pass, 15 skip, none failed. make lint passes. Breaks, each caught: - Suspend without the status write - Suspend writes an empty period end |
||
|
|
bfc24e2c74 |
Serve before Temporal connects, and connect it in the background
Sixth section of #141, which closes the issue's defect: start no longer waits on Temporal before it serves. Start derives the Stripe key's mode and fingerprint, sets the SDK's key, opens the database, applies the operator overrides and the boot data state, binds the listener, and only then starts the Temporal connector and the identity provider's discovery beside the server. A bind error returns before any dial or discovery request. While Temporal is unreachable the console serves: sign-in, the domain ask, the static files and every page that needs no workflow answer as usual, and the Temporal-backed actions answer as the previous section made them. The connector runs the retry loop. Each attempt dials once (NewClient no longer retries on its own; dialWithRetry and its budget are gone), builds the worker and runs its precondition. A precondition that refuses keeps the client and retries at the cap, so FedWiki's refusal over open runs of a removed workflow type is read again on the same connection; one that cannot read what it checks, a worker that fails to build or start, closes the client and dials again. The attempt ends when the worker starts: "connected to Temporal server" is logged with the attempts and the time it took, then the holder is published and the integrations' Startup hooks and the schedules run as before. "Temporal not ready yet, retrying" is a Warn until temporal-connect-timeout (90 seconds by default), whose help now says so, and an Error from then on, under the loop's decay. A Temporal OAuth configuration that cannot be built still refuses at start. start exits 1 when serving fails, at any point, and returns only after the connector and discovery have returned. A failure before the listener keeps the message it had (the overrides, the boot data state, the integration connections); a serving failure logs "server failed to start". The Stripe webhook activity marks an event processing only from received, processing or failed, so a run of an event already completed, skipped or dead-lettered returns at once and dispatches nothing. The handlers and drains that found no Temporal client logged that it was not configured; with Temporal still connecting that was false, so they log "Temporal is not connected" (the six plan-act goldens carry the new drain line). Copies 13, 15 and 16 also carry the host and namespace. Checked by hand on the test stack: with Temporal stopped, the console served /login and the ask at once; the connector logged Warn, then Error from the first attempt after 90 seconds; when Temporal came back the same process logged "connected to Temporal server" after 14 attempts. With the issuer refusing, /login answered the styled 503 with Retry-After: 5 and the ask answered. Full suite on reset databases: 4513 pass, 15 skip, none failed. go test -race -p 1 over cmd, auth, server, retryloop, workflows and the three integrations after reset-test-db.sh: every package passes, no data race. make lint passes. Breaks, each caught (section 1's UseKey break among them). Two depart from item 6.10's first wording, which the item now carries: the synchronous-fetch break sits between listen and the server's start (before listen the row would fail waiting for the address, not at its one-second assertions), and the "returns without waiting" break is caught by the fourth boot row, whose fake dial holds for 200 ms of wall-clock time past the context's end, the least that makes an early return observable. - serveConsole without UseKey - UseKey after the bound server's Serve - Serve without go Discover - Serve fetches the discovery document before serving - serveConsole waits for connectTemporal before Serve - connectTemporal started before Bind returns - serveConsole returns without waiting for its goroutines - serveConsole returns without waiting for its goroutines (race) - skipSession without the ask's path - connectTemporal publishes after afterConnect - connectTemporal publishes before worker.Start - connectTemporal returns after the escalation time - the open-runs refusal closes the client - ErrCannotCheck treated as a refusal - worker.Start failure leaves the client open - listRunsError without its Is method - worker.Start failure logged from inside the attempt - ProcessWebhookEvent without its status guard - the guard without failed - the guard without processing - NewClient without the deadline - connectTemporal under serveConsole's own context - the attempt runs the hooks before copy 14 - Run logs copy 21 for a step failure - the overrides failure is a plain error - a Temporal authorization that cannot be built is not refused |
||
|
|
d1151dd6fe |
Wait for the identity provider's discovery document without blocking start
Third section of #141. start no longer fetches the identity provider's discovery document before it serves. Setup returns a Config whose two discovery values are empty, and Discover runs beside the server from before the listener opens: it fetches the document, publishes the verifier and then the sign-in endpoints, logs "identity provider discovery succeeded; sign-in is available", and returns. Until it has, /login and /callback answer a styled 503 "Sign-in unavailable" and /register "Registration unavailable", each with Retry-After: 5 and a "Try again" action, and a sign-in request wakes the loop's wait. A document whose issuer is not the configured one, or that names no end-session endpoint (or one that does not parse as an absolute URL), publishes nothing that sign-in reads and is retried at the cap. The two values sit behind two atomic.Pointers in auth.Config. The sign-in value holds a pointer to the verifier value it was published with, and is stored only after the verifier, from the same document, so a request never pairs endpoints from one document with a verifier from another. Re-derivation of roles reads the verifier alone, so it runs while sign-in still waits for an end-session endpoint; POST /logout before discovery ends the console's session, lands on /, and revokes nothing. A Config built by hand, as most tests build theirs, reads its exported fields as it did. internal/retryloop is the loop both discovery and, in a later section, the Temporal connection run: waits from 0.5 seconds doubling to a 15-second cap, plus up to half again of jitter; a wake ends a wait no sooner than a second after the last attempt began; a failure marked AtCap makes only the next wait's base the cap, and a wake does not end that wait. Its log decay writes the first failure, a changed error at most once per 30 seconds, otherwise one line per 5 minutes, Warn until the escalation time and Error from it, with the line at the escalation time written at once; every line's attempts is the attempts so far. The htmx 5xx toast reads "Server error. Try again later." Corrections to the change text, made while building: the wait after an AtCap failure is the cap plus the jitter (the design's bound of about 23 seconds after an outage already counted the jitter), and every log line's attempts is the number of attempts so far (one meaning on every line, matching elapsed), where the design said the cap alone and the attempts since the last line. internal/server/testdata/routes-not-ready.golden is new: it differs from routes.golden in the four sign-in rows only. routes.golden and route_pages.golden do not change. Full suite on reset databases: 4422 pass, 15 skip, none failed. The console booted on the test stack and logged the discovery success. go test -race -p 1 over internal/retryloop, internal/auth and internal/server after reset-test-db.sh: every package passes, no data race. make lint passes. Breaks, each caught: - LoginHandler's gate after RenewToken - the gate without the wake - the gate answers through http.Error - the gate answers 500 in place of 503 - the gate without Retry-After - /register without the gate - the sign-in value published from a document without an end-session endpoint - the sign-in value stored outside the issuer check - the verifier published only together with the sign-in value - the verifier published from a document with another issuer - an unusable document returned as a plain error - the attempt on http.DefaultClient - LogoutHandler before publication answers 500 - LogoutHandler before publication redirects without destroying the session - LogoutHandler with the verifier and no end-session endpoint revokes nothing - refreshIdentity ends the session when no verifier is published - Run without the cap - Run without the jitter - a wake that ends an AtCap wait - the wait ignores ctx.Done() - the discovery holder is a plain field in place of an atomic.Pointer - a Failure's level ignored - the decay without its five-minute rule - the escalation never reached - the changed-text rule without its thirty-second floor - a wake that ignores the one-second gap - answer() without the Retry-After line - re-derivation reads the exported fields in place of the holder - a level Error line written at once whatever the last line - an AtCap failure ratchets the doubling to the cap - the discovery log lines without the issuer attribute - the failure lines with a fixed attempts attribute - an end-session endpoint that does not parse or is not absolute is accepted - Run attempts once on a context that has ended - a Failure returned by pointer drops its message and level |
||
|
|
35e14578a2 |
Disable a tier's removal on the card when the holder limit refuses it
The tier card checked the last-tier rule but not the holder limit, so a tier more organizations hold than the limit offered an enabled Preview removal whose preview could only say the removal is refused (#187). The ladder controls' requirement already asks that a control certain to be refused render disabled with its reason. loadPlanLadderTiersData now decides every tier with the same TierRemovalRefusal the preview and the removal call. It reads every tier's counts in one statement, the new CountLiveTierHoldersPerTier, so the page's reads do not grow with its tiers: - over the limit, Preview removal is disabled with the limit sentence, and its Migrate sentence when most holders came from a default; - a refusal the card knows wins over the unread org types on rank 0; - when the counts cannot be read, every held tier's Preview removal is disabled with "Failed to load the tier's holders. Reference: {request id}.", and the failure is logged once with the request id. The refused removal preview stays for a page rendered before the refusal applied. Tests cover each case. The one golden that records the page's statements gains the new read. The design, the plan-ladder-management spec and docs/plan-management.md say the same, and task 8.8 records it. The full suite passes. |
||
|
|
9db9c107de |
Name the way past the last-tier refusal
The refusal of a default ladder's only tier said what was refused and why, but not what to do instead (#187). It now reads, for one org type: "New Personal organizations start on this tier, the only one in their default plan. Add another tier first, or choose a new default for Personal." With two or more types it names them all and ends "choose a new default for each". LastTierRefusal writes it, so the plain delete, the removal, the refused removal preview and the disabled Remove on the tier card all read the same sentence. A table test covers one, two and three types, and the three goldens that carry the sentence change only in it. Ride-along: three tests failed a page that leaked nothing whenever the hex digits of an id on it contained "23505", because they looked for the unique-violation code as a substring. requireNoDriverText matches it as a word of its own. The full suite passes. |
||
|
|
93cb55454f |
Name the org type by its display name in the default change's toast
The default change's success toast named the type by its key, as in "Default change committed for personal: 4 migrated.", while the org types page heads the same card "Personal" and the reorder's toast names the types it reaches by their display names (#187). CommitOrgTypeDefaultChange passes the display name from the type it already reads to render the card, and falls back to the key only if the name is empty. A handler test asserts the display name and the absence of the key. The 18 plan-act goldens that carry the toast change only in that line. The full suite passes. |
||
|
|
eeded12c16 |
Remove a tier on the runner, under the holder limit and last-tier guard
A tier removal with holders ended, aligned and materialized every holder's pool inside one transaction that held the exclusive rendezvous, so its hold grew with the holders and had no limit (#187). Under Keep it removed the last tier of a ladder an org type defaults to, leaving that type's default plan empty; under Migrate the same removal failed at a pool step. Its four step failures answered "Details are in the server logs." RemoveTier now runs on runPlanAct. Inside the exclusive transaction it: - counts the live holders, reads the ladder's tiers and the org types defaulting to it, and refuses through TierRemovalRefusal before any pool row. That function refuses above planActHolderCap, then refuses the last tier of a default ladder under either disposition. A failed read refuses too. - reads the product's and the ladder's names, refusing when they cannot be read; - locks the holders' pools in ascending id and reads them again, then applies the disposition gate; - records the act (tier_removed, with the disposition when default-sourced holders remain), deletes and renumbers, and reconciles each pool. The floor grants Migrate creates carry the act's id. The limit refusal offers "changing that default, or reordering this ladder, with Migrate" only when that advice is true: when the tier is rank 0 of a ladder with another tier and every default-sourced holder's type defaults to it. Otherwise it offers the default change alone. The removal preview reads the same counts and calls the same function with the same limit. A refusal renders "Apply change" disabled with the sentence as its reason. A failed read of the org types or the tiers answers "Failed to preview the removal. Reference: {request id}." in place of the preview, and a failed holder list answers "Failed to load the tier's holders. Reference: {request id}.". The promoted-default note shares the preview's one org-types read. CommitPlanLadderTierRemoval only parses, calls and renders. It answers as follows: - Every failing step is logged through the request's logger with the step, the ladder, the product and the pool or provision. - The four step sentences end "Reference: {request id}.". - A lock wait or deadlock answers "Another change collided with this one; try again.". - A removal with no holder left to reconcile answers "{Product} removed from the ladder.". - A 422 whose preview cannot be rebuilt answers the preview's own read failure. - A gate that was true while the rebuilt preview finds no default-sourced holder answers the generic failure, and logs an Error line with the request id. The design's removal table gains those two rows, and copies 14, 16 and 52 name their new places and condition. docs/database-locks.md lists the six paths that render the collision sentence. docs/plan-management.md gives the removal's limit, its last-tier refusal and its repeated-removal no-op. Goldens: - Behaviour changes in remove-begin-fails, remove-second-pool-fails, remove-keep-rank-0, remove-migrate-pool-held-elsewhere, remove-other-source, remove-migrate-rank-0, remove-two-pools, remove-no-holders-left, remove-last-tier-keep (refused, where it used to empty the default ladder), remove-last-tier-migrate (refused before any pool step), remove-product-name-unread, contention, the four removal-against-default files and tier-change-during-conferral/a-tier-removal. - New: remove-above-the-limit and remove-org-types-unread. - remove-needs-disposition and remove-refused-disposition do not move: the gate writes nothing. Task 8.5's text is corrected to say so. The full suite passes. |
||
|
|
a86547a434 |
Reorder tiers on obligations, with the ladder page's lines and Retry
A tier reorder that changed a default ladder's rank 0 renumbered the tiers and then moved every organization of the types defaulting to the ladder inside the request, from a list read after the renumbering. At size it ran out of time, and a failure part way left the rest unmoved with no way to resume (#187). The org types defaulting to the ladder were read outside the transaction, and a failed read counted as none. ReorderTiers now runs on the runner. Its first transaction holds the exclusive rendezvous and does four things in order: - It reads the tiers and reconciles the submitted order against them inside the transaction. A submission that names none of the ladder's tiers writes nothing and re-renders the page. - It renumbers the tiers. - It records the act. When rank 0 changed, core.record_plan_act reads the org types defaulting to the ladder and records them as reached. - With no disposition given, it rolls back when an organization holds the outgoing default. The listing, the moves and the drain then run as they do for a default change. A move reads the current rank 0, so an organization already on it is settled as already at the default and gets no legacy grant. The toast reads its counts by act id across every type the reorder reaches. The ladder page shows, below the tiers table, the lines of each reorder that owns a type with open work, newest first in the order the acts reached their types. They are read in two statements in one read-only repeatable-read transaction. The page has its own Retry route, which checks that the act is a reorder of this ladder. The old enactment is gone. In entitlements: EnactDefault, enactDefault, enactReorder, Enactment, EnactResult, EnactFailure, ReorderEnactment, OrgLister, DefaultPools, LadderNames, their helpers and the steps only they used. In the server: orgTypeChangeResult, enactFailureReason, dispositionFailure, logEnactFailures, the reorder's own disposition and reconciliation helpers, and OrgTypeChangeResult.Failures with the toast's "failed" part. Along the way: - orgTypesDefaultingToLadder returns its error. The tier card and the reorder preview, and the preview a refused commit rebuilds, show "Failed to read the org types that default to this ladder. Reference: {request id}." with no commit, instead of treating the failed read as no org type. - "Failed to classify affected organizations." takes the reference form, logged through the request's logger. - One helper logs a gate that found an outgoing default when the rebuilt preview finds none, for both the default change and the reorder, with the request id. - The timing test times EnactObligation once, rather than EnactDefault at each holder count; task 1.1's text says so. With counts unread and nothing settled, the reorder's toast is its opening alone, "{Product} is now the default plan for new {types} organizations.", approved with the default change's as copy 62. docs/database-locks.md, docs/plan-management.md and docs/models/plan-ladders-transitions.md follow the code. Goldens: - Behaviour changes in reorder-an-organization-fails, reorder-begin-fails, reorder-cosmetic, reorder-grandfather, reorder-migrate, reorder-needs-disposition, reorder-rank-0-no-default, reorder-redrop, reorder-stale, reorder-two-types, stale-candidate, tier-change-during-conferral/a-reorder and contention (the reorder's collision sentence; later placeholders renumber). routes.golden gains the ladder Retry route's probes. - Retired: reorder-list-fails, because no listing runs outside the act any more, replaced by reorder-listing-fails; reorder-org-types-unread, because that read moved into core.record_plan_act, replaced by reorder-record-fails. New: reorder-retry. - The 7 add-*, 7 delete-* and 13 remove-* goldens change only because every ladder page render now reads its lines. Each gains that read's transaction statements. The three begin-fails among them also gain "Pending plan changes could not be read. Reference: {request id}." and its log line, since their closed database fails that read too. The full suite passes. |
||
|
|
b0df28647f |
Move organizations to a new default as obligations an act owns
A default change saved the new default outside any lock, then moved every organization of the type inside the request, from a list read after the save. At size the request ran out of time. A failure part way left the rest unmoved, with "Details are in the server logs." as the only answer and committing again as the only re-run (#187). Migration 00028 gives the default change work it can own and resume: - core.plan_act_reached_org_types records which types an act reaches. core.plan_act_owns_org_type states once that the newest act to reach a type owns it, so a newer act takes over what an older one has not moved. - core.plan_act_obligations holds one row per organization the act must move. core.plan_act_orgs_without_pool records an organization with no default pool, which is logged and counted, not owed. - core.plan_act_listing_frontier states once which organizations the listing has not reached: the writer, the listing-done read, the poller's read and the card's count all call it, and PostgreSQL inlines it into each. - Four enclosed writers list, settle, record a failure (failed after five attempts) and requeue. core.record_plan_act now writes the reached types and refuses a reorder detail that disagrees with the ladder. core.grants.act_id names the act a grant came from. The commit's first transaction runs on the runner under the exclusive rendezvous, inside a 5 s deadline. It locks the type and refuses a reserved type, a candidate that is already the default ("Already the default.") and a ladder with no rank 0. It then saves the default and records the act. With no disposition given, it rolls back when an organization holds the outgoing default, and the 422 preview is rebuilt after the rollback through the capped read. A listing then writes the obligations in batches of 500, in org type and organization id order. Each move runs in its own transaction under the shared rendezvous. It locks the pool, locks the obligation together with its type's owner, and reads the current default and its rank 0. It then migrates or grandfathers the organization, settles it as already at the default, or, under an act with no disposition, leaves it untouched with a Warn line. A failure is recorded on the obligation alone with its step, in a plain transaction that takes no pool row. The request tries up to 50 obligations within 2 s. Its lock and transaction timeouts are 500 ms and 2 s, and a move they cut records no attempt. It then starts PlanActDrainWorkflow for the rest; PollPlanActs starts a drain for any act still owing work. The toast counts what is not yet reached and what has no default pool, and leaves out any count it could not read. The org type's card shows each open act's lines: organizations not yet reached, and those not moved, each with its reason, with Retry. They are read in two statements in one read-only repeatable-read transaction, and "Pending plan changes could not be read. Reference: {request id}." replaces them when that read fails. Retry is an act of its own: it requeues the failed obligations on the types the act still owns, and a retried organization is recorded under the operator who pressed Retry. It answers a lock wait with the collision sentence and any other failure with "Failed to retry. Reference: {request id}.". Along the way: - The default change's sentences ending "Details are in the server logs.", in six places, now end "Reference: {request id}.", logged through the request's logger. - The select's label becomes "New default", so the form library's refusal reads "Choose a new default from the list." and its required message "Choose a new default.". The change's specs now carry that label for org-types and form-library. The design states the frontier function and that the request's cap counts obligations tried, not settled. docs/database-locks.md, docs/plan-management.md and the two docs quoting the label follow the code. Goldens: - Behaviour changes in contention, default-already-at, default-migrate, default-none-migrate, default-needs-disposition, default-begin-fails, default-initiate-fails, default-migrate-restore-fails, default-grandfather, default-change-twice, default-current-refused, default-save-fails, the four removal-against-default files and the four tier-change-during-conferral files. - The three default form goldens move with the label. routes.golden gains the Retry route's probes. - default-list-fails is retired: no listing runs outside the act any more. New: default-listing-fails, default-superseded, default-request-cap, default-untouched-outgoing, default-retry, default-retry-nothing, default-preview-capped and default-gate-true-preview-empty. - The other 40 plan-act goldens gain only the reached-type, without-pool and obligation sections and the grants' act column. The full suite passes. |
||
|
|
0b48297b0e |
Add a tier on the runner, under a holder limit, with one act
A tier add aligns every organization holding a live provision of the product inside one transaction that holds the exclusive rendezvous, so its hold grew with the product's holders, and it locked their pools in the order their provisions were created, the one catalog path that did not lock in pool id order (#194). It checked the product outside its transaction, answered "Product not found." for any failed read, showed the database's text when a pool already held a rung, and answered a lock wait with "see server logs" (#216). AddTier now runs on runPlanAct. Inside the exclusive transaction it: - reads the product and refuses one that is missing ("Product not found.") or not published; any other failed read is a failed add; - counts the holders and refuses above planActHolderCap, naming the count and the limit, and, when more than half hold the product through an org type's default on a ladder, the way through: a default change with Migrate; - locks the holders' pools in ascending id, reads their provisions again, records the act (tier_added, with names read inside) and inserts the tier; - reads ListTierAddCollisions for the locked pools and refuses the first pool, in pool id order, that already holds a rung on the ladder or holds two live provisions of the product, naming the organization and the product, before any alignment; - aligns each pool and materializes it once, with a reason that names the ladder rather than its id. planActHolderCap is 200. A timed run on PostgreSQL 18.1, one run per holder count from 50 to 3,200, measured a removal with migrate at 2.2 to 2.6 ms per holder and an add at 0.7 to 1.0 ms. The removal, which takes the same cap in a later change, binds: 200 holders hold the rendezvous for about half a second. The timing test lifts the cap so it can still measure past it. CreatePlanLadderTier only parses, calls and renders. A lock wait or a deadlock at any step answers "Another change collided with this one; try again." A collision answers under the product field. Every other failure, including a CF003 the collision read should have prevented, answers "Failed to add tier. Reference: {request id}." and logs the step and the driver text through the request's logger; the driver text is never shown. Goldens: add-aligns-a-live-provision and tier-change-during-conferral/ a-tier-add gain the act row, the ascending pool locks and the ladder's name in the reason; add-begin-fails answers with the reference and logs the step; add-collides-with-a-held-rung is refused by the collision read with no alignment statement; contention's add answers the collision sentence. Placeholders renumber in three of them because a request id or a provision id moved. New: add-above-the-limit, add-above-the-limit-default-sourced, add-product-unread and add-product-held-twice. The full suite passes. |
||
|
|
7382877ed9 |
Record a tier delete as an act, inside one transaction on the runner
A plan-wide change to the catalog left no record of itself, so nothing could say which change moved an organization or offer to run it again (#187). Migration 00027 adds that record: core.acts, one row per act with its cause, actor, note and request id, and core.plan_act_details, the act's plan-specific detail. Both tables are enclosed: only core.record_plan_act writes them, under the exclusive rendezvous for a catalog cause, and core_writer can only read them. core_reader reads every column but the note, which scripts/db-reader-role.sql lists as personal. runPlanAct is the one shape the plan acts will share: begin, find the pools, lock them in ascending id, read again under the locks, act and commit, with every failure named by its step and a refusal returned as it is. The tier delete is the first act on it. Inside its exclusive transaction it counts the tier's live holders (active and suspended), reads the ladder's tiers and the org types defaulting to it, applies the last-tier guard, records the act and deletes and renumbers; before this, the count and the guard's read ran outside the transaction, and a failed read of the org types let the delete through. A delete of a product that is no longer a tier of the ladder, a repeated confirm, writes nothing. The handler only parses, calls and renders: - A lock wait or a deadlock at any step answers "Another change collided with this one; try again." (#216). - Every other failure answers "Failed to remove tier. Reference: {request id}." and logs the step through the request's logger. - A tier held only by suspended positions is a live holder: the tier page routes it to Preview removal, and the plain delete refuses it. The plan-act golden harness builds the act's queriers through the new PlanActs seam, so a scenario can fail a statement inside the act's own transaction, and every golden gains the act and detail sections. Most gain them empty. Behaviour changes in delete-begin-fails (the log line names the step and carries the request id; the answer carries the reference), delete-bottom-tier, delete-top-tier and tier-change-during-conferral/a-tier-delete (the act row), delete-held-tier and delete-last-tier-of-a-default (the reads now run inside the transaction; the answers are unchanged) and contention (the delete answers the collision sentence; later placeholders renumber because the delete's log line now carries a request id). New: delete-org-types-unread and delete-suspended-holder. The design's failure table for the tier delete now gives the reference answer for a failed holder count and a failed tier read, as the approved copy for the delete states, and a row for the repeated delete. The full suite passes. |
||
|
|
ab1c0dbb69 |
Write the list search's hidden sibling inputs in sorted order
An embedded list's search form re-submits its sibling lists' state as hidden inputs. listSearchForm added them by ranging over a map, and a GET form submits its inputs in document order, so with two or more siblings the parameters of the URL the search pushes came out in a different order from one render to the next. They are now added in the sorted order of their keys, so the order is the same on every render. With one sibling, or none, the HTML is unchanged. TestListSearchFormCarriesSiblingInputsInSortedOrder renders the form 20 times with three siblings and checks each render for key order. It failed in 3 of 3 runs at the parent and passed in 3 of 3 at this commit. The new file internal/server/testdata/forms/per-request/list-search-three-siblings.html pins the three-sibling HTML as the other per-request files do, and the comment in forms_golden_test.go that explained why the list search was rendered with one sibling is removed. No existing golden file changes. No assertion of TestFormsHTML changed; it gains one case. How it was shown to change no other output: the names and results of ./internal/server (the whole package, on the test databases) are equal at the parent and at this commit apart from the two added lines, the new test and the new TestFormsHTML/per-request/list-search-three-siblings case, none failing. One break (the sorted keys walked backward) is caught by the new test. The demo-state walkthroughs give 16 pass and 6 skip at the parent and at this commit. Not probed: that a browser submits the inputs in document order. The HTML specification says it does, and no test here drives a browser's form submission. |
||
|
|
bc4a933a3d |
Pin the HTML of every registered form
TestFormsHTML renders each declaration in every binding the library renders it in and compares the text, through golden.Compare, with a file under internal/server/testdata/forms. A form that is not a rows form is rendered unbound, on the edit side and on the create side, unbound with values, and as a record (every field bound to a value its rules accept, three cells per field, an instance and a message); a search is then rendered boosted and with a target, a select and an out-of-band select, and any other form is rendered refused (an empty POST parsed through the declaration) with the refusals of a 600-character value in every field. A rows form is rendered unbound, then with an open row, a staged delta and the tray (or the row's own commit), each row field and one row action. Runtime options are a fixed "Choose one", Alpha and Beta. The subtests are grouped in three. registered/<name> holds the 19 forms forms.All returns in the internal/server test binary; the files number 26 in all. per-request/<case> holds the declarations built per request: the settings form, the org type card's two copies, the rules form, and the list search embedded (one sibling parameter) and standalone. library-defaults holds a table's "Field" and "Value" headings, a textarea's three rows and the htmx verb of GET, PUT, PATCH, DELETE and POST on a form that is not a search. TestFormsGoldenFilesAreAllRendered fails for any file under testdata/forms that TestFormsHTML does not write, so a form that leaves the registry leaves no stale file. TestPerRequestFormsSatisfyTheInvariants runs CheckInvariants over the org type card's two copies and the rules form's copy; the settings form has its own test, and the list search builds its declaration inside listSearchForm and never returns it, so no test can pass it to CheckInvariants and it stays unchecked. Shown to change no output: only a test file and its goldens are added. The names and results of ./internal/forms ./internal/server ./internal/integrations/... (2113 at the parent, 2144 here, none failing, one skip on each side), taken with the test database reset and the app down at the parent and at this commit, are equal apart from the new tests and their 28 subtests. The registered forms' files in name order followed by the defaults' file are byte-equal to the output of a standalone program that renders the same bindings, run at 0abbfac7; no file under internal/forms, internal/server or internal/embeds that is not a test or a golden has changed since. A separate render of the per-request declarations, unbound only, equals the unbound section of the org type card's files and of the rules form's file apart from the runtime options it did not bind, and equals the list search's two files exactly. The other bindings and the settings file were read against their declarations, not compared with another render. Three runs in a row produce the same files, with no -update. 22 breaks in the form library's render.go and the three server files of the per-request cases, each edited once and each caught by the new tests. The old side has no counterpart: every break is a new-side record, since no old test names these. None dropped. Pinned defects, so a later fix flips them by name: the list search's hidden sibling inputs come out in map order with two or more siblings (rendered here with one, so the file does not vary from run to run); the library defaults no registered form reaches, which every earlier test missed. Not probed: the templates' own markup, because the files hold it for a later template change to show while the breaks probe the view the library builds; fieldAttrs, buildRows, RowField and resolveRowsFocus, whose output is in the files; the list search's per-request declaration against the invariants, for the reason above; the stale-file check, which guards the test's own files. A dense checkbox's label position is the one render break this net misses, since no registered form has a dense checkbox; TestFormPartCheckboxLabelSitsAboveTheBox catches it. |
||
|
|
b0f7aa26d3 |
Add and delete a tier through entitlements.AddTier and DeleteTier
The tier add's transaction and the tier delete's transaction lived in the HTTP handlers. They are now entitlements.AddTier and entitlements.DeleteTier. AddTier takes the ladder, the product and the operator; it opens BeginRuleChange and runs the same statements in the same order: the tier insert, the product's live provisions in creation order, for each one core.align_conferral_shape as the operator with the reason "tier added to ladder <id>" and then the pool's materialization, and the commit. The pools lock in provision order, as before. DeleteTier opens BeginRuleChange, runs the tier delete, the list of the remaining tiers and the renumbering that RemoveTier already shares, and commits. Each failure comes back as a PlanActError naming the step (two new steps, creating the tier and reading the provisions); a held rung's ErrConferralShapeCollision comes back inside the alignment step's error, where errors.Is finds it. CreatePlanLadderTier keeps the session, the form parse, the product read and the published check. refuseTierAdd maps the insert's two constraint messages and the held rung to the product field's error, and otherwise logs the line the failed step logged. DeletePlanLadderTier keeps the held-tier count and the last-tier guard before the transaction; logTierDeleteFailure logs the line each step logged. Every sentence is unchanged. The one departure from today: every refusal or failure answer the add and the delete give from inside their transactions is now rendered after the act's rollback, where the handler used to render it before its deferred rollback. Only reads move: no write, Stripe call or sentence changes, and the rendezvous and the pool locks are held for less time. Under concurrency, a request queued on them can commit before the page is read. The one golden line this moves is the rollback in the statements_with_reads view of plan_acts/add-collides-with-a-held-rung.json, which now sits directly before the refusal page's reads instead of after them; it was moved by hand before the run and the golden test then passed without -update, so no other line moved. Docs: the two handler names in the tier add and tier delete lines of docs/database-locks.md become AddTier and DeleteTier. Riding along as a correction: the call-sites list in the package comment of internal/entitlements names internal/entitlements/ladder_acts.go, not the handler, for align_conferral_shape on tier add and tier removal. No gocognit exception is deleted: neither handler had one. Measured scores: CreatePlanLadderTier 21 to 6, DeletePlanLadderTier 16 to 11, AddTier 9, DeleteTier under 4, refuseTierAdd 9. No output changes beyond that departure. Names and results of ./internal/server, ./internal/entitlements and ./internal/testkit/... are equal at the parent and at this commit after a database reset on each side: 1779 results on each side, 1778 passing and the one existing skip. Apart from the one hand-moved golden line, no test, golden or testdata file is edited and no test ran with -update. Screens of operator-plan-ladders, plan-ladder-detail (and its open and invalid states) and org-types, desktop and mobile, are byte-identical at the parent and at this commit; only the masked operator-integration-stripe shots and the 2x28 pixel mask of the invalid desktop shot differ. Every string literal of the plan-ladders and org-types handlers is still found in a non-test file of internal/server. 16 breaks (28 records), each caught by the old and the new code where its text exists on both sides: every live provision is aligned, the reason names the ladder, each pool is materialized, a held rung refuses the add under the product, the operator aligns, a failed begin of the add is logged, the add's toast, an add in contention fails generically, the tiers left are renumbered, the delete's toast, the last tier of a default ladder is refused, a held tier is refused, a failed begin of the delete is logged, a delete in contention fails generically, and the add and the delete take the exclusive rendezvous. None was dropped. Not probed: faults inside the transactions (no querier makes one fail), so none of the failure answers that now render after the rollback is seen moving, nor the add's two constraint messages; the held rung's golden is the only file that shows the move. The delete guard's read of the organization types, which skips the guard when it fails, is unchanged and stays in the handler. |
||
|
|
c5408501f3 |
Characterize the Stripe sync action: one golden per branch, and its re-checks under the product lock
TestSyncProductToStripeAnswers presses the sync action once per branch
and writes testdata/operator_sync_stripe/<branch>.golden with the
answer (status, toast, alerts), the outbox entries of the product and
its prices, both mappings, and the ordered statement names of the
request, the page's reads included. The 14 branches: Stripe not
configured, unknown product, no active price, price read fails, price
of another product, default price with nothing mapped, requested price
of a reachable product, price already synced, price sync pending,
product create in flight, dead-lettered creates retried, product
create dead-lettered and another price pressed, transaction cannot
begin, and the readiness panel naming a dead-lettered sync. Pending and
dead-lettered states are made by pressing the action and then marking
the entries dead-lettered through the outbox activity, as the workflow
does. The first caller of world.MapProductIn outside its own test is
the reachable-product branch.
TestSyncProductToStripeRechecksUnderTheLock holds the product's
advisory lock in a side transaction, presses the action, waits until
the press queues on the lock, then commits what a competing sync
writes: a pending sync (the press answers "already in progress" and
adds no entry beyond the competitor's two) or a synced price (it
answers "already synced" and adds none).
What the goldens pin that is a defect or a surprise:
- An unknown product answers with the products page and "Product not
found"; the handler's own "Failed to load the product." never shows.
- A failed read of the product's prices is answered as "Add an active
price before syncing to Stripe.", the same as no price.
- A price that is already synced is answered after the price-mapping
read alone: no outbox probe, no transaction.
- Pressing a second price while the product's own create is
dead-lettered resets the product's entry ("Retrying ...") and
enqueues nothing for the second price, which has no mapping or entry
afterwards.
- A press that finds the product's create in flight rolls back after
the page's reads.
No production file changes and no output changes. The test names of
./internal/server and ./internal/testkit/... are the parent's 1526
plus these 18 (2 tests, 16 subtests), all passing, each side run after
a reset of the test database. The new test files pass three runs in a
row, and a run without -update leaves the goldens unchanged.
25 breaks of operator_billing.go and operator_products.go, each caught
by the new tests. One record was corrected to compile before the
counting run: the check that refuses another product's price left its
variable unused, so the break keeps the variable in use.
Unprobed, with the reason:
- the seven "sync-stripe: ..." log lines: no test reads a log;
- a failure after a successful begin (the mapping writes, the enqueue,
the commit, the reset of dead-lettered entries): it needs a
statement to fail inside the transaction and no hook exists;
- "Failed to load the product.": it renders only when the page's own
read of the product then succeeds, after the handler's read failed;
- a failed read of the price mapping before the lock: it reads as no
mapping and is not distinguished;
- the stale and other-environment mappings, which
TestSyncProductToStripeCreatesAgainWhenUnreachable pins.
|
||
|
|
340f19e21c |
List same-instant feed events by kind, then the later-written row first
Events of the recent-activity feed that share one timestamp now list by kind (grant, invoice, payment, tier change, as before) and, within a kind, the one the console recorded later first. Invoice and payment events had no Seq, so two of them at one instant fell through to their summary text and organization id: "$100.00" listed above "$20.00" and Zeta above Alpha whichever was recorded later. They now carry their own row's id, as grant and tier events already did, and activityLess breaks the tie on it, so every event has one place in the order. The comment on ActivityEvent.Seq and on activityLess say so. The requirement "Invoice and payment events are placed when they happened" now states the order of events that share one timestamp, through the change same-instant-feed-order, archived in this commit with the delta synced into the main spec. How it was shown to change only that: TestRecentActivityTiesListTheLaterWrittenFirst (invoices of two organizations, invoices of one organization, rows of one kind in any input order) fails in all three subtests at the parent and passes here. The feed's golden diff is exactly two pairs of rows swapped in feed.json and in feed.html: Alpha's invoice above Zeta's, and "$20.00" above "$100.00" for the invoices of one organization. Names and results of internal/server are equal to the parent's (1473 at the parent, 1477 here: the new test and its three subtests), 0 failures. Four breaks (the invoice Seq, the payment Seq, the id direction, the kind direction) are each caught by the new test or the feed's golden. The lint snapshot is unchanged (0 added, 0 removed). Walkthroughs are equal at the parent and here (16 pass, 6 skip); screens at the parent and here differ from the baseline only in the masked regions, so operator-overview is unchanged: the demo feed holds no same-kind ties. Unprobed: payments tied in the database. The database subtests tie invoices only; ties of payments, and of three rows of one kind, are checked without a database through the comparator, because the tie rests on the payment row carrying its id, which the comparator check and the payment break cover. |
||
|
|
a304124215 |
Order an operator's tier changes against every conferral
A conferral reads a ladder's ranks and a product's conferral shape in several statements at READ COMMITTED, and the reorder, the tier delete, the tier removal and the tier add took the rendezvous in shared mode, so one of them could commit between those reads. A grandfathered organization that keeps its product was then recorded as upgraded or downgraded between two ranks of the same product (#213), and TestTierReorder_CommitRacesDefaultChangeCommit failed intermittently on it. The four handlers now open their transactions with BeginRuleChange, which takes the rendezvous in exclusive mode, so each finishes before a conferral starts or starts after it. The default change and the reorder's per-organization enactments keep the shared mode. The cost, accepted for #213: a tier change waits for every materializing transaction in flight, up to the 5 s lock timeout, and new ones wait behind it while it runs; the removal and the add hold the lock for their whole act. A wait past the timeout answers as a rule change in progress does today: the removal with its refusal sentence, the add, the delete and the reorder with their generic failure copy; #216 is the generic timeout answer this leaves. Behaviour changed on purpose, and only here: the rendezvous statement the four acts record, in 25 act golden files (26 lines; the held-rung file records it in two views), shared to exclusive, one line each in the same place; the renumbering-race files, which moved to tier-change-during-conferral/ and now record a transfer at one rank (a fourth file records the tier add); and stale-candidate.json, which loses the second reorder's answer. Every other golden, answer, status and row is byte-equal to the parent. How no other output changed: TestPlanActsGolden was rewritten with -update and the diff read line by line (25 files, 26 lines, each pg_advisory_xact_lock_shared to pg_advisory_xact_lock and nothing else), then run three times in a row without -update on a freshly reset database. At the parent, with this commit's test file and goldens copied over, TestTierChangesWaitForAConferral fails in all four cases and TestTierRemovalAgainstDefaultChange in its two queued cases, each on the 10 s wait for the rendezvous statement, and TestPlanActsGolden fails in the same 25 subtests, on their recorded statements; TestReorderEnactsAgainstTheRankZeroItSaved passes there. Over ./internal/server, ./internal/entitlements and ./internal/testkit/... the names and results at the parent and at the commit are equal, apart from TestRenumberingCommitsDuringAConferral and its three subtests, renamed TestTierChangesWaitForAConferral, and the new subtest "a tier add" (1746 names before, 1747 after; no failure on either side). Tests: TestTierChangesWaitForAConferral starts the tier change while the conferral is paused and waits for it to queue on the exclusive rendezvous before releasing, so the tier change commits after the conferral. The two queued cases of TestTierRemovalAgainstDefaultChange wait on the rendezvous for the second request instead of on the pool row. TestReorderEnactsAgainstTheRankZeroItSaved is rebuilt: a second renumbering would otherwise queue behind the first reorder's enactment, so the test holds a tier row, queues a renumbering on the rendezvous and releases, which still reaches the window between a reorder's renumbering and its enactment. Its stale-candidate.json keeps the first answer and the rows and loses the second answer, which makes that comparison NARROWER; the second reorder's answer is unprobed. The race test keeps its assertions and gets the comment that says why they now hold. 11 breaks, each caught by the new tests (each of the four openers reverted to the shared mode is caught by the interleaving test and by a golden, the removal's also by the two queued cases, and a reorder that enacts against the wrong rank 0 by the rebuilt stale-candidate test); the old side is not run because the new tests are what show the fix. The race test passed 500 of 500 idle and 500 of 500 under eleven busy loops on a 12-core machine (it failed 2 and 19 of 500 before), and a probe written for this race, which commits a renumbering inside a conferral, now ends in a transfer from rank 0 to rank 0 with the reorder waiting for the conferral. Unprobed: - The removal's second read of its holders under the pool locks: the exclusive lock is taken before the first read, so no console transaction can change a holder between them and no test tells the two reads apart. - The tier add's failures without the fix (the exclusion-constraint failure, a conferral committing without the new rung, the refusal about a held rung) were read from the code and not run; the before run shows only that the add did not queue on the rendezvous. - The 5 s timeout answers of the add, the delete and the reorder behind a long shared holder: the held-rule-change test already pins them, and a wait behind a shared holder takes the same path. - The time a removal of a tier with many holders, or an add of a widely held product, holds the lock: not measured. - A conferral that does not materialize would escape the order; none exists, and the database function does not assert the rendezvous. - The plan-management HTTP test, which adds tiers through the changed opener, was not run: the end-to-end packages are outside this commit's checks. The browser screens and the walkthroughs were not run: no template or sentence changed. - The demonstration seed adds its ladder's tiers on the plain handle, outside the rendezvous; the spec, the comments and the docs name the operator's four tier changes, and the seed is left as it is. The entitlements spec's requirement says which transactions take the exclusive lock and gains two scenarios ordering a tier change against a conferral; the change is archived here and the spec synced. docs/database-locks.md and docs/go-conventions.md name the four openers as exclusive. |
||
|
|
6f65c744fe |
Pin how the plan-wide acts interleave with each other, with a rule change and with a conferral
The plan-wide acts (tier removal, default change, reorder, tier add and delete) are now sent against each other and against a conferral, each interleaving chosen by a lock a side transaction holds and releases once the requests wait on it. dbtest.AwaitLockWait proves a request is queued, and PostgreSQL grants a row lock to its waiters in the order they queued, so no test sleeps. The kit gains Backend.Waiters, how many backends must wait at once, with a test of its own; its first caller is these tests. TestTierRemovalAgainstDefaultChange runs a removal and a default change on one pool in each order: queued on the pool's row, and one after the other. Every case ends with no live position on a deleted tier. TestDefaultChangeTwiceSerializesOnThePool sends one default change twice and shows one request grandfathering the pool and the other finding the legacy grant the first wrote. TestPlanActsRefuseWhileARuleChangeHoldsTheRendezvous holds the exclusive rendezvous while all five acts run, and pins each act's contention answer: the removal's and the organization's "Another entitlement set change is in progress. Try again." and the generic sentence of the reorder, the add and the delete. It waits out the 5 s lock timeout once. TestRenumberingCommitsDuringAConferral commits a reorder, a tier delete and a tier removal while a default change's conferral waits between its rank reads; the files pin the transition as it is recorded today (upgrade 0 to 1 for the reorder where a transfer belongs; downgrade 1 to 0 for the delete and the removal), because a renumbering takes the rendezvous in shared mode only. Pinned with #187: TestReorderEnactsAgainstTheRankZeroItSaved sends two reorders whose enactments queue on one pool; the first enacts against the rank 0 it saved after the second has put the other product back at rank 0, so it grandfathers the holder onto a legacy grant of the product that is rank 0 again, and the second finds the holder on that grant and leaves it. No output changed: the commit adds tests, their golden files and one field of the test kit, and touches no production file. The names and results of ./internal/server and ./internal/testkit/..., run after a database reset at the parent and at this commit, differ by the 13 new tests and subtests, all passing; the new tests passed three runs in a row and 20 runs of the four that do not wait out the timeout. The edit to the plan-wide acts' golden test is its JSON encoder, split so the new tests encode their files the same way; its 44 files are unchanged. The kit's wait.go and wait_test.go and the AwaitLockWait bullet of docs/testing.md change beside it. 14 breaks, each caught by its new test (new side only): 12 edit production code unchanged from the parent and 2 edit this commit's wait.go. The section's tests take about 6 s, 5 s of it the one wait. Unprobed: - Interleavings the tests do not choose, such as a removal that arrives between the default change's save and its pool lock; the cases cover the two queue orders on the pool and the two serial orders. - A conferral other than the default change's during a renumbering (a reconcile, a grant act, the floor); it is the same database function, which the reorder case reaches. - The default change's own stale candidate: a renumbering between its save and its enactment. It uses the same fixed product the reorder case reaches; no case pauses the default change there. - The two new failure messages of AwaitLockWait for a count above one; they are text, read only when a wait fails. |
||
|
|
7cfcb62f26 |
Characterize the plan-wide acts: answers, log lines, writes and locks, and the rows they leave
TestPlanActsGolden sends one request to the handler of each plan-wide act (tier removal with holders, tier delete, tier add, an organization type's default change, a ladder's tier reorder) on a fixture of its own and compares what came of it with testdata/plan_acts/<scenario>.json, 44 scenarios in all. A file holds the answer (status, toast, alert and field-error text, checked radios), the handler's log lines, the statements that open, lock, write or close a transaction in order (reads left out, because a refactor may move them), and the fixture's tiers, type defaults, grants, provisions, positions, transitions and materialized entitlements afterwards, with the fixture's ids named and the ids the act wrote numbered by first appearance. add-collides-with-a-held-rung also holds every statement of the request, reads included, to show where the rollback falls against the refusal page's reads. Alert and field-error regions that hold no text are left out of the answer, because the page carries one for each form it shows and which forms it shows depends on the products the rest of the database holds. The files pin these defects as the code has them today: a removal that empties a default ladder, and one that rolls back because the restoration finds no rank 0; the removal's toast when nobody is left to count, which ends in ": ."; the driver's own text (SQLSTATE and all) in a failed organization's reason, in the toasts and in the logs; the commit re-runs the answers advertise that cannot run, because the form refuses the current default and a re-dropped order enacts nothing (#187); the reorder that reads a failed listing of the organization types as a ladder no type defaults to (#207); the products with no entitlement set that the edit form can leave (#153) and that make a conferral fail; and the tier add that stores the ladder's id in the provisions' transition reason. No output changed: the commit adds one test file and its files and touches no production file. The names and results of ./internal/server, run after a database reset at the parent and at this commit, differ by the new test and its 44 subtests, all passing; the test passed 30 runs in a row and then on a database the rest of the package had written to. 64 breaks, each caught by its scenario's file (new side only; the code they edit is the parent's). Unprobed: - A fault inside a transaction. No test can make a statement fail after the begin, so the sentence and log line of each such failure is not read: the removal's holder reads, pool locks, delete, listing, renumbering, endings, alignments, materialization and commit; the add's provision read, alignment, materialization and commit; the delete's statements; each organization's position read, endings, materialization and commit; and the reorder's renumbering and commit. Three removal failures log nothing. The failures that are read come from database states, a closed handle and refusing queriers. - The removal's atomicity as code: PostgreSQL aborts a transaction at its first error, so a break that commits after a failed step cannot write. The scenario with a failing second pool shows the result, not the code. - Which reads each act runs, and where, apart from the one scenario named above. - The add's two constraint messages, reachable only when another add commits between the form's option read and the insert; no scenario pauses there. - The order in which organizations of the same name are enacted; the fixtures' names differ. - The default change's other form refusals (an unknown ladder, a reserved type), which the existing handler tests read. - A reorder enacted against a rank 0 that changed after the order was saved; it needs a pause between two acts. - Markup outside the answer's regions, which the existing goldens of the ladder page pin for the tiers table, the preview's rows being read through the same builders; and whether an alert region is empty, because which forms the page shows depends on the products the rest of the database holds. |
||
|
|
944e77f744 |
Drive every page, Checkout and the member pages through the router
No production file changes. internal/server/new_handler_db_test.go builds the router on the test database with a Stripe mock and adds three tests. TestNewHandlerPages signs in a member and an operator of the World and GETs every route of the route table that is outside the sign-in flow and is not the panicking fake route, as the operator under /operator and /partials/operator and as the member elsewhere. It writes status, Location whole and the page's heading (or a fragment's first opening tag) to testdata/route_pages.golden, plus the certificate ask for an unknown domain. The golden was written once with -update and read against the code. A Stripe mock refuses every call, so no page reaches Stripe. The two member lists that print what the database holds (plans and add-ons) record status and location only, because their first element changes with the ladders and products other tests leave in the shared database. TestCheckoutThroughTheRouter posts a price through the router as the buyer: 303 to the session's URL, the key mode asked once, and the session request returning to the configured base URL. TestMemberPagesThroughTheRouter has nine subtests on one router and a recording mock: the workspace region from two workspaces, each region equal to what its partial answers, only pending claims listed, the checkout banner for success, cancel and other, a successful return reconciling and a canceled one not, the reconcile conferring a rung before the page reads the organization's holdings and before it includes the regions that show them (one organization is read with no card, another with the entitlements partial as the only card), no reconcile without a Stripe key, the billing facts repeated on a successful return, and the empty state following the organization's holdings. Shown to change no output: no production file is touched, and the names and results of internal/server and internal/testkit equal the parent's apart from the three new tests and their nine subtests. The three pass three runs in a row on a reset database. Of the 28 breaks of the plan (27, and one that moves the reconcile after the workspaces include and the cards loop), each applied to the production code after a green control run, 26 are caught; two were missed by TestNewHandlerPages and are dropped: the operator domains page reading the registry, and the operator partials reading the integrations' configuration (the page answers the same status and heading when the read is absent). The reconcile's write before the holdings read is probed by "a successful return shows what the reconcile conferred" on the organization read with no card, and its write before the includes by the same subtest on the card that shows the ladder; the break that moves the reconcile after both is caught. Left unprobed: - The reconcile's order against the workspaces include alone: the region shows the organization's workspaces, not what it holds, so a reconcile placed between that include and the cards loop changes nothing a test reads. - The dashboard's root-only check: the route-aware dispatch answers every unknown path before the dashboard runs, so only an include reaches it, and without the check the dashboard would include itself without end. - The dashboard's billing-facts gate on a successful return: the template prints the facts only inside the success banner, so the gate changes nothing a member sees. - The member shell's fields (the rail's active entry, the operator switch, the account menu): every member page renders them and the screens capture them; no subtest reads them. - Wiring the pages golden does not reach: the Stripe mode, the key fingerprint, the dashboard URL, the configured flag, the Temporal client, the integration configurations, both registries' policy, the external-claim gate, the connect target, the ask fallback URL, each logger and the member domains handler's domain queries. A page fact per field would be needed, and the headings do not hold one. - Each page's own answer beyond its status and heading. |
||
|
|
eb4a7552bd |
Pin every route's pattern and answer through the router
No production file changes. authtest gains Cookie, which commits a session and returns its cookie, for a handler wrapped in the session middleware, such as the whole router, where Get and Post cannot put the session on the request's context. docs/testing.md names it and the kit table's authtest row says "signed-in sessions, their cookies, and in-process requests". internal/server/new_handler_test.go (no database) builds the router from a configured profile and a bare one (no Stripe key), with a fake route mount and two UI mounts, and adds five tests. TestNewHandlerRouteTable writes one block per registered pattern (the 97 literal HandleFunc patterns the lint scan finds in internal/server and internal/auth, plus the two static-file Handle registrations and the fake mount's three routes) to testdata/routes.golden: the pattern the mux resolves a probe path to, and the answer to an anonymous request, a cross-site request for a method that changes state, and a signed-in member where the handler answers before any read; then eight extra probes and the bare profile's checkout row. The golden was written once with -update and read against the code: every operator row answers 302 /login anonymously and 403 Forbidden to the member, the cross-site writes answer the CSRF refusal except the fake webhook (204), the two wrong methods and the bare checkout resolve to / and the styled 404, and the body one byte over 1 MiB answers 413. TestNewHandlerTrustsTheBaseURLOrigin, TestNewHandlerRefusedSignInIsStyled, TestNewHandlerRefusesAMountTemplateOutsideItsKey and TestNewHandlerPassesDepsToMounts cover the base URL's origin, the styled refused sign-in, the key-prefix refusal and the Deps a mount is handed. Shown to change no output: no production file is touched, and the names and results of internal/server and internal/testkit equal the parent's apart from the five new tests, which pass three runs in a row. The 47 breaks of the plan, each applied to the new side after a green control run, are all caught. Left unprobed: - The answers the golden records that code outside the router produces (each handler's own answer, the auth handlers, the operator role guard, each middleware's behaviour): each has its own tests, and no edit to the router changes them; the golden holds them so a change shows. - middleware.Timeout(32s): a short value races fast handlers, and a probe of the real value waits 32 seconds. - middleware.Logging and the order of RequestID before it: they write a log line no test reads. - The http.Server fields: Start keeps them, and nothing builds the server in a test. - The dashboard's root-only check: a later commit probes it. - The real integrations' routes (FedWiki, the Stripe webhook, Discourse): they mount from cmd/start.go, and the fake mount stands in. - The Config cmd/start.go builds (including StripeKeyMode and BaseURL) and the Start call with its exit path: no test builds the command's Config; these tests build their own. |
||
|
|
2fd63eaca2 |
Characterize the recent-activity feed: its rows, its failed reads, its reads per event and the landing's paging
No production file changes. The feed's output is unchanged because nothing the page runs was edited; the commit adds tests, one kit function and the files the golden compares with. TestRecentActivityGolden builds nine organizations on one World, for ten cases (the floor, an upgrade and a revocation on a plan the type defaults to; an upgrade by a subscription that then ends and resumes the grant; a live extension; an extension of a grant on hold; a ladder of its own; a reorder after the tier changes; the console's own removal of a tier, which writes the only end row with no rank; invoices and payments around one instant; two organizations with one invoice each at one instant). It compares the events the feed lists for them, in order, as JSON, and the activity panel's render of those events, with files under testdata/operator_activity. Instants are named t01 and on, newest first, because only their order is a fact. The files were read against the fixture before they were committed. The feed's four existing readers now go through wholeFeed. TestRecentActivityReadFailures fails each of ten reads in turn (the four sources, the product, the tiers, the organization, the person, a handler without a database and a closed one) and pins the events the feed keeps and the warning it writes. TestRecentActivityReadsPerEvent grows the feed by two organizations on a scratch database and expects exactly the product, ladder, organization and person reads of two more of each, and no change in the four source reads or the raw ladder read. The landing page tests read GET /operator on a scratch database: each source is cut at 100 rows (the 101st invoice is on no page and the pager counts the 101 events the feed holds), and a feed of 30 events pages by act_page and act_per, with a page past the end showing page 1 and an unknown size reading as 10. dbtest.CountedAt is Counted over a given address. Shown to change no output: 33 breaks of the feed's code, each applied on its own, are all caught by the new tests; the tests of ./internal/server and ./internal/testkit/... pass three times in a row, each on a reset database, with no test or subtest of the parent missing or changed in result and 19 added in ./internal/server (the golden, the failure table and its ten subtests, the reads test, the cut test, the paging test and its four subtests); the golden passes a second run without -update and leaves the tree unchanged. Unprobed, and why: - Each source is read once per render: no one-line edit doubles a source read without also changing the rows. - The order of two warnings when two sources fail: no subtest fails two reads. - The When cells: the golden replaces them with tokens, and the test of billing events' times pins their text. - The template's id fallback for an unnamed organization or actor: the overview render tests, which build their events by hand, pin it. - An extension older than the transitions window shows as its grant row: pinning it needs about 100 later tier changes. |
||
|
|
94b7c364de |
Characterize the organization page: a snapshot of its data and render, a table of its failed reads, and its reads per row
No production file changes. The page's output is unchanged because nothing the page runs was edited; the commit adds tests and the builders they use. TestOrgEnrollmentGolden compares, for nine states (no organization, no pool, the default plan alone, a rich organization with one plan on two ladders and a subscription on a third, a grant with a note, a flagged subscription, usage, an open overdue invoice and a rule change, a removed tier, a second pool, a suspended subscription, twelve tier changes paged, and the rich one searched), the data the loader returns and the operator_enrollment.html render with files under testdata/org_enrollment. The data leaves out the Issue grant form, the grants ledger's form and the grants rows, which the render holds. The files were read against the state table before they were committed. TestOrgEnrollmentReadFailures fails each of 23 reads in turn and pins the section it feeds and, where it is words on the page, the render. The operator read-count test gains the row "organization page" at 9 reads per row, as predicted. world gains Member, Suspend, Pool and ConferOn, and each says which state it writes that the console does not: Pool and ConferOn, a second pool; Member, a member who is not an organization's owner; Suspend, a suspended subscription whose row keeps its status. Shown to change no output: 66 breaks of the page's code, each applied on its own, are all caught by the new tests; the tests of ./internal/server and ./internal/testkit/... pass, with no test or subtest of the parent missing or changed in result, and 35 added in ./internal/server (the golden's 10, the failure table's 24 and the read-count row); the golden passes three runs in a row on a reset database. Unprobed, and why: - The two advisory failure rows keep their fallback in the nothing-held state; no one-line edit flags a product without the rows the failed read did not return, so a break there cannot be written. - The order of the rungs of one act (Hosting before Chat) is the position query's order among equal keys, which it does not specify; accepted. - The members cap: no state holds more than 16 members. - Values copied unchanged from a row (organization, pool, set and resource names, keys, statuses, emails, roles, notes, limit lines): a copy can only be dropped, and the golden shows a drop. - The ledger's rows, its History tab and its search beyond one break: the grants goldens pin them; this page's render only holds them. - The pager's own markup (the Showing line, Previous and Next): the pager test of the embedded lists and the list-scale tests pin it. - The pool type badge, which is a copied value the template renders. |
||
|
|
67a5d5badc |
Characterize the grants listing: both queries' rows, both pages, and their reads per row
Adds tests, one test-kit option and goldens only; no production file changes. TestGrantsListingRowsGolden builds the waiting fixture of the grant resumption tests (eight organizations, eleven grants) plus seven organizations (an extended grant, a revoked one, an expired one, a restored default, a marked duplicate of the default, a grant on no ladder and twelve grants for the pager), 33 grants in all, and writes the rows of each fixture organization's ListGrantsWithDelivery call and of the search call ListGrantsWithDeliveryPage on the fixture's suffix (all grants, each state, and four pages of ten) under testdata/grants_listing/rows. Its drift subtest holds the two queries to the same 25 columns for every fixture grant and names the grant and each column that differs. It passes at the parent, so the two queries already agree on this data. TestGrantsListingPagesGolden writes, for 8 requests of the Grants page and for the Active and History tabs of each fixture organization's grants ledger (plus a reason search, a product search, page 2 and a page past the end), the data each surface renders as JSON, captured through the handler, and the HTML of the real render (the page's #operator-body, the ledger's whole answer). Each file was read against the fixture before it was committed: every waiting row's hover against the table of TestGrantResumptionRows_WaitingStatesRenderAlike and every revoke body against TestGrantResumptionRevoke_Bodies. grWorld gains manage, the Manage save's extension; world.IssueOpts gains By, the person who issues the grant; newGoldenNormalizer's body moves to a form that takes the database and the suffix. The read-count table gains grants (5 reads per row), grants-ledger (2) and grants-ledger search (2), each as the plan predicted. Names and results of ./internal/server and ./internal/testkit/... match the parent apart from the new tests, which pass: TestGrantsListingRowsGolden with its 18 subtests, TestGrantsListingPagesGolden with its 44, and the three read-count subtests. A second run without -update leaves the files unchanged, and the tests pass three runs in a row after a reset. Nineteen edits to the listing code, each breaking one fact, fail the test that names it (each caught by the new test; the rows golden and its drift subtest are one test, so a break in a column the golden holds is caught by both). Unprobed: the target badge, the quantity, the Resumes column, the hover wording, the list controls and the ledger's Manage markup (each is a column copy or a template literal, probed by the waiting-row tests that the next two commits convert); the Grants page's past-the-end reload, which the merge of the two queries probes; the floor's revoke body, which the resumption revoke test probes; the pool the ledger evaluates for an organization with no active default pool, because no fixture organization lacks one; the order of grants that share created_at, which no fixture grant does. |
||
|
|
2a913ca635 |
Characterize the plan-ladders page, the ladder page's tiers and the reorder preview
Adds tests, one test-kit builder and goldens only; no production file changes. TestPlanLaddersGolden builds three ladders (Hosting with Basic and Shared, Support with Shared, Empty with no tier), five products (a shared one, an add-on, one with no category, a draft), three organization types (defaulting to Hosting, to nothing and to the empty ladder) and an organization holding Basic, and compares with files under testdata/operator_plan_ladders: the topology's columns, rank rows, shared products, off-ladder strip and type summary together with the ladder list (page); the ladder page's tiers with their organization counts (detail); and the reorder preview's order for the ladder's own order (preview). The page lists every ladder in the database, so the files hold the fixture's ladders, products and types. The properties subtest holds the whole grid to the ladders' own reads: rows ascend by rank, every row has one cell per column, and every column, the fixture's or not, counts what CountActiveAttachmentsByLadder returns and holds the tiers ListTiersByLadderWithProducts returns. Each file was read against the fixture before it was committed; a type whose default ladder has no rank-0 tier reads as no default. TestPlanLaddersReadFailures pins, per read (ladder listing, shared products, ladder counts, tier reads, product list, rank-0 listing, organization types, tier counts on the ladder page and in the preview), what the pages show when that read fails. world gains AddTier. The read-count table gains plan-ladders (4 reads per ladder), plan-ladder-detail (1 per tier) and tier-reorder-preview (2 per tier), each as the plan predicted; the table's request can now be a POST. Names and results of ./internal/server and ./internal/testkit/... match the parent apart from the new tests, which pass: TestPlanLaddersGolden and its four subtests, TestPlanLaddersReadFailures and its nine, and the three read-count rows. A second run without -update leaves the tree unchanged, and the tests pass three runs in a row after a reset. Thirty-two edits to the topology and the ladder code, each breaking one fact, fail the test that names it. Unprobed: each column's and list row's IsActive and description, the org-type summary's display names (moved unchanged); the list's name filter and paging, which no section of this step edits; the order of same-named products in the shared index, which is unspecified today and which no section of this step fixes; the reorder preview's classification path (rank 0 does not change in the fixture); the topology's template rendering, which its own tests keep. |
||
|
|
0fe2305905 |
Characterize the products list: a golden of its rows and a table of its failed reads
Adds tests, two test-kit builders and goldens only; no production file changes. TestProductsListGolden builds thirteen products (a ladder tier, an off-ladder product, an add-on, one with no set, one with no rules, one priced in the other environment, an unlisted, an inactive, a draft, a retired, one needing an unconfigured provider, and two sharing a set) and compares the rows the loader builds, and the page rendered from them, with files under testdata/operator_products, with Stripe configured and not. Each file was read against the fixture before it was committed, and each failure file against the fallback that read gives every row when it fails. The golden files show the verdict, note, set name, category and visibility of every row, the provider leg naming the unconfigured provider, and both set cells naming the shared set. TestProductsListReadFailures pins, per read of the page (shapes, set names, prices, price mappings, set rules, resource keys, and the page itself), the rows the loader builds when that read fails, and that a failed page read says "Failed to load products" and builds no row. world gains EditProduct and Lifecycle. The read-count table already holds the products row at 0 per row. Names and results of ./internal/server and ./internal/testkit/... match the parent apart from the new tests, which pass: TestProductsListGolden and its two subtests, TestProductsListReadFailures and its seven. A second run without -update leaves the tree unchanged, and the tests pass three runs in a row after a reset. Nineteen edits to the list code, its readiness helper and its template, each breaking one fact, fail the test that names it. One of them (the price reader told "configured" when Stripe is not) changes no output in that state, because the mapping read is skipped there and the map stays empty; it is run in the configured state with "not configured" instead. Unprobed: the view model's ProductID, Name, Description, IsActive, LifecycleStatus, Lifecycle and EntitlementSetID fields as individual facts (the golden compares them, and the other breaks show it compares every field); the skipped mapping read when Stripe is not configured, since no output depends on it; the order of the six reads; the row order, which the list query fixes; that two products sharing a set pass its id to the set and rule reads once (the ids a batch read receives change no output, and the page reads the sets in one statement either way). |
||
|
|
f53186bb6b |
Characterize the four billing lists and the lookup's candidates, with their read failures and reads per row
Adds tests, three test-kit builders and goldens only; no production file changes. TestBillingListsGolden compares each list's data, in the default and the all state, with a file under testdata/operator_billing for a fixture of two organizations and four billing accounts (live, unrecorded, unmapped, test), each with a subscription, an invoice and a payment. Each file was read against the fixture before it was committed: the test-mapped row is hidden by default with the line counting one, shown with the test badge in the all state, the unmapped row reads not_mapped, and every row names its own organization. TestBillingListsKeepTheirFallbacksWhenAReadFails pins, per list, that a failed organization read leaves the name empty and a failed mapping read shows not mapped. TestLookupInvoiceCandidatesNameTheirOrganizations pins the invoice candidates' organization names and the organization id as their fallback. The read-count table gains billing accounts, billing subscriptions, billing invoices, billing payments and lookup rows. Each grow adds its rows on a new organization named from a counter, and the lookup row is sent as an htmx request. Measured per row: 2 for each billing list, 1 for the lookup, as predicted. world gains MapAccount, MapSubscription and MapPayment, which record no environment, beside MapInvoice. Names and results of ./internal/server and ./internal/testkit/... match the parent apart from the new tests, which pass: TestBillingListsGolden and its twelve subtests, TestBillingListsKeepTheirFallbacksWhenAReadFails and its twelve, TestLookupInvoiceCandidatesNameTheirOrganizations and its two, and the read-count subtests billing_accounts, billing_subscriptions, billing_invoices, billing_payments and lookup. A second run without -update leaves the tree unchanged, and the tests pass three runs in a row after a reset. Thirty-two edits to the list code and the lookup, each breaking one fact, fail the test that names it. Unprobed: the goldens pin the loaders' other fields (dates, amounts, statuses, account names, payment method, invoice numbers, Overdue, the pager and the absence line) with no break, because no later section edits the code that fills them; the breaks probe the fields the mapping and naming changes move. |
||
|
|
ee3b298287 |
Characterize the Products page, the entitlements panel, plan features and the price readers
Tests and fixtures only; no production file changes, so no output of the
console changes.
internal/testkit/world gains the plan fixtures, each through the console's own
query or function: Price, OneTimePrice, MapPrice, MapPriceIn, SortOrder,
Issue, Revoke, Floor, Use, Retire, Subscribe (with SubOpts, whose Policy is
written in the same statement as CommitmentEnd because the schema refuses a
commitment end without one), ConferUnprojected, ScheduleDowngrade,
ScheduleCancellation and CancelAtPeriodEnd. Every fixture date the page prints
is 12:00 UTC.
internal/server:
- TestMemberPlansGolden: three ladders and thirteen organizations, one subtest
per state; buildPlansData as JSON and the member_plans.html render are
compared with testdata/member_plans/<state>.{json,html}.
- TestMemberEntitlementsGolden: four organizations; GetEntitlements through a
capture template set (data as JSON) and through the member set, compared
with testdata/member_entitlements/<state>.{json,html}.
- TestMemberEntitlementsReadFailures: one subtest per failed read, plus the
401 without a session.
- TestPlanFeatures, TestPriceReadersTrackTheDefaultAndClassifyItsMapping,
TestOrgHasEntitlement, TestCheckoutAnswersHTMXWithAClientRedirect.
- failingEntitlementsQ and failingBillingQ gain the methods the new tests
fail, and TestPlansShowTheLoadErrorWhenAReadFails and
TestCheckoutRefusesWhenAReadFails gain a GetActiveAttachmentsByPool row.
internal/fulfillment: TestPlanChangeRefusesAnOrganizationWithoutAPool.
Shown to change no output: git diff against the parent touches no non-test
file outside internal/testkit/world; over internal/server, internal/fulfillment
and internal/testkit, run with the app down on a freshly reset test database,
all 1171 tests and subtests of the parent still pass with the same names and
results, and the 88 new ones (the tests above, their subtests, and the two new
rows) pass; each golden test passes three runs in a row on a reset database
and a second run without -update leaves git status clean. 25 breaks, each
caught by the new tests; none dropped, none left unprobed.
|