Skip to content

Commit 05c3725

Browse files
BunsDevCopilot
andauthored
feat: billing entitlements — Marketplace plans drive tier limits and the hosted paid gate (#66)
* feat: billing entitlements — Marketplace plans drive tier limits and the hosted paid gate Turn purchased plans into enforced entitlements, the monetization layer on top of the shipped limit knobs (docs/pricing.md): - config: PlanTier (starter/team/dedicated) with the pricing-matrix limits, effective_limits precedence (explicit TOML wins per field), and a [billing] require_plan gate for hosted deployments. - store (schema v6): account_plans + installation_accounts — Marketplace bills accounts, tenancy keys installations; the mapping joins them. Uninstall forgets the mapping but keeps the account's plan. - webhook: marketplace_purchase deliveries (purchased/changed/cancelled, trial state, pending changes deferred) upsert the plan idempotently and land in the audit trail; installation events keep the account mapping current; intake resolves plan-tier limits and, with require_plan, records plan-less installations as ignored:no_plan. - worker: claim-time concurrency caps merge plan tiers under explicit TOML caps, re-read each claim so plan changes apply without restart. Unknown plan names fail safe to Starter limits so a listing rename never grants unlimited service or locks a paying customer out. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Val Alexander <bunsthedev@gmail.com> * fix(webhook,store): apply marketplace plan transitions atomically with delivery dedup Review finding: the handler recorded the delivery id in one transaction and applied the plan in another. A storage failure between the two marked the delivery seen while dropping the transition — a redelivery would dedupe and never repair it, leaving a cancelled account entitled forever or a paying customer locked out under require_plan. record_delivery_with_plan now lands the dedup row and the plan upsert in one Immediate transaction (mirroring record_delivery_sync's task handling), so 'delivery seen' and 'plan applied' cannot diverge. Stale replays still cannot clobber newer state. Audit remains non-fatal: observability, not entitlement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Val Alexander <bunsthedev@gmail.com> --------- Signed-off-by: Val Alexander <bunsthedev@gmail.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 63296ff commit 05c3725

9 files changed

Lines changed: 1051 additions & 7 deletions

File tree

HOSTED.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ flowchart LR
5151

5252
Launch with flat monthly tiers and task caps. Avoid pure usage billing until task duration and model-cost distribution are known. The concrete tier matrix, launch price proposal, and the enforcement mechanism behind every promised limit live in [docs/pricing.md](docs/pricing.md).
5353

54+
The adapter enforces purchased plans directly: GitHub Marketplace
55+
`marketplace_purchase` webhooks record each account's plan, `installation`
56+
events map installations to their account, and intake plus the worker apply
57+
the tier's task caps and concurrency automatically. `[billing] require_plan`
58+
turns the hosted deployment into a paid gate — installations without an
59+
entitled plan (and no explicit `[[installations]]` entry) are recorded
60+
`ignored:no_plan`. Explicit TOML limits always win, which is how Dedicated
61+
contracts get custom numbers.
62+
5463
## Buyer Promise
5564

5665
> Your familiar on your GitHub: the one that already knows your code, your team, and how you ship.

config/example.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,18 @@ trigger_labels = ["coven:fix", "coven:review"] # Labels that trigger this famil
9696
# [installations.repos."your-org/quiet"]
9797
# labels = false # only the label lane off
9898

99+
# ── Billing entitlements (hosted, docs/pricing.md) ──────────────────────────
100+
# Marketplace purchases map plans to the same limit knobs automatically:
101+
# `marketplace_purchase` webhooks record the purchasing account's plan, and
102+
# `installation` events map installations to their account. Explicit
103+
# [installations.limits] values always win over plan defaults (Dedicated
104+
# contracts, operator overrides).
105+
# [billing]
106+
# require_plan = false # true (hosted): installations with neither an
107+
# # entitled plan (active/trial) nor an explicit
108+
# # [[installations]] entry are recorded
109+
# # ignored:no_plan — the monetization gate.
110+
99111
# ── Automatic review policy ─────────────────────────────────────────────────
100112
# Hosted PR review lanes (issue #10). Push/commit review is parsed today and
101113
# ships with headless contract v3.

