Built for people who think better out loud.
1use chrono::{Datelike, Duration, TimeZone, Utc};
2use sqlx::PgPool;
3
4use crate::domain::{UsageDecision, UsagePeriod, UsageSummary};
5use crate::infrastructure::db::entitlements as entitlements_db;
6
7const FREE_TRANSCRIPTION_LIMIT_SECONDS: f64 = 1200.0;
8const TRANSCRIPTION_FEATURE_KEY: &str = "transcription";
9
10#[derive(Debug)]
11/// Errors emitted while resolving entitlements.
12pub enum EntitlementError {
13 DatabaseError(String),
14}
15
16impl EntitlementError {
17 pub fn message(&self) -> String {
18 match self {
19 Self::DatabaseError(message) => message.clone(),
20 }
21 }
22}
23
24/// Summarizes transcription usage and limits for a user.
25pub async fn transcription_usage_summary(
26 db_pool: &PgPool,
27 user_did: &str,
28) -> Result<UsageSummary, EntitlementError> {
29 if cfg!(test) {
30 let period = current_month_period();
31 return Ok(UsageSummary {
32 limit_seconds: Some(FREE_TRANSCRIPTION_LIMIT_SECONDS),
33 used_seconds: 0.0,
34 period,
35 });
36 }
37
38 let period = current_month_period();
39 let limit_seconds = entitlements_db::resolve_feature_limit(
40 db_pool,
41 user_did,
42 TRANSCRIPTION_FEATURE_KEY,
43 )
44 .await
45 .map_err(|err| EntitlementError::DatabaseError(err.to_string()))?
46 .unwrap_or(Some(FREE_TRANSCRIPTION_LIMIT_SECONDS));
47 let used_seconds = entitlements_db::load_usage(
48 db_pool,
49 user_did,
50 TRANSCRIPTION_FEATURE_KEY,
51 &period,
52 )
53 .await
54 .map_err(|err| EntitlementError::DatabaseError(err.to_string()))?;
55
56 Ok(UsageSummary {
57 limit_seconds,
58 used_seconds,
59 period,
60 })
61}
62
63/// Result of evaluating transcription access for a user.
64pub enum TranscriptionEntitlement {
65 Allowed { usage: UsageSummary },
66 Exceeded {
67 usage: UsageSummary,
68 limit_seconds: f64,
69 used_seconds: f64,
70 },
71}
72
73/// Evaluates whether the user can request another transcription.
74pub async fn transcription_entitlement(
75 db_pool: &PgPool,
76 user_did: &str,
77) -> Result<TranscriptionEntitlement, EntitlementError> {
78 let usage = transcription_usage_summary(db_pool, user_did).await?;
79 match usage.decision() {
80 UsageDecision::Allowed => Ok(TranscriptionEntitlement::Allowed { usage }),
81 UsageDecision::Exceeded {
82 limit_seconds,
83 used_seconds,
84 } => Ok(
85 TranscriptionEntitlement::Exceeded {
86 usage,
87 limit_seconds,
88 used_seconds,
89 },
90 ),
91 }
92}
93
94/// Records transcription usage for the current billing period.
95pub async fn record_transcription_usage(
96 db_pool: &PgPool,
97 user_did: &str,
98 seconds: f64,
99) -> Result<(), EntitlementError> {
100 if cfg!(test) {
101 return Ok(());
102 }
103
104 let period = current_month_period();
105 entitlements_db::record_feature_usage(
106 db_pool,
107 user_did,
108 TRANSCRIPTION_FEATURE_KEY,
109 &period,
110 seconds,
111 )
112 .await
113 .map_err(|err| EntitlementError::DatabaseError(err.to_string()))?;
114
115 Ok(())
116}
117
118fn current_month_period() -> UsagePeriod {
119 let now = Utc::now();
120 let start = Utc
121 .with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0)
122 .single()
123 .unwrap_or(now);
124 let end = if now.month() == 12 {
125 Utc.with_ymd_and_hms(now.year() + 1, 1, 1, 0, 0, 0)
126 .single()
127 .unwrap_or(start + Duration::days(31))
128 } else {
129 Utc.with_ymd_and_hms(now.year(), now.month() + 1, 1, 0, 0, 0)
130 .single()
131 .unwrap_or(start + Duration::days(31))
132 };
133
134 UsagePeriod { start, end }
135}