1use crate::ids::{TenantId, UserId};
8use nexcore_chrono::DateTime;
9use serde::{Deserialize, Serialize};
10
11#[non_exhaustive]
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum SubscriptionTier {
20 Academic,
22 Explorer,
24 Accelerator,
26 Enterprise,
28 Custom,
30}
31
32impl SubscriptionTier {
33 #[must_use]
35 pub fn monthly_price_cents(&self) -> u64 {
36 match self {
37 Self::Academic => 25_000,
38 Self::Explorer => 50_000,
39 Self::Accelerator => 250_000,
40 Self::Enterprise => 1_000_000,
41 Self::Custom => 0, }
43 }
44
45 #[must_use]
47 #[allow(
48 clippy::arithmetic_side_effects,
49 reason = "monthly * 10 overflows only above u64::MAX / 10 ≈ 1.8e18 cents, which no subscription tier approaches"
50 )]
51 pub fn annual_price_cents(&self) -> u64 {
52 let monthly = self.monthly_price_cents();
53 monthly * 10
55 }
56
57 #[must_use]
59 pub fn max_programs(&self) -> Option<u32> {
60 match self {
61 Self::Academic => Some(3),
62 Self::Explorer => Some(1),
63 Self::Accelerator => Some(5),
64 Self::Enterprise => None, Self::Custom => None,
66 }
67 }
68
69 #[must_use]
71 pub fn max_users(&self) -> Option<u32> {
72 match self {
73 Self::Academic => Some(10),
74 Self::Explorer => Some(3),
75 Self::Accelerator => Some(10),
76 Self::Enterprise => Some(50),
77 Self::Custom => None,
78 }
79 }
80
81 #[must_use]
83 pub fn storage_bytes(&self) -> u64 {
84 match self {
85 Self::Academic => 25 * 1_073_741_824, Self::Explorer => 5 * 1_073_741_824, Self::Accelerator => 50 * 1_073_741_824, Self::Enterprise => 500 * 1_073_741_824, Self::Custom => 1_000 * 1_073_741_824, }
91 }
92
93 #[must_use]
95 pub fn max_virtual_screens_per_month(&self) -> Option<u32> {
96 match self {
97 Self::Academic => Some(5),
98 Self::Explorer => None, Self::Accelerator => Some(10),
100 Self::Enterprise => None, Self::Custom => None,
102 }
103 }
104
105 #[must_use]
107 pub fn has_api_access(&self) -> bool {
108 !matches!(self, Self::Explorer)
109 }
110
111 #[must_use]
113 pub fn has_sso(&self) -> bool {
114 matches!(self, Self::Enterprise | Self::Custom)
115 }
116
117 #[must_use]
119 pub fn sla_uptime_bps(&self) -> u32 {
120 match self {
121 Self::Academic => 9950,
122 Self::Explorer => 9950,
123 Self::Accelerator => 9990,
124 Self::Enterprise => 9995,
125 Self::Custom => 9999,
126 }
127 }
128
129 #[must_use]
131 pub fn rank(&self) -> u8 {
132 match self {
133 Self::Academic => 1,
134 Self::Explorer => 2,
135 Self::Accelerator => 3,
136 Self::Enterprise => 4,
137 Self::Custom => 5,
138 }
139 }
140
141 #[must_use]
143 pub fn includes(&self, required_tier: &SubscriptionTier) -> bool {
144 self.rank() >= required_tier.rank()
145 }
146}
147
148#[non_exhaustive]
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum UserRole {
157 Owner,
159 Admin,
161 Scientist,
163 BusinessDev,
165 Viewer,
167 External,
169}
170
171impl UserRole {
172 #[must_use]
174 pub fn rank(&self) -> u8 {
175 match self {
176 Self::Owner => 6,
177 Self::Admin => 5,
178 Self::Scientist => 4,
179 Self::BusinessDev => 3,
180 Self::Viewer => 2,
181 Self::External => 1,
182 }
183 }
184
185 #[must_use]
187 pub fn has_at_least(&self, required: &UserRole) -> bool {
188 self.rank() >= required.rank()
189 }
190
191 #[must_use]
193 pub fn can_manage_team(&self) -> bool {
194 matches!(self, Self::Owner | Self::Admin)
195 }
196
197 #[must_use]
199 pub fn can_access_billing(&self) -> bool {
200 matches!(self, Self::Owner)
201 }
202
203 #[must_use]
205 pub fn can_write_programs(&self) -> bool {
206 matches!(self, Self::Owner | Self::Admin | Self::Scientist)
207 }
208
209 #[must_use]
211 pub fn can_manage_deals(&self) -> bool {
212 matches!(self, Self::Owner | Self::Admin | Self::BusinessDev)
213 }
214}
215
216#[non_exhaustive]
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum Action {
225 Create,
226 Read,
227 Update,
228 Delete,
229 Export,
230 Admin,
231 Execute,
232}
233
234#[non_exhaustive]
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
237#[serde(rename_all = "snake_case")]
238pub enum Resource {
239 Program,
240 Compound,
241 Assay,
242 Deal,
243 Asset,
244 Order,
245 Team,
246 Billing,
247 Settings,
248 ApiKey,
249 AuditLog,
250 Terminal,
251 TerminalAi,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct Permissions {
257 grants: Vec<(Action, Resource)>,
259}
260
261impl Permissions {
262 #[must_use]
264 pub fn from_role(role: &UserRole) -> Self {
265 use Action::*;
266 use Resource::*;
267
268 let grants = match role {
269 UserRole::Owner => vec![
270 (Create, Program),
271 (Read, Program),
272 (Update, Program),
273 (Delete, Program),
274 (Create, Compound),
275 (Read, Compound),
276 (Update, Compound),
277 (Delete, Compound),
278 (Create, Assay),
279 (Read, Assay),
280 (Update, Assay),
281 (Create, Deal),
282 (Read, Deal),
283 (Update, Deal),
284 (Delete, Deal),
285 (Create, Asset),
286 (Read, Asset),
287 (Update, Asset),
288 (Create, Order),
289 (Read, Order),
290 (Create, Team),
291 (Read, Team),
292 (Update, Team),
293 (Delete, Team),
294 (Read, Billing),
295 (Update, Billing),
296 (Read, Settings),
297 (Update, Settings),
298 (Create, ApiKey),
299 (Read, ApiKey),
300 (Delete, ApiKey),
301 (Read, AuditLog),
302 (Admin, Settings),
303 (Execute, Terminal),
304 (Execute, TerminalAi),
305 (Admin, Terminal),
306 ],
307 UserRole::Admin => vec![
308 (Create, Program),
309 (Read, Program),
310 (Update, Program),
311 (Delete, Program),
312 (Create, Compound),
313 (Read, Compound),
314 (Update, Compound),
315 (Delete, Compound),
316 (Create, Assay),
317 (Read, Assay),
318 (Update, Assay),
319 (Create, Deal),
320 (Read, Deal),
321 (Update, Deal),
322 (Create, Asset),
323 (Read, Asset),
324 (Update, Asset),
325 (Create, Order),
326 (Read, Order),
327 (Create, Team),
328 (Read, Team),
329 (Update, Team),
330 (Read, Settings),
331 (Update, Settings),
332 (Create, ApiKey),
333 (Read, ApiKey),
334 (Read, AuditLog),
335 (Execute, Terminal),
336 (Execute, TerminalAi),
337 (Admin, Terminal),
338 ],
339 UserRole::Scientist => vec![
340 (Create, Program),
341 (Read, Program),
342 (Update, Program),
343 (Create, Compound),
344 (Read, Compound),
345 (Update, Compound),
346 (Create, Assay),
347 (Read, Assay),
348 (Update, Assay),
349 (Read, Deal),
350 (Create, Order),
351 (Read, Order),
352 (Read, Settings),
353 (Execute, Terminal),
354 (Execute, TerminalAi),
355 ],
356 UserRole::BusinessDev => vec![
357 (Read, Program),
358 (Read, Compound),
359 (Read, Assay),
360 (Create, Deal),
361 (Read, Deal),
362 (Update, Deal),
363 (Create, Asset),
364 (Read, Asset),
365 (Update, Asset),
366 (Read, Order),
367 (Read, Settings),
368 (Execute, Terminal),
369 (Execute, TerminalAi),
370 ],
371 UserRole::Viewer => vec![
372 (Read, Program),
373 (Read, Compound),
374 (Read, Assay),
375 (Read, Deal),
376 (Read, Order),
377 ],
378 UserRole::External => vec![(Read, Program), (Read, Compound)],
379 };
380
381 Self { grants }
382 }
383
384 #[must_use]
386 pub fn allows(&self, action: Action, resource: Resource) -> bool {
387 self.grants
388 .iter()
389 .any(|&(a, r)| a == action && r == resource)
390 }
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct TenantContext {
402 tenant_id: TenantId,
403 user_id: UserId,
404 role: UserRole,
405 tier: SubscriptionTier,
406 permissions: Permissions,
407}
408
409impl TenantContext {
410 #[must_use]
413 pub fn new(
414 tenant_id: TenantId,
415 user_id: UserId,
416 role: UserRole,
417 tier: SubscriptionTier,
418 ) -> Self {
419 let permissions = Permissions::from_role(&role);
420 Self {
421 tenant_id,
422 user_id,
423 role,
424 tier,
425 permissions,
426 }
427 }
428
429 #[must_use]
430 pub fn tenant_id(&self) -> &TenantId {
431 &self.tenant_id
432 }
433
434 #[must_use]
435 pub fn user_id(&self) -> &UserId {
436 &self.user_id
437 }
438
439 #[must_use]
440 pub fn role(&self) -> &UserRole {
441 &self.role
442 }
443
444 #[must_use]
445 pub fn tier(&self) -> &SubscriptionTier {
446 &self.tier
447 }
448
449 #[must_use]
451 pub fn can(&self, action: Action, resource: Resource) -> bool {
452 self.permissions.allows(action, resource)
453 }
454
455 #[must_use]
457 pub fn tier_includes(&self, required_tier: &SubscriptionTier) -> bool {
458 self.tier.includes(required_tier)
459 }
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize)]
470pub struct TenantScoped<T> {
471 tenant_id: TenantId,
472 inner: T,
473}
474
475impl<T> TenantScoped<T> {
476 #[must_use]
479 pub fn new(ctx: &TenantContext, inner: T) -> Self {
480 Self {
481 tenant_id: *ctx.tenant_id(),
482 inner,
483 }
484 }
485
486 #[must_use]
487 pub fn tenant_id(&self) -> &TenantId {
488 &self.tenant_id
489 }
490
491 #[must_use]
493 pub fn inner(&self) -> &T {
494 &self.inner
495 }
496
497 #[must_use]
499 pub fn into_inner(self) -> T {
500 self.inner
501 }
502
503 #[must_use]
506 pub fn verify(self, ctx: &TenantContext) -> Option<T> {
507 if self.tenant_id == *ctx.tenant_id() {
508 Some(self.inner)
509 } else {
510 None
511 }
512 }
513
514 #[must_use]
516 pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> TenantScoped<U> {
517 TenantScoped {
518 tenant_id: self.tenant_id,
519 inner: f(self.inner),
520 }
521 }
522}
523
524#[non_exhaustive]
530#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
531#[serde(rename_all = "snake_case")]
532pub enum TenantStatus {
533 Trial,
535 Active,
537 PastDue,
539 Suspended,
541 Offboarding,
543 Deprovisioned,
545}
546
547impl TenantStatus {
548 #[must_use]
550 pub fn is_accessible(&self) -> bool {
551 matches!(self, Self::Trial | Self::Active | Self::PastDue)
552 }
553
554 #[must_use]
556 pub fn is_billable(&self) -> bool {
557 matches!(self, Self::Active | Self::PastDue)
558 }
559}
560
561#[non_exhaustive]
567#[derive(Debug, Clone, Serialize, Deserialize)]
568pub struct Tenant {
569 pub id: TenantId,
570 pub name: String,
571 pub slug: String,
572 pub tier: SubscriptionTier,
573 pub status: TenantStatus,
574 pub trial_ends_at: Option<DateTime>,
575 pub settings: serde_json::Value,
576 pub created_at: DateTime,
577 pub updated_at: DateTime,
578}
579
580impl Tenant {
581 #[must_use]
583 pub fn new(
584 id: TenantId,
585 name: String,
586 slug: String,
587 tier: SubscriptionTier,
588 status: TenantStatus,
589 trial_ends_at: Option<DateTime>,
590 settings: serde_json::Value,
591 created_at: DateTime,
592 updated_at: DateTime,
593 ) -> Self {
594 Self {
595 id,
596 name,
597 slug,
598 tier,
599 status,
600 trial_ends_at,
601 settings,
602 created_at,
603 updated_at,
604 }
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611
612 #[test]
613 fn tier_pricing() {
614 assert_eq!(SubscriptionTier::Explorer.monthly_price_cents(), 50_000);
615 assert_eq!(SubscriptionTier::Accelerator.monthly_price_cents(), 250_000);
616 assert_eq!(
617 SubscriptionTier::Enterprise.monthly_price_cents(),
618 1_000_000
619 );
620 assert_eq!(SubscriptionTier::Academic.monthly_price_cents(), 25_000);
621 }
622
623 #[test]
624 fn annual_discount_is_16_7_percent() {
625 let monthly = SubscriptionTier::Accelerator.monthly_price_cents();
626 let annual = SubscriptionTier::Accelerator.annual_price_cents();
627 assert_eq!(annual, monthly * 10);
629 let full_year = monthly * 12;
630 let discount_pct = 100.0 * (1.0 - (annual as f64 / full_year as f64));
631 assert!((discount_pct - 16.67).abs() < 0.1);
632 }
633
634 #[test]
635 fn tier_limits() {
636 assert_eq!(SubscriptionTier::Explorer.max_programs(), Some(1));
637 assert_eq!(SubscriptionTier::Enterprise.max_programs(), None);
638 assert_eq!(SubscriptionTier::Explorer.max_users(), Some(3));
639 }
640
641 #[test]
642 fn tier_includes_checks_rank() {
643 assert!(SubscriptionTier::Enterprise.includes(&SubscriptionTier::Explorer));
644 assert!(!SubscriptionTier::Explorer.includes(&SubscriptionTier::Enterprise));
645 assert!(SubscriptionTier::Accelerator.includes(&SubscriptionTier::Accelerator));
646 }
647
648 #[test]
649 fn role_permissions() {
650 let ctx = TenantContext::new(
651 TenantId::new(),
652 UserId::new(),
653 UserRole::Scientist,
654 SubscriptionTier::Accelerator,
655 );
656 assert!(ctx.can(Action::Create, Resource::Compound));
657 assert!(ctx.can(Action::Read, Resource::Program));
658 assert!(!ctx.can(Action::Delete, Resource::Program)); assert!(!ctx.can(Action::Read, Resource::Billing)); }
661
662 #[test]
663 fn owner_has_all_permissions() {
664 let ctx = TenantContext::new(
665 TenantId::new(),
666 UserId::new(),
667 UserRole::Owner,
668 SubscriptionTier::Enterprise,
669 );
670 assert!(ctx.can(Action::Admin, Resource::Settings));
671 assert!(ctx.can(Action::Read, Resource::Billing));
672 assert!(ctx.can(Action::Delete, Resource::Team));
673 }
674
675 #[test]
676 fn tenant_scoped_verify() {
677 let ctx1 = TenantContext::new(
678 TenantId::new(),
679 UserId::new(),
680 UserRole::Owner,
681 SubscriptionTier::Explorer,
682 );
683 let ctx2 = TenantContext::new(
684 TenantId::new(),
685 UserId::new(),
686 UserRole::Owner,
687 SubscriptionTier::Explorer,
688 );
689
690 let scoped = TenantScoped::new(&ctx1, "secret data");
691 assert!(TenantScoped::new(&ctx1, "x").verify(&ctx1).is_some());
693 assert!(scoped.verify(&ctx2).is_none());
695 }
696
697 #[test]
698 fn tenant_scoped_map() {
699 let ctx = TenantContext::new(
700 TenantId::new(),
701 UserId::new(),
702 UserRole::Scientist,
703 SubscriptionTier::Accelerator,
704 );
705 let scoped = TenantScoped::new(&ctx, 42);
706 let doubled = scoped.map(|x| x * 2);
707 assert_eq!(*doubled.inner(), 84);
708 }
709
710 #[test]
711 fn tenant_status_accessibility() {
712 assert!(TenantStatus::Trial.is_accessible());
713 assert!(TenantStatus::Active.is_accessible());
714 assert!(TenantStatus::PastDue.is_accessible());
715 assert!(!TenantStatus::Suspended.is_accessible());
716 assert!(!TenantStatus::Offboarding.is_accessible());
717 }
718}