main
16
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f3fa8e7456 |
Confirm a plan switch in the shared dialog
#291. A plan switch (Upgrade, Downgrade, or Pay for this plan on a granted tier's card) was confirmed in a panel at the top of the Plans section, above the cards and away from the button that opened it. It now confirms in the same dialog as Cancel plan and Resume plan. The button opens the dialog at once, titled with the panel's question ("Switch to Pro?", or "Pay for Pro?" on the granted tier's own card), and the dialog's body loads from the switch's preview route, because the charge it states comes from Stripe at that moment. Keep current plan closes the dialog and sends nothing. An answer that belongs in the dialog stays there: a declined card, a payment awaiting the bank's approval, stale figures; focus moves to the new form. An answer that lands outside closes it: the switch made or refused, or a plan withdrawn after the page loaded. A load that fails closes the dialog with the error toast; a commit that fails (403 or 5xx, which the pages' htmx config does not swap) leaves it open with the toast. Closing the dialog while its body loads aborts the load, and the aborted load shows no toast. The shared dialog gains a region the loaded form lives in; the forms library renders a preview that leaves by the dialog without a box of its own; the lint accepts `data-action-load` as a trigger's route. No string is added or reworded. docs/design-system.md, docs/operator-ux-conventions.md and docs/stripe.md say the same. Tests: TestSwitchAnswersNameTheirRegion, TestPlanMoveTitle, TestSwitchControlOpensTheSharedDialog, TestConfirmModalCarriesTheLoadedRegion, TestPreviewInTheDialogRendersNoBox, and the walkthrough TestSwitchDialogWalkthrough (seven steps in a browser, among them a load answered offline, a load closed mid-flight, and a commit answered 503 by the browser so nothing reaches Stripe). The walkthrough resets the application database, so it runs only with SWITCH_DIALOG_WALK=1, as the merchant-disclosure sheet runs only when asked; make test skips it. 35 goldens change: every member plans golden loses one blank line, the switch controls of 12 lose their hx-* attributes for the dialog's, and the plan move form's golden loses its box. Full suite on reset databases: 5249 passed, 16 skipped, none failed. Sandbox walk on this commit, all twelve steps passed: the dialog opens at once and loads Stripe's figure ($20.00 equal to the preview's amount_due); Keep current plan sends nothing; Cancel plan shows its own form; offline the dialog closes with the toast; stale figures, a declined card and a payment awaiting approval stay in the dialog with focus on the new form; the made switch closes it and Max is current; a plan withdrawn in another session refuses in the Plans region; Pay for this plan reads "Pay for Max?". make screens: no page this commit touches changed. Three operator captures differ for other reasons: person detail and invoice detail show new ids, because the demo snapshot was rebuilt when the previous landing added a FedWiki migration, and the domains list's age column counts one more day (#120). Not walked by the walkthrough, with the reason: a request in flight at a chosen moment (the dialog closed while the commit runs, another switch opened) and a page restored from the browser's cache; a load answered 400 and a commit the switch refuses answer through the same close rule as the withdrawn plan and the made switch that the walkthrough and the hand walk cover. Breaks, all CAUGHT (19 test records): - own card titled as a switch (title test) - own card titled as a switch (control test) - setMove leaves the title unset - control loads without the price (control test) - control loads without the price (render test) - control loads the switch's route - heading left in the message - current tier's name unescaped - refused preview left in the dialog - confirmation answered into the Plans region (a declined switch) - confirmation answered into the Plans region (a stale switch) - confirmation answered into the Plans region (a switch awaiting approval) - decline answered 200 - way out a link again - commit's description dropped - region renamed in the partial - box rule removed - box rule widened to every kind - box rule widened to every way out Breaks of the walkthrough, all CAUGHT (8 records, each building and booting the app from the edited tree): - the close not deferred - the abort on close dropped - the cancelled mark ignored by the toast - the load not sent - the loaded region left showing under a static trigger - the close on an answer outside the dialog removed - the failed commit closing the dialog - the way out a link again Seen by no test: renderConfirmation without its describeCommit call (the database tests' handlers carry no Stripe key fingerprint, so the line is never shown there; describeCommit itself is pinned); data-action-load dropped from the lint's condition (no template interpolates a route into it); the spinner and aria-busy not set, and the focus after a stay-open answer not moved, which only the hand walk reaches, because the walkthrough stops before any Stripe write. |
||
|
|
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 |
||
|
|
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. |
||
|
|
95a863e05b |
Offer no tier removal that is certain to be refused or cannot be read
A refused removal preview still asked for a disposition and showed the Keep and Migrate choices beside a disabled "Apply change" whose reason showed only in a tooltip. The tier card's rank 0 offered Remove while it could not read the org types its last-tier guard needs, and the only tier of a default ladder offered a Remove the act always refuses (#187). - The form library gains FormView.WayOutOnly, a commit row with the way out alone. CommitHidden still wins over it, and it wins over CommitState and CommitLink. - A removal preview the holder limit or the last-tier guard refuses shows its heading and the refusal, with Discard alone, and so does a rejected submission whose rebuilt preview finds the refusal. - The tier card renders rank 0's Remove or Preview removal disabled, through the disabled-control part, with the failed read as its reason when the org types cannot be read, and with the last-tier sentence when the ladder's only tier is an org type's default. - The reorder preview that cannot read the org types rendered no commit row, so the pending order held the table with no Discard. It now offers Discard alone. No sentence is new. Tests pin each state, the rebuild paths and the precedence of the new field. The design, the plan-ladder-management, plan-acts and form-library specs, docs/plan-management.md and docs/design-system.md say the same. 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. |
||
|
|
09cf0ec540 |
Build a field's view in named steps
renderFieldAs keeps its signature and now calls fieldBase (what the declaration and the family decide: the hidden label only in a bar or a dense row, a checkbox's label above in stacked and dense, "(optional)" never in a bar or a table, cells and footer only in a table, a textarea's 3 rows, the column class and the small controls), bindFieldValue (the bound value unless unbound, always for a Hidden field, a static row's own text), the error, selectOptions or radioOptions, and describeField (the ids under the control and the described-by order: hint, notice, error, slot), then fieldAttrs as before. Cells and the control footer are now set only in a table instead of set and cleared outside one. The small-control rule is one expression. fieldAttrs and the rest are unchanged. The gocognit exclusion for renderFieldAs (34) is deleted. Measured with the pinned golangci-lint, min-complexity 1: renderFieldAs 4, fieldBase 9, selectOptions 5, bindFieldValue 4, describeField 4. radioOptions scores 1 and the linter at that threshold does not list it. fieldAttrs stays 28. No output changes. Shown by: - ./internal/forms ./internal/server ./internal/integrations/fedwiki/web ./internal/integrations/discourse/web, app down, test databases reset before each side: 1756 test and subtest names with identical results at the parent and here, 0 failed, 0 skipped, including the 15 TestFormPart tests. No golden file changed. No flaky test failed. - The whole screens run (41 screens, 82 shots) at the parent and here, each side prepared right before it from the demo snapshot. Every shot is byte-equal except three. operator-integration-stripe at desktop and mobile is a page that shows read times. operator-plan-ladder- detail-open at desktop differs in 4 pixels by 1 in one colour channel (around x 264 to 266, y 579 to 608); a second run here gave the same 4 pixels against the parent, and the -invalid shot of the same page differed in 6 pixels by 1 between those two runs here. These are edge-pixel differences between runs; the -invalid one is between two runs of the same code. The pixels were not inspected beyond their coordinates and values. The forms catalogue (forms.json) is byte-equal. The contact sheets differ in their heading's timestamp and in one status line each: the parent run marks operator-plan- ladder-detail-open at desktop as changed against the tool's reference (2x29 at 264,579) and the first run here marks it same; the second run here marks -invalid at desktop as changed (2x28 at 1574,509). The change lists differ only on that line. Not compared with the accepted baseline, because the snapshot was rebuilt since it was taken. - render.go's string literals: the distinct set is equal; the empty string occurs 54 times where the parent has 55 (the footer is no longer reset to ""). - 14 break records (7 per side) on render.go: each caught on the old side at the parent and on the new side here, a dense checkbox's label position by TestFormPartCheckboxLabelSitsAboveTheBox and the rest by TestFormsHTML. The first-option and radio-id records are one per side, because the code moved. - go vet, gofmt, project lint, golangci-lint (0 issues) and the lint snapshot (nothing added or removed) are clean. Unprobed: the templates' own markup, because no section of this step edits a template and the goldens hold their output; fieldAttrs, buildRows, RowField and resolveRowsFocus, which this commit does not move; the list search's declaration, built inside the function that renders it, so no test reaches it without a production change and it stays unchecked against the invariants; the goldens' check for stale files, which guards the test's own files and which no production edit reaches. |
||
|
|
2308be4727 |
Render a form through named steps
Render keeps its signature and now calls resolveSide (the method, action, rendered kind, commit label and way out for the binding's side), newFormView (the form id with its instance, the target, the family booleans, the table headings' defaults, the preview at rest and the confirm's message id), formHX (a search's push-url, select and select-oob, nothing when boosted; otherwise the verb, target, swap, disable and indicator) and renderFields (Rows focus, then each field the side, the placement and ShowIf keep, through libraryRenders; a table row cut or padded to one cell per column through padCells; autofocus on the first refused field). resolveSide and newFormView carry doc comments. renderFieldAs, boxFor, hxVerb, buildRows and the rest are unchanged, and FormSpec.Bind stays the one-line alias. The gocognit exclusion for Render (55) is deleted. Measured with the pinned golangci-lint, min-complexity 1: Render 3, resolveSide 12, renderFields 11, newFormView 7, formHX 4, libraryRenders 4. padCells scores 1 and the linter at that threshold does not list it. renderFieldAs stays 34. No output changes. Shown by: - ./internal/forms ./internal/server ./internal/integrations/fedwiki/web ./internal/integrations/discourse/web, app down, test databases reset before each side: 1756 test and subtest names with identical results at the parent and here, 0 failed, 0 skipped, including the 15 TestFormPart tests. No golden file changed. No flaky test failed. - The whole screens run (41 screens, 82 shots) at the parent and here, each side prepared right before it from the demo snapshot: every shot is byte-equal except operator-integration-stripe at desktop and mobile, a page that shows read times and differed between two runs of the same code earlier (the pixels were not inspected here); both sides' change lists are identical. The two contact sheets differ in their heading's timestamp only. Not compared with the accepted baseline, because the snapshot was rebuilt since it was taken. - render.go's string literals: the distinct set is equal; the empty string occurs 55 times where the parent has 54 (formHX returns "" for a boosted search). - 24 break records (12 per side) on render.go, each CAUGHT by TestFormsHTML on the old side at the parent and on the new side here. The table-row cut is one record per side, because the code moved. - go vet, gofmt, project lint, golangci-lint (0 issues), deadcode, and the lint snapshot (nothing added or removed) are clean. Not probed: the templates' own markup, which this commit does not touch and the form goldens hold; fieldAttrs, buildRows, RowField and resolveRowsFocus, which Render still calls unchanged and whose output the goldens hold; the list search's declaration, built inside the function that renders it, so no test reaches it without a production change and it stays unchecked against the invariants; the goldens' check for stale files, which guards the test's own files and which no production edit reaches. |
||
|
|
113c672e3b |
Resume a marked grant when the plan that replaced it ends, and archive the change
An operator can mark a grant to resume. When a plan replaces a marked grant and that plan later ends (the subscription ends, or a granted plan is revoked or expires), the grant delivers again from its own row before the org-type floor runs (core migration 00023, core.grant_waits). - Grants: a resumes_after_supersession column, off by default and never on a default grant; delivery states gain On hold, with a badge hover that names what the grant waits on. - Operator: one Manage action per grant opens a card under its row (Note, Valid until, Resumes; Save, Cancel, Revoke). A changed Valid until extends the grant; otherwise the note and mark change in place. An extension now keeps its predecessor's valid_from. - Member: a held rung reads Granted and is reached by cancelling; the cancel confirmation names each ladder's outcome; Cancel plan sits beside Keep plan while a downgrade is scheduled. - Smaller fixes from the screens reviews: 24-hour times everywhere, search placeholders that name what they match, a checkbox label above its box, and the org-types walkthrough restoring the default it changes. |
||
|
|
27fb02fbfa |
Show members who bills them before they pay, and archive the change
Stripe Checkout, receipts and the card statement carry the Stripe account's public business name and statement descriptor, which Stripe fills from the registered legal name until they are set. Where that name differs from the deployment's, a member first meets it above the card fields (#13). Each install has to notice this on its own, since a fix in one account's settings reaches no other deployment. - A Temporal workflow reads the public details at every boot and on request, with whatever permission the key holds: the account itself with Accounts Read, otherwise an invoice preview and the latest charge. The result is a JSON instance setting; the account id is never stored. - The Stripe provider page gains a Public details section. Its Read again control, and the Environment check's Check now, move into the section header as a refresh action. - The Products page and the checkout return name who bills the member and what the card statement says, only when a name would not be recognised against deployment-name. Every control that costs money, the switch preview's commit included, references the line through FormView.CommitDescribedBy. - docs/stripe.md gains "What members see"; production-deployment.md points to it. golang.org/x/text becomes a direct dependency for the name comparison. OpenSpec change merchant-disclosure is archived and its six deltas synced into openspec/specs/. |
||
|
|
fba272fedb |
Charge upgrades now and defer downgrades to renewal
A paid-to-paid switch sent create_prorations, which charged nothing now: the difference waited on the next renewal invoice, an immediate cancellation lost it, and the confirmation's "charged now" was false. A downgrade took effect at once, so moving to a cheaper paid tier cost more access than cancelling (OpenSpec change plan-switch-timing, unarchived: it awaits the maintainer's review, task 11.4). - An upgrade is invoiced at once (always_invoice) and applies once paid (pending_if_incomplete). A declined card voids the invoice and leaves the plan as it was; a card that needs the bank's approval links to Stripe's invoice page. The preview pins its proration moment and the switch charges at it, so the amount charged is the amount shown. - A downgrade takes effect at renewal through a subscription schedule and is recorded in the scheduled-change ledger. - Every paid plan's card offers Cancel plan; a scheduled downgrade or cancellation shows on the card with Keep, which drops it at no charge. A paid rank-0 tier is a downgrade target, and a rung a grant holds moves up through Checkout with no move down. - Reconcile keeps the ledger true to Stripe, and marks ended the subscription item a schedule's phase change replaced (core migration 00021, subscription_items.ended_at). The sweeper reconciles a period-end move instead of firing it. - The form library gains a commit that is a link out of the console. The verify skill and the first-contact process now say the gap in scripted screens is tracked as #118 and that a new issue waits for the maintainer's approval. |
||
|
|
a2acd7fd32 |
Render a hidden field's bound value in an unbound form
An unbound render decides a ShowIf from the handler's bound marker but left the hidden marker itself empty. The form then submitted an empty marker, and the parse dropped the field the render had shown: the site create form's custom-domain box was shown and then silently ignored. A Hidden field now carries its bound value in every mode, since it holds what the handler derived, never a person's input. Visible fields of an unbound render stay empty. |
||
|
|
f8a3478f2a |
Rebuild the entitlement set Rules surface as a staged batch
The Rules section is one record table grouped by kind, Limit then Boolean, on fixed columns, edited in place: Edit opens a row's controls in their columns, Add rule opens a dense row above the table, and every change is staged into a tray that lists the deltas with Undo and applies them as one rule-change act. The reduction policy is a column of the rule beside its limit. History shows counts only. Group rows are a quiet heading rather than a divider, the maintainer's pick from four rounds of outside-model ideation. Dense rows align to the top and render each error under its control in every form family (design D16), replacing the below-row error block; the forms library gains the batch form (rows plus one tray) and the RowField dense and label-hidden options. Migration 00019 records the governing reduction policy on effect rows. Archive staged-rule-changes with its spec updates (entitlement-set- management, entitlement-set-history, entitlements, form-library, form-conventions, ui-quality-gate). Screens accepted 2026-09-19. |
||
|
|
88db730fcc |
Add dual licensing and SPDX headers
Introduce a commercial license option alongside AGPL-3.0-only, require a CLA for contributors, and document the terms in COMMERCIAL.md and NOTICE. Add a script to stamp SPDX headers on Go files and apply it across the tree. |
||
|
|
d47c47ef0f |
Make control type decide dense form width
Width becomes a one-rung step from the control's natural fraction, and invariants bound the ladder. The grant's free text is renamed to GrantNote, and both grant ledgers show it as a muted note line. |
||
|
|
e581bf81c3 |
Add typed config keys and config CLI
Parse every config value through a single declared type at save, boot, and validate, and expose the settings seam from the terminal so operators can list, set, clear, and validate overrides without SQL. |
||
|
|
9ff516ba95 |
Introduce the forms library and migrate all forms
Every form on both surfaces is now a declared FormSpec in internal/forms, rendered through the shared form part and parsed by its handler through the same declaration. Create and edit share one field list, refusals answer 422 with values carried back, and layout, buttons and errors come from one part. Also adds the form registry with its invariant and route-mapping tests, the raw-form, filler-copy and Go em-dash lint rules, declared action triggers, and the capture-coverage cross-check. The forms-library change is archived under openspec/changes/archive/2026-09-05-forms-library. |