Files
pulse/pkg/licensing/stripe_subscription.go
T
rcourtman 6be2af1c19 Let providers evaluate MSP without asking permission first
Two mandatory round-trips stood between an interested MSP and their first
screen, and neither was technical.

setup.sh required four image digests shipped as literal <pin>
placeholders, so the only way to get them was to ask. All four images are
publicly readable, so there was never anything to hand out. setup.sh now
resolves each blank pin to an immutable digest from its published tag via
buildx imagetools and writes it back to .env; hand-set values are left
alone.

setup.sh then died outright without a licence file, so nobody could start
the stack, create a workspace, or see the portal until a human minted a
licence for them. The control plane already ran unlicensed via
ProviderMSPPlanSourceEnvFallback; only the installer refused. A licence
path that is set but missing is still a hard failure, since that is a
misconfiguration rather than a choice.

Unlicensed now means evaluation rather than the cheapest paid tier. The
env fallback defaulted to msp_starter, handing every unlicensed
deployment the full 5-client Starter allowance and leaving no boundary
between evaluating and buying. Adds msp_eval at 2 workspaces: same
capabilities, smaller cap, not purchasable, not on the public ladder.

An isolation guarantee is the one claim a provider cannot evaluate from a
screenshot, and both MSP leads this year went quiet at exactly this step.

Contracts: cloud-paid records the unlicensed plan rule and the
strictly-below-paid invariant; deployment-installability records
credential-free, correspondence-free installability.

Verification: TestMSPEvalCapStaysBelowCheapestPaidTier,
TestCanonicalizePlanVersion_MSPEval,
TestProviderMSPSetupScriptSupportsUnlicensedEvaluation. The last was
negative-tested by reintroducing a <pin> placeholder and confirming it
fails. ensure_image_pins exercised against the live registries.
licensing, cloudcp, control-plane and installtests all green.
2026-08-03 16:31:24 +01:00

105 lines
2.9 KiB
Go

package licensing
import "strings"
func MapStripeSubscriptionStatusToState(status string) SubscriptionState {
switch strings.ToLower(strings.TrimSpace(status)) {
case "active":
return SubStateActive
case "trialing":
return SubStateTrial
case "past_due", "unpaid":
return SubStateGrace
case "canceled":
return SubStateCanceled
case "paused":
return SubStateSuspended
case "incomplete", "incomplete_expired":
return SubStateExpired
default:
// Fail closed: unknown status should not grant paid capabilities.
return SubStateExpired
}
}
// ParseStoredSubscriptionState converts the normalized state stored in
// stripe_accounts.subscription_state back into a SubscriptionState. The stored
// form ("active", "trial", "past_due", "canceled") is distinct from the raw
// Stripe status form ("trialing", "incomplete", ...) accepted by
// MapStripeSubscriptionStatusToState.
func ParseStoredSubscriptionState(stored string) SubscriptionState {
switch strings.ToLower(strings.TrimSpace(stored)) {
case "active":
return SubStateActive
case "trial":
return SubStateTrial
case "past_due":
return SubStateGrace
case "canceled":
return SubStateCanceled
case "grace":
return SubStateGrace
case "suspended":
return SubStateSuspended
case "expired":
return SubStateExpired
default:
// Fail closed: unknown stored values must not grant paid capabilities.
return SubStateExpired
}
}
func ShouldGrantPaidCapabilities(state SubscriptionState) bool {
switch state {
case SubStateActive, SubStateTrial, SubStateGrace:
return true
default:
return false
}
}
func DeriveStripePlanVersion(metadata map[string]string, priceID string) string {
if metadata != nil {
if v := strings.TrimSpace(metadata["plan_version"]); v != "" {
return CanonicalizePlanVersion(v)
}
if v := strings.TrimSpace(metadata["plan"]); v != "" {
return CanonicalizePlanVersion(v)
}
if v := strings.TrimSpace(metadata["price_id"]); v != "" {
priceID = v
} else if v := strings.TrimSpace(metadata["plan_id"]); v != "" {
priceID = v
}
}
trimmedPrice := strings.TrimSpace(priceID)
if trimmedPrice != "" {
// Try canonical price→plan lookup before falling back to opaque prefix.
if plan, ok := PlanVersionForPriceID(trimmedPrice); ok {
return plan
}
return "stripe_price:" + trimmedPrice
}
return "stripe"
}
func CanonicalizePlanVersion(raw string) string {
trimmed := strings.TrimSpace(raw)
switch strings.ToLower(trimmed) {
case "cloud", "starter", "cloud-v1", "cloud_v1", "cloud-starter", "cloud_starter":
return "cloud_starter"
case "msp", "msp-hosted-v1", "msp_hosted_v1", "msp-starter", "msp_starter":
return "msp_starter"
case "msp-eval", "msp_eval":
return PlanVersionMSPEval
case "power", "cloud-power", "cloud_power":
return "cloud_power"
case "max", "cloud-max", "cloud_max":
return "cloud_max"
case "founding", "cloud-founding", "cloud_founding":
return "cloud_founding"
default:
return trimmed
}
}