1pub mod export;
14
15use haste_fhir_model::r4::generated::resources::ResourceType;
16use haste_jwt::claims::SubscriptionTier;
17use serde::Serialize;
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "snake_case")]
22pub enum ResourceLimit {
23 Count(u64),
25 Unlimited,
27}
28
29impl ResourceLimit {
30 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#[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
53pub const OPERATION_POINTS: OperationPoints = OperationPoints {
77 read: 1,
78 write: 25,
79 search: 6,
80 history: 8,
81 invocation: 4,
82};
83
84#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
86#[serde(rename_all = "snake_case")]
87pub enum RequestBudget {
88 Points(usize),
90 Unmetered,
92}
93
94impl RequestBudget {
95 pub fn as_points(&self) -> usize {
98 match self {
99 RequestBudget::Points(points) => *points,
100 RequestBudget::Unmetered => usize::MAX,
101 }
102 }
103}
104
105#[derive(Clone, Debug, Serialize)]
107pub struct TierLimits {
108 pub tier: SubscriptionTier,
111
112 pub request_budget: RequestBudget,
115 pub total_resources: ResourceLimit,
118 pub projects: ResourceLimit,
120 pub search_parameters: ResourceLimit,
122 pub operation_definitions: ResourceLimit,
124 pub subscriptions: ResourceLimit,
126 pub identity_providers: ResourceLimit,
128 pub tenant_customization: bool,
130
131 pub display_name: &'static str,
135 pub price: &'static str,
137 pub cadence: Option<&'static str>,
139 pub audience: &'static str,
141 pub support: &'static str,
143 pub uptime_sla: &'static str,
145 pub baa_available: bool,
147 pub self_serve: bool,
149}
150
151pub const TIERS: &[TierLimits] = &[
156 TierLimits {
160 tier: SubscriptionTier::Free,
161 request_budget: RequestBudget::Points(250_000),
166 total_resources: ResourceLimit::Count(250_000),
167 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 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 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 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
253pub const DEFAULT_TIER: SubscriptionTier = SubscriptionTier::Free;
255
256pub 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
267pub 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
285pub 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 #[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 #[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 #[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 #[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 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 assert!(ResourceLimit::Count(0).is_exceeded_at(0));
437 assert!(!ResourceLimit::Unlimited.is_exceeded_at(u64::MAX));
438 }
439
440 #[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 #[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}