crates/config/src/lib.rs

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,106 @@ pub struct Config {
3333
/// fails closed for installations not listed.
3434
#[serde(default)]
3535
pub installations: Vec<InstallationConfig>,
36+
/// Hosted billing entitlement policy (docs/pricing.md). Absent section =
37+
/// billing off: no plan is required and TOML limits are the only limits.
38+
#[serde(default)]
39+
pub billing: BillingConfig,
40+
}
41+
42+
/// Hosted billing entitlement policy: how purchased plans gate intake.
43+
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
44+
pub struct BillingConfig {
45+
/// When true, deliveries from installations that have neither an
46+
/// entitled purchased plan (active or on trial) nor an explicit
47+
/// `[[installations]]` entry are recorded `ignored:no_plan` — the hosted
48+
/// monetization gate. Default false: self-hosted deployments never
49+
/// require a plan.
50+
#[serde(default)]
51+
pub require_plan: bool,
52+
}
53+
54+
/// A hosted pricing tier (docs/pricing.md). Purchased plans map to the same
55+
/// enforcement knobs as `[installations.limits]`; explicit TOML values always
56+
/// win so operators can customize (e.g. Dedicated contracts).
57+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58+
pub enum PlanTier {
59+
Starter,
60+
Team,
61+
/// Custom limits by contract — the tier itself imposes none; the
62+
/// operator sets them per installation in TOML.
63+
Dedicated,
64+
/// A plan name we could not classify (e.g. a renamed Marketplace
65+
/// listing). Treated as Starter — the most conservative paid tier — so
66+
/// a rename never grants unlimited service or locks a paying customer
67+
/// out.
68+
Unknown,
69+
}
70+
71+
impl PlanTier {
72+
/// Classify a marketplace plan name, e.g. "Hosted Team" → `Team`.
73+
pub fn parse(name: &str) -> Self {
74+
let name = name.to_ascii_lowercase();
75+
if name.contains("starter") {
76+
Self::Starter
77+
} else if name.contains("team") {
78+
Self::Team
79+
} else if name.contains("dedicated") {
80+
Self::Dedicated
81+
} else {
82+
Self::Unknown
83+
}
84+
}
85+
86+
/// Stable identifier for storage and audit records.
87+
pub fn as_str(&self) -> &'static str {
88+
match self {
89+
Self::Starter => "starter",
90+
Self::Team => "team",
91+
Self::Dedicated => "dedicated",
92+
Self::Unknown => "unknown",
93+
}
94+
}
95+
96+
/// The tier's default limits (docs/pricing.md tier matrix).
97+
pub fn limits(&self) -> InstallationLimits {
98+
match self {
99+
Self::Starter | Self::Unknown => InstallationLimits {
100+
max_concurrent: Some(1),
101+
max_tasks_per_day: Some(25),
102+
},
103+
Self::Team => InstallationLimits {
104+
max_concurrent: Some(4),
105+
max_tasks_per_day: Some(150),
106+
},
107+
Self::Dedicated => InstallationLimits::default(),
108+
}
109+
}
110+
}
111+
112+
impl std::str::FromStr for PlanTier {
113+
type Err = std::convert::Infallible;
114+
fn from_str(s: &str) -> Result<Self, Self::Err> {
115+
Ok(match s {
116+
"starter" => Self::Starter,
117+
"team" => Self::Team,
118+
"dedicated" => Self::Dedicated,
119+
_ => Self::Unknown,
120+
})
121+
}
122+
}
123+
124+
/// Effective limits for one installation: explicit TOML values win per field
125+
/// (operator override, Dedicated contracts); a purchased plan supplies tier
126+
/// defaults for the rest; neither = unlimited (the self-hosted default).
127+
pub fn effective_limits(
128+
explicit: InstallationLimits,
129+
plan: Option<PlanTier>,
130+
) -> InstallationLimits {
131+
let tier = plan.map(|p| p.limits()).unwrap_or_default();
132+
InstallationLimits {
133+
max_concurrent: explicit.max_concurrent.or(tier.max_concurrent),
134+
max_tasks_per_day: explicit.max_tasks_per_day.or(tier.max_tasks_per_day),
135+
}
36136
}
37137

38138
/// Routing and trigger policy for one GitHub App installation (issue #7).
@@ -737,6 +837,12 @@ impl Config {
737837
.unwrap_or_default()
738838
}
739839

