Skip to main content

stateset_db/sqlite/
subscriptions.rs

1//! SQLite repository for subscriptions
2
3use chrono::{Duration, Utc};
4use r2d2::Pool;
5use r2d2_sqlite::SqliteConnectionManager;
6use rusqlite::OptionalExtension;
7use rust_decimal::Decimal;
8use stateset_core::{
9    BillingCycle, BillingCycleFilter, BillingCycleStatus, BillingInterval, CancelSubscription,
10    CreateBillingCycle, CreateSubscription, CreateSubscriptionItem, CreateSubscriptionPlan,
11    CreateSubscriptionPlanItem, CustomerId, OrderId, PauseSubscription, PlanStatus, ProductId,
12    Result, SkipBillingCycle, Subscription, SubscriptionEvent, SubscriptionEventType,
13    SubscriptionFilter, SubscriptionId, SubscriptionItem, SubscriptionPlan, SubscriptionPlanFilter,
14    SubscriptionPlanItem, SubscriptionRepository, SubscriptionStatus, UpdateSubscription,
15    UpdateSubscriptionPlan, generate_plan_code, generate_subscription_number,
16};
17use uuid::Uuid;
18
19use super::{
20    parse_datetime_opt_row, parse_datetime_row, parse_decimal_opt_row, parse_decimal_row,
21    parse_enum_row, parse_json_opt_row, parse_uuid_opt_row, parse_uuid_row,
22};
23
24#[derive(Debug)]
25pub struct SqliteSubscriptionRepository {
26    pool: Pool<SqliteConnectionManager>,
27}
28
29impl SqliteSubscriptionRepository {
30    const MAX_SUBSCRIPTION_NUMBER_RETRIES: usize = 8;
31
32    #[must_use]
33    pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
34        Self { pool }
35    }
36
37    fn is_subscription_number_unique_violation(err: &rusqlite::Error) -> bool {
38        match err {
39            rusqlite::Error::SqliteFailure(_, message) => message.as_deref().is_some_and(|msg| {
40                msg.contains("UNIQUE constraint failed: subscriptions.subscription_number")
41            }),
42            _ => err
43                .to_string()
44                .contains("UNIQUE constraint failed: subscriptions.subscription_number"),
45        }
46    }
47
48    // ========================================================================
49    // Subscription Plans
50    // ========================================================================
51
52    pub fn create_plan(&self, input: CreateSubscriptionPlan) -> Result<SubscriptionPlan> {
53        stateset_core::Validate::validate(&input)?;
54        let id = Uuid::new_v4();
55        let code = input.code.clone().unwrap_or_else(|| generate_plan_code(&input.name));
56        let now = Utc::now();
57        let items = input.items.clone();
58
59        // Insert plan - connection is scoped to this block
60        {
61            let conn = self.pool.get().map_err(|e| {
62                stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
63            })?;
64
65            conn.execute(
66                "INSERT INTO subscription_plans (
67                    id, code, name, description, status,
68                    billing_interval, custom_interval_days, price, setup_fee, currency,
69                    trial_days, trial_requires_payment_method,
70                    min_cycles, max_cycles,
71                    discount_percent, discount_amount,
72                    metadata, created_at, updated_at
73                ) VALUES (
74                    ?1, ?2, ?3, ?4, ?5,
75                    ?6, ?7, ?8, ?9, ?10,
76                    ?11, ?12,
77                    ?13, ?14,
78                    ?15, ?16,
79                    ?17, ?18, ?19
80                )",
81                rusqlite::params![
82                    id.to_string(),
83                    code,
84                    input.name,
85                    input.description,
86                    PlanStatus::Draft.to_string(),
87                    format!("{}", input.billing_interval),
88                    input.custom_interval_days,
89                    input.price.to_string(),
90                    input.setup_fee.map(|d| d.to_string()),
91                    input.currency.unwrap_or_default(),
92                    input.trial_days.unwrap_or(0),
93                    i32::from(input.trial_requires_payment_method.unwrap_or(true)),
94                    input.min_cycles,
95                    input.max_cycles,
96                    input.discount_percent.map(|d| d.to_string()),
97                    input.discount_amount.map(|d| d.to_string()),
98                    input.metadata.as_ref().map(|m| serde_json::to_string(m).unwrap_or_default()),
99                    now.to_rfc3339(),
100                    now.to_rfc3339(),
101                ],
102            )
103            .map_err(|e| {
104                stateset_core::CommerceError::DatabaseError(format!("Insert error: {e}"))
105            })?;
106        } // Connection is dropped here
107
108        // Create plan items (each gets its own connection)
109        if let Some(items) = items {
110            for item in items {
111                self.create_plan_item(id, item)?;
112            }
113        }
114
115        self.get_plan(id)?.ok_or_else(|| {
116            stateset_core::CommerceError::DatabaseError("Failed to retrieve created plan".into())
117        })
118    }
119
120    pub fn get_plan(&self, id: Uuid) -> Result<Option<SubscriptionPlan>> {
121        // Get plan - connection scoped to this block
122        let plan = {
123            let conn = self.pool.get().map_err(|e| {
124                stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
125            })?;
126
127            let mut stmt = conn
128                .prepare("SELECT * FROM subscription_plans WHERE id = ?1")
129                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
130
131            stmt.query_row([id.to_string()], |row| self.row_to_plan(row))
132                .optional()
133                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?
134        }; // Connection dropped here
135
136        if let Some(mut p) = plan {
137            p.items = self.get_plan_items(id)?;
138            Ok(Some(p))
139        } else {
140            Ok(None)
141        }
142    }
143
144    pub fn get_plan_by_code(&self, code: &str) -> Result<Option<SubscriptionPlan>> {
145        // Get plan - connection scoped to this block
146        let plan = {
147            let conn = self.pool.get().map_err(|e| {
148                stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
149            })?;
150
151            let mut stmt = conn
152                .prepare("SELECT * FROM subscription_plans WHERE code = ?1")
153                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
154
155            stmt.query_row([code], |row| self.row_to_plan(row))
156                .optional()
157                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?
158        }; // Connection dropped here
159
160        if let Some(mut p) = plan {
161            p.items = self.get_plan_items(p.id)?;
162            Ok(Some(p))
163        } else {
164            Ok(None)
165        }
166    }
167
168    pub fn list_plans(&self, filter: SubscriptionPlanFilter) -> Result<Vec<SubscriptionPlan>> {
169        let conn = self.pool.get().map_err(|e| {
170            stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
171        })?;
172
173        let mut plans: Vec<SubscriptionPlan> = {
174            let mut sql = "SELECT * FROM subscription_plans WHERE 1=1".to_string();
175            let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
176
177            if let Some(status) = &filter.status {
178                sql.push_str(" AND status = ?");
179                params.push(Box::new(status.to_string()));
180            }
181
182            if let Some(interval) = &filter.billing_interval {
183                sql.push_str(" AND billing_interval = ?");
184                params.push(Box::new(format!("{interval}")));
185            }
186
187            if let Some(search) = &filter.search {
188                sql.push_str(" AND (name LIKE ? OR code LIKE ? OR description LIKE ?)");
189                let pattern = format!("%{search}%");
190                params.push(Box::new(pattern.clone()));
191                params.push(Box::new(pattern.clone()));
192                params.push(Box::new(pattern));
193            }
194
195            sql.push_str(" ORDER BY created_at DESC");
196
197            crate::sqlite::append_limit_offset(&mut sql, filter.limit, filter.offset);
198
199            let mut stmt = conn
200                .prepare(&sql)
201                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
202
203            let param_refs: Vec<&dyn rusqlite::ToSql> =
204                params.iter().map(std::convert::AsRef::as_ref).collect();
205
206            let rows = stmt
207                .query_map(param_refs.as_slice(), |row| self.row_to_plan(row))
208                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
209
210            let mut result = Vec::new();
211            for row in rows {
212                let plan =
213                    row.map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
214                result.push(plan);
215            }
216            result
217        };
218
219        // Batch-load items for all listed plans on the same connection.
220        let ids: Vec<Uuid> = plans.iter().map(|p| p.id).collect();
221        let mut items_by_id = Self::load_plan_items_batch(&conn, &ids)?;
222        for plan in &mut plans {
223            plan.items = items_by_id.remove(&plan.id).unwrap_or_default();
224        }
225
226        Ok(plans)
227    }
228
229    pub fn update_plan(&self, id: Uuid, input: UpdateSubscriptionPlan) -> Result<SubscriptionPlan> {
230        stateset_core::Validate::validate(&input)?;
231        // Update plan - connection scoped to this block
232        {
233            let conn = self.pool.get().map_err(|e| {
234                stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
235            })?;
236
237            let now = Utc::now();
238
239            conn.execute(
240                "UPDATE subscription_plans SET
241                    name = COALESCE(?1, name),
242                    description = COALESCE(?2, description),
243                    status = COALESCE(?3, status),
244                    price = COALESCE(?4, price),
245                    setup_fee = COALESCE(?5, setup_fee),
246                    trial_days = COALESCE(?6, trial_days),
247                    trial_requires_payment_method = COALESCE(?7, trial_requires_payment_method),
248                    min_cycles = COALESCE(?8, min_cycles),
249                    max_cycles = COALESCE(?9, max_cycles),
250                    discount_percent = COALESCE(?10, discount_percent),
251                    discount_amount = COALESCE(?11, discount_amount),
252                    metadata = COALESCE(?12, metadata),
253                    updated_at = ?13
254                 WHERE id = ?14",
255                rusqlite::params![
256                    input.name,
257                    input.description,
258                    input.status.map(|s| s.to_string()),
259                    input.price.map(|d| d.to_string()),
260                    input.setup_fee.map(|d| d.to_string()),
261                    input.trial_days,
262                    input.trial_requires_payment_method.map(i32::from),
263                    input.min_cycles,
264                    input.max_cycles,
265                    input.discount_percent.map(|d| d.to_string()),
266                    input.discount_amount.map(|d| d.to_string()),
267                    input.metadata.as_ref().map(|m| serde_json::to_string(m).unwrap_or_default()),
268                    now.to_rfc3339(),
269                    id.to_string(),
270                ],
271            )
272            .map_err(|e| {
273                stateset_core::CommerceError::DatabaseError(format!("Update error: {e}"))
274            })?;
275        } // Connection dropped here
276
277        self.get_plan(id)?.ok_or(stateset_core::CommerceError::NotFound)
278    }
279
280    pub fn activate_plan(&self, id: Uuid) -> Result<SubscriptionPlan> {
281        self.update_plan(
282            id,
283            UpdateSubscriptionPlan { status: Some(PlanStatus::Active), ..Default::default() },
284        )
285    }
286
287    pub fn archive_plan(&self, id: Uuid) -> Result<SubscriptionPlan> {
288        self.update_plan(
289            id,
290            UpdateSubscriptionPlan { status: Some(PlanStatus::Archived), ..Default::default() },
291        )
292    }
293
294    fn create_plan_item(
295        &self,
296        plan_id: Uuid,
297        input: CreateSubscriptionPlanItem,
298    ) -> Result<SubscriptionPlanItem> {
299        let conn = self.pool.get().map_err(|e| {
300            stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
301        })?;
302
303        let id = Uuid::new_v4();
304
305        conn.execute(
306            "INSERT INTO subscription_plan_items (id, plan_id, product_id, variant_id, sku, name, quantity, min_quantity, max_quantity, is_required, unit_price)
307             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
308            rusqlite::params![
309                id.to_string(),
310                plan_id.to_string(),
311                input.product_id.to_string(),
312                input.variant_id.map(|i| i.to_string()),
313                input.sku,
314                input.name,
315                input.quantity,
316                input.min_quantity,
317                input.max_quantity,
318                i32::from(input.is_required.unwrap_or(true)),
319                input.unit_price.map(|d| d.to_string()),
320            ],
321        ).map_err(|e| stateset_core::CommerceError::DatabaseError(format!("Insert error: {e}")))?;
322
323        Ok(SubscriptionPlanItem {
324            id,
325            plan_id,
326            product_id: input.product_id,
327            variant_id: input.variant_id,
328            sku: input.sku,
329            name: input.name,
330            quantity: input.quantity,
331            min_quantity: input.min_quantity,
332            max_quantity: input.max_quantity,
333            is_required: input.is_required.unwrap_or(true),
334            unit_price: input.unit_price,
335        })
336    }
337
338    fn row_to_plan_item(row: &rusqlite::Row<'_>) -> rusqlite::Result<SubscriptionPlanItem> {
339        Ok(SubscriptionPlanItem {
340            id: parse_uuid_row(&row.get::<_, String>(0)?, "subscription_plan_item", "id")?,
341            plan_id: parse_uuid_row(
342                &row.get::<_, String>(1)?,
343                "subscription_plan_item",
344                "plan_id",
345            )?,
346            product_id: ProductId::from(parse_uuid_row(
347                &row.get::<_, String>(2)?,
348                "subscription_plan_item",
349                "product_id",
350            )?),
351            variant_id: parse_uuid_opt_row(
352                row.get::<_, Option<String>>(3)?,
353                "subscription_plan_item",
354                "variant_id",
355            )?,
356            sku: row.get(4)?,
357            name: row.get(5)?,
358            quantity: row.get(6)?,
359            min_quantity: row.get(7)?,
360            max_quantity: row.get(8)?,
361            is_required: row.get::<_, i32>(9)? != 0,
362            unit_price: parse_decimal_opt_row(
363                row.get::<_, Option<String>>(10)?,
364                "subscription_plan_item",
365                "unit_price",
366            )?,
367        })
368    }
369
370    fn get_plan_items(&self, plan_id: Uuid) -> Result<Vec<SubscriptionPlanItem>> {
371        let conn = self.pool.get().map_err(|e| {
372            stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
373        })?;
374
375        let mut stmt = conn.prepare(
376            "SELECT id, plan_id, product_id, variant_id, sku, name, quantity, min_quantity, max_quantity, is_required, unit_price
377             FROM subscription_plan_items WHERE plan_id = ?1"
378        ).map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
379
380        let rows = stmt
381            .query_map([plan_id.to_string()], Self::row_to_plan_item)
382            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
383
384        rows.collect::<std::result::Result<Vec<_>, _>>()
385            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))
386    }
387
388    /// Batch-load plan items for many plans in chunked `IN`-clause queries,
389    /// grouped by plan id. Uses the caller's connection.
390    fn load_plan_items_batch(
391        conn: &rusqlite::Connection,
392        ids: &[Uuid],
393    ) -> Result<std::collections::HashMap<Uuid, Vec<SubscriptionPlanItem>>> {
394        let mut map: std::collections::HashMap<Uuid, Vec<SubscriptionPlanItem>> =
395            std::collections::HashMap::with_capacity(ids.len());
396        let id_strings: Vec<String> = ids.iter().map(Uuid::to_string).collect();
397        for chunk in id_strings.chunks(500) {
398            let placeholders = crate::sqlite::build_in_clause(chunk.len());
399            let sql = format!(
400                "SELECT id, plan_id, product_id, variant_id, sku, name, quantity, min_quantity, max_quantity, is_required, unit_price
401                 FROM subscription_plan_items WHERE plan_id IN ({placeholders})"
402            );
403            let mut stmt = conn
404                .prepare(&sql)
405                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
406            let param_refs: Vec<&dyn rusqlite::ToSql> =
407                chunk.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
408            let rows = stmt
409                .query_map(param_refs.as_slice(), |row| {
410                    let item = Self::row_to_plan_item(row)?;
411                    Ok((item.plan_id, item))
412                })
413                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
414            for row in rows {
415                let (parent, item) =
416                    row.map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
417                map.entry(parent).or_default().push(item);
418            }
419        }
420        Ok(map)
421    }
422
423    // ========================================================================
424    // Subscriptions
425    // ========================================================================
426
427    pub fn create_subscription(&self, input: CreateSubscription) -> Result<Subscription> {
428        stateset_core::Validate::validate(&input)?;
429        // Get the plan first (uses its own connection)
430        let plan = self.get_plan(input.plan_id)?.ok_or(stateset_core::CommerceError::NotFound)?;
431
432        if plan.status != PlanStatus::Active {
433            return Err(stateset_core::CommerceError::ValidationError("Plan is not active".into()));
434        }
435
436        let now = input.start_date.unwrap_or_else(Utc::now);
437
438        // Calculate period end and trial
439        let interval_days = if plan.billing_interval == BillingInterval::Custom {
440            i64::from(plan.custom_interval_days.unwrap_or(30))
441        } else {
442            plan.billing_interval.days()
443        };
444
445        let skip_trial = input.skip_trial.unwrap_or(false);
446        let trial_ends_at = if !skip_trial && plan.trial_days > 0 {
447            Some(now + Duration::days(i64::from(plan.trial_days)))
448        } else {
449            None
450        };
451
452        let current_period_end = if let Some(trial_end) = trial_ends_at {
453            trial_end
454        } else {
455            now + Duration::days(interval_days)
456        };
457
458        let next_billing_date =
459            if trial_ends_at.is_some() { trial_ends_at } else { Some(current_period_end) };
460
461        let status = if trial_ends_at.is_some() {
462            SubscriptionStatus::Trial
463        } else {
464            SubscriptionStatus::Active
465        };
466
467        let price = input.price.unwrap_or(plan.price);
468
469        // Prepare items to create
470        let items_to_create: Vec<CreateSubscriptionItem> =
471            if let Some(custom_items) = input.items.clone() {
472                custom_items
473            } else {
474                plan.items
475                    .iter()
476                    .map(|pi| CreateSubscriptionItem {
477                        product_id: pi.product_id,
478                        variant_id: pi.variant_id,
479                        sku: pi.sku.clone(),
480                        name: pi.name.clone(),
481                        quantity: pi.quantity,
482                        unit_price: pi.unit_price,
483                    })
484                    .collect()
485            };
486
487        let mut created_subscription_id = None;
488        for attempt in 0..Self::MAX_SUBSCRIPTION_NUMBER_RETRIES {
489            let id = SubscriptionId::new();
490            let subscription_number = generate_subscription_number();
491
492            let mut conn = self.pool.get().map_err(|e| {
493                stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
494            })?;
495            let tx = super::begin_immediate(&mut conn).map_err(|e| {
496                stateset_core::CommerceError::DatabaseError(format!("Transaction error: {e}"))
497            })?;
498
499            let insert_result = tx.execute(
500                "INSERT INTO subscriptions (
501                    id, subscription_number, customer_id, plan_id, plan_name, status,
502                    billing_interval, custom_interval_days, price, currency, payment_method_id,
503                    started_at, current_period_start, current_period_end, next_billing_date, trial_ends_at,
504                    billing_cycle_count, failed_payment_attempts,
505                    shipping_address, billing_address,
506                    discount_percent, discount_amount, coupon_code,
507                    metadata, created_at, updated_at
508                ) VALUES (
509                    ?1, ?2, ?3, ?4, ?5, ?6,
510                    ?7, ?8, ?9, ?10, ?11,
511                    ?12, ?13, ?14, ?15, ?16,
512                    0, 0,
513                    ?17, ?18,
514                    ?19, ?20, ?21,
515                    ?22, ?23, ?24
516                )",
517                rusqlite::params![
518                    id.to_string(),
519                    subscription_number,
520                    input.customer_id.to_string(),
521                    input.plan_id.to_string(),
522                    plan.name.clone(),
523                    format!("{}", status),
524                    format!("{}", plan.billing_interval),
525                    plan.custom_interval_days,
526                    price.to_string(),
527                    plan.currency.clone(),
528                    input.payment_method_id.clone(),
529                    now.to_rfc3339(),
530                    now.to_rfc3339(),
531                    current_period_end.to_rfc3339(),
532                    next_billing_date.as_ref().map(chrono::DateTime::to_rfc3339),
533                    trial_ends_at.as_ref().map(chrono::DateTime::to_rfc3339),
534                    input.shipping_address
535                        .as_ref()
536                        .map(|a| serde_json::to_string(a).unwrap_or_default()),
537                    input.billing_address
538                        .as_ref()
539                        .map(|a| serde_json::to_string(a).unwrap_or_default()),
540                    plan.discount_percent.map(|d| d.to_string()),
541                    plan.discount_amount.map(|d| d.to_string()),
542                    input.coupon_code.clone(),
543                    input.metadata
544                        .as_ref()
545                        .map(|m| serde_json::to_string(m).unwrap_or_default()),
546                    now.to_rfc3339(),
547                    now.to_rfc3339(),
548                ],
549            );
550
551            if let Err(err) = insert_result {
552                if Self::is_subscription_number_unique_violation(&err)
553                    && attempt + 1 < Self::MAX_SUBSCRIPTION_NUMBER_RETRIES
554                {
555                    continue;
556                }
557                return Err(stateset_core::CommerceError::DatabaseError(format!(
558                    "Insert error: {err}"
559                )));
560            }
561
562            for item in items_to_create {
563                self.create_subscription_item_with_conn(&tx, id, item, &plan)?;
564            }
565
566            self.record_event_with_conn(
567                &tx,
568                id,
569                SubscriptionEventType::Created,
570                "Subscription created",
571                None,
572                None,
573            )?;
574
575            if let Some(trial_end) = trial_ends_at.as_ref() {
576                self.record_event_with_conn(
577                    &tx,
578                    id,
579                    SubscriptionEventType::TrialStarted,
580                    &format!("Trial started, ends on {}", trial_end.format("%Y-%m-%d")),
581                    None,
582                    None,
583                )?;
584            } else {
585                self.record_event_with_conn(
586                    &tx,
587                    id,
588                    SubscriptionEventType::Activated,
589                    "Subscription activated",
590                    None,
591                    None,
592                )?;
593            }
594
595            tx.commit().map_err(|e| {
596                stateset_core::CommerceError::DatabaseError(format!("Commit error: {e}"))
597            })?;
598            created_subscription_id = Some(id);
599            break;
600        }
601
602        let id = created_subscription_id.ok_or_else(|| {
603            stateset_core::CommerceError::Conflict(
604                "unable to allocate unique subscription number after retries".to_string(),
605            )
606        })?;
607
608        // Create the initial billing cycle for the subscription
609        self.create_billing_cycle(CreateBillingCycle {
610            subscription_id: id,
611            cycle_number: 1,
612            period_start: now,
613            period_end: current_period_end,
614        })?;
615
616        self.get_subscription(id)?.ok_or_else(|| {
617            stateset_core::CommerceError::DatabaseError(
618                "Failed to retrieve created subscription".into(),
619            )
620        })
621    }
622
623    pub fn get_subscription(&self, id: SubscriptionId) -> Result<Option<Subscription>> {
624        // Get subscription - connection scoped to this block
625        let subscription = {
626            let conn = self.pool.get().map_err(|e| {
627                stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
628            })?;
629
630            let mut stmt = conn
631                .prepare("SELECT * FROM subscriptions WHERE id = ?1")
632                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
633
634            stmt.query_row([id.to_string()], |row| self.row_to_subscription(row))
635                .optional()
636                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?
637        }; // Connection dropped here
638
639        if let Some(mut sub) = subscription {
640            sub.items = self.get_subscription_items(id)?;
641            Ok(Some(sub))
642        } else {
643            Ok(None)
644        }
645    }
646
647    pub fn get_subscription_by_number(&self, number: &str) -> Result<Option<Subscription>> {
648        // Get subscription - connection scoped to this block
649        let subscription = {
650            let conn = self.pool.get().map_err(|e| {
651                stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
652            })?;
653
654            let mut stmt = conn
655                .prepare("SELECT * FROM subscriptions WHERE subscription_number = ?1")
656                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
657
658            stmt.query_row([number], |row| self.row_to_subscription(row))
659                .optional()
660                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?
661        }; // Connection dropped here
662
663        if let Some(mut sub) = subscription {
664            sub.items = self.get_subscription_items(sub.id)?;
665            Ok(Some(sub))
666        } else {
667            Ok(None)
668        }
669    }
670
671    pub fn list_subscriptions(&self, filter: SubscriptionFilter) -> Result<Vec<Subscription>> {
672        let conn = self.pool.get().map_err(|e| {
673            stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
674        })?;
675
676        let mut subscriptions: Vec<Subscription> = {
677            let mut sql = "SELECT * FROM subscriptions WHERE 1=1".to_string();
678            let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
679
680            if let Some(customer_id) = &filter.customer_id {
681                sql.push_str(" AND customer_id = ?");
682                params.push(Box::new(customer_id.to_string()));
683            }
684
685            if let Some(plan_id) = &filter.plan_id {
686                sql.push_str(" AND plan_id = ?");
687                params.push(Box::new(plan_id.to_string()));
688            }
689
690            if let Some(status) = &filter.status {
691                sql.push_str(" AND status = ?");
692                params.push(Box::new(format!("{status}")));
693            }
694
695            if let Some(from) = &filter.from_date {
696                sql.push_str(" AND created_at >= ?");
697                params.push(Box::new(from.to_rfc3339()));
698            }
699
700            if let Some(to) = &filter.to_date {
701                sql.push_str(" AND created_at <= ?");
702                params.push(Box::new(to.to_rfc3339()));
703            }
704
705            if let Some(search) = &filter.search {
706                sql.push_str(" AND (subscription_number LIKE ? OR plan_name LIKE ?)");
707                let pattern = format!("%{search}%");
708                params.push(Box::new(pattern.clone()));
709                params.push(Box::new(pattern));
710            }
711
712            sql.push_str(" ORDER BY created_at DESC");
713
714            crate::sqlite::append_limit_offset(&mut sql, filter.limit, filter.offset);
715
716            let mut stmt = conn
717                .prepare(&sql)
718                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
719
720            let param_refs: Vec<&dyn rusqlite::ToSql> =
721                params.iter().map(std::convert::AsRef::as_ref).collect();
722
723            let rows = stmt
724                .query_map(param_refs.as_slice(), |row| self.row_to_subscription(row))
725                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
726
727            let mut result = Vec::new();
728            for row in rows {
729                let sub =
730                    row.map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
731                result.push(sub);
732            }
733            result
734        };
735
736        // Batch-load items for all listed subscriptions on the same connection.
737        let ids: Vec<SubscriptionId> = subscriptions.iter().map(|s| s.id).collect();
738        let mut items_by_id = Self::load_subscription_items_batch(&conn, &ids)?;
739        for sub in &mut subscriptions {
740            sub.items = items_by_id.remove(&sub.id).unwrap_or_default();
741        }
742
743        Ok(subscriptions)
744    }
745
746    pub fn update_subscription(
747        &self,
748        id: SubscriptionId,
749        input: UpdateSubscription,
750    ) -> Result<Subscription> {
751        stateset_core::Validate::validate(&input)?;
752        // Update subscription - connection scoped to this block
753        {
754            let conn = self.pool.get().map_err(|e| {
755                stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
756            })?;
757
758            let now = Utc::now();
759
760            conn.execute(
761                "UPDATE subscriptions SET
762                    status = COALESCE(?1, status),
763                    price = COALESCE(?2, price),
764                    payment_method_id = COALESCE(?3, payment_method_id),
765                    shipping_address = COALESCE(?4, shipping_address),
766                    billing_address = COALESCE(?5, billing_address),
767                    next_billing_date = COALESCE(?6, next_billing_date),
768                    discount_percent = COALESCE(?7, discount_percent),
769                    discount_amount = COALESCE(?8, discount_amount),
770                    coupon_code = COALESCE(?9, coupon_code),
771                    metadata = COALESCE(?10, metadata),
772                    updated_at = ?11
773                 WHERE id = ?12",
774                rusqlite::params![
775                    input.status.map(|s| format!("{s}")),
776                    input.price.map(|d| d.to_string()),
777                    input.payment_method_id,
778                    input
779                        .shipping_address
780                        .as_ref()
781                        .map(|a| serde_json::to_string(a).unwrap_or_default()),
782                    input
783                        .billing_address
784                        .as_ref()
785                        .map(|a| serde_json::to_string(a).unwrap_or_default()),
786                    input.next_billing_date.map(|d| d.to_rfc3339()),
787                    input.discount_percent.map(|d| d.to_string()),
788                    input.discount_amount.map(|d| d.to_string()),
789                    input.coupon_code,
790                    input.metadata.as_ref().map(|m| serde_json::to_string(m).unwrap_or_default()),
791                    now.to_rfc3339(),
792                    id.to_string(),
793                ],
794            )
795            .map_err(|e| {
796                stateset_core::CommerceError::DatabaseError(format!("Update error: {e}"))
797            })?;
798        } // Connection dropped here
799
800        self.get_subscription(id)?.ok_or(stateset_core::CommerceError::NotFound)
801    }
802
803    // ========================================================================
804    // Subscription Lifecycle Operations
805    // ========================================================================
806
807    pub fn pause_subscription(
808        &self,
809        id: SubscriptionId,
810        input: PauseSubscription,
811    ) -> Result<Subscription> {
812        let sub = self.get_subscription(id)?.ok_or(stateset_core::CommerceError::NotFound)?;
813
814        if !sub.can_pause() {
815            return Err(stateset_core::CommerceError::ValidationError(format!(
816                "Cannot pause subscription in {} status",
817                sub.status
818            )));
819        }
820
821        let description = match input.reason.clone() {
822            Some(reason) => format!("Paused: {reason}"),
823            None => "Paused by customer".to_string(),
824        };
825
826        // Update subscription - connection scoped to this block
827        {
828            let conn = self.pool.get().map_err(|e| {
829                stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
830            })?;
831
832            let now = Utc::now();
833
834            conn.execute(
835                "UPDATE subscriptions SET
836                    status = 'paused',
837                    paused_at = ?1,
838                    resume_at = ?2,
839                    next_billing_date = NULL,
840                    updated_at = ?3
841                 WHERE id = ?4",
842                rusqlite::params![
843                    now.to_rfc3339(),
844                    input.resume_at.map(|d| d.to_rfc3339()),
845                    now.to_rfc3339(),
846                    id.to_string(),
847                ],
848            )
849            .map_err(|e| {
850                stateset_core::CommerceError::DatabaseError(format!("Update error: {e}"))
851            })?;
852        } // Connection dropped here
853
854        self.record_event(id, SubscriptionEventType::Paused, &description, None, None)?;
855
856        self.get_subscription(id)?.ok_or(stateset_core::CommerceError::NotFound)
857    }
858
859    pub fn resume_subscription(&self, id: SubscriptionId) -> Result<Subscription> {
860        let sub = self.get_subscription(id)?.ok_or(stateset_core::CommerceError::NotFound)?;
861
862        if !sub.can_resume() {
863            return Err(stateset_core::CommerceError::ValidationError(format!(
864                "Cannot resume subscription in {} status",
865                sub.status
866            )));
867        }
868
869        // Update subscription - connection scoped to this block
870        {
871            let conn = self.pool.get().map_err(|e| {
872                stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
873            })?;
874
875            let now = Utc::now();
876
877            // Calculate new billing dates
878            let interval_days = if sub.billing_interval == BillingInterval::Custom {
879                i64::from(sub.custom_interval_days.unwrap_or(30))
880            } else {
881                sub.billing_interval.days()
882            };
883
884            let new_period_end = now + Duration::days(interval_days);
885
886            conn.execute(
887                "UPDATE subscriptions SET
888                    status = 'active',
889                    paused_at = NULL,
890                    resume_at = NULL,
891                    current_period_start = ?1,
892                    current_period_end = ?2,
893                    next_billing_date = ?3,
894                    updated_at = ?4
895                 WHERE id = ?5",
896                rusqlite::params![
897                    now.to_rfc3339(),
898                    new_period_end.to_rfc3339(),
899                    new_period_end.to_rfc3339(),
900                    now.to_rfc3339(),
901                    id.to_string(),
902                ],
903            )
904            .map_err(|e| {
905                stateset_core::CommerceError::DatabaseError(format!("Update error: {e}"))
906            })?;
907        } // Connection dropped here
908
909        self.record_event(id, SubscriptionEventType::Resumed, "Subscription resumed", None, None)?;
910
911        self.get_subscription(id)?.ok_or(stateset_core::CommerceError::NotFound)
912    }
913
914    pub fn cancel_subscription(
915        &self,
916        id: SubscriptionId,
917        input: CancelSubscription,
918    ) -> Result<Subscription> {
919        let sub = self.get_subscription(id)?.ok_or(stateset_core::CommerceError::NotFound)?;
920
921        if !sub.can_cancel() {
922            return Err(stateset_core::CommerceError::ValidationError(format!(
923                "Cannot cancel subscription in {} status",
924                sub.status
925            )));
926        }
927
928        let reason = input.reason.clone().unwrap_or_else(|| "Cancelled by customer".to_string());
929        let data = input.feedback.clone().map(|f| serde_json::json!({"feedback": f}));
930
931        // Update subscription - connection scoped to this block
932        {
933            let conn = self.pool.get().map_err(|e| {
934                stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
935            })?;
936
937            let now = Utc::now();
938            let immediate = input.immediate.unwrap_or(false);
939
940            let (new_status, ends_at) =
941                if immediate { ("expired", now) } else { ("cancelled", sub.current_period_end) };
942
943            conn.execute(
944                "UPDATE subscriptions SET
945                    status = ?1,
946                    cancelled_at = ?2,
947                    ends_at = ?3,
948                    next_billing_date = NULL,
949                    updated_at = ?4
950                 WHERE id = ?5",
951                rusqlite::params![
952                    new_status,
953                    now.to_rfc3339(),
954                    ends_at.to_rfc3339(),
955                    now.to_rfc3339(),
956                    id.to_string(),
957                ],
958            )
959            .map_err(|e| {
960                stateset_core::CommerceError::DatabaseError(format!("Update error: {e}"))
961            })?;
962        } // Connection dropped here
963
964        self.record_event(id, SubscriptionEventType::Cancelled, &reason, data, None)?;
965
966        self.get_subscription(id)?.ok_or(stateset_core::CommerceError::NotFound)
967    }
968
969    pub fn skip_billing_cycle(
970        &self,
971        id: SubscriptionId,
972        input: SkipBillingCycle,
973    ) -> Result<Subscription> {
974        let sub = self.get_subscription(id)?.ok_or(stateset_core::CommerceError::NotFound)?;
975
976        if sub.status != SubscriptionStatus::Active {
977            return Err(stateset_core::CommerceError::ValidationError(
978                "Can only skip billing for active subscriptions".into(),
979            ));
980        }
981
982        let reason = input.reason.unwrap_or_else(|| "Customer skipped billing cycle".to_string());
983
984        let interval_days = if sub.billing_interval == BillingInterval::Custom {
985            i64::from(sub.custom_interval_days.unwrap_or(30))
986        } else {
987            sub.billing_interval.days()
988        };
989
990        let new_billing_date =
991            sub.next_billing_date.unwrap_or(sub.current_period_end) + Duration::days(interval_days);
992
993        {
994            let conn = self.pool.get().map_err(|e| {
995                stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
996            })?;
997
998            let now = Utc::now();
999
1000            conn.execute(
1001                "UPDATE subscriptions SET
1002                    next_billing_date = ?1,
1003                    current_period_end = ?2,
1004                    updated_at = ?3
1005                 WHERE id = ?4",
1006                rusqlite::params![
1007                    new_billing_date.to_rfc3339(),
1008                    new_billing_date.to_rfc3339(),
1009                    now.to_rfc3339(),
1010                    id.to_string(),
1011                ],
1012            )
1013            .map_err(|e| {
1014                stateset_core::CommerceError::DatabaseError(format!("Update error: {e}"))
1015            })?;
1016        } // Connection dropped here
1017
1018        self.record_event(id, SubscriptionEventType::Skipped, &reason, None, None)?;
1019
1020        self.get_subscription(id)?.ok_or(stateset_core::CommerceError::NotFound)
1021    }
1022
1023    // ========================================================================
1024    // Subscription Items
1025    // ========================================================================
1026
1027    fn create_subscription_item_with_conn(
1028        &self,
1029        conn: &rusqlite::Connection,
1030        subscription_id: SubscriptionId,
1031        input: CreateSubscriptionItem,
1032        plan: &SubscriptionPlan,
1033    ) -> Result<SubscriptionItem> {
1034        let id = Uuid::new_v4();
1035        let unit_price =
1036            input.unit_price.unwrap_or(plan.price / Decimal::from(plan.items.len().max(1)));
1037        let line_total = unit_price * Decimal::from(input.quantity);
1038
1039        conn.execute(
1040            "INSERT INTO subscription_items (id, subscription_id, product_id, variant_id, sku, name, quantity, unit_price, line_total)
1041             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1042            rusqlite::params![
1043                id.to_string(),
1044                subscription_id.to_string(),
1045                input.product_id.to_string(),
1046                input.variant_id.map(|i| i.to_string()),
1047                input.sku,
1048                input.name,
1049                input.quantity,
1050                unit_price.to_string(),
1051                line_total.to_string(),
1052            ],
1053        ).map_err(|e| stateset_core::CommerceError::DatabaseError(format!("Insert error: {e}")))?;
1054
1055        Ok(SubscriptionItem {
1056            id,
1057            subscription_id,
1058            product_id: input.product_id,
1059            variant_id: input.variant_id,
1060            sku: input.sku,
1061            name: input.name,
1062            quantity: input.quantity,
1063            unit_price,
1064            line_total,
1065        })
1066    }
1067
1068    fn row_to_subscription_item(row: &rusqlite::Row<'_>) -> rusqlite::Result<SubscriptionItem> {
1069        Ok(SubscriptionItem {
1070            id: parse_uuid_row(&row.get::<_, String>(0)?, "subscription_item", "id")?,
1071            subscription_id: SubscriptionId::from(parse_uuid_row(
1072                &row.get::<_, String>(1)?,
1073                "subscription_item",
1074                "subscription_id",
1075            )?),
1076            product_id: ProductId::from(parse_uuid_row(
1077                &row.get::<_, String>(2)?,
1078                "subscription_item",
1079                "product_id",
1080            )?),
1081            variant_id: parse_uuid_opt_row(
1082                row.get::<_, Option<String>>(3)?,
1083                "subscription_item",
1084                "variant_id",
1085            )?,
1086            sku: row.get(4)?,
1087            name: row.get(5)?,
1088            quantity: row.get(6)?,
1089            unit_price: parse_decimal_row(
1090                &row.get::<_, String>(7)?,
1091                "subscription_item",
1092                "unit_price",
1093            )?,
1094            line_total: parse_decimal_row(
1095                &row.get::<_, String>(8)?,
1096                "subscription_item",
1097                "line_total",
1098            )?,
1099        })
1100    }
1101
1102    fn get_subscription_items(
1103        &self,
1104        subscription_id: SubscriptionId,
1105    ) -> Result<Vec<SubscriptionItem>> {
1106        let conn = self.pool.get().map_err(|e| {
1107            stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
1108        })?;
1109
1110        let mut stmt = conn.prepare(
1111            "SELECT id, subscription_id, product_id, variant_id, sku, name, quantity, unit_price, line_total
1112             FROM subscription_items WHERE subscription_id = ?1"
1113        ).map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1114
1115        let rows = stmt
1116            .query_map([subscription_id.to_string()], Self::row_to_subscription_item)
1117            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1118
1119        rows.collect::<std::result::Result<Vec<_>, _>>()
1120            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))
1121    }
1122
1123    /// Batch-load subscription items for many subscriptions in chunked
1124    /// `IN`-clause queries, grouped by subscription id. Uses the caller's connection.
1125    fn load_subscription_items_batch(
1126        conn: &rusqlite::Connection,
1127        ids: &[SubscriptionId],
1128    ) -> Result<std::collections::HashMap<SubscriptionId, Vec<SubscriptionItem>>> {
1129        let mut map: std::collections::HashMap<SubscriptionId, Vec<SubscriptionItem>> =
1130            std::collections::HashMap::with_capacity(ids.len());
1131        let id_strings: Vec<String> = ids.iter().map(ToString::to_string).collect();
1132        for chunk in id_strings.chunks(500) {
1133            let placeholders = crate::sqlite::build_in_clause(chunk.len());
1134            let sql = format!(
1135                "SELECT id, subscription_id, product_id, variant_id, sku, name, quantity, unit_price, line_total
1136                 FROM subscription_items WHERE subscription_id IN ({placeholders})"
1137            );
1138            let mut stmt = conn
1139                .prepare(&sql)
1140                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1141            let param_refs: Vec<&dyn rusqlite::ToSql> =
1142                chunk.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
1143            let rows = stmt
1144                .query_map(param_refs.as_slice(), |row| {
1145                    let item = Self::row_to_subscription_item(row)?;
1146                    Ok((item.subscription_id, item))
1147                })
1148                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1149            for row in rows {
1150                let (parent, item) =
1151                    row.map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1152                map.entry(parent).or_default().push(item);
1153            }
1154        }
1155        Ok(map)
1156    }
1157
1158    // ========================================================================
1159    // Billing Cycles
1160    // ========================================================================
1161
1162    pub fn create_billing_cycle(&self, input: CreateBillingCycle) -> Result<BillingCycle> {
1163        let CreateBillingCycle { subscription_id, cycle_number, period_start, period_end } = input;
1164        // Get subscription first (uses its own connection)
1165        let sub = self
1166            .get_subscription(subscription_id)?
1167            .ok_or(stateset_core::CommerceError::NotFound)?;
1168
1169        let id = Uuid::new_v4();
1170        let subtotal = sub.calculate_total();
1171        let discount = sub.discount_amount.unwrap_or(Decimal::ZERO)
1172            + (sub.discount_percent.unwrap_or(Decimal::ZERO) * subtotal);
1173        let total = (subtotal - discount).max(Decimal::ZERO);
1174        let currency = sub.currency;
1175
1176        // Insert billing cycle - connection scoped to this block
1177        {
1178            let conn = self.pool.get().map_err(|e| {
1179                stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
1180            })?;
1181
1182            let now = Utc::now();
1183
1184            conn.execute(
1185                "INSERT INTO billing_cycles (
1186                    id, subscription_id, cycle_number, status,
1187                    period_start, period_end,
1188                    subtotal, discount, tax, total, currency,
1189                    created_at, updated_at
1190                ) VALUES (
1191                    ?1, ?2, ?3, 'scheduled',
1192                    ?4, ?5,
1193                    ?6, ?7, '0', ?8, ?9,
1194                    ?10, ?11
1195                )",
1196                rusqlite::params![
1197                    id.to_string(),
1198                    subscription_id.to_string(),
1199                    cycle_number,
1200                    period_start.to_rfc3339(),
1201                    period_end.to_rfc3339(),
1202                    subtotal.to_string(),
1203                    discount.to_string(),
1204                    total.to_string(),
1205                    currency,
1206                    now.to_rfc3339(),
1207                    now.to_rfc3339(),
1208                ],
1209            )
1210            .map_err(|e| {
1211                stateset_core::CommerceError::DatabaseError(format!("Insert error: {e}"))
1212            })?;
1213        } // Connection dropped here
1214
1215        self.get_billing_cycle(id)?.ok_or_else(|| {
1216            stateset_core::CommerceError::DatabaseError(
1217                "Failed to retrieve created billing cycle".into(),
1218            )
1219        })
1220    }
1221
1222    pub fn get_billing_cycle(&self, id: Uuid) -> Result<Option<BillingCycle>> {
1223        let conn = self.pool.get().map_err(|e| {
1224            stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
1225        })?;
1226
1227        let mut stmt = conn
1228            .prepare("SELECT * FROM billing_cycles WHERE id = ?1")
1229            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1230
1231        stmt.query_row([id.to_string()], |row| self.row_to_billing_cycle(row))
1232            .optional()
1233            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))
1234    }
1235
1236    pub fn list_billing_cycles(&self, filter: BillingCycleFilter) -> Result<Vec<BillingCycle>> {
1237        let conn = self.pool.get().map_err(|e| {
1238            stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
1239        })?;
1240
1241        let mut sql = "SELECT * FROM billing_cycles WHERE 1=1".to_string();
1242        let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1243
1244        if let Some(sub_id) = &filter.subscription_id {
1245            sql.push_str(" AND subscription_id = ?");
1246            params.push(Box::new(sub_id.to_string()));
1247        }
1248
1249        if let Some(status) = &filter.status {
1250            sql.push_str(" AND status = ?");
1251            params.push(Box::new(status.to_string()));
1252        }
1253        // `period_start`/`period_end` are stored as RFC3339 timestamps (as is the
1254        // bound value), so the string comparison is chronological — matching
1255        // Postgres, which filters `period_start >= from_date` / `period_end <= to_date`.
1256        if let Some(from_date) = &filter.from_date {
1257            sql.push_str(" AND period_start >= ?");
1258            params.push(Box::new(from_date.to_rfc3339()));
1259        }
1260        if let Some(to_date) = &filter.to_date {
1261            sql.push_str(" AND period_end <= ?");
1262            params.push(Box::new(to_date.to_rfc3339()));
1263        }
1264
1265        // Order by period, matching Postgres (not `cycle_number`).
1266        sql.push_str(" ORDER BY period_start DESC");
1267
1268        crate::sqlite::append_limit_offset(&mut sql, filter.limit, filter.offset);
1269
1270        let mut stmt = conn
1271            .prepare(&sql)
1272            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1273
1274        let param_refs: Vec<&dyn rusqlite::ToSql> =
1275            params.iter().map(std::convert::AsRef::as_ref).collect();
1276
1277        let rows = stmt
1278            .query_map(param_refs.as_slice(), |row| self.row_to_billing_cycle(row))
1279            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1280
1281        rows.collect::<std::result::Result<Vec<_>, _>>()
1282            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))
1283    }
1284
1285    pub fn update_billing_cycle_status(
1286        &self,
1287        id: Uuid,
1288        status: BillingCycleStatus,
1289        payment_id: Option<String>,
1290        failure_reason: Option<String>,
1291    ) -> Result<BillingCycle> {
1292        // Update billing cycle - connection scoped to this block
1293        {
1294            let conn = self.pool.get().map_err(|e| {
1295                stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
1296            })?;
1297
1298            let now = Utc::now();
1299            let billed_at =
1300                if status == BillingCycleStatus::Paid || status == BillingCycleStatus::Failed {
1301                    Some(now)
1302                } else {
1303                    None
1304                };
1305
1306            conn.execute(
1307                "UPDATE billing_cycles SET
1308                    status = ?1,
1309                    payment_id = COALESCE(?2, payment_id),
1310                    billed_at = COALESCE(?3, billed_at),
1311                    failure_reason = ?4,
1312                    retry_count = CASE WHEN ?1 = 'failed' THEN retry_count + 1 ELSE retry_count END,
1313                    updated_at = ?5
1314                 WHERE id = ?6",
1315                rusqlite::params![
1316                    status.to_string(),
1317                    payment_id,
1318                    billed_at.map(|d| d.to_rfc3339()),
1319                    failure_reason,
1320                    now.to_rfc3339(),
1321                    id.to_string(),
1322                ],
1323            )
1324            .map_err(|e| {
1325                stateset_core::CommerceError::DatabaseError(format!("Update error: {e}"))
1326            })?;
1327        } // Connection dropped here
1328
1329        self.get_billing_cycle(id)?.ok_or(stateset_core::CommerceError::NotFound)
1330    }
1331
1332    // ========================================================================
1333    // Events
1334    // ========================================================================
1335
1336    pub fn record_event(
1337        &self,
1338        subscription_id: SubscriptionId,
1339        event_type: SubscriptionEventType,
1340        description: &str,
1341        data: Option<serde_json::Value>,
1342        triggered_by: Option<&str>,
1343    ) -> Result<SubscriptionEvent> {
1344        let conn = self.pool.get().map_err(|e| {
1345            stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
1346        })?;
1347
1348        self.record_event_with_conn(
1349            &conn,
1350            subscription_id,
1351            event_type,
1352            description,
1353            data,
1354            triggered_by,
1355        )
1356    }
1357
1358    fn record_event_with_conn(
1359        &self,
1360        conn: &rusqlite::Connection,
1361        subscription_id: SubscriptionId,
1362        event_type: SubscriptionEventType,
1363        description: &str,
1364        data: Option<serde_json::Value>,
1365        triggered_by: Option<&str>,
1366    ) -> Result<SubscriptionEvent> {
1367        let id = Uuid::new_v4();
1368        let now = Utc::now();
1369
1370        conn.execute(
1371            "INSERT INTO subscription_events (id, subscription_id, event_type, description, data, triggered_by, created_at)
1372             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1373            rusqlite::params![
1374                id.to_string(),
1375                subscription_id.to_string(),
1376                event_type.to_string(),
1377                description,
1378                data.as_ref().map(|d| serde_json::to_string(d).unwrap_or_default()),
1379                triggered_by,
1380                now.to_rfc3339(),
1381            ],
1382        ).map_err(|e| stateset_core::CommerceError::DatabaseError(format!("Insert error: {e}")))?;
1383
1384        Ok(SubscriptionEvent {
1385            id,
1386            subscription_id,
1387            event_type,
1388            description: description.to_string(),
1389            data,
1390            triggered_by: triggered_by.map(String::from),
1391            created_at: now,
1392        })
1393    }
1394
1395    pub fn get_subscription_events(
1396        &self,
1397        subscription_id: SubscriptionId,
1398        limit: Option<u32>,
1399    ) -> Result<Vec<SubscriptionEvent>> {
1400        let conn = self.pool.get().map_err(|e| {
1401            stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
1402        })?;
1403
1404        let mut sql = "SELECT id, subscription_id, event_type, description, data, triggered_by, created_at
1405                       FROM subscription_events WHERE subscription_id = ?1 ORDER BY created_at DESC".to_string();
1406
1407        if let Some(l) = limit {
1408            sql.push_str(&format!(" LIMIT {l}"));
1409        }
1410
1411        let mut stmt = conn
1412            .prepare(&sql)
1413            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1414
1415        let rows = stmt
1416            .query_map([subscription_id.to_string()], |row| {
1417                Ok(SubscriptionEvent {
1418                    id: parse_uuid_row(&row.get::<_, String>(0)?, "subscription_event", "id")?,
1419                    subscription_id: SubscriptionId::from(parse_uuid_row(
1420                        &row.get::<_, String>(1)?,
1421                        "subscription_event",
1422                        "subscription_id",
1423                    )?),
1424                    event_type: parse_enum_row(
1425                        &row.get::<_, String>(2)?,
1426                        "subscription_event",
1427                        "event_type",
1428                    )?,
1429                    description: row.get(3)?,
1430                    data: parse_json_opt_row(
1431                        row.get::<_, Option<String>>(4)?,
1432                        "subscription_event",
1433                        "data",
1434                    )?,
1435                    triggered_by: row.get(5)?,
1436                    created_at: parse_datetime_row(
1437                        &row.get::<_, String>(6)?,
1438                        "subscription_event",
1439                        "created_at",
1440                    )?,
1441                })
1442            })
1443            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1444
1445        rows.collect::<std::result::Result<Vec<_>, _>>()
1446            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))
1447    }
1448
1449    // ========================================================================
1450    // Helper Methods
1451    // ========================================================================
1452
1453    fn row_to_plan(&self, row: &rusqlite::Row<'_>) -> rusqlite::Result<SubscriptionPlan> {
1454        Ok(SubscriptionPlan {
1455            id: parse_uuid_row(&row.get::<_, String>(0)?, "subscription_plan", "id")?,
1456            code: row.get(1)?,
1457            name: row.get(2)?,
1458            description: row.get(3)?,
1459            status: parse_enum_row(&row.get::<_, String>(4)?, "subscription_plan", "status")?,
1460            billing_interval: parse_enum_row(
1461                &row.get::<_, String>(5)?,
1462                "subscription_plan",
1463                "billing_interval",
1464            )?,
1465            custom_interval_days: row.get(6)?,
1466            price: parse_decimal_row(&row.get::<_, String>(7)?, "subscription_plan", "price")?,
1467            setup_fee: parse_decimal_opt_row(
1468                row.get::<_, Option<String>>(8)?,
1469                "subscription_plan",
1470                "setup_fee",
1471            )?,
1472            currency: row.get(9)?,
1473            trial_days: row.get(10)?,
1474            trial_requires_payment_method: row.get::<_, i32>(11)? != 0,
1475            min_cycles: row.get(12)?,
1476            max_cycles: row.get(13)?,
1477            discount_percent: parse_decimal_opt_row(
1478                row.get::<_, Option<String>>(14)?,
1479                "subscription_plan",
1480                "discount_percent",
1481            )?,
1482            discount_amount: parse_decimal_opt_row(
1483                row.get::<_, Option<String>>(15)?,
1484                "subscription_plan",
1485                "discount_amount",
1486            )?,
1487            metadata: parse_json_opt_row(
1488                row.get::<_, Option<String>>(16)?,
1489                "subscription_plan",
1490                "metadata",
1491            )?,
1492            created_at: parse_datetime_row(
1493                &row.get::<_, String>(17)?,
1494                "subscription_plan",
1495                "created_at",
1496            )?,
1497            updated_at: parse_datetime_row(
1498                &row.get::<_, String>(18)?,
1499                "subscription_plan",
1500                "updated_at",
1501            )?,
1502            items: Vec::new(), // Loaded separately
1503        })
1504    }
1505
1506    fn row_to_subscription(&self, row: &rusqlite::Row<'_>) -> rusqlite::Result<Subscription> {
1507        Ok(Subscription {
1508            id: SubscriptionId::from(parse_uuid_row(
1509                &row.get::<_, String>(0)?,
1510                "subscription",
1511                "id",
1512            )?),
1513            subscription_number: row.get(1)?,
1514            customer_id: CustomerId::from(parse_uuid_row(
1515                &row.get::<_, String>(2)?,
1516                "subscription",
1517                "customer_id",
1518            )?),
1519            plan_id: parse_uuid_row(&row.get::<_, String>(3)?, "subscription", "plan_id")?,
1520            plan_name: row.get(4)?,
1521            status: parse_enum_row(&row.get::<_, String>(5)?, "subscription", "status")?,
1522            billing_interval: parse_enum_row(
1523                &row.get::<_, String>(6)?,
1524                "subscription",
1525                "billing_interval",
1526            )?,
1527            custom_interval_days: row.get(7)?,
1528            price: parse_decimal_row(&row.get::<_, String>(8)?, "subscription", "price")?,
1529            currency: row.get(9)?,
1530            payment_method_id: row.get(10)?,
1531            started_at: parse_datetime_row(
1532                &row.get::<_, String>(11)?,
1533                "subscription",
1534                "started_at",
1535            )?,
1536            current_period_start: parse_datetime_row(
1537                &row.get::<_, String>(12)?,
1538                "subscription",
1539                "current_period_start",
1540            )?,
1541            current_period_end: parse_datetime_row(
1542                &row.get::<_, String>(13)?,
1543                "subscription",
1544                "current_period_end",
1545            )?,
1546            next_billing_date: parse_datetime_opt_row(
1547                row.get::<_, Option<String>>(14)?,
1548                "subscription",
1549                "next_billing_date",
1550            )?,
1551            trial_ends_at: parse_datetime_opt_row(
1552                row.get::<_, Option<String>>(15)?,
1553                "subscription",
1554                "trial_ends_at",
1555            )?,
1556            cancelled_at: parse_datetime_opt_row(
1557                row.get::<_, Option<String>>(16)?,
1558                "subscription",
1559                "cancelled_at",
1560            )?,
1561            ends_at: parse_datetime_opt_row(
1562                row.get::<_, Option<String>>(17)?,
1563                "subscription",
1564                "ends_at",
1565            )?,
1566            paused_at: parse_datetime_opt_row(
1567                row.get::<_, Option<String>>(18)?,
1568                "subscription",
1569                "paused_at",
1570            )?,
1571            resume_at: parse_datetime_opt_row(
1572                row.get::<_, Option<String>>(19)?,
1573                "subscription",
1574                "resume_at",
1575            )?,
1576            billing_cycle_count: row.get(20)?,
1577            failed_payment_attempts: row.get(21)?,
1578            shipping_address: parse_json_opt_row(
1579                row.get::<_, Option<String>>(22)?,
1580                "subscription",
1581                "shipping_address",
1582            )?,
1583            billing_address: parse_json_opt_row(
1584                row.get::<_, Option<String>>(23)?,
1585                "subscription",
1586                "billing_address",
1587            )?,
1588            discount_percent: parse_decimal_opt_row(
1589                row.get::<_, Option<String>>(24)?,
1590                "subscription",
1591                "discount_percent",
1592            )?,
1593            discount_amount: parse_decimal_opt_row(
1594                row.get::<_, Option<String>>(25)?,
1595                "subscription",
1596                "discount_amount",
1597            )?,
1598            coupon_code: row.get(26)?,
1599            metadata: parse_json_opt_row(
1600                row.get::<_, Option<String>>(27)?,
1601                "subscription",
1602                "metadata",
1603            )?,
1604            created_at: parse_datetime_row(
1605                &row.get::<_, String>(28)?,
1606                "subscription",
1607                "created_at",
1608            )?,
1609            updated_at: parse_datetime_row(
1610                &row.get::<_, String>(29)?,
1611                "subscription",
1612                "updated_at",
1613            )?,
1614            items: Vec::new(), // Loaded separately
1615        })
1616    }
1617
1618    fn row_to_billing_cycle(&self, row: &rusqlite::Row<'_>) -> rusqlite::Result<BillingCycle> {
1619        Ok(BillingCycle {
1620            id: parse_uuid_row(&row.get::<_, String>(0)?, "billing_cycle", "id")?,
1621            subscription_id: SubscriptionId::from(parse_uuid_row(
1622                &row.get::<_, String>(1)?,
1623                "billing_cycle",
1624                "subscription_id",
1625            )?),
1626            cycle_number: row.get(2)?,
1627            status: parse_enum_row(&row.get::<_, String>(3)?, "billing_cycle", "status")?,
1628            period_start: parse_datetime_row(
1629                &row.get::<_, String>(4)?,
1630                "billing_cycle",
1631                "period_start",
1632            )?,
1633            period_end: parse_datetime_row(
1634                &row.get::<_, String>(5)?,
1635                "billing_cycle",
1636                "period_end",
1637            )?,
1638            billed_at: parse_datetime_opt_row(
1639                row.get::<_, Option<String>>(6)?,
1640                "billing_cycle",
1641                "billed_at",
1642            )?,
1643            subtotal: parse_decimal_row(&row.get::<_, String>(7)?, "billing_cycle", "subtotal")?,
1644            discount: parse_decimal_row(&row.get::<_, String>(8)?, "billing_cycle", "discount")?,
1645            tax: parse_decimal_row(&row.get::<_, String>(9)?, "billing_cycle", "tax")?,
1646            total: parse_decimal_row(&row.get::<_, String>(10)?, "billing_cycle", "total")?,
1647            currency: row.get(11)?,
1648            payment_id: row.get(12)?,
1649            order_id: parse_uuid_opt_row(
1650                row.get::<_, Option<String>>(13)?,
1651                "billing_cycle",
1652                "order_id",
1653            )?
1654            .map(OrderId::from),
1655            invoice_id: parse_uuid_opt_row(
1656                row.get::<_, Option<String>>(14)?,
1657                "billing_cycle",
1658                "invoice_id",
1659            )?,
1660            failure_reason: row.get(15)?,
1661            retry_count: row.get(16)?,
1662            next_retry_at: parse_datetime_opt_row(
1663                row.get::<_, Option<String>>(17)?,
1664                "billing_cycle",
1665                "next_retry_at",
1666            )?,
1667            created_at: parse_datetime_row(
1668                &row.get::<_, String>(18)?,
1669                "billing_cycle",
1670                "created_at",
1671            )?,
1672            updated_at: parse_datetime_row(
1673                &row.get::<_, String>(19)?,
1674                "billing_cycle",
1675                "updated_at",
1676            )?,
1677        })
1678    }
1679}
1680
1681// ============================================================================
1682// Parsing Helpers
1683// ============================================================================
1684
1685impl SubscriptionRepository for SqliteSubscriptionRepository {
1686    fn create_plan(&self, input: CreateSubscriptionPlan) -> Result<SubscriptionPlan> {
1687        Self::create_plan(self, input)
1688    }
1689
1690    fn get_plan(&self, id: Uuid) -> Result<Option<SubscriptionPlan>> {
1691        Self::get_plan(self, id)
1692    }
1693
1694    fn get_plan_by_code(&self, code: &str) -> Result<Option<SubscriptionPlan>> {
1695        Self::get_plan_by_code(self, code)
1696    }
1697
1698    fn list_plans(&self, filter: SubscriptionPlanFilter) -> Result<Vec<SubscriptionPlan>> {
1699        Self::list_plans(self, filter)
1700    }
1701
1702    fn update_plan(&self, id: Uuid, input: UpdateSubscriptionPlan) -> Result<SubscriptionPlan> {
1703        Self::update_plan(self, id, input)
1704    }
1705
1706    fn activate_plan(&self, id: Uuid) -> Result<SubscriptionPlan> {
1707        Self::activate_plan(self, id)
1708    }
1709
1710    fn archive_plan(&self, id: Uuid) -> Result<SubscriptionPlan> {
1711        Self::archive_plan(self, id)
1712    }
1713
1714    fn create_subscription(&self, input: CreateSubscription) -> Result<Subscription> {
1715        Self::create_subscription(self, input)
1716    }
1717
1718    fn get_subscription(&self, id: SubscriptionId) -> Result<Option<Subscription>> {
1719        Self::get_subscription(self, id)
1720    }
1721
1722    fn get_subscription_by_number(&self, number: &str) -> Result<Option<Subscription>> {
1723        Self::get_subscription_by_number(self, number)
1724    }
1725
1726    fn list_subscriptions(&self, filter: SubscriptionFilter) -> Result<Vec<Subscription>> {
1727        Self::list_subscriptions(self, filter)
1728    }
1729
1730    fn update_subscription(
1731        &self,
1732        id: SubscriptionId,
1733        input: UpdateSubscription,
1734    ) -> Result<Subscription> {
1735        Self::update_subscription(self, id, input)
1736    }
1737
1738    fn cancel_subscription(
1739        &self,
1740        id: SubscriptionId,
1741        input: CancelSubscription,
1742    ) -> Result<Subscription> {
1743        Self::cancel_subscription(self, id, input)
1744    }
1745
1746    fn pause_subscription(
1747        &self,
1748        id: SubscriptionId,
1749        input: PauseSubscription,
1750    ) -> Result<Subscription> {
1751        Self::pause_subscription(self, id, input)
1752    }
1753
1754    fn resume_subscription(&self, id: SubscriptionId) -> Result<Subscription> {
1755        Self::resume_subscription(self, id)
1756    }
1757
1758    fn create_billing_cycle(&self, input: CreateBillingCycle) -> Result<BillingCycle> {
1759        Self::create_billing_cycle(self, input)
1760    }
1761
1762    fn get_billing_cycle(&self, id: Uuid) -> Result<Option<BillingCycle>> {
1763        Self::get_billing_cycle(self, id)
1764    }
1765
1766    fn list_billing_cycles(&self, filter: BillingCycleFilter) -> Result<Vec<BillingCycle>> {
1767        Self::list_billing_cycles(self, filter)
1768    }
1769
1770    fn update_billing_cycle_status(
1771        &self,
1772        id: Uuid,
1773        status: BillingCycleStatus,
1774    ) -> Result<BillingCycle> {
1775        Self::update_billing_cycle_status(self, id, status, None, None)
1776    }
1777
1778    fn skip_billing_cycle(
1779        &self,
1780        id: SubscriptionId,
1781        input: SkipBillingCycle,
1782    ) -> Result<Subscription> {
1783        Self::skip_billing_cycle(self, id, input)
1784    }
1785
1786    fn record_event(
1787        &self,
1788        subscription_id: SubscriptionId,
1789        event_type: SubscriptionEventType,
1790        notes: Option<String>,
1791    ) -> Result<SubscriptionEvent> {
1792        let description = notes.as_deref().unwrap_or("");
1793        Self::record_event(self, subscription_id, event_type, description, None, None)
1794    }
1795
1796    fn get_subscription_events(
1797        &self,
1798        subscription_id: SubscriptionId,
1799    ) -> Result<Vec<SubscriptionEvent>> {
1800        Self::get_subscription_events(self, subscription_id, None)
1801    }
1802}
1803
1804#[cfg(test)]
1805mod tests {
1806    use super::SqliteSubscriptionRepository;
1807    use crate::SqliteDatabase;
1808    use rust_decimal_macros::dec;
1809    use stateset_core::{
1810        BillingCycleFilter, BillingInterval, CommerceError, CreateBillingCycle, CreateSubscription,
1811        CreateSubscriptionPlan, CustomerId,
1812    };
1813
1814    fn create_subscription_input(
1815        customer_id: CustomerId,
1816        plan_id: uuid::Uuid,
1817    ) -> CreateSubscription {
1818        CreateSubscription {
1819            customer_id,
1820            plan_id,
1821            items: None,
1822            price: None,
1823            payment_method_id: None,
1824            shipping_address: None,
1825            billing_address: None,
1826            skip_trial: None,
1827            start_date: None,
1828            coupon_code: None,
1829            metadata: None,
1830        }
1831    }
1832
1833    fn seed_customer(repo: &SqliteSubscriptionRepository, id: CustomerId) {
1834        let conn = repo.pool.get().expect("conn");
1835        conn.execute(
1836            "INSERT INTO customers (id, email, first_name, last_name) VALUES (?1, ?2, 'Sub', 'Scriber')",
1837            rusqlite::params![id.to_string(), format!("sub-{id}@example.com")],
1838        )
1839        .expect("seed customer");
1840    }
1841
1842    #[test]
1843    fn create_subscription_seeds_an_initial_billing_cycle() {
1844        let repo = SqliteDatabase::in_memory().expect("in-memory").subscriptions();
1845        let customer = CustomerId::new();
1846        seed_customer(&repo, customer);
1847        let plan = repo.create_plan(plan_input()).expect("create plan");
1848        repo.activate_plan(plan.id).expect("activate plan");
1849        let sub = repo
1850            .create_subscription(create_subscription_input(customer, plan.id))
1851            .expect("create subscription");
1852
1853        // A new subscription's current period is cycle 1 (matching Postgres, which
1854        // used to create no billing cycle at all).
1855        let cycles = repo
1856            .list_billing_cycles(BillingCycleFilter {
1857                subscription_id: Some(sub.id),
1858                ..Default::default()
1859            })
1860            .expect("list cycles");
1861        assert_eq!(cycles.len(), 1, "a new subscription must have an initial billing cycle");
1862        assert_eq!(cycles[0].cycle_number, 1);
1863    }
1864
1865    #[test]
1866    fn list_billing_cycles_filters_by_date_and_orders_by_period_start() {
1867        let repo = SqliteDatabase::in_memory().expect("in-memory").subscriptions();
1868        let customer = CustomerId::new();
1869        seed_customer(&repo, customer);
1870        let plan = repo.create_plan(plan_input()).expect("create plan");
1871        repo.activate_plan(plan.id).expect("activate plan");
1872        // create_subscription seeds cycle 1 with period_start = now.
1873        let sub = repo
1874            .create_subscription(create_subscription_input(customer, plan.id))
1875            .expect("create subscription");
1876
1877        let dt = |s: &str| s.parse::<chrono::DateTime<chrono::Utc>>().unwrap();
1878        // Two explicit past cycles with known, well-separated period windows (2020),
1879        // so the comparison against the auto-seeded cycle 1 (period_start = now) is
1880        // unambiguous regardless of when the test runs.
1881        repo.create_billing_cycle(CreateBillingCycle {
1882            subscription_id: sub.id,
1883            cycle_number: 2,
1884            period_start: dt("2020-01-15T00:00:00Z"),
1885            period_end: dt("2020-01-31T00:00:00Z"),
1886        })
1887        .expect("cycle 2");
1888        repo.create_billing_cycle(CreateBillingCycle {
1889            subscription_id: sub.id,
1890            cycle_number: 3,
1891            period_start: dt("2020-02-15T00:00:00Z"),
1892            period_end: dt("2020-02-28T00:00:00Z"),
1893        })
1894        .expect("cycle 3");
1895
1896        let base = || BillingCycleFilter { subscription_id: Some(sub.id), ..Default::default() };
1897
1898        // from_date/to_date must scope by the period (previously dropped on SQLite,
1899        // so every cycle was returned).
1900        let jan = repo
1901            .list_billing_cycles(BillingCycleFilter {
1902                from_date: Some(dt("2020-01-01T00:00:00Z")),
1903                to_date: Some(dt("2020-01-31T00:00:00Z")),
1904                ..base()
1905            })
1906            .expect("list jan");
1907        assert_eq!(jan.len(), 1, "date window should select only cycle 2");
1908        assert_eq!(jan[0].cycle_number, 2);
1909
1910        let janfeb = repo
1911            .list_billing_cycles(BillingCycleFilter {
1912                from_date: Some(dt("2020-01-01T00:00:00Z")),
1913                to_date: Some(dt("2020-02-28T00:00:00Z")),
1914                ..base()
1915            })
1916            .expect("list jan-feb");
1917        assert_eq!(janfeb.len(), 2, "date window should select cycles 2 and 3");
1918
1919        // Ordering is by period_start DESC (matching Postgres): the auto-seeded cycle
1920        // 1 (period_start = now) sorts first, ahead of the 2020 cycles — even though
1921        // it has the lowest cycle_number (which the old `cycle_number DESC` put last).
1922        let all = repo.list_billing_cycles(base()).expect("list all");
1923        assert_eq!(all.len(), 3);
1924        assert_eq!(all[0].cycle_number, 1, "newest period_start (cycle 1) must sort first");
1925    }
1926
1927    fn plan_input() -> CreateSubscriptionPlan {
1928        CreateSubscriptionPlan {
1929            code: None,
1930            name: "Test Plan".into(),
1931            description: None,
1932            billing_interval: BillingInterval::Monthly,
1933            custom_interval_days: None,
1934            price: dec!(10.00),
1935            setup_fee: None,
1936            currency: None,
1937            trial_days: None,
1938            trial_requires_payment_method: None,
1939            min_cycles: None,
1940            max_cycles: None,
1941            items: None,
1942            discount_percent: None,
1943            discount_amount: None,
1944            metadata: None,
1945        }
1946    }
1947
1948    #[test]
1949    fn create_plan_rejects_invalid_pricing() {
1950        let db = SqliteDatabase::in_memory().expect("in-memory");
1951        let repo = db.subscriptions();
1952
1953        // discount_percent is a fraction — 10 would mean 1000% off (billing
1954        // multiplies it directly by the subtotal, flooring the total at 0).
1955        let err = repo
1956            .create_plan(CreateSubscriptionPlan {
1957                discount_percent: Some(dec!(10)),
1958                ..plan_input()
1959            })
1960            .expect_err("out-of-range discount_percent rejected");
1961        assert!(matches!(err, CommerceError::InvalidInput { .. }), "got {err:?}");
1962
1963        // Negative money fields rejected; a surcharge-by-negative-discount
1964        // must not be expressible.
1965        for input in [
1966            CreateSubscriptionPlan { price: dec!(-1.00), ..plan_input() },
1967            CreateSubscriptionPlan { setup_fee: Some(dec!(-1.00)), ..plan_input() },
1968            CreateSubscriptionPlan { discount_amount: Some(dec!(-5.00)), ..plan_input() },
1969        ] {
1970            assert!(matches!(
1971                repo.create_plan(input).unwrap_err(),
1972                CommerceError::InvalidInput { .. }
1973            ));
1974        }
1975
1976        // A sane fractional discount still works.
1977        let plan = repo
1978            .create_plan(CreateSubscriptionPlan {
1979                discount_percent: Some(dec!(0.10)),
1980                ..plan_input()
1981            })
1982            .expect("valid plan");
1983        assert_eq!(plan.discount_percent, Some(dec!(0.10)));
1984    }
1985
1986    #[test]
1987    fn detects_subscription_number_unique_violation() {
1988        let err = rusqlite::Error::SqliteFailure(
1989            rusqlite::ffi::Error {
1990                code: rusqlite::ErrorCode::ConstraintViolation,
1991                extended_code: 2067,
1992            },
1993            Some("UNIQUE constraint failed: subscriptions.subscription_number".to_string()),
1994        );
1995        assert!(SqliteSubscriptionRepository::is_subscription_number_unique_violation(&err));
1996    }
1997
1998    fn seed_product(repo: &SqliteSubscriptionRepository) -> stateset_core::ProductId {
1999        let id = stateset_core::ProductId::new();
2000        let conn = repo.pool.get().expect("conn");
2001        conn.execute(
2002            "INSERT INTO products (id, name, slug) VALUES (?1, ?2, ?3)",
2003            rusqlite::params![id.to_string(), format!("Product {id}"), format!("product-{id}")],
2004        )
2005        .expect("seed product");
2006        id
2007    }
2008
2009    fn plan_item(
2010        repo: &SqliteSubscriptionRepository,
2011        sku: &str,
2012    ) -> stateset_core::CreateSubscriptionPlanItem {
2013        stateset_core::CreateSubscriptionPlanItem {
2014            product_id: seed_product(repo),
2015            variant_id: None,
2016            sku: sku.into(),
2017            name: sku.into(),
2018            quantity: 1,
2019            min_quantity: None,
2020            max_quantity: None,
2021            is_required: None,
2022            unit_price: Some(dec!(5.00)),
2023        }
2024    }
2025
2026    #[test]
2027    fn list_plans_batched_item_loading_preserves_per_plan_items() {
2028        let repo = SqliteDatabase::in_memory().expect("in-memory").subscriptions();
2029        for (name, skus) in [
2030            ("BatchPlan A", vec!["A-1", "A-2"]),
2031            ("BatchPlan B", vec!["B-1"]),
2032            ("BatchPlan C", vec!["C-1", "C-2", "C-3"]),
2033        ] {
2034            repo.create_plan(CreateSubscriptionPlan {
2035                name: name.into(),
2036                items: Some(skus.into_iter().map(|sku| plan_item(&repo, sku)).collect()),
2037                ..plan_input()
2038            })
2039            .expect("create plan");
2040        }
2041
2042        // The database may pre-seed plans; scope the list to the ones created here.
2043        let plans = repo
2044            .list_plans(stateset_core::SubscriptionPlanFilter {
2045                search: Some("BatchPlan".into()),
2046                ..Default::default()
2047            })
2048            .expect("list plans");
2049        assert_eq!(plans.len(), 3);
2050        for plan in &plans {
2051            let fetched = repo.get_plan(plan.id).expect("get").expect("present");
2052            let listed: Vec<_> = plan.items.iter().map(|i| i.sku.clone()).collect();
2053            let direct: Vec<_> = fetched.items.iter().map(|i| i.sku.clone()).collect();
2054            assert_eq!(listed, direct, "plan {} items must match", plan.name);
2055            assert!(
2056                plan.items.iter().all(|i| i.plan_id == plan.id),
2057                "items must belong to their own plan"
2058            );
2059        }
2060    }
2061
2062    #[test]
2063    fn list_subscriptions_batched_item_loading_preserves_per_subscription_items() {
2064        let repo = SqliteDatabase::in_memory().expect("in-memory").subscriptions();
2065        let plan = repo
2066            .create_plan(CreateSubscriptionPlan {
2067                items: Some(vec![plan_item(&repo, "SUB-1"), plan_item(&repo, "SUB-2")]),
2068                ..plan_input()
2069            })
2070            .expect("create plan");
2071        repo.activate_plan(plan.id).expect("activate plan");
2072
2073        for _ in 0..3 {
2074            let customer = CustomerId::new();
2075            seed_customer(&repo, customer);
2076            repo.create_subscription(create_subscription_input(customer, plan.id))
2077                .expect("create subscription");
2078        }
2079
2080        let subs = repo
2081            .list_subscriptions(stateset_core::SubscriptionFilter::default())
2082            .expect("list subscriptions");
2083        assert_eq!(subs.len(), 3);
2084        for sub in &subs {
2085            assert_eq!(sub.items.len(), 2, "each subscription keeps its own two items");
2086            assert!(
2087                sub.items.iter().all(|i| i.subscription_id == sub.id),
2088                "items must belong to their own subscription"
2089            );
2090            let direct =
2091                repo.get_subscription(sub.id).expect("get subscription").expect("present").items;
2092            let mut listed_skus: Vec<_> = sub.items.iter().map(|i| i.sku.clone()).collect();
2093            let mut direct_skus: Vec<_> = direct.iter().map(|i| i.sku.clone()).collect();
2094            listed_skus.sort();
2095            direct_skus.sort();
2096            assert_eq!(listed_skus, direct_skus);
2097        }
2098    }
2099}