Files
cgalo5758 dacb8b57a9 Deliver a displaced subscription again in the act that ends its holder
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.
2026-10-28 11:30:05 -05:00

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
}
}