1use crate::sqlite::parse_helpers::{
4 parse_datetime as parse_datetime_safe, parse_decimal as parse_decimal_safe,
5};
6use crate::sqlite::{
7 map_db_error, parse_datetime_opt_row, parse_datetime_row, parse_decimal_opt_row,
8 parse_decimal_row, parse_enum_row, parse_uuid, parse_uuid_opt_row, parse_uuid_row,
9 sum_decimal_query, with_immediate_transaction,
10};
11use chrono::Utc;
12use r2d2::Pool;
13use r2d2_sqlite::SqliteConnectionManager;
14use rusqlite::{OptionalExtension, params, params_from_iter, types::Value};
15use rust_decimal::Decimal;
16use stateset_core::{
17 AccountsReceivableRepository, ApplyCreditMemo, ApplyPaymentToInvoices, ArAgingFilter,
18 ArAgingSummary, ArPaymentApplication, CollectionActivity, CollectionActivityFilter,
19 CollectionActivityType, CollectionStatus, CreateCollectionActivity, CreateCreditMemo,
20 CreateWriteOff, CreditMemo, CreditMemoFilter, CreditMemoStatus, CustomerArAging,
21 CustomerArSummary, CustomerId, CustomerStatement, DunningLetterType, GenerateStatementRequest,
22 Invoice, InvoiceId, OrderId, Result, StatementLineItem, StatementTransactionType, WriteOff,
23 WriteOffFilter, generate_credit_memo_number, generate_write_off_number,
24};
25use std::collections::HashMap;
26use uuid::Uuid;
27
28#[derive(Debug)]
29pub struct SqliteAccountsReceivableRepository {
30 pool: Pool<SqliteConnectionManager>,
31}
32
33impl SqliteAccountsReceivableRepository {
34 #[must_use]
35 pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
36 Self { pool }
37 }
38
39 fn map_collection_activity_row(
40 row: &rusqlite::Row<'_>,
41 ) -> rusqlite::Result<CollectionActivity> {
42 let dunning_letter_type = match row.get::<_, Option<String>>(5)? {
43 Some(value) => {
44 Some(parse_enum_row(&value, "collection_activity", "dunning_letter_type")?)
45 }
46 None => None,
47 };
48
49 Ok(CollectionActivity {
50 id: parse_uuid_row(&row.get::<_, String>(0)?, "collection_activity", "id")?,
51 invoice_id: parse_uuid_row(
52 &row.get::<_, String>(1)?,
53 "collection_activity",
54 "invoice_id",
55 )?,
56 customer_id: parse_uuid_row(
57 &row.get::<_, String>(2)?,
58 "collection_activity",
59 "customer_id",
60 )?,
61 activity_type: parse_enum_row(
62 &row.get::<_, String>(3)?,
63 "collection_activity",
64 "activity_type",
65 )?,
66 activity_date: parse_datetime_row(
67 &row.get::<_, String>(4)?,
68 "collection_activity",
69 "activity_date",
70 )?,
71 dunning_letter_type,
72 notes: row.get(6)?,
73 contact_method: row.get(7)?,
74 contact_result: row.get(8)?,
75 promise_to_pay_date: parse_datetime_opt_row(
76 row.get::<_, Option<String>>(9)?,
77 "collection_activity",
78 "promise_to_pay_date",
79 )?,
80 promise_to_pay_amount: parse_decimal_opt_row(
81 row.get::<_, Option<String>>(10)?,
82 "collection_activity",
83 "promise_to_pay_amount",
84 )?,
85 performed_by: row.get(11)?,
86 created_at: parse_datetime_row(
87 &row.get::<_, String>(12)?,
88 "collection_activity",
89 "created_at",
90 )?,
91 })
92 }
93
94 fn map_write_off_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<WriteOff> {
95 Ok(WriteOff {
96 id: parse_uuid_row(&row.get::<_, String>(0)?, "write_off", "id")?,
97 write_off_number: row.get(1)?,
98 invoice_id: parse_uuid_row(&row.get::<_, String>(2)?, "write_off", "invoice_id")?,
99 customer_id: parse_uuid_row(&row.get::<_, String>(3)?, "write_off", "customer_id")?,
100 amount: parse_decimal_row(&row.get::<_, String>(4)?, "write_off", "amount")?,
101 reason: parse_enum_row(&row.get::<_, String>(5)?, "write_off", "reason")?,
102 notes: row.get(6)?,
103 write_off_date: parse_datetime_row(
104 &row.get::<_, String>(7)?,
105 "write_off",
106 "write_off_date",
107 )?,
108 approved_by: row.get(8)?,
109 approved_at: parse_datetime_opt_row(
110 row.get::<_, Option<String>>(9)?,
111 "write_off",
112 "approved_at",
113 )?,
114 reversed_at: parse_datetime_opt_row(
115 row.get::<_, Option<String>>(10)?,
116 "write_off",
117 "reversed_at",
118 )?,
119 gl_journal_entry_id: parse_uuid_opt_row(
120 row.get::<_, Option<String>>(11)?,
121 "write_off",
122 "gl_journal_entry_id",
123 )?,
124 created_at: parse_datetime_row(&row.get::<_, String>(12)?, "write_off", "created_at")?,
125 })
126 }
127
128 fn map_credit_memo_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CreditMemo> {
129 Ok(CreditMemo {
130 id: parse_uuid_row(&row.get::<_, String>(0)?, "credit_memo", "id")?,
131 credit_memo_number: row.get(1)?,
132 customer_id: parse_uuid_row(&row.get::<_, String>(2)?, "credit_memo", "customer_id")?,
133 original_invoice_id: parse_uuid_opt_row(
134 row.get::<_, Option<String>>(3)?,
135 "credit_memo",
136 "original_invoice_id",
137 )?,
138 reason: parse_enum_row(&row.get::<_, String>(4)?, "credit_memo", "reason")?,
139 amount: parse_decimal_row(&row.get::<_, String>(5)?, "credit_memo", "amount")?,
140 applied_amount: parse_decimal_row(
141 &row.get::<_, String>(6)?,
142 "credit_memo",
143 "applied_amount",
144 )?,
145 unapplied_amount: parse_decimal_row(
146 &row.get::<_, String>(7)?,
147 "credit_memo",
148 "unapplied_amount",
149 )?,
150 status: parse_enum_row(&row.get::<_, String>(8)?, "credit_memo", "status")?,
151 notes: row.get(9)?,
152 issue_date: parse_datetime_row(
153 &row.get::<_, String>(10)?,
154 "credit_memo",
155 "issue_date",
156 )?,
157 gl_journal_entry_id: parse_uuid_opt_row(
158 row.get::<_, Option<String>>(11)?,
159 "credit_memo",
160 "gl_journal_entry_id",
161 )?,
162 created_at: parse_datetime_row(
163 &row.get::<_, String>(12)?,
164 "credit_memo",
165 "created_at",
166 )?,
167 updated_at: parse_datetime_row(
168 &row.get::<_, String>(13)?,
169 "credit_memo",
170 "updated_at",
171 )?,
172 })
173 }
174
175 fn map_payment_application_row(
176 row: &rusqlite::Row<'_>,
177 ) -> rusqlite::Result<ArPaymentApplication> {
178 Ok(ArPaymentApplication {
179 id: parse_uuid_row(&row.get::<_, String>(0)?, "payment_application", "id")?,
180 payment_id: parse_uuid_row(
181 &row.get::<_, String>(1)?,
182 "payment_application",
183 "payment_id",
184 )?,
185 invoice_id: parse_uuid_row(
186 &row.get::<_, String>(2)?,
187 "payment_application",
188 "invoice_id",
189 )?,
190 applied_amount: parse_decimal_row(
191 &row.get::<_, String>(3)?,
192 "payment_application",
193 "applied_amount",
194 )?,
195 applied_date: parse_datetime_row(
196 &row.get::<_, String>(4)?,
197 "payment_application",
198 "applied_date",
199 )?,
200 created_at: parse_datetime_row(
201 &row.get::<_, String>(5)?,
202 "payment_application",
203 "created_at",
204 )?,
205 })
206 }
207
208 fn get_invoice_customer_id(&self, invoice_id: InvoiceId) -> Result<Uuid> {
209 let conn = self
210 .pool
211 .get()
212 .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
213 let customer_id: String = conn
214 .query_row(
215 "SELECT customer_id FROM invoices WHERE id = ?1",
216 params![invoice_id.to_string()],
217 |row| row.get(0),
218 )
219 .map_err(map_db_error)?;
220 parse_uuid_row(&customer_id, "invoice", "customer_id").map_err(map_db_error)
221 }
222
223 fn recalculate_invoice_with_conn(
224 conn: &rusqlite::Connection,
225 invoice_id: InvoiceId,
226 ) -> Result<()> {
227 let inv = invoice_id.to_string();
232 let paid_dec = sum_decimal_query(
233 conn,
234 "SELECT applied_amount FROM ar_payment_applications WHERE invoice_id = ?1",
235 &[&inv as &dyn rusqlite::ToSql],
236 "invoice",
237 "paid_amount",
238 )?;
239 let credits_dec = sum_decimal_query(
240 conn,
241 "SELECT applied_amount FROM ar_credit_memo_applications WHERE invoice_id = ?1",
242 &[&inv as &dyn rusqlite::ToSql],
243 "invoice",
244 "credits_amount",
245 )?;
246 let total_applied = paid_dec + credits_dec;
247
248 let total: String = conn
250 .query_row(
251 "SELECT total FROM invoices WHERE id = ?1",
252 params![invoice_id.to_string()],
253 |row| row.get(0),
254 )
255 .map_err(map_db_error)?;
256
257 let total_dec = parse_decimal_safe(&total, "invoice", "total")?;
258 let balance_due = total_dec - total_applied;
259
260 let status = if balance_due <= Decimal::ZERO {
262 "paid"
263 } else if total_applied > Decimal::ZERO {
264 "partially_paid"
265 } else {
266 "sent" };
268
269 conn.execute(
270 "UPDATE invoices SET amount_paid = ?1, balance_due = ?2, status = ?3 WHERE id = ?4",
271 params![
272 total_applied.to_string(),
273 balance_due.to_string(),
274 status,
275 invoice_id.to_string()
276 ],
277 )
278 .map_err(map_db_error)?;
279
280 Ok(())
281 }
282
283 fn recalculate_invoice(&self, invoice_id: InvoiceId) -> Result<()> {
284 let conn = self
285 .pool
286 .get()
287 .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
288 Self::recalculate_invoice_with_conn(&conn, invoice_id)
289 }
290}
291
292impl AccountsReceivableRepository for SqliteAccountsReceivableRepository {
293 fn get_aging_summary(&self) -> Result<ArAgingSummary> {
294 let conn = self
295 .pool
296 .get()
297 .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
298
299 let now = Utc::now();
300 let cutoff_30 = now - chrono::Duration::days(30);
301 let cutoff_60 = now - chrono::Duration::days(60);
302 let cutoff_90 = now - chrono::Duration::days(90);
303
304 let mut current = Decimal::ZERO;
305 let mut days_1_30 = Decimal::ZERO;
306 let mut days_31_60 = Decimal::ZERO;
307 let mut days_61_90 = Decimal::ZERO;
308 let mut days_over_90 = Decimal::ZERO;
309
310 let mut stmt = conn.prepare(
311 "SELECT due_date, balance_due FROM invoices WHERE status NOT IN ('paid', 'voided', 'written_off')",
312 ).map_err(map_db_error)?;
313 let mut rows = stmt.query([]).map_err(map_db_error)?;
314
315 while let Some(row) = rows.next().map_err(map_db_error)? {
316 let due_date_str: String = row.get(0).map_err(map_db_error)?;
317 let due_date = parse_datetime_safe(&due_date_str, "invoice", "due_date")?;
318 let balance_str: String = row.get(1).map_err(map_db_error)?;
319 let balance = parse_decimal_safe(&balance_str, "invoice", "balance_due")?;
320 if balance <= Decimal::ZERO {
321 continue;
322 }
323
324 if due_date >= now {
325 current += balance;
326 } else if due_date >= cutoff_30 {
327 days_1_30 += balance;
328 } else if due_date >= cutoff_60 {
329 days_31_60 += balance;
330 } else if due_date >= cutoff_90 {
331 days_61_90 += balance;
332 } else {
333 days_over_90 += balance;
334 }
335 }
336
337 Ok(ArAgingSummary {
338 current,
339 days_1_30,
340 days_31_60,
341 days_61_90,
342 days_over_90,
343 total: current + days_1_30 + days_31_60 + days_61_90 + days_over_90,
344 as_of_date: now,
345 })
346 }
347
348 fn get_customer_aging(&self, customer_id: Uuid) -> Result<Option<CustomerArAging>> {
349 let conn = self
350 .pool
351 .get()
352 .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
353 let customer_row: Option<(String, String, String)> = conn
354 .query_row(
355 "SELECT first_name, last_name, email FROM customers WHERE id = ?1",
356 params![customer_id.to_string()],
357 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
358 )
359 .optional()
360 .map_err(map_db_error)?;
361
362 let (first_name, last_name, email) = match customer_row {
363 Some(row) => row,
364 None => return Ok(None),
365 };
366
367 let now = Utc::now();
368 let cutoff_30 = now - chrono::Duration::days(30);
369 let cutoff_60 = now - chrono::Duration::days(60);
370 let cutoff_90 = now - chrono::Duration::days(90);
371
372 let mut current = Decimal::ZERO;
373 let mut days_1_30 = Decimal::ZERO;
374 let mut days_31_60 = Decimal::ZERO;
375 let mut days_61_90 = Decimal::ZERO;
376 let mut days_over_90 = Decimal::ZERO;
377 let mut invoice_count: i32 = 0;
378 let mut oldest_invoice_date: Option<chrono::DateTime<Utc>> = None;
379
380 let mut stmt = conn
381 .prepare(
382 "SELECT due_date, balance_due, created_at
383 FROM invoices
384 WHERE customer_id = ?1
385 AND status NOT IN ('paid', 'voided', 'written_off')",
386 )
387 .map_err(map_db_error)?;
388 let mut rows = stmt.query(params![customer_id.to_string()]).map_err(map_db_error)?;
389
390 while let Some(row) = rows.next().map_err(map_db_error)? {
391 let balance_str: String = row.get(1).map_err(map_db_error)?;
392 let balance = parse_decimal_safe(&balance_str, "invoice", "balance_due")?;
393 if balance <= Decimal::ZERO {
394 continue;
395 }
396
397 invoice_count += 1;
398 let due_date_str: String = row.get(0).map_err(map_db_error)?;
399 let due_date = parse_datetime_safe(&due_date_str, "invoice", "due_date")?;
400 let created_at_str: String = row.get(2).map_err(map_db_error)?;
401 let created_at = parse_datetime_safe(&created_at_str, "invoice", "created_at")?;
402
403 oldest_invoice_date = match oldest_invoice_date {
404 Some(existing) if existing <= created_at => Some(existing),
405 _ => Some(created_at),
406 };
407
408 if due_date >= now {
409 current += balance;
410 } else if due_date >= cutoff_30 {
411 days_1_30 += balance;
412 } else if due_date >= cutoff_60 {
413 days_31_60 += balance;
414 } else if due_date >= cutoff_90 {
415 days_61_90 += balance;
416 } else {
417 days_over_90 += balance;
418 }
419 }
420
421 Ok(Some(CustomerArAging {
426 customer_id,
427 customer_name: Some(format!("{first_name} {last_name}")),
428 customer_email: Some(email),
429 current,
430 days_1_30,
431 days_31_60,
432 days_61_90,
433 days_over_90,
434 total_outstanding: current + days_1_30 + days_31_60 + days_61_90 + days_over_90,
435 invoice_count,
436 oldest_invoice_date,
437 last_payment_date: None,
438 }))
439 }
440
441 fn get_aging_report(&self, filter: ArAgingFilter) -> Result<Vec<CustomerArAging>> {
442 let conn = self
443 .pool
444 .get()
445 .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
446
447 let mut sql = String::from(
448 "SELECT
449 i.customer_id,
450 c.first_name,
451 c.last_name,
452 c.email,
453 i.due_date,
454 i.balance_due,
455 i.created_at
456 FROM invoices i
457 LEFT JOIN customers c ON i.customer_id = c.id
458 WHERE i.status NOT IN ('paid', 'voided', 'written_off')",
459 );
460
461 if filter.customer_id.is_some() {
462 sql.push_str(" AND i.customer_id = ?1");
463 }
464
465 let now = Utc::now();
466 let cutoff_30 = now - chrono::Duration::days(30);
467 let cutoff_60 = now - chrono::Duration::days(60);
468 let cutoff_90 = now - chrono::Duration::days(90);
469
470 #[derive(Default)]
471 struct AgingAccum {
472 customer_id: Uuid,
473 customer_name: Option<String>,
474 customer_email: Option<String>,
475 current: Decimal,
476 days_1_30: Decimal,
477 days_31_60: Decimal,
478 days_61_90: Decimal,
479 days_over_90: Decimal,
480 invoice_count: i32,
481 oldest_invoice_date: Option<chrono::DateTime<Utc>>,
482 }
483
484 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
485 let mut rows = match filter.customer_id {
486 Some(cid) => stmt.query(params![cid.to_string()]).map_err(map_db_error)?,
487 None => stmt.query([]).map_err(map_db_error)?,
488 };
489
490 let mut by_customer: HashMap<Uuid, AgingAccum> = HashMap::new();
491
492 while let Some(row) = rows.next().map_err(map_db_error)? {
493 let id_str: String = row.get(0).map_err(map_db_error)?;
494 let customer_id = parse_uuid(&id_str, "invoice", "customer_id")?;
495 let first_name: Option<String> = row.get(1).map_err(map_db_error)?;
500 let last_name: Option<String> = row.get(2).map_err(map_db_error)?;
501 let email: Option<String> = row.get(3).map_err(map_db_error)?;
502 let due_date_str: String = row.get(4).map_err(map_db_error)?;
503 let balance_str: String = row.get(5).map_err(map_db_error)?;
504 let created_at_str: String = row.get(6).map_err(map_db_error)?;
505
506 let balance = parse_decimal_safe(&balance_str, "invoice", "balance_due")?;
507 if balance <= Decimal::ZERO {
508 continue;
509 }
510
511 let due_date = parse_datetime_safe(&due_date_str, "invoice", "due_date")?;
512 let created_at = parse_datetime_safe(&created_at_str, "invoice", "created_at")?;
513
514 let customer_name = match (first_name, last_name) {
515 (Some(f), Some(l)) => Some(format!("{f} {l}")),
516 (Some(f), None) => Some(f),
517 (None, Some(l)) => Some(l),
518 (None, None) => None,
519 };
520 let entry = by_customer.entry(customer_id).or_insert_with(|| AgingAccum {
521 customer_id,
522 customer_name,
523 customer_email: email,
524 ..Default::default()
525 });
526
527 entry.invoice_count += 1;
528 entry.oldest_invoice_date = match entry.oldest_invoice_date {
529 Some(existing) if existing <= created_at => Some(existing),
530 _ => Some(created_at),
531 };
532
533 if due_date >= now {
534 entry.current += balance;
535 } else if due_date >= cutoff_30 {
536 entry.days_1_30 += balance;
537 } else if due_date >= cutoff_60 {
538 entry.days_31_60 += balance;
539 } else if due_date >= cutoff_90 {
540 entry.days_61_90 += balance;
541 } else {
542 entry.days_over_90 += balance;
543 }
544 }
545
546 let mut results: Vec<CustomerArAging> = by_customer
547 .into_values()
548 .filter(|entry| {
549 if filter.overdue_only.unwrap_or(false)
551 && entry.days_1_30 + entry.days_31_60 + entry.days_61_90 + entry.days_over_90
552 <= Decimal::ZERO
553 {
554 return false;
555 }
556 if let Some(min_balance) = filter.min_balance {
560 let total = entry.current
561 + entry.days_1_30
562 + entry.days_31_60
563 + entry.days_61_90
564 + entry.days_over_90;
565 if total < min_balance {
566 return false;
567 }
568 }
569 if let Some(bucket) = filter.aging_bucket {
572 let has_bucket_balance = match bucket {
573 stateset_core::AgingBucket::Current => entry.current > Decimal::ZERO,
574 stateset_core::AgingBucket::Days1To30 => entry.days_1_30 > Decimal::ZERO,
575 stateset_core::AgingBucket::Days31To60 => entry.days_31_60 > Decimal::ZERO,
576 stateset_core::AgingBucket::Days61To90 => entry.days_61_90 > Decimal::ZERO,
577 stateset_core::AgingBucket::DaysOver90 => {
578 entry.days_over_90 > Decimal::ZERO
579 }
580 _ => true,
582 };
583 if !has_bucket_balance {
584 return false;
585 }
586 }
587 true
588 })
589 .map(|entry| {
590 let total = entry.current
591 + entry.days_1_30
592 + entry.days_31_60
593 + entry.days_61_90
594 + entry.days_over_90;
595 CustomerArAging {
596 customer_id: entry.customer_id,
597 customer_name: entry.customer_name,
598 customer_email: entry.customer_email,
599 current: entry.current,
600 days_1_30: entry.days_1_30,
601 days_31_60: entry.days_31_60,
602 days_61_90: entry.days_61_90,
603 days_over_90: entry.days_over_90,
604 total_outstanding: total,
605 invoice_count: entry.invoice_count,
606 oldest_invoice_date: entry.oldest_invoice_date,
607 last_payment_date: None,
608 }
609 })
610 .collect();
611
612 results.sort_by(|a, b| {
617 b.total_outstanding
618 .cmp(&a.total_outstanding)
619 .then_with(|| a.customer_id.cmp(&b.customer_id))
620 });
621
622 let offset = filter.offset.unwrap_or(0) as usize;
623 let limit = filter.limit.map(|l| l as usize);
624 let results = if offset >= results.len() {
625 Vec::new()
626 } else {
627 let mut sliced = results.split_off(offset);
628 if let Some(limit) = limit {
629 if sliced.len() > limit {
630 sliced.truncate(limit);
631 }
632 }
633 sliced
634 };
635
636 Ok(results)
637 }
638
639 fn log_collection_activity(
640 &self,
641 input: CreateCollectionActivity,
642 ) -> Result<CollectionActivity> {
643 let conn = self
644 .pool
645 .get()
646 .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
647
648 let id = Uuid::new_v4();
649 let now = Utc::now();
650 let customer_id = self.get_invoice_customer_id(input.invoice_id.into())?;
651
652 conn.execute(
653 "INSERT INTO ar_collection_activities (id, invoice_id, customer_id, activity_type, activity_date, dunning_letter_type, notes, contact_method, contact_result, promise_to_pay_date, promise_to_pay_amount, performed_by, created_at)
654 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
655 params![
656 id.to_string(),
657 input.invoice_id.to_string(),
658 customer_id.to_string(),
659 input.activity_type.to_string(),
660 now.to_rfc3339(),
661 input.dunning_letter_type.map(|d| d.to_string()),
662 input.notes,
663 input.contact_method,
664 input.contact_result,
665 input.promise_to_pay_date.map(|d| d.to_rfc3339()),
666 input.promise_to_pay_amount.map(|a| a.to_string()),
667 input.performed_by,
668 now.to_rfc3339()
669 ],
670 ).map_err(map_db_error)?;
671
672 Ok(CollectionActivity {
673 id,
674 invoice_id: input.invoice_id,
675 customer_id,
676 activity_type: input.activity_type,
677 activity_date: now,
678 dunning_letter_type: input.dunning_letter_type,
679 notes: input.notes,
680 contact_method: input.contact_method,
681 contact_result: input.contact_result,
682 promise_to_pay_date: input.promise_to_pay_date,
683 promise_to_pay_amount: input.promise_to_pay_amount,
684 performed_by: input.performed_by,
685 created_at: now,
686 })
687 }
688
689 fn list_collection_activities(
690 &self,
691 filter: CollectionActivityFilter,
692 ) -> Result<Vec<CollectionActivity>> {
693 let conn = self
694 .pool
695 .get()
696 .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
697
698 let mut sql = String::from(
699 "SELECT id, invoice_id, customer_id, activity_type, activity_date, dunning_letter_type, notes, contact_method, contact_result, promise_to_pay_date, promise_to_pay_amount, performed_by, created_at
700 FROM ar_collection_activities WHERE 1=1"
701 );
702 let mut params_vec: Vec<Value> = Vec::new();
703
704 if let Some(inv_id) = &filter.invoice_id {
705 sql.push_str(" AND invoice_id = ?");
706 params_vec.push(Value::Text(inv_id.to_string()));
707 }
708 if let Some(cust_id) = &filter.customer_id {
709 sql.push_str(" AND customer_id = ?");
710 params_vec.push(Value::Text(cust_id.to_string()));
711 }
712 if let Some(atype) = &filter.activity_type {
713 sql.push_str(" AND activity_type = ?");
714 params_vec.push(Value::Text(atype.to_string()));
715 }
716
717 sql.push_str(" ORDER BY activity_date DESC");
718
719 if let Some(limit) = filter.limit {
720 sql.push_str(" LIMIT ?");
721 params_vec.push(Value::Integer(i64::from(limit)));
722 }
723 if let Some(offset) = filter.offset {
724 sql.push_str(" OFFSET ?");
725 params_vec.push(Value::Integer(i64::from(offset)));
726 }
727
728 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
729 let rows = stmt
730 .query_map(params_from_iter(params_vec), Self::map_collection_activity_row)
731 .map_err(map_db_error)?;
732 rows.collect::<rusqlite::Result<Vec<_>>>().map_err(map_db_error)
733 }
734
735 fn update_collection_status(
736 &self,
737 invoice_id: InvoiceId,
738 status: CollectionStatus,
739 ) -> Result<()> {
740 let conn = self
741 .pool
742 .get()
743 .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
744
745 conn.execute(
746 "UPDATE invoices SET collection_status = ?1 WHERE id = ?2",
747 params![status.to_string(), invoice_id.to_string()],
748 )
749 .map_err(map_db_error)?;
750
751 Ok(())
752 }
753
754 fn get_invoices_due_for_dunning(&self) -> Result<Vec<Invoice>> {
755 let conn = self
757 .pool
758 .get()
759 .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
760
761 let mut stmt = conn.prepare(
765 "SELECT id, invoice_number, order_id, customer_id, status, invoice_date, due_date, subtotal, tax_amount, shipping_amount, discount_amount, total, amount_paid, balance_due, currency, notes, terms, created_at, updated_at
766 FROM invoices
767 WHERE status NOT IN ('paid', 'voided', 'written_off')
768 AND due_date < datetime('now')
769 AND (last_dunning_date IS NULL OR last_dunning_date < datetime('now', '-7 days'))
770 ORDER BY due_date ASC"
771 ).map_err(map_db_error)?;
772
773 let rows = stmt
774 .query_map([], |row| {
775 Ok(Invoice {
776 id: InvoiceId::from(parse_uuid_row(
777 &row.get::<_, String>(0)?,
778 "invoice",
779 "id",
780 )?),
781 invoice_number: row.get(1)?,
782 order_id: parse_uuid_opt_row(
783 row.get::<_, Option<String>>(2)?,
784 "invoice",
785 "order_id",
786 )?
787 .map(OrderId::from),
788 customer_id: CustomerId::from(parse_uuid_row(
789 &row.get::<_, String>(3)?,
790 "invoice",
791 "customer_id",
792 )?),
793 status: parse_enum_row(&row.get::<_, String>(4)?, "invoice", "status")?,
794 invoice_type: stateset_core::InvoiceType::Standard,
795 invoice_date: parse_datetime_row(
796 &row.get::<_, String>(5)?,
797 "invoice",
798 "invoice_date",
799 )?,
800 due_date: parse_datetime_row(&row.get::<_, String>(6)?, "invoice", "due_date")?,
801 payment_terms: None,
802 subtotal: parse_decimal_row(&row.get::<_, String>(7)?, "invoice", "subtotal")?,
803 tax_amount: parse_decimal_row(
804 &row.get::<_, String>(8)?,
805 "invoice",
806 "tax_amount",
807 )?,
808 tax_rate: None,
809 shipping_amount: parse_decimal_row(
810 &row.get::<_, String>(9)?,
811 "invoice",
812 "shipping_amount",
813 )?,
814 discount_amount: parse_decimal_row(
815 &row.get::<_, String>(10)?,
816 "invoice",
817 "discount_amount",
818 )?,
819 discount_percent: None,
820 total: parse_decimal_row(&row.get::<_, String>(11)?, "invoice", "total")?,
821 amount_paid: parse_decimal_row(
822 &row.get::<_, String>(12)?,
823 "invoice",
824 "amount_paid",
825 )?,
826 balance_due: parse_decimal_row(
827 &row.get::<_, String>(13)?,
828 "invoice",
829 "balance_due",
830 )?,
831 currency: row.get(14)?,
832 billing_name: None,
833 billing_email: None,
834 billing_address: None,
835 billing_city: None,
836 billing_state: None,
837 billing_postal_code: None,
838 billing_country: None,
839 po_number: None,
840 notes: row.get(15)?,
841 terms: row.get(16)?,
842 footer: None,
843 sent_at: None,
844 viewed_at: None,
845 paid_at: None,
846 voided_at: None,
847 items: vec![],
848 created_at: parse_datetime_row(
849 &row.get::<_, String>(17)?,
850 "invoice",
851 "created_at",
852 )?,
853 updated_at: parse_datetime_row(
854 &row.get::<_, String>(18)?,
855 "invoice",
856 "updated_at",
857 )?,
858 })
859 })
860 .map_err(map_db_error)?;
861
862 let invoices = rows.collect::<rusqlite::Result<Vec<_>>>().map_err(map_db_error)?;
863 Ok(invoices.into_iter().filter(|invoice| invoice.balance_due > Decimal::ZERO).collect())
866 }
867
868 fn send_dunning_letter(
869 &self,
870 invoice_id: InvoiceId,
871 letter_type: DunningLetterType,
872 sent_by: Option<&str>,
873 ) -> Result<CollectionActivity> {
874 let conn = self
875 .pool
876 .get()
877 .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
878
879 conn.execute(
881 "UPDATE invoices SET last_dunning_date = datetime('now'), dunning_count = COALESCE(dunning_count, 0) + 1 WHERE id = ?1",
882 params![invoice_id.to_string()],
883 ).map_err(map_db_error)?;
884
885 let new_status = match letter_type {
887 DunningLetterType::Reminder1 => CollectionStatus::Reminder1Sent,
888 DunningLetterType::Reminder2 => CollectionStatus::Reminder2Sent,
889 DunningLetterType::Reminder3 => CollectionStatus::Reminder3Sent,
890 DunningLetterType::DemandLetter | DunningLetterType::CollectionNotice => {
891 CollectionStatus::InCollections
892 }
893 _ => CollectionStatus::InCollections,
894 };
895
896 self.update_collection_status(invoice_id, new_status)?;
897
898 self.log_collection_activity(CreateCollectionActivity {
900 invoice_id: invoice_id.into(),
901 activity_type: CollectionActivityType::DunningLetterSent,
902 dunning_letter_type: Some(letter_type),
903 notes: Some(format!("Sent {letter_type} dunning letter")),
904 performed_by: sent_by.map(std::string::ToString::to_string),
905 ..Default::default()
906 })
907 }
908
909 fn create_write_off(&self, input: CreateWriteOff) -> Result<WriteOff> {
910 let id = Uuid::new_v4();
911 let now = Utc::now();
912 let write_off_number = generate_write_off_number();
913
914 let customer_id = with_immediate_transaction(&self.pool, |tx| {
919 let to_rusqlite = |e: stateset_core::CommerceError| {
920 rusqlite::Error::ToSqlConversionFailure(Box::new(e))
921 };
922
923 let customer_str: String = tx.query_row(
924 "SELECT customer_id FROM invoices WHERE id = ?1",
925 params![input.invoice_id.to_string()],
926 |row| row.get(0),
927 )?;
928 let customer_id = parse_uuid_row(&customer_str, "invoice", "customer_id")?;
929
930 let rows = tx.execute(
931 "UPDATE invoices SET status = 'written_off', collection_status = 'written_off'
932 WHERE id = ?1 AND status != 'written_off'",
933 params![input.invoice_id.to_string()],
934 )?;
935 if rows == 0 {
936 return Err(to_rusqlite(stateset_core::CommerceError::Conflict(
937 "Invoice not found or already written off".into(),
938 )));
939 }
940
941 tx.execute(
942 "INSERT INTO ar_write_offs (id, write_off_number, invoice_id, customer_id, amount, reason, notes, write_off_date, approved_by, approved_at, created_at)
943 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
944 params![
945 id.to_string(),
946 write_off_number,
947 input.invoice_id.to_string(),
948 customer_id.to_string(),
949 input.amount.to_string(),
950 input.reason.to_string(),
951 &input.notes,
952 now.to_rfc3339(),
953 &input.approved_by,
954 input.approved_by.as_ref().map(|_| now.to_rfc3339()),
955 now.to_rfc3339()
956 ],
957 )?;
958
959 Ok(customer_id)
960 })?;
961
962 Ok(WriteOff {
963 id,
964 write_off_number,
965 invoice_id: input.invoice_id,
966 customer_id,
967 amount: input.amount,
968 reason: input.reason,
969 notes: input.notes,
970 write_off_date: now,
971 approved_by: input.approved_by.clone(),
972 approved_at: input.approved_by.map(|_| now),
973 reversed_at: None,
974 gl_journal_entry_id: None,
975 created_at: now,
976 })
977 }
978
979 fn get_write_off(&self, id: Uuid) -> Result<Option<WriteOff>> {
980 let conn = self
981 .pool
982 .get()
983 .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
984
985 let result = conn.query_row(
986 "SELECT id, write_off_number, invoice_id, customer_id, amount, reason, notes, write_off_date, approved_by, approved_at, reversed_at, gl_journal_entry_id, created_at
987 FROM ar_write_offs WHERE id = ?1",
988 params![id.to_string()],
989 Self::map_write_off_row,
990 );
991
992 match result {
993 Ok(wo) => Ok(Some(wo)),
994 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
995 Err(e) => Err(map_db_error(e)),
996 }
997 }
998
999 fn list_write_offs(&self, filter: WriteOffFilter) -> Result<Vec<WriteOff>> {
1000 let conn = self
1001 .pool
1002 .get()
1003 .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1004
1005 let mut sql = String::from(
1006 "SELECT id, write_off_number, invoice_id, customer_id, amount, reason, notes, write_off_date, approved_by, approved_at, reversed_at, gl_journal_entry_id, created_at
1007 FROM ar_write_offs WHERE 1=1"
1008 );
1009 let mut params_vec: Vec<Value> = Vec::new();
1010
1011 if let Some(cust_id) = &filter.customer_id {
1012 sql.push_str(" AND customer_id = ?");
1013 params_vec.push(Value::Text(cust_id.to_string()));
1014 }
1015 if let Some(inv_id) = &filter.invoice_id {
1016 sql.push_str(" AND invoice_id = ?");
1017 params_vec.push(Value::Text(inv_id.to_string()));
1018 }
1019 if !filter.include_reversed.unwrap_or(false) {
1020 sql.push_str(" AND reversed_at IS NULL");
1021 }
1022
1023 sql.push_str(" ORDER BY write_off_date DESC");
1024
1025 if let Some(limit) = filter.limit {
1026 sql.push_str(" LIMIT ?");
1027 params_vec.push(Value::Integer(i64::from(limit)));
1028 }
1029
1030 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
1031 let rows = stmt
1032 .query_map(params_from_iter(params_vec), Self::map_write_off_row)
1033 .map_err(map_db_error)?;
1034 rows.collect::<rusqlite::Result<Vec<_>>>().map_err(map_db_error)
1035 }
1036
1037 fn reverse_write_off(&self, id: Uuid) -> Result<WriteOff> {
1038 let now = Utc::now();
1039
1040 let wo = self.get_write_off(id)?.ok_or(stateset_core::CommerceError::NotFound)?;
1042
1043 if wo.reversed_at.is_some() {
1044 return Err(stateset_core::CommerceError::ValidationError(
1045 "Write-off already reversed".into(),
1046 ));
1047 }
1048
1049 with_immediate_transaction(&self.pool, |tx| {
1053 let to_rusqlite = |e: stateset_core::CommerceError| {
1054 rusqlite::Error::ToSqlConversionFailure(Box::new(e))
1055 };
1056
1057 let rows = tx.execute(
1058 "UPDATE ar_write_offs SET reversed_at = ?1 WHERE id = ?2 AND reversed_at IS NULL",
1059 params![now.to_rfc3339(), id.to_string()],
1060 )?;
1061 if rows == 0 {
1062 return Err(to_rusqlite(stateset_core::CommerceError::Conflict(
1063 "Write-off not found or already reversed".into(),
1064 )));
1065 }
1066
1067 tx.execute(
1068 "UPDATE invoices SET status = 'overdue', collection_status = 'none' WHERE id = ?1",
1069 params![wo.invoice_id.to_string()],
1070 )?;
1071
1072 Ok(())
1073 })?;
1074
1075 Ok(WriteOff { reversed_at: Some(now), ..wo })
1076 }
1077
1078 fn create_credit_memo(&self, input: CreateCreditMemo) -> Result<CreditMemo> {
1079 let conn = self
1080 .pool
1081 .get()
1082 .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1083
1084 let id = Uuid::new_v4();
1085 let now = Utc::now();
1086 let credit_memo_number = generate_credit_memo_number();
1087
1088 conn.execute(
1089 "INSERT INTO ar_credit_memos (id, credit_memo_number, customer_id, original_invoice_id, reason, amount, applied_amount, unapplied_amount, status, notes, issue_date, created_at, updated_at)
1090 VALUES (?1, ?2, ?3, ?4, ?5, ?6, '0', ?7, 'open', ?8, ?9, ?10, ?11)",
1091 params![
1092 id.to_string(),
1093 credit_memo_number,
1094 input.customer_id.to_string(),
1095 input.original_invoice_id.map(|i| i.to_string()),
1096 input.reason.to_string(),
1097 input.amount.to_string(),
1098 input.amount.to_string(), input.notes,
1100 now.to_rfc3339(),
1101 now.to_rfc3339(),
1102 now.to_rfc3339()
1103 ],
1104 ).map_err(map_db_error)?;
1105
1106 Ok(CreditMemo {
1107 id,
1108 credit_memo_number,
1109 customer_id: input.customer_id,
1110 original_invoice_id: input.original_invoice_id,
1111 reason: input.reason,
1112 amount: input.amount,
1113 applied_amount: Decimal::ZERO,
1114 unapplied_amount: input.amount,
1115 status: CreditMemoStatus::Open,
1116 notes: input.notes,
1117 issue_date: now,
1118 gl_journal_entry_id: None,
1119 created_at: now,
1120 updated_at: now,
1121 })
1122 }
1123
1124 fn get_credit_memo(&self, id: Uuid) -> Result<Option<CreditMemo>> {
1125 let conn = self
1126 .pool
1127 .get()
1128 .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1129
1130 let result = conn.query_row(
1131 "SELECT id, credit_memo_number, customer_id, original_invoice_id, reason, amount, applied_amount, unapplied_amount, status, notes, issue_date, gl_journal_entry_id, created_at, updated_at
1132 FROM ar_credit_memos WHERE id = ?1",
1133 params![id.to_string()],
1134 Self::map_credit_memo_row,
1135 );
1136
1137 match result {
1138 Ok(cm) => Ok(Some(cm)),
1139 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1140 Err(e) => Err(map_db_error(e)),
1141 }
1142 }
1143
1144 fn get_credit_memo_by_number(&self, number: &str) -> Result<Option<CreditMemo>> {
1145 let conn = self
1146 .pool
1147 .get()
1148 .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1149
1150 let result = conn.query_row(
1151 "SELECT id, credit_memo_number, customer_id, original_invoice_id, reason, amount, applied_amount, unapplied_amount, status, notes, issue_date, gl_journal_entry_id, created_at, updated_at
1152 FROM ar_credit_memos WHERE credit_memo_number = ?1",
1153 params![number],
1154 Self::map_credit_memo_row,
1155 );
1156
1157 match result {
1158 Ok(cm) => Ok(Some(cm)),
1159 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1160 Err(e) => Err(map_db_error(e)),
1161 }
1162 }
1163
1164 fn list_credit_memos(&self, filter: CreditMemoFilter) -> Result<Vec<CreditMemo>> {
1165 let conn = self
1166 .pool
1167 .get()
1168 .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1169
1170 let mut sql = String::from(
1171 "SELECT id, credit_memo_number, customer_id, original_invoice_id, reason, amount, applied_amount, unapplied_amount, status, notes, issue_date, gl_journal_entry_id, created_at, updated_at
1172 FROM ar_credit_memos WHERE 1=1"
1173 );
1174 let mut params_vec: Vec<Value> = Vec::new();
1175
1176 if let Some(cust_id) = &filter.customer_id {
1177 sql.push_str(" AND customer_id = ?");
1178 params_vec.push(Value::Text(cust_id.to_string()));
1179 }
1180 if let Some(status) = &filter.status {
1181 sql.push_str(" AND status = ?");
1182 params_vec.push(Value::Text(status.to_string()));
1183 }
1184 let has_unapplied = filter.has_unapplied.unwrap_or(false);
1189
1190 sql.push_str(" ORDER BY issue_date DESC");
1191
1192 if !has_unapplied {
1193 if let Some(limit) = filter.limit {
1194 sql.push_str(" LIMIT ?");
1195 params_vec.push(Value::Integer(i64::from(limit)));
1196 }
1197 }
1198
1199 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
1200 let rows = stmt
1201 .query_map(params_from_iter(params_vec), Self::map_credit_memo_row)
1202 .map_err(map_db_error)?;
1203 let mut memos = rows.collect::<rusqlite::Result<Vec<_>>>().map_err(map_db_error)?;
1204 if has_unapplied {
1205 memos.retain(|memo| memo.unapplied_amount > Decimal::ZERO);
1206 if let Some(limit) = filter.limit {
1207 memos.truncate(limit as usize);
1208 }
1209 }
1210 Ok(memos)
1211 }
1212
1213 fn apply_credit_memo(&self, input: ApplyCreditMemo) -> Result<CreditMemo> {
1214 if input.amount <= Decimal::ZERO {
1215 return Err(stateset_core::CommerceError::ValidationError(
1216 "Credit memo application amount must be greater than zero".into(),
1217 ));
1218 }
1219
1220 let cm = self
1221 .get_credit_memo(input.credit_memo_id)?
1222 .ok_or(stateset_core::CommerceError::NotFound)?;
1223
1224 if !cm.can_apply() {
1225 return Err(stateset_core::CommerceError::ValidationError(
1226 "Credit memo cannot be applied".into(),
1227 ));
1228 }
1229
1230 if input.amount > cm.unapplied_amount {
1231 return Err(stateset_core::CommerceError::ValidationError(
1232 "Amount exceeds unapplied balance".into(),
1233 ));
1234 }
1235
1236 let invoice_customer_id = self.get_invoice_customer_id(input.invoice_id.into())?;
1237 if invoice_customer_id != cm.customer_id {
1238 return Err(stateset_core::CommerceError::ValidationError(
1239 "Credit memo and invoice customer must match".into(),
1240 ));
1241 }
1242
1243 let now = Utc::now();
1244 let app_id = Uuid::new_v4();
1245 let cm_id_str = input.credit_memo_id.to_string();
1246 let invoice_id_str = input.invoice_id.to_string();
1247
1248 let (new_applied, new_unapplied, new_status) = with_immediate_transaction(
1255 &self.pool,
1256 |tx| {
1257 let to_rusqlite = |e: stateset_core::CommerceError| {
1258 rusqlite::Error::ToSqlConversionFailure(Box::new(e))
1259 };
1260
1261 let (applied_str, unapplied_str, status_str): (String, String, String) = tx
1265 .query_row(
1266 "SELECT applied_amount, unapplied_amount, status FROM ar_credit_memos WHERE id = ?1",
1267 params![cm_id_str],
1268 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1269 )?;
1270 if status_str == "voided" {
1271 return Err(to_rusqlite(stateset_core::CommerceError::Conflict(
1272 "Credit memo was voided concurrently".into(),
1273 )));
1274 }
1275 let cur_applied = parse_decimal_safe(&applied_str, "credit_memo", "applied_amount")
1276 .map_err(to_rusqlite)?;
1277 let cur_unapplied =
1278 parse_decimal_safe(&unapplied_str, "credit_memo", "unapplied_amount")
1279 .map_err(to_rusqlite)?;
1280
1281 if input.amount > cur_unapplied {
1282 return Err(to_rusqlite(stateset_core::CommerceError::ValidationError(
1283 "Amount exceeds unapplied balance".into(),
1284 )));
1285 }
1286
1287 let balance_str: String = tx.query_row(
1289 "SELECT balance_due FROM invoices WHERE id = ?1",
1290 params![invoice_id_str],
1291 |row| row.get(0),
1292 )?;
1293 let balance_due = parse_decimal_safe(&balance_str, "invoice", "balance_due")
1294 .map_err(to_rusqlite)?;
1295 if input.amount > balance_due {
1296 return Err(to_rusqlite(stateset_core::CommerceError::ValidationError(
1297 "Credit amount exceeds invoice balance due".into(),
1298 )));
1299 }
1300
1301 let new_applied = cur_applied + input.amount;
1302 let new_unapplied = cur_unapplied - input.amount;
1303 let new_status = if new_unapplied <= Decimal::ZERO {
1304 CreditMemoStatus::FullyApplied
1305 } else {
1306 CreditMemoStatus::PartiallyApplied
1307 };
1308
1309 tx.execute(
1310 "INSERT INTO ar_credit_memo_applications (id, credit_memo_id, invoice_id, applied_amount, applied_date, created_at)
1311 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1312 params![
1313 app_id.to_string(),
1314 cm_id_str,
1315 invoice_id_str,
1316 input.amount.to_string(),
1317 now.to_rfc3339(),
1318 now.to_rfc3339()
1319 ],
1320 )?;
1321
1322 let rows = tx.execute(
1325 "UPDATE ar_credit_memos SET applied_amount = ?1, unapplied_amount = ?2, status = ?3
1326 WHERE id = ?4 AND unapplied_amount = ?5",
1327 params![
1328 new_applied.to_string(),
1329 new_unapplied.to_string(),
1330 new_status.to_string(),
1331 cm_id_str,
1332 unapplied_str
1333 ],
1334 )?;
1335 if rows == 0 {
1336 return Err(to_rusqlite(stateset_core::CommerceError::Conflict(
1337 "Credit memo was modified concurrently".into(),
1338 )));
1339 }
1340
1341 Self::recalculate_invoice_with_conn(tx, input.invoice_id.into())
1342 .map_err(to_rusqlite)?;
1343
1344 Ok((new_applied, new_unapplied, new_status))
1345 },
1346 )?;
1347
1348 Ok(CreditMemo {
1349 applied_amount: new_applied,
1350 unapplied_amount: new_unapplied,
1351 status: new_status,
1352 updated_at: now,
1353 ..cm
1354 })
1355 }
1356
1357 fn void_credit_memo(&self, id: Uuid) -> Result<CreditMemo> {
1358 let cm = self.get_credit_memo(id)?.ok_or(stateset_core::CommerceError::NotFound)?;
1359
1360 with_immediate_transaction(&self.pool, |tx| {
1365 let to_rusqlite = |e: stateset_core::CommerceError| {
1366 rusqlite::Error::ToSqlConversionFailure(Box::new(e))
1367 };
1368
1369 let applied_str: String = tx.query_row(
1370 "SELECT applied_amount FROM ar_credit_memos WHERE id = ?1",
1371 params![id.to_string()],
1372 |row| row.get(0),
1373 )?;
1374 let applied = parse_decimal_safe(&applied_str, "credit_memo", "applied_amount")
1375 .map_err(to_rusqlite)?;
1376 if applied > Decimal::ZERO {
1377 return Err(to_rusqlite(stateset_core::CommerceError::ValidationError(
1378 "Cannot void credit memo with applications".into(),
1379 )));
1380 }
1381
1382 let rows = tx.execute(
1383 "UPDATE ar_credit_memos SET status = 'voided' WHERE id = ?1 AND status != 'voided'",
1384 params![id.to_string()],
1385 )?;
1386 if rows == 0 {
1387 return Err(to_rusqlite(stateset_core::CommerceError::Conflict(
1388 "Credit memo not found or already voided".into(),
1389 )));
1390 }
1391 Ok(())
1392 })?;
1393
1394 Ok(CreditMemo { status: CreditMemoStatus::Voided, updated_at: Utc::now(), ..cm })
1395 }
1396
1397 fn get_unapplied_credits(&self, customer_id: Uuid) -> Result<Vec<CreditMemo>> {
1398 self.list_credit_memos(CreditMemoFilter {
1399 customer_id: Some(customer_id),
1400 has_unapplied: Some(true),
1401 ..Default::default()
1402 })
1403 }
1404
1405 fn apply_payment_to_invoices(
1406 &self,
1407 input: ApplyPaymentToInvoices,
1408 ) -> Result<Vec<ArPaymentApplication>> {
1409 if input.applications.is_empty() {
1410 return Ok(Vec::new());
1411 }
1412
1413 let now = Utc::now();
1414
1415 let applications = with_immediate_transaction(&self.pool, |tx| {
1421 let to_rusqlite = |e: stateset_core::CommerceError| {
1422 rusqlite::Error::ToSqlConversionFailure(Box::new(e))
1423 };
1424 let mut applications = Vec::new();
1425 let mut expected_customer_id: Option<Uuid> = None;
1426
1427 for app in &input.applications {
1428 if app.amount <= Decimal::ZERO {
1429 return Err(to_rusqlite(stateset_core::CommerceError::ValidationError(
1430 "Payment application amount must be greater than zero".into(),
1431 )));
1432 }
1433
1434 let customer_str: String = tx.query_row(
1439 "SELECT customer_id FROM invoices WHERE id = ?1",
1440 params![app.invoice_id.to_string()],
1441 |row| row.get(0),
1442 )?;
1443 let invoice_customer_id = parse_uuid_row(&customer_str, "invoice", "customer_id")?;
1444 if let Some(expected) = expected_customer_id {
1445 if expected != invoice_customer_id {
1446 return Err(to_rusqlite(stateset_core::CommerceError::ValidationError(
1447 "All invoice applications for a payment must belong to the same customer"
1448 .into(),
1449 )));
1450 }
1451 } else {
1452 expected_customer_id = Some(invoice_customer_id);
1453 }
1454
1455 let balance_str: String = tx.query_row(
1456 "SELECT balance_due FROM invoices WHERE id = ?1",
1457 params![app.invoice_id.to_string()],
1458 |row| row.get(0),
1459 )?;
1460 let balance_due = parse_decimal_safe(&balance_str, "invoice", "balance_due")
1461 .map_err(to_rusqlite)?;
1462 if app.amount > balance_due {
1463 return Err(to_rusqlite(stateset_core::CommerceError::ValidationError(
1464 "Payment application amount exceeds invoice balance due".into(),
1465 )));
1466 }
1467
1468 let app_id = Uuid::new_v4();
1469
1470 tx.execute(
1471 "INSERT INTO ar_payment_applications (id, payment_id, invoice_id, applied_amount, applied_date, created_at)
1472 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1473 params![
1474 app_id.to_string(),
1475 input.payment_id.to_string(),
1476 app.invoice_id.to_string(),
1477 app.amount.to_string(),
1478 now.to_rfc3339(),
1479 now.to_rfc3339()
1480 ],
1481 )?;
1482
1483 Self::recalculate_invoice_with_conn(tx, app.invoice_id.into())
1484 .map_err(to_rusqlite)?;
1485
1486 applications.push(ArPaymentApplication {
1487 id: app_id,
1488 payment_id: input.payment_id,
1489 invoice_id: app.invoice_id,
1490 applied_amount: app.amount,
1491 applied_date: now,
1492 created_at: now,
1493 });
1494 }
1495
1496 Ok(applications)
1497 })?;
1498
1499 Ok(applications)
1500 }
1501
1502 fn get_payment_applications(&self, payment_id: Uuid) -> Result<Vec<ArPaymentApplication>> {
1503 let conn = self
1504 .pool
1505 .get()
1506 .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1507
1508 let mut stmt = conn
1509 .prepare(
1510 "SELECT id, payment_id, invoice_id, applied_amount, applied_date, created_at
1511 FROM ar_payment_applications WHERE payment_id = ?1",
1512 )
1513 .map_err(map_db_error)?;
1514
1515 let rows = stmt
1516 .query_map(params![payment_id.to_string()], Self::map_payment_application_row)
1517 .map_err(map_db_error)?;
1518 rows.collect::<rusqlite::Result<Vec<_>>>().map_err(map_db_error)
1519 }
1520
1521 fn unapply_payment(&self, application_id: Uuid) -> Result<()> {
1522 let conn = self
1523 .pool
1524 .get()
1525 .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1526
1527 let invoice_id: String = conn
1529 .query_row(
1530 "SELECT invoice_id FROM ar_payment_applications WHERE id = ?1",
1531 params![application_id.to_string()],
1532 |row| row.get(0),
1533 )
1534 .map_err(map_db_error)?;
1535
1536 conn.execute(
1538 "DELETE FROM ar_payment_applications WHERE id = ?1",
1539 params![application_id.to_string()],
1540 )
1541 .map_err(map_db_error)?;
1542
1543 let parsed_invoice_id = parse_uuid_row(&invoice_id, "payment_application", "invoice_id")
1545 .map_err(map_db_error)?;
1546 self.recalculate_invoice(parsed_invoice_id.into())?;
1547
1548 Ok(())
1549 }
1550
1551 fn get_customer_summary(&self, customer_id: Uuid) -> Result<Option<CustomerArSummary>> {
1552 let aging = match self.get_customer_aging(customer_id)? {
1553 Some(aging) => aging,
1554 None => return Ok(None),
1555 };
1556
1557 let unapplied_credits =
1559 self.get_unapplied_credits(customer_id)?.iter().map(|cm| cm.unapplied_amount).sum();
1560
1561 let total_overdue = aging.total_overdue();
1562 Ok(Some(CustomerArSummary {
1563 customer_id,
1564 customer_name: aging.customer_name.clone(),
1565 total_outstanding: aging.total_outstanding,
1566 total_overdue,
1567 credit_limit: None,
1568 available_credit: None,
1569 unapplied_credits,
1570 unapplied_payments: Decimal::ZERO,
1571 average_days_to_pay: None,
1572 oldest_open_invoice: aging.oldest_invoice_date,
1573 last_activity_date: None,
1574 collection_status: CollectionStatus::None,
1575 }))
1576 }
1577
1578 fn generate_statement(&self, request: GenerateStatementRequest) -> Result<CustomerStatement> {
1579 let conn = self
1580 .pool
1581 .get()
1582 .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1583
1584 let now = Utc::now();
1585 let period_start = request.period_start.unwrap_or_else(|| now - chrono::Duration::days(30));
1586 let period_end = request.period_end.unwrap_or(now);
1587
1588 let (customer_name, customer_email): (String, Option<String>) = conn
1590 .query_row(
1591 "SELECT first_name || ' ' || last_name, email FROM customers WHERE id = ?1",
1592 params![request.customer_id.to_string()],
1593 |row| Ok((row.get(0)?, row.get(1)?)),
1594 )
1595 .map_err(map_db_error)?;
1596
1597 let aging = self
1599 .get_customer_aging(request.customer_id)?
1600 .ok_or(stateset_core::CommerceError::NotFound)?;
1601
1602 let mut line_items: Vec<StatementLineItem> = Vec::new();
1604 let mut running_balance = Decimal::ZERO;
1605
1606 let mut stmt = conn
1608 .prepare(
1609 "SELECT created_at, invoice_number, total FROM invoices
1610 WHERE customer_id = ?1 AND created_at >= ?2 AND created_at <= ?3
1611 ORDER BY created_at",
1612 )
1613 .map_err(map_db_error)?;
1614
1615 let inv_rows = stmt
1616 .query_map(
1617 params![
1618 request.customer_id.to_string(),
1619 period_start.to_rfc3339(),
1620 period_end.to_rfc3339()
1621 ],
1622 |row| {
1623 let date: String = row.get(0)?;
1624 let number: String = row.get(1)?;
1625 let total: String = row.get(2)?;
1626 Ok((date, number, total))
1627 },
1628 )
1629 .map_err(map_db_error)?;
1630
1631 for inv in inv_rows {
1632 let (date, number, total) = inv.map_err(map_db_error)?;
1633 let amount = parse_decimal_safe(&total, "statement_invoice", "total")?;
1634 running_balance += amount;
1635 line_items.push(StatementLineItem {
1636 date: parse_datetime_safe(&date, "statement_invoice", "created_at")?,
1637 transaction_type: StatementTransactionType::Invoice,
1638 reference_number: number,
1639 description: "Invoice".into(),
1640 debit: Some(amount),
1641 credit: None,
1642 balance: running_balance,
1643 });
1644 }
1645
1646 let mut stmt = conn
1648 .prepare(
1649 "SELECT pa.applied_date, p.id, pa.applied_amount
1650 FROM ar_payment_applications pa
1651 JOIN payments p ON pa.payment_id = p.id
1652 JOIN invoices i ON pa.invoice_id = i.id
1653 WHERE i.customer_id = ?1 AND pa.applied_date >= ?2 AND pa.applied_date <= ?3
1654 ORDER BY pa.applied_date",
1655 )
1656 .map_err(map_db_error)?;
1657
1658 let pay_rows = stmt
1659 .query_map(
1660 params![
1661 request.customer_id.to_string(),
1662 period_start.to_rfc3339(),
1663 period_end.to_rfc3339()
1664 ],
1665 |row| {
1666 let date: String = row.get(0)?;
1667 let id: String = row.get(1)?;
1668 let amount: String = row.get(2)?;
1669 Ok((date, id, amount))
1670 },
1671 )
1672 .map_err(map_db_error)?;
1673
1674 for pay in pay_rows {
1675 let (date, id, amount_str) = pay.map_err(map_db_error)?;
1676 let amount = parse_decimal_safe(&amount_str, "statement_payment", "applied_amount")?;
1677 running_balance -= amount;
1678 line_items.push(StatementLineItem {
1679 date: parse_datetime_safe(&date, "statement_payment", "applied_date")?,
1680 transaction_type: StatementTransactionType::Payment,
1681 reference_number: id[..8].to_string(),
1682 description: "Payment".into(),
1683 debit: None,
1684 credit: Some(amount),
1685 balance: running_balance,
1686 });
1687 }
1688
1689 line_items.sort_by(|a, b| a.date.cmp(&b.date));
1691
1692 let total_invoices: Decimal = line_items
1694 .iter()
1695 .filter(|l| matches!(l.transaction_type, StatementTransactionType::Invoice))
1696 .filter_map(|l| l.debit)
1697 .sum();
1698 let total_payments: Decimal = line_items
1699 .iter()
1700 .filter(|l| matches!(l.transaction_type, StatementTransactionType::Payment))
1701 .filter_map(|l| l.credit)
1702 .sum();
1703
1704 Ok(CustomerStatement {
1705 customer_id: request.customer_id,
1706 customer_name,
1707 customer_email,
1708 billing_address: None,
1709 statement_date: now,
1710 period_start,
1711 period_end,
1712 opening_balance: Decimal::ZERO,
1713 total_invoices,
1714 total_payments,
1715 total_credits: Decimal::ZERO,
1716 closing_balance: aging.total_outstanding,
1717 aging,
1718 line_items,
1719 })
1720 }
1721
1722 fn get_total_outstanding(&self) -> Result<Decimal> {
1723 let summary = self.get_aging_summary()?;
1724 Ok(summary.total)
1725 }
1726
1727 fn get_dso(&self, days: i32) -> Result<Decimal> {
1728 let conn = self
1729 .pool
1730 .get()
1731 .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1732
1733 let ar_balance = sum_decimal_query(
1735 &conn,
1736 "SELECT balance_due FROM invoices WHERE status NOT IN ('paid', 'voided', 'written_off')",
1737 &[],
1738 "invoices",
1739 "balance_due",
1740 )?;
1741
1742 let sales_sql = format!(
1743 "SELECT total FROM invoices WHERE created_at >= datetime('now', '-{days} days')"
1744 );
1745 let total_sales = sum_decimal_query(&conn, &sales_sql, &[], "invoices", "total")?;
1746
1747 if total_sales == Decimal::ZERO {
1748 return Ok(Decimal::ZERO);
1749 }
1750
1751 let dso = (ar_balance / total_sales) * Decimal::from(days);
1752 Ok(dso)
1753 }
1754
1755 fn get_average_days_to_pay(&self, customer_id: Uuid) -> Result<Option<i32>> {
1756 let conn = self
1757 .pool
1758 .get()
1759 .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
1760
1761 let result: rusqlite::Result<f64> = conn.query_row(
1762 "SELECT AVG(JULIANDAY(pa.applied_date) - JULIANDAY(i.invoice_date))
1763 FROM ar_payment_applications pa
1764 JOIN invoices i ON pa.invoice_id = i.id
1765 WHERE i.customer_id = ?1 AND i.status = 'paid'",
1766 params![customer_id.to_string()],
1767 |row| row.get(0),
1768 );
1769
1770 match result {
1771 Ok(avg) => Ok(Some(avg as i32)),
1772 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1773 Err(rusqlite::Error::InvalidColumnType(_, _, _)) => Ok(None),
1774 Err(e) => Err(map_db_error(e)),
1775 }
1776 }
1777
1778 fn get_customers_batch(&self, ids: Vec<Uuid>) -> Result<Vec<CustomerArSummary>> {
1779 let mut summaries = Vec::new();
1780 for id in ids {
1781 if let Some(summary) = self.get_customer_summary(id)? {
1782 summaries.push(summary);
1783 }
1784 }
1785 Ok(summaries)
1786 }
1787}
1788
1789#[cfg(test)]
1790mod tests {
1791 use super::*;
1792 use crate::SqliteDatabase;
1793 use rust_decimal_macros::dec;
1794 use stateset_core::{
1795 AccountsReceivableRepository, AgingBucket, ApplyCreditMemo, ArAgingFilter,
1796 CreateCreditMemo, CreditMemoFilter, CreditMemoReason, CreditMemoStatus,
1797 };
1798
1799 fn fresh_repo() -> SqliteAccountsReceivableRepository {
1800 SqliteDatabase::in_memory().expect("in-memory").accounts_receivable()
1801 }
1802
1803 fn make_memo(
1804 repo: &SqliteAccountsReceivableRepository,
1805 customer_id: Uuid,
1806 amount: Decimal,
1807 reason: CreditMemoReason,
1808 ) -> CreditMemo {
1809 repo.create_credit_memo(CreateCreditMemo {
1810 customer_id,
1811 original_invoice_id: None,
1812 reason,
1813 amount,
1814 notes: Some("test".into()),
1815 })
1816 .expect("create memo")
1817 }
1818
1819 fn seed_payment(repo: &SqliteAccountsReceivableRepository, id: Uuid, number: &str) {
1820 let conn = repo.pool.get().expect("conn");
1821 let now = Utc::now().to_rfc3339();
1822 conn.execute(
1823 "INSERT INTO payments (id, payment_number, amount, created_at, updated_at)
1824 VALUES (?1, ?2, '100', ?3, ?3)",
1825 params![id.to_string(), number, now],
1826 )
1827 .expect("seed payment");
1828 }
1829
1830 fn seed_paid_invoice(
1831 repo: &SqliteAccountsReceivableRepository,
1832 id: Uuid,
1833 number: &str,
1834 customer_id: Uuid,
1835 invoice_date: chrono::DateTime<Utc>,
1836 ) {
1837 let conn = repo.pool.get().expect("conn");
1838 let d = invoice_date.to_rfc3339();
1839 conn.execute(
1840 "INSERT INTO invoices (id, invoice_number, customer_id, status, invoice_date,
1841 due_date, total, amount_paid, balance_due, created_at, updated_at)
1842 VALUES (?1, ?2, ?3, 'paid', ?4, ?4, '100', '100', '0', ?4, ?4)",
1843 params![id.to_string(), number, customer_id.to_string(), d],
1844 )
1845 .expect("seed paid invoice");
1846 }
1847
1848 fn seed_application(
1849 repo: &SqliteAccountsReceivableRepository,
1850 payment_id: Uuid,
1851 invoice_id: Uuid,
1852 applied_date: chrono::DateTime<Utc>,
1853 ) {
1854 let conn = repo.pool.get().expect("conn");
1855 conn.execute(
1856 "INSERT INTO ar_payment_applications (id, payment_id, invoice_id, applied_amount, applied_date, created_at)
1857 VALUES (?1, ?2, ?3, '100', ?4, ?5)",
1858 params![
1859 Uuid::new_v4().to_string(),
1860 payment_id.to_string(),
1861 invoice_id.to_string(),
1862 applied_date.to_rfc3339(),
1863 Utc::now().to_rfc3339(),
1864 ],
1865 )
1866 .expect("seed application");
1867 }
1868
1869 #[test]
1870 fn average_days_to_pay_uses_fractional_days() {
1871 let repo = fresh_repo();
1872 let customer = Uuid::new_v4();
1873 let payment = Uuid::new_v4();
1874 seed_payment(&repo, payment, "PAY-ADP");
1875
1876 let base = Utc::now();
1877 let inv1 = Uuid::new_v4();
1878 let inv2 = Uuid::new_v4();
1879 seed_paid_invoice(&repo, inv1, "INV-ADP-1", customer, base);
1880 seed_paid_invoice(&repo, inv2, "INV-ADP-2", customer, base);
1881 seed_application(&repo, payment, inv1, base + chrono::Duration::hours(10 * 24 + 12));
1883 seed_application(&repo, payment, inv2, base + chrono::Duration::hours(11 * 24 + 12));
1884
1885 assert_eq!(repo.get_average_days_to_pay(customer).expect("avg"), Some(11));
1888 }
1889
1890 fn insert_overdue_invoice(
1893 repo: &SqliteAccountsReceivableRepository,
1894 number: &str,
1895 balance_due: &str,
1896 ) {
1897 let conn = repo.pool.get().expect("conn");
1898 let now = Utc::now();
1899 let due = now - chrono::Duration::days(30);
1900 conn.execute(
1901 "INSERT INTO invoices (id, invoice_number, customer_id, status, invoice_date,
1902 due_date, total, amount_paid, balance_due, created_at, updated_at)
1903 VALUES (?1, ?2, ?3, 'sent', ?4, ?5, ?6, '0', ?6, ?4, ?4)",
1904 params![
1905 Uuid::new_v4().to_string(),
1906 number,
1907 Uuid::new_v4().to_string(),
1908 now.to_rfc3339(),
1909 due.to_rfc3339(),
1910 balance_due,
1911 ],
1912 )
1913 .expect("insert invoice");
1914 }
1915
1916 #[test]
1917 fn dunning_keeps_tiny_positive_balances_and_drops_zero_or_negative() {
1918 let repo = fresh_repo();
1919 insert_overdue_invoice(&repo, "INV-DUN-1", "0.005");
1920 insert_overdue_invoice(&repo, "INV-DUN-2", "0.00");
1921 insert_overdue_invoice(&repo, "INV-DUN-3", "0.0000000000000000000000000001");
1923 insert_overdue_invoice(&repo, "INV-DUN-4", "-0.01");
1924
1925 let due = repo.get_invoices_due_for_dunning().expect("ok");
1926 let numbers: Vec<&str> = due.iter().map(|i| i.invoice_number.as_str()).collect();
1927 assert!(numbers.contains(&"INV-DUN-1"), "tiny positive balance is due for dunning");
1928 assert!(numbers.contains(&"INV-DUN-3"), "1e-28 balance is still positive");
1929 assert!(!numbers.contains(&"INV-DUN-2"), "zero balance must not be dunned");
1930 assert!(!numbers.contains(&"INV-DUN-4"), "credit balance must not be dunned");
1931
1932 let tiny = due.iter().find(|i| i.invoice_number == "INV-DUN-1").expect("found");
1933 assert_eq!(tiny.balance_due, dec!(0.005), "balance must round-trip exactly");
1934 }
1935
1936 fn insert_invoice_due_days(
1940 repo: &SqliteAccountsReceivableRepository,
1941 customer_id: Uuid,
1942 number: &str,
1943 balance_due: &str,
1944 due_days_ago: i64,
1945 ) {
1946 let conn = repo.pool.get().expect("conn");
1947 let now = Utc::now();
1948 let due = now - chrono::Duration::days(due_days_ago);
1949 conn.execute(
1950 "INSERT INTO invoices (id, invoice_number, customer_id, status, invoice_date,
1951 due_date, total, amount_paid, balance_due, created_at, updated_at)
1952 VALUES (?1, ?2, ?3, 'sent', ?4, ?5, ?6, '0', ?6, ?4, ?4)",
1953 params![
1954 Uuid::new_v4().to_string(),
1955 number,
1956 customer_id.to_string(),
1957 now.to_rfc3339(),
1958 due.to_rfc3339(),
1959 balance_due,
1960 ],
1961 )
1962 .expect("insert invoice");
1963 }
1964
1965 #[test]
1966 fn get_aging_report_honors_min_balance() {
1967 let repo = fresh_repo();
1969 insert_overdue_invoice(&repo, "INV-SMALL", "100.00");
1970 insert_overdue_invoice(&repo, "INV-BIG", "5000.00");
1971
1972 let all = repo.get_aging_report(ArAgingFilter::default()).expect("all");
1973 assert_eq!(all.len(), 2, "both customers appear without a min_balance filter");
1974
1975 let filtered = repo
1976 .get_aging_report(ArAgingFilter { min_balance: Some(dec!(1000)), ..Default::default() })
1977 .expect("filtered");
1978 assert_eq!(filtered.len(), 1, "only the >= 1000 customer must pass min_balance");
1979 assert_eq!(filtered[0].total_outstanding, dec!(5000.00));
1980 }
1981
1982 #[test]
1983 fn get_aging_report_honors_aging_bucket() {
1984 let repo = fresh_repo();
1985 let current_cust = Uuid::new_v4();
1986 let over90_cust = Uuid::new_v4();
1987 insert_invoice_due_days(&repo, current_cust, "INV-CUR", "200.00", -5);
1989 insert_invoice_due_days(&repo, over90_cust, "INV-OLD", "300.00", 120);
1991
1992 let over90 = repo
1993 .get_aging_report(ArAgingFilter {
1994 aging_bucket: Some(AgingBucket::DaysOver90),
1995 ..Default::default()
1996 })
1997 .expect("over90");
1998 assert_eq!(over90.len(), 1, "only the >90-day customer matches DaysOver90: {over90:?}");
1999 assert_eq!(over90[0].customer_id, over90_cust);
2000
2001 let current = repo
2002 .get_aging_report(ArAgingFilter {
2003 aging_bucket: Some(AgingBucket::Current),
2004 ..Default::default()
2005 })
2006 .expect("current");
2007 assert_eq!(current.len(), 1, "only the not-yet-due customer matches Current: {current:?}");
2008 assert_eq!(current[0].customer_id, current_cust);
2009 }
2010
2011 fn insert_customer(repo: &SqliteAccountsReceivableRepository, id: Uuid, email: &str) {
2013 let conn = repo.pool.get().expect("conn");
2014 conn.execute(
2015 "INSERT INTO customers (id, email, first_name, last_name) VALUES (?1, ?2, 'Paid', 'Up')",
2016 params![id.to_string(), email],
2017 )
2018 .expect("insert customer");
2019 }
2020
2021 #[test]
2022 fn get_customer_aging_returns_zeros_for_existing_customer_without_invoices() {
2023 let repo = fresh_repo();
2029 let existing = Uuid::new_v4();
2030 insert_customer(&repo, existing, "paidup@example.com");
2031
2032 let aging = repo
2033 .get_customer_aging(existing)
2034 .expect("an existing customer must not error")
2035 .expect("an existing customer must return Some, even with no open invoices");
2036 assert_eq!(aging.invoice_count, 0);
2037 assert_eq!(aging.total_outstanding, dec!(0));
2038 assert_eq!(aging.current, dec!(0));
2039 assert!(aging.oldest_invoice_date.is_none());
2040
2041 let missing =
2043 repo.get_customer_aging(Uuid::new_v4()).expect("unknown customer is not error");
2044 assert!(missing.is_none(), "unknown customer must be None");
2045 }
2046
2047 #[test]
2048 fn has_unapplied_filter_is_exact_and_limit_applies_after_filtering() {
2049 let repo = fresh_repo();
2050 let cust = Uuid::new_v4();
2051 let m1 = make_memo(&repo, cust, dec!(10), CreditMemoReason::ReturnedGoods);
2052 let m2 = make_memo(&repo, cust, dec!(20), CreditMemoReason::ReturnedGoods);
2053 let m3 = make_memo(&repo, cust, dec!(30), CreditMemoReason::ReturnedGoods);
2054
2055 {
2056 let conn = repo.pool.get().expect("conn");
2057 for (id, unapplied) in [(m1.id, "0.00"), (m2.id, "0"), (m3.id, "0.005")] {
2060 conn.execute(
2061 "UPDATE ar_credit_memos SET unapplied_amount = ?1 WHERE id = ?2",
2062 params![unapplied, id.to_string()],
2063 )
2064 .expect("update memo");
2065 }
2066 }
2067
2068 let got = repo
2069 .list_credit_memos(CreditMemoFilter {
2070 customer_id: Some(cust),
2071 has_unapplied: Some(true),
2072 limit: Some(1),
2073 ..Default::default()
2074 })
2075 .expect("list");
2076 assert_eq!(got.len(), 1, "only one memo has an unapplied balance");
2077 assert_eq!(got[0].id, m3.id);
2078 assert_eq!(got[0].unapplied_amount, dec!(0.005));
2079 }
2080
2081 #[test]
2082 fn empty_aging_summary_is_zero() {
2083 let repo = fresh_repo();
2084 let summary = repo.get_aging_summary().expect("ok");
2085 assert_eq!(summary.total, dec!(0));
2086 assert_eq!(summary.current, dec!(0));
2087 assert_eq!(summary.days_over_90, dec!(0));
2088 }
2089
2090 #[test]
2091 fn empty_aging_report_returns_empty() {
2092 let repo = fresh_repo();
2093 let rows = repo.get_aging_report(ArAgingFilter::default()).expect("ok");
2094 assert!(rows.is_empty());
2095 }
2096
2097 #[test]
2098 fn empty_total_outstanding_is_zero() {
2099 let repo = fresh_repo();
2100 assert_eq!(repo.get_total_outstanding().expect("ok"), dec!(0));
2101 }
2102
2103 #[test]
2104 fn empty_dso_is_zero() {
2105 let repo = fresh_repo();
2106 assert_eq!(repo.get_dso(30).expect("ok"), dec!(0));
2107 }
2108
2109 #[test]
2110 fn average_days_to_pay_for_unknown_customer_is_none() {
2111 let repo = fresh_repo();
2112 assert!(repo.get_average_days_to_pay(Uuid::new_v4()).expect("ok").is_none());
2113 }
2114
2115 #[test]
2116 fn create_credit_memo_starts_open_with_full_unapplied() {
2117 let repo = fresh_repo();
2118 let cust = Uuid::new_v4();
2119 let memo = make_memo(&repo, cust, dec!(100), CreditMemoReason::ReturnedGoods);
2120 assert_eq!(memo.amount, dec!(100));
2121 assert_eq!(memo.applied_amount, dec!(0));
2122 assert_eq!(memo.unapplied_amount, dec!(100));
2123 assert_eq!(memo.status, CreditMemoStatus::Open);
2124 assert!(memo.credit_memo_number.starts_with("CM-"));
2125 }
2126
2127 #[test]
2128 fn get_credit_memo_round_trips() {
2129 let repo = fresh_repo();
2130 let cust = Uuid::new_v4();
2131 let memo = make_memo(&repo, cust, dec!(50), CreditMemoReason::PricingError);
2132 let by_id = repo.get_credit_memo(memo.id).expect("ok").expect("found");
2133 assert_eq!(by_id.id, memo.id);
2134 let by_num =
2135 repo.get_credit_memo_by_number(&memo.credit_memo_number).expect("ok").expect("found");
2136 assert_eq!(by_num.id, memo.id);
2137 assert!(repo.get_credit_memo_by_number("missing").expect("ok").is_none());
2138 }
2139
2140 #[test]
2141 fn list_credit_memos_filters_by_customer() {
2142 let repo = fresh_repo();
2143 let cust_a = Uuid::new_v4();
2144 let cust_b = Uuid::new_v4();
2145 make_memo(&repo, cust_a, dec!(10), CreditMemoReason::Damaged);
2146 make_memo(&repo, cust_a, dec!(20), CreditMemoReason::Overpayment);
2147 make_memo(&repo, cust_b, dec!(30), CreditMemoReason::ReturnedGoods);
2148
2149 let for_a = repo
2150 .list_credit_memos(CreditMemoFilter { customer_id: Some(cust_a), ..Default::default() })
2151 .expect("list");
2152 assert_eq!(for_a.len(), 2);
2153 assert!(for_a.iter().all(|m| m.customer_id == cust_a));
2154 }
2155
2156 #[test]
2157 fn list_credit_memos_filters_by_status() {
2158 let repo = fresh_repo();
2159 let cust = Uuid::new_v4();
2160 let to_void = make_memo(&repo, cust, dec!(10), CreditMemoReason::ReturnedGoods);
2161 let _staying_open = make_memo(&repo, cust, dec!(20), CreditMemoReason::ReturnedGoods);
2162 repo.void_credit_memo(to_void.id).expect("void");
2163
2164 let opens = repo
2165 .list_credit_memos(CreditMemoFilter {
2166 status: Some(CreditMemoStatus::Open),
2167 ..Default::default()
2168 })
2169 .expect("opens");
2170 let voided = repo
2171 .list_credit_memos(CreditMemoFilter {
2172 status: Some(CreditMemoStatus::Voided),
2173 ..Default::default()
2174 })
2175 .expect("voided");
2176 assert_eq!(opens.len(), 1);
2177 assert_eq!(voided.len(), 1);
2178 }
2179
2180 #[test]
2181 fn void_credit_memo_rejects_when_applied_and_guards_double_void() {
2182 let repo = fresh_repo();
2183 let cust = Uuid::new_v4();
2184 let invoice_id = insert_invoice_for(&repo, cust, "INV-VCM", "1000");
2185
2186 let memo = make_memo(&repo, cust, dec!(100), CreditMemoReason::ReturnedGoods);
2188 repo.apply_credit_memo(ApplyCreditMemo {
2189 credit_memo_id: memo.id,
2190 invoice_id,
2191 amount: dec!(40),
2192 })
2193 .expect("apply");
2194 assert!(repo.void_credit_memo(memo.id).is_err(), "cannot void a memo with applications");
2195
2196 let memo2 = make_memo(&repo, cust, dec!(50), CreditMemoReason::ReturnedGoods);
2199 repo.void_credit_memo(memo2.id).expect("first void");
2200 assert!(repo.void_credit_memo(memo2.id).is_err(), "double void rejected");
2201 assert!(
2202 repo.apply_credit_memo(ApplyCreditMemo {
2203 credit_memo_id: memo2.id,
2204 invoice_id,
2205 amount: dec!(10),
2206 })
2207 .is_err(),
2208 "cannot apply a voided memo"
2209 );
2210 }
2211
2212 #[test]
2213 fn void_credit_memo_changes_status() {
2214 let repo = fresh_repo();
2215 let cust = Uuid::new_v4();
2216 let memo = make_memo(&repo, cust, dec!(15), CreditMemoReason::ReturnedGoods);
2217 let voided = repo.void_credit_memo(memo.id).expect("void");
2218 assert_eq!(voided.status, CreditMemoStatus::Voided);
2219 }
2220
2221 #[test]
2222 fn get_unapplied_credits_filters_by_customer() {
2223 let repo = fresh_repo();
2224 let cust_a = Uuid::new_v4();
2225 let cust_b = Uuid::new_v4();
2226 make_memo(&repo, cust_a, dec!(40), CreditMemoReason::ReturnedGoods);
2227 make_memo(&repo, cust_b, dec!(50), CreditMemoReason::ReturnedGoods);
2228
2229 let unapplied = repo.get_unapplied_credits(cust_a).expect("ok");
2230 assert_eq!(unapplied.len(), 1);
2231 assert_eq!(unapplied[0].customer_id, cust_a);
2232 }
2233
2234 #[test]
2235 fn apply_credit_memo_to_nonexistent_invoice_errors() {
2236 let repo = fresh_repo();
2237 let cust = Uuid::new_v4();
2238 let memo = make_memo(&repo, cust, dec!(10), CreditMemoReason::ReturnedGoods);
2239 let result = repo.apply_credit_memo(ApplyCreditMemo {
2240 credit_memo_id: memo.id,
2241 invoice_id: Uuid::new_v4(),
2242 amount: dec!(5),
2243 });
2244 assert!(result.is_err(), "should fail without an invoice");
2245 }
2246
2247 fn insert_invoice_for(
2251 repo: &SqliteAccountsReceivableRepository,
2252 customer_id: Uuid,
2253 number: &str,
2254 balance_due: &str,
2255 ) -> Uuid {
2256 let id = Uuid::new_v4();
2257 let conn = repo.pool.get().expect("conn");
2258 let now = Utc::now();
2259 conn.execute(
2260 "INSERT INTO invoices (id, invoice_number, customer_id, status, invoice_date,
2261 due_date, total, amount_paid, balance_due, created_at, updated_at)
2262 VALUES (?1, ?2, ?3, 'sent', ?4, ?4, ?5, '0', ?5, ?4, ?4)",
2263 params![id.to_string(), number, customer_id.to_string(), now.to_rfc3339(), balance_due],
2264 )
2265 .expect("insert invoice");
2266 id
2267 }
2268
2269 #[test]
2270 fn aging_report_orders_ties_by_customer_id() {
2271 let repo = fresh_repo();
2275 let mut ids: Vec<Uuid> = (0..5).map(|_| Uuid::new_v4()).collect();
2276 for (i, id) in ids.iter().enumerate() {
2277 {
2278 let conn = repo.pool.get().expect("conn");
2279 conn.execute(
2280 "INSERT INTO customers (id, email, first_name, last_name)
2281 VALUES (?1, ?2, 'Test', 'Customer')",
2282 params![id.to_string(), format!("tie-{i}@example.com")],
2283 )
2284 .expect("insert customer");
2285 }
2286 insert_invoice_for(&repo, *id, &format!("INV-TIE-{i}"), "100");
2287 }
2288
2289 let report = repo.get_aging_report(ArAgingFilter::default()).expect("report");
2290 let returned: Vec<Uuid> = report.iter().map(|r| r.customer_id).collect();
2291 ids.sort();
2292 assert_eq!(
2293 returned, ids,
2294 "equal-balance customers must be ordered by ascending customer_id"
2295 );
2296 }
2297
2298 #[test]
2299 fn aging_report_tolerates_invoice_with_missing_customer() {
2300 let repo = fresh_repo();
2305 let orphan = Uuid::new_v4();
2306 insert_invoice_for(&repo, orphan, "INV-ORPHAN", "250");
2307
2308 let report = repo
2309 .get_aging_report(ArAgingFilter::default())
2310 .expect("report must not crash on an invoice whose customer is missing");
2311 assert_eq!(report.len(), 1);
2312 assert_eq!(report[0].customer_id, orphan);
2313 assert_eq!(report[0].customer_name, None, "missing customer must yield no name");
2314 assert_eq!(report[0].customer_email, None);
2315 assert_eq!(report[0].total_outstanding, dec!(250));
2316 }
2317
2318 #[test]
2319 fn apply_credit_memo_is_atomic_under_concurrency() {
2320 use std::sync::{Arc, Barrier};
2321
2322 let repo = fresh_repo();
2323 let cust = Uuid::new_v4();
2324 let memo = make_memo(&repo, cust, dec!(100), CreditMemoReason::ReturnedGoods);
2327 let invoice_id = insert_invoice_for(&repo, cust, "INV-RACE", "100000");
2328
2329 const THREADS: usize = 10;
2330 let barrier = Arc::new(Barrier::new(THREADS));
2331
2332 let successes = std::thread::scope(|s| {
2336 let handles: Vec<_> = (0..THREADS)
2337 .map(|_| {
2338 let barrier = Arc::clone(&barrier);
2339 let repo = &repo;
2340 s.spawn(move || {
2341 barrier.wait();
2342 repo.apply_credit_memo(ApplyCreditMemo {
2343 credit_memo_id: memo.id,
2344 invoice_id,
2345 amount: dec!(100),
2346 })
2347 .is_ok()
2348 })
2349 })
2350 .collect();
2351 handles.into_iter().map(|h| h.join().unwrap()).filter(|&ok| ok).count()
2352 });
2353
2354 assert!(
2359 successes <= 1,
2360 "the 100-unit memo was applied more than once across {THREADS} threads"
2361 );
2362 let after = repo.get_credit_memo(memo.id).expect("get").expect("memo");
2363 assert_eq!(
2364 after.applied_amount,
2365 dec!(100) * Decimal::from(successes as u64),
2366 "applied_amount must reflect exactly the successful application"
2367 );
2368 assert_eq!(after.unapplied_amount, dec!(100) - after.applied_amount);
2369 let expected_status =
2370 if successes == 1 { CreditMemoStatus::FullyApplied } else { CreditMemoStatus::Open };
2371 assert_eq!(after.status, expected_status);
2372 }
2373
2374 fn insert_payment(
2375 repo: &SqliteAccountsReceivableRepository,
2376 number: &str,
2377 amount: &str,
2378 ) -> Uuid {
2379 let id = Uuid::new_v4();
2380 let conn = repo.pool.get().expect("conn");
2381 let now = Utc::now();
2382 conn.execute(
2383 "INSERT INTO payments (id, payment_number, amount, created_at, updated_at)
2384 VALUES (?1, ?2, ?3, ?4, ?4)",
2385 params![id.to_string(), number, amount, now.to_rfc3339()],
2386 )
2387 .expect("insert payment");
2388 id
2389 }
2390
2391 #[test]
2392 fn apply_payment_to_invoice_is_atomic_under_concurrency() {
2393 use stateset_core::{ApplyPaymentToInvoices, PaymentApplicationLine};
2394 use std::sync::{Arc, Barrier};
2395
2396 let repo = fresh_repo();
2397 let cust = Uuid::new_v4();
2398 let invoice_id = insert_invoice_for(&repo, cust, "INV-PAY-RACE", "100");
2400 let payment_id = insert_payment(&repo, "PAY-RACE", "1000");
2402
2403 const THREADS: usize = 10;
2404 let barrier = Arc::new(Barrier::new(THREADS));
2405
2406 let successes = std::thread::scope(|s| {
2409 let handles: Vec<_> = (0..THREADS)
2410 .map(|_| {
2411 let barrier = Arc::clone(&barrier);
2412 let repo = &repo;
2413 s.spawn(move || {
2414 barrier.wait();
2415 repo.apply_payment_to_invoices(ApplyPaymentToInvoices {
2416 payment_id,
2417 applications: vec![PaymentApplicationLine {
2418 invoice_id,
2419 amount: dec!(100),
2420 }],
2421 })
2422 .map(|apps| !apps.is_empty())
2423 .unwrap_or(false)
2424 })
2425 })
2426 .collect();
2427 handles.into_iter().map(|h| h.join().unwrap()).filter(|&ok| ok).count()
2428 });
2429
2430 assert!(
2435 successes <= 1,
2436 "more than one full payment bound to the invoice across {THREADS} threads"
2437 );
2438 let balance: String = {
2439 let conn = repo.pool.get().expect("conn");
2440 conn.query_row(
2441 "SELECT balance_due FROM invoices WHERE id = ?1",
2442 params![invoice_id.to_string()],
2443 |r| r.get(0),
2444 )
2445 .expect("balance")
2446 };
2447 let balance = parse_decimal_safe(&balance, "invoice", "balance_due").expect("parse");
2448 assert_eq!(
2449 balance,
2450 dec!(100) - dec!(100) * Decimal::from(successes as u64),
2451 "invoice balance must reflect exactly the successful payment"
2452 );
2453 }
2454
2455 #[test]
2456 fn write_off_is_atomic_and_guards_double_write_off() {
2457 use stateset_core::{CreateWriteOff, WriteOffReason};
2458 let repo = fresh_repo();
2459 let cust = Uuid::new_v4();
2460 let invoice_id = insert_invoice_for(&repo, cust, "INV-WO", "100");
2461
2462 let wo = repo
2463 .create_write_off(CreateWriteOff {
2464 invoice_id,
2465 amount: dec!(100),
2466 reason: WriteOffReason::Uncollectible,
2467 notes: None,
2468 approved_by: None,
2469 })
2470 .expect("first write-off");
2471 assert_eq!(wo.invoice_id, invoice_id);
2472
2473 let second = repo.create_write_off(CreateWriteOff {
2475 invoice_id,
2476 amount: dec!(100),
2477 reason: WriteOffReason::Uncollectible,
2478 notes: None,
2479 approved_by: None,
2480 });
2481 assert!(second.is_err(), "cannot write off an already-written-off invoice");
2482 }
2483
2484 #[test]
2485 fn reverse_write_off_guards_double_reverse() {
2486 use stateset_core::{CreateWriteOff, WriteOffReason};
2487 let repo = fresh_repo();
2488 let cust = Uuid::new_v4();
2489 let invoice_id = insert_invoice_for(&repo, cust, "INV-WO-REV", "100");
2490
2491 let wo = repo
2492 .create_write_off(CreateWriteOff {
2493 invoice_id,
2494 amount: dec!(100),
2495 reason: WriteOffReason::Uncollectible,
2496 notes: None,
2497 approved_by: None,
2498 })
2499 .expect("write-off");
2500
2501 repo.reverse_write_off(wo.id).expect("first reverse succeeds");
2502 let second = repo.reverse_write_off(wo.id);
2503 assert!(second.is_err(), "cannot reverse an already-reversed write-off");
2504 }
2505
2506 #[test]
2507 fn apply_credit_memo_rejects_second_application_beyond_unapplied() {
2508 let repo = fresh_repo();
2509 let cust = Uuid::new_v4();
2510 let memo = make_memo(&repo, cust, dec!(100), CreditMemoReason::ReturnedGoods);
2511 let invoice_id = insert_invoice_for(&repo, cust, "INV-SEQ", "100000");
2512
2513 repo.apply_credit_memo(ApplyCreditMemo {
2514 credit_memo_id: memo.id,
2515 invoice_id,
2516 amount: dec!(100),
2517 })
2518 .expect("first application succeeds");
2519
2520 let second = repo.apply_credit_memo(ApplyCreditMemo {
2523 credit_memo_id: memo.id,
2524 invoice_id,
2525 amount: dec!(1),
2526 });
2527 assert!(second.is_err(), "cannot apply beyond the unapplied balance");
2528 let after = repo.get_credit_memo(memo.id).expect("get").expect("memo");
2529 assert_eq!(after.applied_amount, dec!(100));
2530 assert_eq!(after.unapplied_amount, dec!(0));
2531 }
2532
2533 #[test]
2534 fn customer_summary_for_unknown_customer_is_none() {
2535 let repo = fresh_repo();
2536 assert!(repo.get_customer_summary(Uuid::new_v4()).expect("ok").is_none());
2537 }
2538
2539 #[test]
2540 fn get_payment_applications_empty_for_unknown_payment() {
2541 let repo = fresh_repo();
2542 let apps = repo.get_payment_applications(Uuid::new_v4()).expect("ok");
2543 assert!(apps.is_empty());
2544 }
2545
2546 #[test]
2547 fn get_invoices_due_for_dunning_empty_on_fresh_db() {
2548 let repo = fresh_repo();
2549 let invoices = repo.get_invoices_due_for_dunning().expect("ok");
2550 assert!(invoices.is_empty());
2551 }
2552
2553 #[test]
2554 fn get_customers_batch_returns_only_existing() {
2555 let repo = fresh_repo();
2556 let result = repo.get_customers_batch(vec![Uuid::new_v4(), Uuid::new_v4()]).expect("ok");
2557 assert!(result.is_empty());
2558 }
2559}