840+
/// Whether an installation has an explicit `[[installations]]` entry —
841+
/// an operator-vouched tenant, exempt from `billing.require_plan`.
842+
pub fn installation_listed(&self, installation_id: u64) -> bool {
843+
self.installations.iter().any(|i| i.id == installation_id)
844+
}
845+
740846
/// `installation id → max_concurrent` for every configured cap — the
741847
/// worker's claim filter (issue #15).
742848
pub fn concurrency_caps(&self) -> std::collections::HashMap<u64, u32> {
@@ -1488,6 +1594,7 @@ mod tests {
14881594
gardener: GardenerConfig::default(),
14891595
api: ApiConfig::default(),
14901596
installations: vec![],
1597+
billing: BillingConfig::default(),
14911598
}
14921599
}
14931600

@@ -2406,3 +2513,66 @@ mod tests {
24062513
assert!(errs.contains(&"familiars[].bot_username"));
24072514
}
24082515
}
2516+
2517+
#[cfg(test)]
2518+
mod plan_tier_tests {
2519+
//! Plan → limits mapping and precedence (docs/pricing.md).
2520+
use super::*;
2521+
2522+
#[test]
2523+
fn marketplace_plan_names_classify_to_tiers() {
2524+
assert_eq!(PlanTier::parse("Hosted Starter"), PlanTier::Starter);
2525+
assert_eq!(PlanTier::parse("Hosted Team"), PlanTier::Team);
2526+
assert_eq!(PlanTier::parse("Hosted Dedicated"), PlanTier::Dedicated);
2527+
assert_eq!(PlanTier::parse("TEAM (annual)"), PlanTier::Team);
2528+
assert_eq!(PlanTier::parse("Mystery Plan"), PlanTier::Unknown);
2529+
}
2530+
2531+
#[test]
2532+
fn tier_limits_match_the_pricing_matrix() {
2533+
let starter = PlanTier::Starter.limits();
2534+
assert_eq!(starter.max_concurrent, Some(1));
2535+
assert_eq!(starter.max_tasks_per_day, Some(25));
2536+
2537+
let team = PlanTier::Team.limits();
2538+
assert_eq!(team.max_concurrent, Some(4));
2539+
assert_eq!(team.max_tasks_per_day, Some(150));
2540+
2541+
// Dedicated is custom-by-contract: the tier imposes nothing.
2542+
let dedicated = PlanTier::Dedicated.limits();
2543+
assert_eq!(dedicated.max_concurrent, None);
2544+
assert_eq!(dedicated.max_tasks_per_day, None);
2545+
}
2546+
2547+
#[test]
2548+
fn unknown_plan_names_fail_safe_to_starter_limits() {
2549+
assert_eq!(PlanTier::Unknown.limits().max_tasks_per_day, Some(25));
2550+
}
2551+
2552+
#[test]
2553+
fn explicit_toml_limits_override_plan_defaults_per_field() {
2554+
let explicit = InstallationLimits {
2555+
max_concurrent: Some(8),
2556+
max_tasks_per_day: None,
2557+
};
2558+
let effective = effective_limits(explicit, Some(PlanTier::Starter));
2559+
// Operator's concurrency wins; the tier still supplies the daily cap.
2560+
assert_eq!(effective.max_concurrent, Some(8));
2561+
assert_eq!(effective.max_tasks_per_day, Some(25));
2562+
}
2563+
2564+
#[test]
2565+
fn no_plan_and_no_toml_means_unlimited() {
2566+
let effective = effective_limits(InstallationLimits::default(), None);
2567+
assert_eq!(effective.max_concurrent, None);
2568+
assert_eq!(effective.max_tasks_per_day, None);
2569+
}
2570+
2571+
#[test]
2572+
fn tier_ids_round_trip_through_storage_strings() {
2573+
for tier in [PlanTier::Starter, PlanTier::Team, PlanTier::Dedicated] {
2574+
assert_eq!(tier.as_str().parse::<PlanTier>().unwrap(), tier);
2575+
}
2576+
assert_eq!("bogus".parse::<PlanTier>().unwrap(), PlanTier::Unknown);
2577+
}
2578+
}

0 commit comments

Comments
 (0)