Skip to main content

haste_subscription/
lib.rs

1//! The single source of truth for what each [`SubscriptionTier`] allows.
2//!
3//! Everything a tier permits lives in the [`TIERS`] table below: the request
4//! budget the rate limiter enforces, the resource caps the tenant tier limits
5//! middleware enforces, whether a tenant may set its own name and logo, and the
6//! commercial facts (price, SLA, support response) the pricing page publishes.
7//!
8//! Nothing else in the workspace should hardcode a per-tier number. The server's
9//! `subscription_limits` modules read this table, and
10//! `cargo run subscription export` writes it to the website as JSON so the
11//! published prices cannot drift from the enforced limits.
12
13pub mod export;
14
15use haste_fhir_model::r4::generated::resources::ResourceType;
16use haste_jwt::claims::SubscriptionTier;
17use serde::Serialize;
18
19/// How many resources of one type a tenant may store.
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "snake_case")]
22pub enum ResourceLimit {
23    /// At most this many. `0` denies the resource type outright.
24    Count(u64),
25    /// No cap. Usage above what the plan includes is metered, not refused.
26    Unlimited,
27}
28
29impl ResourceLimit {
30    /// Whether storing one more resource would exceed this limit, given how
31    /// many the tenant already has.
32    pub fn is_exceeded_at(&self, current_total: u64) -> bool {
33        match self {
34            ResourceLimit::Count(limit) => current_total >= *limit,
35            ResourceLimit::Unlimited => false,
36        }
37    }
38}
39
40/// What one request costs against a tier's daily budget.
41///
42/// Uniform across tiers — a tier buys a larger budget, not cheaper operations —
43/// so the pricing page can state the costs once.
44#[derive(Clone, Copy, Debug, Serialize)]
45pub struct OperationPoints {
46    pub read: u32,
47    pub write: u32,
48    pub search: u32,
49    pub history: u32,
50    pub invocation: u32,
51}
52
53/// The cost of each kind of request, in budget points.
54///
55/// Weighted by what the operation actually costs this server, which is not what
56/// it costs a server built differently:
57///
58/// - A **read** is a primary-key lookup. It is the unit of cost, priced at 1.
59/// - A **write** is the expensive one. Storage is append-only, so a write adds a
60///   history row, then deletes and re-inserts that resource's index rows across
61///   the typed index tables — an Observation has 76 search parameters, a Patient
62///   46 — and every bit of it is WAL plus an fsync on the primary, which cannot
63///   be scaled out. Hence 25.
64/// - A **search** is a bounded indexed `SELECT` with joins. Real work, but it
65///   reads warm shared buffers and can move to a read replica, so 6 rather than
66///   the write's 25.
67/// - **History** walks the version chain: heavier than a read, far cheaper than
68///   a write.
69/// - An **invocation** is an operation whose cost varies by definition; 4 covers
70///   the dispatch, and whatever the operation itself does is billed by the reads
71///   and writes it performs.
72///
73/// Deliberately not modelled on servers that make search the most expensive
74/// operation. With Postgres-backed search the binding constraint is write
75/// throughput on the primary, not search CPU.
76pub const OPERATION_POINTS: OperationPoints = OperationPoints {
77    read: 1,
78    write: 25,
79    search: 6,
80    history: 8,
81    invocation: 4,
82};
83
84/// A tier's daily request budget, in [`OPERATION_POINTS`].
85#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
86#[serde(rename_all = "snake_case")]
87pub enum RequestBudget {
88    /// This many points per rate limit window.
89    Points(usize),
90    /// Not rate limited.
91    Unmetered,
92}
93
94impl RequestBudget {
95    /// The budget as a point count, for the rate limiter. [`Self::Unmetered`]
96    /// saturates rather than being special-cased at every call site.
97    pub fn as_points(&self) -> usize {
98        match self {
99            RequestBudget::Points(points) => *points,
100            RequestBudget::Unmetered => usize::MAX,
101        }
102    }
103}
104
105/// Everything one subscription tier allows, and how it is sold.
106#[derive(Clone, Debug, Serialize)]
107pub struct TierLimits {
108    /// The tier this describes. Serializes to its claim value (`"free"`, …),
109    /// which is how the website keys its cards.
110    pub tier: SubscriptionTier,
111
112    // ---- What the server enforces ----
113    /// Requests allowed per rate limit window. See [`OPERATION_POINTS`].
114    pub request_budget: RequestBudget,
115    /// Total current-version resources the tenant may store across all
116    /// projects. History does not count.
117    pub total_resources: ResourceLimit,
118    /// Projects the tenant may create, including the `system` project.
119    pub projects: ResourceLimit,
120    /// Custom search parameters the tenant may define.
121    pub search_parameters: ResourceLimit,
122    /// Custom operations the tenant may define.
123    pub operation_definitions: ResourceLimit,
124    /// FHIR Subscriptions the tenant may register.
125    pub subscriptions: ResourceLimit,
126    /// External identity providers the tenant may configure.
127    pub identity_providers: ResourceLimit,
128    /// Whether the tenant may set its own display name and logo.
129    pub tenant_customization: bool,
130
131    // ---- How the tier is sold ----
132    /// Name shown on the pricing page. Deliberately not the claim value: the
133    /// claim is an API contract, this is marketing copy.
134    pub display_name: &'static str,
135    /// Formatted price, e.g. `"$1,500"`.
136    pub price: &'static str,
137    /// Billing cadence, or `None` for the tiers that are not billed monthly.
138    pub cadence: Option<&'static str>,
139    /// Who the tier is for.
140    pub audience: &'static str,
141    /// Support response commitment.
142    pub support: &'static str,
143    /// Uptime commitment.
144    pub uptime_sla: &'static str,
145    /// Whether a signed BAA is available, which gates holding PHI.
146    pub baa_available: bool,
147    /// Whether signup can put a tenant straight onto this tier.
148    pub self_serve: bool,
149}
150
151/// Every tier, in the order the pricing page presents them.
152///
153/// Ordered cheapest-first by hosted commitment, with the self-hosted tier last
154/// because it is not a hosted plan at all.
155pub const TIERS: &[TierLimits] = &[
156    // The tier every hosted signup starts on: a sandbox big enough to evaluate
157    // the server and build a prototype, with the compute-intensive meta
158    // resources withheld.
159    TierLimits {
160        tier: SubscriptionTier::Free,
161        // Enough to evaluate the server against real data: a Synthea patient is
162        // roughly 750 resources, so this affords loading a handful of them and
163        // still querying them the same day. A budget that cannot absorb one
164        // realistic import is a budget that ends the evaluation.
165        request_budget: RequestBudget::Points(250_000),
166        total_resources: ResourceLimit::Count(250_000),
167        // Two: the `system` project, plus one to build in.
168        projects: ResourceLimit::Count(2),
169        search_parameters: ResourceLimit::Count(0),
170        operation_definitions: ResourceLimit::Count(0),
171        subscriptions: ResourceLimit::Count(0),
172        identity_providers: ResourceLimit::Count(0),
173        tenant_customization: false,
174
175        display_name: "Developer",
176        price: "$0",
177        cadence: Some("/month"),
178        audience: "Evaluating, prototyping, building a demo",
179        support: "Community",
180        uptime_sla: "None",
181        baa_available: false,
182        self_serve: true,
183    },
184    // The first tier that may hold real patient data, so everything withheld on
185    // Developer opens up and the compliance artifacts come with it.
186    TierLimits {
187        tier: SubscriptionTier::Professional,
188        request_budget: RequestBudget::Points(10_000_000),
189        total_resources: ResourceLimit::Unlimited,
190        projects: ResourceLimit::Unlimited,
191        search_parameters: ResourceLimit::Unlimited,
192        operation_definitions: ResourceLimit::Unlimited,
193        subscriptions: ResourceLimit::Unlimited,
194        identity_providers: ResourceLimit::Unlimited,
195        tenant_customization: true,
196
197        display_name: "Production",
198        price: "$1,500",
199        cadence: Some("/month"),
200        audience: "Startups carrying real patient data",
201        support: "1 business day",
202        uptime_sla: "99.9%",
203        baa_available: true,
204        self_serve: false,
205    },
206    // Same permissions as Production, bought with a larger budget and a tighter
207    // support commitment.
208    TierLimits {
209        tier: SubscriptionTier::Team,
210        request_budget: RequestBudget::Points(50_000_000),
211        total_resources: ResourceLimit::Unlimited,
212        projects: ResourceLimit::Unlimited,
213        search_parameters: ResourceLimit::Unlimited,
214        operation_definitions: ResourceLimit::Unlimited,
215        subscriptions: ResourceLimit::Unlimited,
216        identity_providers: ResourceLimit::Unlimited,
217        tenant_customization: true,
218
219        display_name: "Scale",
220        price: "From $6,000",
221        cadence: Some("/month"),
222        audience: "Platforms at population scale",
223        support: "4 business hours",
224        uptime_sla: "99.95%",
225        baa_available: true,
226        self_serve: false,
227    },
228    // What a self-hosted deployment runs as: nothing metered, because there is
229    // no one to meter it for. Also the tier for hosted customers on a
230    // single-tenant deployment.
231    TierLimits {
232        tier: SubscriptionTier::Unlimited,
233        request_budget: RequestBudget::Unmetered,
234        total_resources: ResourceLimit::Unlimited,
235        projects: ResourceLimit::Unlimited,
236        search_parameters: ResourceLimit::Unlimited,
237        operation_definitions: ResourceLimit::Unlimited,
238        subscriptions: ResourceLimit::Unlimited,
239        identity_providers: ResourceLimit::Unlimited,
240        tenant_customization: true,
241
242        display_name: "Self-Hosted",
243        price: "Free",
244        cadence: None,
245        audience: "Teams who want to own their infrastructure",
246        support: "Community",
247        uptime_sla: "You operate it",
248        baa_available: false,
249        self_serve: false,
250    },
251];
252
253/// The tier every hosted signup starts on.
254pub const DEFAULT_TIER: SubscriptionTier = SubscriptionTier::Free;
255
256/// The limits for a tier.
257///
258/// Infallible: [`TIERS`] covers every [`SubscriptionTier`] variant, and
259/// `tiers_are_exhaustive` holds that true.
260pub fn limits_for(tier: &SubscriptionTier) -> &'static TierLimits {
261    TIERS
262        .iter()
263        .find(|limits| &limits.tier == tier)
264        .expect("TIERS covers every SubscriptionTier variant")
265}
266
267/// The cap on one resource type for a tier, or [`ResourceLimit::Unlimited`] for
268/// the types a tier does not cap individually.
269///
270/// This is the per-type cap only. A tenant is also held to
271/// [`TierLimits::total_resources`] across every type.
272pub fn resource_limit_for(tier: &SubscriptionTier, resource_type: &ResourceType) -> ResourceLimit {
273    let limits = limits_for(tier);
274
275    match resource_type {
276        ResourceType::Project => limits.projects,
277        ResourceType::SearchParameter => limits.search_parameters,
278        ResourceType::OperationDefinition => limits.operation_definitions,
279        ResourceType::Subscription => limits.subscriptions,
280        ResourceType::IdentityProvider => limits.identity_providers,
281        _ => ResourceLimit::Unlimited,
282    }
283}
284
285/// Whether a tier may set a tenant display name and logo.
286pub fn allows_tenant_customization(tier: &SubscriptionTier) -> bool {
287    limits_for(tier).tenant_customization
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    /// `limits_for` panics on a tier missing from `TIERS`, so every variant must
295    /// be present. Add a variant to `SubscriptionTier` and this fails until the
296    /// tier is described here too.
297    #[test]
298    fn tiers_are_exhaustive() {
299        let all = [
300            SubscriptionTier::Free,
301            SubscriptionTier::Professional,
302            SubscriptionTier::Team,
303            SubscriptionTier::Unlimited,
304        ];
305
306        for tier in &all {
307            limits_for(tier);
308        }
309
310        assert_eq!(
311            TIERS.len(),
312            all.len(),
313            "TIERS has a duplicate or extra tier"
314        );
315    }
316
317    #[test]
318    fn free_tier_withholds_compute_intensive_resources() {
319        let free = limits_for(&SubscriptionTier::Free);
320
321        assert_eq!(free.search_parameters, ResourceLimit::Count(0));
322        assert_eq!(free.operation_definitions, ResourceLimit::Count(0));
323        assert_eq!(free.subscriptions, ResourceLimit::Count(0));
324        assert_eq!(free.identity_providers, ResourceLimit::Count(0));
325        assert!(!free.tenant_customization);
326    }
327
328    /// A paid tier must not be more restrictive than the free one, in either
329    /// budget or resource caps.
330    #[test]
331    fn paid_tiers_are_not_more_restrictive_than_free() {
332        let free = limits_for(&SubscriptionTier::Free);
333
334        for paid in TIERS
335            .iter()
336            .filter(|limits| limits.tier != SubscriptionTier::Free)
337        {
338            assert!(
339                paid.request_budget.as_points() >= free.request_budget.as_points(),
340                "{} has a smaller request budget than Developer",
341                paid.display_name
342            );
343            assert!(
344                !paid
345                    .total_resources
346                    .is_exceeded_at(match free.total_resources {
347                        ResourceLimit::Count(count) => count,
348                        ResourceLimit::Unlimited => u64::MAX,
349                    }),
350                "{} caps total resources below Developer",
351                paid.display_name
352            );
353            assert!(
354                paid.tenant_customization,
355                "{} cannot customize its tenant",
356                paid.display_name
357            );
358        }
359    }
360
361    /// The weights encode this server's cost shape: a write is the most
362    /// expensive operation because it fans out across the index tables and
363    /// fsyncs on the primary, and a search costs more than a read but well under
364    /// a write because it can be served from a replica. If these invert, the
365    /// rate limiter is no longer charging for what the server actually spends.
366    #[test]
367    fn point_costs_track_what_operations_cost_us() {
368        let p = OPERATION_POINTS;
369
370        assert!(
371            p.write > p.search,
372            "a write must cost more than a search: it is the operation that cannot scale out"
373        );
374        assert!(
375            p.search > p.read,
376            "a search must cost more than a primary-key read"
377        );
378        assert!(
379            p.history > p.read && p.history < p.write,
380            "history sits between a read and a write"
381        );
382        assert_eq!(p.read, 1, "a read is the unit of cost");
383    }
384
385    /// The free tier has to absorb one realistic data import or it cannot be
386    /// evaluated. A Synthea patient is roughly 750 resources.
387    #[test]
388    fn free_tier_can_load_and_query_real_data() {
389        let free = limits_for(&SubscriptionTier::Free);
390        let budget = free.request_budget.as_points();
391
392        let synthea_patient_resources = 744;
393        let cost_of_five_patients = 5 * synthea_patient_resources * OPERATION_POINTS.write as usize;
394
395        assert!(
396            cost_of_five_patients < budget,
397            "the free budget ({budget}) cannot load five Synthea patients ({cost_of_five_patients})"
398        );
399
400        // And the cap must hold far more than the day's writes can create, so
401        // the two limits do not contradict each other.
402        let ResourceLimit::Count(cap) = free.total_resources else {
403            panic!("the free tier is expected to cap total resources");
404        };
405        let writes_per_day = budget / OPERATION_POINTS.write as usize;
406        assert!(
407            (cap as usize) > writes_per_day,
408            "the resource cap ({cap}) is below what one day of writes can create ({writes_per_day})"
409        );
410    }
411
412    #[test]
413    fn unlimited_tier_is_unmetered() {
414        let unlimited = limits_for(&SubscriptionTier::Unlimited);
415
416        assert_eq!(unlimited.request_budget, RequestBudget::Unmetered);
417        assert_eq!(unlimited.request_budget.as_points(), usize::MAX);
418        assert_eq!(unlimited.total_resources, ResourceLimit::Unlimited);
419    }
420
421    #[test]
422    fn resource_limit_for_uncapped_type_is_unlimited() {
423        assert_eq!(
424            resource_limit_for(&SubscriptionTier::Free, &ResourceType::Patient),
425            ResourceLimit::Unlimited
426        );
427    }
428
429    #[test]
430    fn counts_are_exceeded_at_the_limit_not_past_it() {
431        let two = ResourceLimit::Count(2);
432
433        assert!(!two.is_exceeded_at(1));
434        assert!(two.is_exceeded_at(2));
435        // A zero count denies the first write.
436        assert!(ResourceLimit::Count(0).is_exceeded_at(0));
437        assert!(!ResourceLimit::Unlimited.is_exceeded_at(u64::MAX));
438    }
439
440    /// Only the tier signup assigns may be self-serve; the rest require a
441    /// deliberate upgrade.
442    #[test]
443    fn only_the_default_tier_is_self_serve() {
444        for limits in TIERS {
445            assert_eq!(
446                limits.self_serve,
447                limits.tier == DEFAULT_TIER,
448                "{} has the wrong self_serve flag",
449                limits.display_name
450            );
451        }
452    }
453
454    /// A tier that may hold PHI must offer a BAA.
455    #[test]
456    fn tiers_without_a_baa_are_the_unbilled_ones() {
457        for limits in TIERS {
458            if limits.baa_available {
459                assert!(
460                    limits.cadence.is_some(),
461                    "{} offers a BAA but is not billed",
462                    limits.display_name
463                );
464            }
465        }
466    }
467}