Skip to main content

stateset_db/sqlite/
accounts_receivable.rs

1//! SQLite implementation of Accounts Receivable repository
2
3use 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        // Sum applications exactly in Rust: `SUM()` over a TEXT decimal column
228        // coerces to float/int in SQLite (and returns a non-TEXT type), which is
229        // both lossy and mistyped on read. `sum_decimal_query` reads each stored
230        // decimal and adds them with `rust_decimal`.
231        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        // Get invoice total
249        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        // Determine status
261        let status = if balance_due <= Decimal::ZERO {
262            "paid"
263        } else if total_applied > Decimal::ZERO {
264            "partially_paid"
265        } else {
266            "sent" // Keep original status if nothing applied
267        };
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        // The customer exists (checked above); an empty open-invoice set is a
422        // zero-filled aging, not NotFound — matching the Postgres backend and
423        // keeping get_customer_summary / generate_statement working for paid-up
424        // customers.
425        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            // first_name/last_name/email come from a LEFT JOIN, so they are NULL
496            // when an invoice references a customer that no longer exists. Read
497            // them as optional rather than crashing the whole report on one
498            // orphaned invoice.
499            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                // overdue_only: the customer must have some past-due balance.
550                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                // min_balance: total outstanding must meet the threshold. Mirrors
557                // the Postgres `HAVING SUM(balance_due) >= min_balance`, applied
558                // before pagination so the offset/limit see the filtered set.
559                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                // aging_bucket: the customer must have a positive balance in the
570                // requested bucket (matches the Postgres per-bucket HAVING).
571                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                        // Unknown bucket: don't filter (matches Postgres `1=1`).
581                        _ => 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        // Sort by outstanding balance, then customer_id as a total-order
613        // tiebreaker: customers with equal balances must have a stable, unique
614        // ordering, otherwise the offset/limit pagination below (and the Postgres
615        // ORDER BY) can skip or duplicate rows across pages.
616        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        // Return invoices that are overdue and haven't had recent dunning
756        let conn = self
757            .pool
758            .get()
759            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
760
761        // `balance_due` is a TEXT decimal, so it is not filtered in SQL (a
762        // CAST(... AS REAL) comparison coerces to IEEE-754 floats); the exact
763        // `Decimal` filter is applied after parsing, below.
764        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        // Only invoices that still owe money are due for dunning; compare on
864        // the exact parsed Decimal rather than a float-coerced SQL cast.
865        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        // Update invoice dunning info
880        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        // Update collection status based on letter type
886        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        // Log the activity
899        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        // Atomic: mark the invoice written-off and insert the write-off row in one
915        // immediate transaction. Previously two autocommit statements — a crash
916        // between them left the write-off and the invoice status inconsistent. The
917        // guarded status flip also refuses to double-write-off an invoice.
918        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        // Get the write-off (cheap early-out; re-checked under the lock below).
1041        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        // Atomic + guarded: mark reversed and restore the invoice in one immediate
1050        // transaction. The guard (reversed_at IS NULL) makes a concurrent/duplicate
1051        // reversal a no-op conflict instead of a double restore.
1052        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(), // unapplied = full amount initially
1099                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        // `unapplied_amount` is a TEXT decimal, so the has_unapplied filter is
1185        // applied below on the exact parsed `Decimal` (a SQL CAST(... AS REAL)
1186        // comparison coerces to IEEE-754 floats), and the LIMIT after it so
1187        // filtering never eats into the page.
1188        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        // Apply atomically. The authoritative unapplied balance and the invoice
1249        // balance are re-read INSIDE the immediate (write-locked) transaction and
1250        // the memo update is guarded on the re-read unapplied amount, so two
1251        // concurrent applications of the same memo cannot both validate against a
1252        // stale balance and double-apply (the pre-transaction checks above stay as
1253        // cheap early-outs). Mirrors the pattern used by orders/inventory/GL.
1254        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                // Re-read the memo's current amounts AND status under the write
1262                // lock: a concurrent void_credit_memo (also an immediate txn) may
1263                // have voided it since the pre-transaction can_apply() check.
1264                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                // Re-read the invoice balance under the same lock.
1288                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                // Guard on the unapplied amount we just read: if a concurrent
1323                // writer already moved it, this affects 0 rows -> conflict.
1324                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        // Atomic + guarded: re-read the applied amount under the write lock so a
1361        // concurrent apply_credit_memo (also an immediate txn) cannot slip an
1362        // application in between the check and the void; the status guard blocks a
1363        // double void.
1364        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        // Immediate (write-locked) transaction so concurrent payments against the
1416        // same invoice serialize: each application re-reads the invoice balance
1417        // under the lock, so a second payment sees the balance the first already
1418        // reduced and cannot over-apply. `with_immediate_transaction` also retries
1419        // on SQLITE_BUSY, which a raw deferred `super::begin_immediate(&mut conn)` did not.
1420        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                // Read the customer id on the transaction's own connection — a
1435                // nested `self.pool.get()` here would take a second pooled
1436                // connection while this one is held, deadlocking the pool under
1437                // concurrency.
1438                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        // Get invoice_id first
1528        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        // Delete application
1537        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        // Recalculate invoice
1544        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        // Get unapplied credits
1558        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        // Get customer info
1589        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        // Get aging
1598        let aging = self
1599            .get_customer_aging(request.customer_id)?
1600            .ok_or(stateset_core::CommerceError::NotFound)?;
1601
1602        // Build line items from invoices and payments
1603        let mut line_items: Vec<StatementLineItem> = Vec::new();
1604        let mut running_balance = Decimal::ZERO;
1605
1606        // Get invoices
1607        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        // Get payments
1647        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        // Sort by date
1690        line_items.sort_by(|a, b| a.date.cmp(&b.date));
1691
1692        // Calculate totals
1693        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        // DSO = (Accounts Receivable / Total Credit Sales) × Number of Days
1734        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        // Paid at +10.5 and +11.5 days.
1882        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        // Fractional per-invoice days: AVG(10.5, 11.5) = 11.0 -> 11. Postgres's
1886        // whole-day EXTRACT(DAY) truncation would give AVG(10, 11) = 10.5 -> 10.
1887        assert_eq!(repo.get_average_days_to_pay(customer).expect("avg"), Some(11));
1888    }
1889
1890    /// Insert an overdue, unpaid invoice directly so balance filters can be
1891    /// exercised with exact TEXT decimal values.
1892    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        // Smallest positive value a Decimal can represent (scale 28).
1922        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    /// Insert an unpaid invoice for a specific customer with a chosen due-date
1937    /// offset (positive = days in the past, negative = days in the future), so
1938    /// aging-bucket filters can be exercised deterministically.
1939    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        // Each insert_overdue_invoice creates a distinct customer.
1968        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        // Not yet due (5 days in the future) => Current bucket.
1988        insert_invoice_due_days(&repo, current_cust, "INV-CUR", "200.00", -5);
1989        // 120 days overdue => DaysOver90 bucket.
1990        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    /// Insert a bare customer row (id + required NOT NULL columns).
2012    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        // A customer that exists but owes nothing must return a zero-filled aging
2024        // (matching Postgres), NOT NotFound — the truly-missing case is the only
2025        // one that is None. SQLite previously returned Err(NotFound) here, which
2026        // also broke get_customer_summary / generate_statement for paid-up
2027        // customers.
2028        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        // A truly-unknown customer is still None (not an error).
2042        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            // Fully-applied memos whose zero balance is stored non-canonically,
2058            // plus one memo with a sub-cent positive balance.
2059            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        // A memo with an application cannot be voided.
2187        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        // A fresh unapplied memo voids once; a second void is rejected, and
2197        // applying a voided memo is rejected.
2198        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    /// Insert an unpaid invoice for a specific customer and return its id, so
2248    /// credit-memo application (which requires the invoice/memo customers to
2249    /// match) can be exercised.
2250    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        // Five customers with identical outstanding balances must come back in a
2272        // stable, unique order (by customer_id) — otherwise the report's order,
2273        // and the offset/limit pagination built on it, is non-deterministic.
2274        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        // An invoice can reference a customer that no longer exists (deleted).
2301        // The report LEFT JOINs customers, so that row's name/email come back
2302        // NULL — it must surface the balance with an empty name rather than
2303        // erroring out and taking down the whole report.
2304        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        // A memo with exactly 100 unapplied, against an invoice whose balance is
2325        // large enough that only the unapplied balance can bind.
2326        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        // All threads apply the full 100 at once. Before the fix (balance read
2333        // outside the transaction) several could validate against the stale
2334        // unapplied=100 and double-apply; now exactly one may win.
2335        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        // Safety invariant: the memo can be applied AT MOST once (never
2355        // double-applied). Under extreme lock contention the sole winner can fail
2356        // transiently (the caller retries), so zero successes is acceptable; two
2357        // or more would be the double-apply bug this guards against.
2358        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        // Invoice with a balance of exactly 100.
2399        let invoice_id = insert_invoice_for(&repo, cust, "INV-PAY-RACE", "100");
2400        // One payment parent row (FK target); all attempts apply against it.
2401        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        // Each thread applies a distinct payment of the full 100 to the same
2407        // invoice. Only one may succeed; the rest must see the reduced balance.
2408        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        // Safety invariant: at most one full payment can bind to the 100-balance
2431        // invoice (no double-apply). Under extreme lock contention the winner can
2432        // fail transiently (retryable), so zero is acceptable; two or more is the
2433        // bug this guards against.
2434        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        // Writing off an already-written-off invoice must be rejected.
2474        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        // The memo is now fully applied; a second application must fail and must
2521        // not move the balance.
2522        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}