1use 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 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 {
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 } 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 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 }; 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 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 }; 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 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 {
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 } 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 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 pub fn create_subscription(&self, input: CreateSubscription) -> Result<Subscription> {
428 stateset_core::Validate::validate(&input)?;
429 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 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 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 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 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 }; 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 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 }; 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 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 {
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 } self.get_subscription(id)?.ok_or(stateset_core::CommerceError::NotFound)
801 }
802
803 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 {
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 } 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 {
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 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 } 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 {
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 } 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 } self.record_event(id, SubscriptionEventType::Skipped, &reason, None, None)?;
1019
1020 self.get_subscription(id)?.ok_or(stateset_core::CommerceError::NotFound)
1021 }
1022
1023 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 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 pub fn create_billing_cycle(&self, input: CreateBillingCycle) -> Result<BillingCycle> {
1163 let CreateBillingCycle { subscription_id, cycle_number, period_start, period_end } = input;
1164 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 {
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 } 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 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 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 {
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 } self.get_billing_cycle(id)?.ok_or(stateset_core::CommerceError::NotFound)
1330 }
1331
1332 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 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(), })
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(), })
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
1681impl 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 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 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 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 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 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 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 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 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 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}