Seventh section of #269's change, and #270. Until now a subscription a grant displaced delivered again only at its next reconcile, so the act that ended the grant restored the organization type's default in between. Now a revocation, an expiry, the subscription reconcile's ended branch and its end of a surplus provision call entitlements.DeliverDisplacedSubscriptionsTx when they ended at least one provision: after the resumption of marked grants and before the default floor, under the pool row the act holds and without any subscription's advisory lock. It reads the recorded current items of the organization's active, trialing, past_due, unpaid and paused subscriptions, keeps each product with an entitlement set that has no live provision of the subscription, applies the displacement rule to them and the reconcile's choice of tiers to the subscription's products the rule does not leave alone, and confers each, sourced by the subscription: first the subscriptions the ending plan superseded, then the rest, each in subscription id order. Each subscription's delivery runs under its own savepoint, with a past-due, unpaid or paused subscription's suspension inside it; a conferral the database refuses rolls back that subscription alone, is logged, and the act goes on. The Tier changes table labels the four reasons "Subscription delivers again after a revocation", "... after a grant expired", "... after a subscription ended" and "... after a subscription dropped its tier". docs/stripe.md, docs/plan-management.md, docs/models/plan-ladders-transitions.md, docs/database-locks.md and the demo seed's README say the same. Tests: TestDeliverDisplaced_* in entitlements (revocation, with a trialing subscription and a set-less item; expiry; past_due, unpaid and paused coming back suspended; a resumable grant first; a refused conferral rolled back alone, and the whole subscription rolled back when one of its conferrals is refused; a replay delivers nothing; id order; the subscription the ending holder displaced first; a held higher tier not downgraded; a canceled subscription not delivered; the pool row awaited without the subscription's lock) and in fulfillment (the holder's end and a dropped item). Seven trace goldens gain the one read. TestHumanizeTransitionReason gains the four rows. Tests changed outside the tasks' first list, now listed in 7.5: three tests of #212 in displaced_subscription_test.go no longer reached the state they test, because the revocation now delivers. They are rebuilt on states the console reaches (a refused delivery then the product published again, and a tier removal), and TestReconcile_ARevocationDeliversTheSubscriptionAndItsReconcileWritesNothing is added. twoOnOneLadder gives the subscription that drops its tier a second item, since Stripe keeps a subscription's last item, which also changes section 4's TestSurplusEndRequestsTheOtherSubscriptions. Review: round 1, two majors (a savepoint per conferral where the spec wants one per subscription, so a half-delivered subscription kept its billing paused; a test helper that faked a state the console never writes) and six minors, all fixed; round 2, no major. The change's text: the spec and design D7 now give the per-subscription savepoint and run the choice of tiers over the products the rule does not leave alone, and task 7.2 lists the fourth reason. Breaks: 27 of 27 caught (24 in code, 3 in migration 00034 on reset test databases). Full suite on reset databases: 4831 pass, 14 skip. make screens, after the demo snapshot's rebuild: Carlos Member's Tier changes read the revocation and expiry labels where they read "Default restored after ...", his Active grants tab is empty, the grants list loses the two restored defaults (11 to 9), and the overview's recent activity loses their two rows. With Carlos's tab empty, no capture showed the Manage grant form, so the screens manifest gains operator-organization-detail-grant, Bob Builder's organization, whose default delivers. Hand captures of the four Tier changes labels sit beside the change's screens.
130 lines
6.1 KiB
Go
130 lines
6.1 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
)
|
|
|
|
// humanizeTransitionReason renders a machine-written
|
|
// pool_provision_transitions.reason value as the sentence design D3
|
|
// assigns it, matched by prefix where the writer appends an identifier or
|
|
// a label. known is false for a reason the map does not recognize; callers
|
|
// then fall back to the raw string (D3: "a reason the map does not know
|
|
// renders raw"). Issue grant copies the operator's note into the reason, so
|
|
// the reasons an act writes for itself count only from the writer that
|
|
// writes them: a resumption's and a delivery's from "system", an extension's
|
|
// from an "operator" transition of the type its conferral records (transfer,
|
|
// or the incumbent's end beside it); the same text from anyone else renders
|
|
// raw.
|
|
func humanizeTransitionReason(raw, transitionType, actorType string) (label string, known bool) {
|
|
bySystem := actorType == "system"
|
|
extension := actorType == "operator" && (transitionType == "transfer" || transitionType == "end")
|
|
switch {
|
|
case raw == "operator_revocation":
|
|
return "Revoked by an operator", true
|
|
case raw == entitlements.ExtensionTransitionReason && extension:
|
|
return "Extended by an operator", true
|
|
case raw == "post-revocation default restoration":
|
|
return "Default restored after a revocation", true
|
|
case raw == "post-cancellation default restoration":
|
|
return "Default restored after a subscription ended", true
|
|
case raw == "auto-provisioning on org creation":
|
|
return "Default on organization creation", true
|
|
case raw == "demoseed: floor personal pool at org-type default":
|
|
return "Seeded default", true
|
|
case bySystem && strings.HasPrefix(raw, entitlements.ResumptionReasonSubscription):
|
|
return "Grant resumed after a subscription ended", true
|
|
case bySystem && strings.HasPrefix(raw, entitlements.ResumptionReasonRevocation):
|
|
return "Grant resumed after a revocation", true
|
|
case bySystem && strings.HasPrefix(raw, entitlements.ResumptionReasonExpiration):
|
|
return "Grant resumed after a grant expired", true
|
|
case bySystem && strings.HasPrefix(raw, entitlements.SubscriptionDeliveryRevocation):
|
|
return "Subscription delivers again after a revocation", true
|
|
case bySystem && strings.HasPrefix(raw, entitlements.SubscriptionDeliveryExpiration):
|
|
return "Subscription delivers again after a grant expired", true
|
|
case bySystem && strings.HasPrefix(raw, entitlements.SubscriptionDeliverySubscription):
|
|
return "Subscription delivers again after a subscription ended", true
|
|
case bySystem && strings.HasPrefix(raw, entitlements.SubscriptionDeliveryDropped):
|
|
return "Subscription delivers again after a subscription dropped its tier", true
|
|
case strings.HasPrefix(raw, "grant-expiration:") && strings.HasSuffix(raw, " default restoration"):
|
|
return "Default restored after a grant expired", true
|
|
case strings.HasPrefix(raw, "grant-expiration:"):
|
|
return "Grant expired", true
|
|
case strings.HasPrefix(raw, "org-type default change ("):
|
|
return "Org-type default changed", true
|
|
case strings.HasPrefix(raw, "tier added to ladder "):
|
|
return "Tier added to the ladder", true
|
|
case strings.HasPrefix(raw, "plan-ladder tier reorder ("):
|
|
return "Ladder tiers reordered", true
|
|
case strings.HasPrefix(raw, "tier removal ("):
|
|
return "Tier removed from the ladder", true
|
|
default:
|
|
return raw, false
|
|
}
|
|
}
|
|
|
|
// transitionChangeLabels maps the transition_type enum to operator
|
|
// vocabulary. transfer gets a tooltip: same rank, different funding
|
|
// source (model card invariant: callers never choose the type; confer
|
|
// derives it from ranks), except the transfer an extension causes, which
|
|
// is labelled Extended and needs no tooltip (transitionChange).
|
|
var transitionChangeLabels = map[string]string{
|
|
"initiate": "Started",
|
|
"upgrade": "Upgraded",
|
|
"downgrade": "Downgraded",
|
|
"transfer": "Transferred",
|
|
"end": "Ended",
|
|
}
|
|
|
|
// transitionChange is the one label rule for a tier change, wherever one is
|
|
// named: the Tier changes list's Change badge and the recent-activity line
|
|
// (grant-resumption D22). kind is the kind of change the database names for
|
|
// the row (core.transition_change_kind, which also says when a reason
|
|
// counts): a resumption's start reads "Resumed" and an extension's transfer
|
|
// "Extended"; every other row is its type and keeps its type's label, so a
|
|
// restored default stays "Started" and its reason says what it restored
|
|
// after, and the incumbent's end that core.confer writes beside an
|
|
// extension's transfer keeps "Ended", as it keeps it beside any other
|
|
// supersession. kind is also the badge map's key for the label.
|
|
func transitionChange(kind string) string {
|
|
switch kind {
|
|
case "extension", "resumption":
|
|
return badgeMap[kind].Label
|
|
}
|
|
if label := transitionChangeLabels[kind]; label != "" {
|
|
return label
|
|
}
|
|
return kind
|
|
}
|
|
|
|
// humanizeTransitionSummary renders one transition event's one-line
|
|
// summary in the composite's vocabulary: the verb the Tier changes list's
|
|
// Change badge reads (transitionChange, so a resumption reads "Resumed" and
|
|
// an extension "Extended", grant-resumption D22) plus the resolved tier
|
|
// label(s) the change moved between. kind is the kind of change the
|
|
// database names for the row (transitionChange). fromLabel and toLabel are
|
|
// already resolved product names (or a "rank N" / "—" fallback — see
|
|
// tierLabel) so this function only picks which label(s) the sentence needs
|
|
// for the given change type.
|
|
func humanizeTransitionSummary(transitionType, kind, fromLabel, toLabel string) string {
|
|
verb := transitionChange(kind)
|
|
// Separators are a colon and the word "to": UI copy carries no em
|
|
// dashes (design-system §4; the lint rule covers templates, this
|
|
// string reaches the page through one).
|
|
switch transitionType {
|
|
case "upgrade", "downgrade":
|
|
return verb + ": " + fromLabel + " to " + toLabel
|
|
case "end":
|
|
return verb + ": " + fromLabel
|
|
default:
|
|
// initiate, transfer, and any future type: the position lands ON
|
|
// toLabel (transfer keeps the same tier under a new funding source,
|
|
// so fromLabel/toLabel are equal there).
|
|
return verb + ": " + toLabel
|
|
}
|
|
}
|