Skip to main content

stateset_db/sqlite/
revenue_recognition.rs

1//! SQLite implementation of the revenue recognition (ASC 606) repository
2
3use super::{
4    map_db_error, parse_date_row, parse_datetime_row, parse_decimal_row, parse_enum_row,
5    parse_json_row, parse_uuid_opt_row, parse_uuid_row, with_immediate_transaction,
6};
7use chrono::{NaiveDate, Utc};
8use r2d2::Pool;
9use r2d2_sqlite::SqliteConnectionManager;
10use rust_decimal::Decimal;
11use stateset_core::{
12    CommerceError, CreateRevenueContract, CurrencyCode, PerformanceObligation, RecognitionMethod,
13    Result, RevenueContract, RevenueContractFilter, RevenueContractStatus, RevenueEntryStatus,
14    RevenueSchedule, RevenueScheduleEntry, UpdateRevenueContract, Validate,
15    generate_revenue_contract_number, generate_revenue_schedule,
16};
17use uuid::Uuid;
18
19#[derive(Debug)]
20pub struct SqliteRevenueRecognitionRepository {
21    pool: Pool<SqliteConnectionManager>,
22}
23
24impl SqliteRevenueRecognitionRepository {
25    #[must_use]
26    pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
27        Self { pool }
28    }
29
30    fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
31        self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))
32    }
33
34    fn conflict(msg: &str) -> rusqlite::Error {
35        rusqlite::Error::ToSqlConversionFailure(Box::new(CommerceError::Conflict(msg.to_string())))
36    }
37
38    fn row_to_contract(row: &rusqlite::Row<'_>) -> rusqlite::Result<RevenueContract> {
39        Ok(RevenueContract {
40            id: parse_uuid_row(&row.get::<_, String>("id")?, "revenue_contract", "id")?,
41            contract_number: row.get("contract_number")?,
42            customer_id: parse_uuid_row(
43                &row.get::<_, String>("customer_id")?,
44                "revenue_contract",
45                "customer_id",
46            )?,
47            order_id: parse_uuid_opt_row(
48                row.get::<_, Option<String>>("order_id")?,
49                "revenue_contract",
50                "order_id",
51            )?,
52            invoice_id: parse_uuid_opt_row(
53                row.get::<_, Option<String>>("invoice_id")?,
54                "revenue_contract",
55                "invoice_id",
56            )?,
57            transaction_price: parse_decimal_row(
58                &row.get::<_, String>("transaction_price")?,
59                "revenue_contract",
60                "transaction_price",
61            )?,
62            currency: parse_enum_row::<CurrencyCode>(
63                &row.get::<_, String>("currency")?,
64                "revenue_contract",
65                "currency",
66            )?,
67            status: parse_enum_row::<RevenueContractStatus>(
68                &row.get::<_, String>("status")?,
69                "revenue_contract",
70                "status",
71            )?,
72            effective_date: parse_date_row(
73                &row.get::<_, String>("effective_date")?,
74                "revenue_contract",
75                "effective_date",
76            )?,
77            obligations: Vec::new(),
78            created_at: parse_datetime_row(
79                &row.get::<_, String>("created_at")?,
80                "revenue_contract",
81                "created_at",
82            )?,
83            updated_at: parse_datetime_row(
84                &row.get::<_, String>("updated_at")?,
85                "revenue_contract",
86                "updated_at",
87            )?,
88        })
89    }
90
91    fn row_to_obligation(row: &rusqlite::Row<'_>) -> rusqlite::Result<PerformanceObligation> {
92        let ssp = match row.get::<_, Option<String>>("standalone_selling_price")? {
93            Some(s) => {
94                Some(parse_decimal_row(&s, "performance_obligation", "standalone_selling_price")?)
95            }
96            None => None,
97        };
98        Ok(PerformanceObligation {
99            id: parse_uuid_row(&row.get::<_, String>("id")?, "performance_obligation", "id")?,
100            contract_id: parse_uuid_row(
101                &row.get::<_, String>("contract_id")?,
102                "performance_obligation",
103                "contract_id",
104            )?,
105            description: row.get("description")?,
106            standalone_selling_price: ssp,
107            allocated_amount: parse_decimal_row(
108                &row.get::<_, String>("allocated_amount")?,
109                "performance_obligation",
110                "allocated_amount",
111            )?,
112            recognition_method: parse_json_row::<RecognitionMethod>(
113                &row.get::<_, String>("recognition_method")?,
114                "performance_obligation",
115                "recognition_method",
116            )?,
117            recognized_amount: parse_decimal_row(
118                &row.get::<_, String>("recognized_amount")?,
119                "performance_obligation",
120                "recognized_amount",
121            )?,
122            created_at: parse_datetime_row(
123                &row.get::<_, String>("created_at")?,
124                "performance_obligation",
125                "created_at",
126            )?,
127            updated_at: parse_datetime_row(
128                &row.get::<_, String>("updated_at")?,
129                "performance_obligation",
130                "updated_at",
131            )?,
132        })
133    }
134
135    fn row_to_entry(row: &rusqlite::Row<'_>) -> rusqlite::Result<RevenueScheduleEntry> {
136        Ok(RevenueScheduleEntry {
137            period: row.get::<_, i64>("period")? as u32,
138            period_start: parse_date_row(
139                &row.get::<_, String>("period_start")?,
140                "revenue_schedule_entry",
141                "period_start",
142            )?,
143            amount: parse_decimal_row(
144                &row.get::<_, String>("amount")?,
145                "revenue_schedule_entry",
146                "amount",
147            )?,
148            status: parse_enum_row::<RevenueEntryStatus>(
149                &row.get::<_, String>("status")?,
150                "revenue_schedule_entry",
151                "status",
152            )?,
153        })
154    }
155
156    fn load_obligations(
157        conn: &rusqlite::Connection,
158        contract_id: &str,
159    ) -> rusqlite::Result<Vec<PerformanceObligation>> {
160        let mut stmt = conn.prepare(
161            "SELECT * FROM performance_obligations WHERE contract_id = ? ORDER BY created_at, id",
162        )?;
163        stmt.query_map([contract_id], Self::row_to_obligation)?.collect()
164    }
165
166    fn load_obligations_batch(
167        conn: &rusqlite::Connection,
168        ids: &[String],
169    ) -> rusqlite::Result<std::collections::HashMap<String, Vec<PerformanceObligation>>> {
170        let mut map: std::collections::HashMap<String, Vec<PerformanceObligation>> =
171            std::collections::HashMap::with_capacity(ids.len());
172        for chunk in ids.chunks(500) {
173            let placeholders = super::build_in_clause(chunk.len());
174            let sql = format!(
175                "SELECT * FROM performance_obligations WHERE contract_id IN ({placeholders}) ORDER BY created_at, id"
176            );
177            let mut stmt = conn.prepare(&sql)?;
178            let param_refs: Vec<&dyn rusqlite::types::ToSql> =
179                chunk.iter().map(|s| s as &dyn rusqlite::types::ToSql).collect();
180            let rows = stmt.query_map(param_refs.as_slice(), |row| {
181                let parent: String = row.get("contract_id")?;
182                Ok((parent, Self::row_to_obligation(row)?))
183            })?;
184            for row in rows {
185                let (parent, obligation) = row?;
186                map.entry(parent).or_default().push(obligation);
187            }
188        }
189        Ok(map)
190    }
191
192    fn load_full(conn: &rusqlite::Connection, id: &str) -> rusqlite::Result<RevenueContract> {
193        let mut head = conn.query_row(
194            "SELECT * FROM revenue_contracts WHERE id = ?",
195            [id],
196            Self::row_to_contract,
197        )?;
198        head.obligations = Self::load_obligations(conn, id)?;
199        Ok(head)
200    }
201
202    fn load_obligation(
203        conn: &rusqlite::Connection,
204        id: &str,
205    ) -> rusqlite::Result<PerformanceObligation> {
206        conn.query_row(
207            "SELECT * FROM performance_obligations WHERE id = ?",
208            [id],
209            Self::row_to_obligation,
210        )
211    }
212
213    fn load_entries(
214        conn: &rusqlite::Connection,
215        obligation_id: &str,
216    ) -> rusqlite::Result<Vec<RevenueScheduleEntry>> {
217        let mut stmt = conn.prepare(
218            "SELECT * FROM revenue_schedule_entries WHERE obligation_id = ? ORDER BY period",
219        )?;
220        stmt.query_map([obligation_id], Self::row_to_entry)?.collect()
221    }
222}
223
224impl stateset_core::RevenueRecognitionRepository for SqliteRevenueRecognitionRepository {
225    fn create_contract(&self, input: CreateRevenueContract) -> Result<RevenueContract> {
226        input.validate()?;
227        let id = Uuid::new_v4();
228        let id_str = id.to_string();
229        let now = Utc::now().to_rfc3339();
230        let contract_number =
231            input.contract_number.clone().unwrap_or_else(generate_revenue_contract_number);
232        let currency = input.currency.unwrap_or_default();
233        with_immediate_transaction(&self.pool, |tx| {
234            tx.execute(
235                "INSERT INTO revenue_contracts (id, contract_number, customer_id, order_id, invoice_id, transaction_price, currency, status, effective_date, created_at, updated_at)
236                 VALUES (?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?)",
237                rusqlite::params![
238                    &id_str,
239                    &contract_number,
240                    input.customer_id.to_string(),
241                    input.order_id.map(|u| u.to_string()),
242                    input.invoice_id.map(|u| u.to_string()),
243                    input.transaction_price.to_string(),
244                    currency.to_string(),
245                    input.effective_date.to_string(),
246                    &now,
247                    &now,
248                ],
249            )?;
250            for ob in &input.obligations {
251                let method_json = serde_json::to_string(&ob.recognition_method).map_err(|e| {
252                    rusqlite::Error::ToSqlConversionFailure(Box::new(CommerceError::DatabaseError(
253                        e.to_string(),
254                    )))
255                })?;
256                tx.execute(
257                    "INSERT INTO performance_obligations (id, contract_id, description, standalone_selling_price, allocated_amount, recognition_method, recognized_amount, created_at, updated_at)
258                     VALUES (?, ?, ?, ?, ?, ?, '0', ?, ?)",
259                    rusqlite::params![
260                        Uuid::new_v4().to_string(),
261                        &id_str,
262                        &ob.description,
263                        ob.standalone_selling_price.map(|d| d.to_string()),
264                        ob.allocated_amount.to_string(),
265                        &method_json,
266                        &now,
267                        &now,
268                    ],
269                )?;
270            }
271            Self::load_full(tx, &id_str)
272        })
273    }
274
275    fn get_contract(&self, id: Uuid) -> Result<Option<RevenueContract>> {
276        let conn = self.conn()?;
277        match Self::load_full(&conn, &id.to_string()) {
278            Ok(c) => Ok(Some(c)),
279            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
280            Err(e) => Err(map_db_error(e)),
281        }
282    }
283
284    fn list_contracts(&self, filter: RevenueContractFilter) -> Result<Vec<RevenueContract>> {
285        let conn = self.conn()?;
286        let mut sql = "SELECT * FROM revenue_contracts WHERE 1=1".to_string();
287        let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
288        if let Some(customer) = filter.customer_id {
289            sql.push_str(" AND customer_id = ?");
290            params.push(Box::new(customer.to_string()));
291        }
292        if let Some(order) = filter.order_id {
293            sql.push_str(" AND order_id = ?");
294            params.push(Box::new(order.to_string()));
295        }
296        if let Some(invoice) = filter.invoice_id {
297            sql.push_str(" AND invoice_id = ?");
298            params.push(Box::new(invoice.to_string()));
299        }
300        if let Some(status) = filter.status {
301            sql.push_str(" AND status = ?");
302            params.push(Box::new(status.to_string()));
303        }
304        if let Some(from) = filter.effective_from {
305            sql.push_str(" AND effective_date >= ?");
306            params.push(Box::new(from.to_string()));
307        }
308        if let Some(to) = filter.effective_to {
309            sql.push_str(" AND effective_date <= ?");
310            params.push(Box::new(to.to_string()));
311        }
312        if let Some(search) = filter.search.as_deref() {
313            sql.push_str(" AND contract_number LIKE ? ESCAPE '\\'");
314            params.push(Box::new(format!("%{}%", super::escape_like(search))));
315        }
316        // Keyset cursor: (created_at, id) for stable DESC ordering
317        if let Some((cursor_created, cursor_id)) = &filter.after_cursor {
318            sql.push_str(" AND (created_at < ? OR (created_at = ? AND id < ?))");
319            params.push(Box::new(cursor_created.clone()));
320            params.push(Box::new(cursor_created.clone()));
321            params.push(Box::new(cursor_id.clone()));
322        }
323        sql.push_str(" ORDER BY created_at DESC, id DESC");
324        // Offset pagination applies only in non-cursor mode.
325        let offset = if filter.after_cursor.is_none() { filter.offset } else { None };
326        crate::sqlite::append_limit_offset(&mut sql, filter.limit, offset);
327        let param_refs: Vec<&dyn rusqlite::types::ToSql> =
328            params.iter().map(|p| p.as_ref()).collect();
329        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
330        let heads = stmt
331            .query_map(param_refs.as_slice(), Self::row_to_contract)
332            .map_err(map_db_error)?
333            .collect::<std::result::Result<Vec<_>, _>>()
334            .map_err(map_db_error)?;
335        let ids: Vec<String> = heads.iter().map(|h| h.id.to_string()).collect();
336        let mut obligations_by_id =
337            Self::load_obligations_batch(&conn, &ids).map_err(map_db_error)?;
338        let mut out = Vec::with_capacity(heads.len());
339        for mut head in heads {
340            head.obligations = obligations_by_id.remove(&head.id.to_string()).unwrap_or_default();
341            out.push(head);
342        }
343        Ok(out)
344    }
345
346    fn update_contract(&self, id: Uuid, input: UpdateRevenueContract) -> Result<RevenueContract> {
347        let id_str = id.to_string();
348        let now = Utc::now().to_rfc3339();
349        with_immediate_transaction(&self.pool, |tx| {
350            let contract = Self::load_full(tx, &id_str)?;
351            let status = match input.status {
352                Some(next) if next != contract.status => {
353                    if !contract.status.can_transition_to(next) {
354                        return Err(Self::conflict(&format!(
355                            "revenue contract cannot transition from {} to {next}",
356                            contract.status
357                        )));
358                    }
359                    next
360                }
361                _ => contract.status,
362            };
363            let order_id = input.order_id.or(contract.order_id);
364            let invoice_id = input.invoice_id.or(contract.invoice_id);
365            let effective_date = input.effective_date.unwrap_or(contract.effective_date);
366            tx.execute(
367                "UPDATE revenue_contracts SET order_id = ?, invoice_id = ?, status = ?, effective_date = ?, updated_at = ? WHERE id = ?",
368                rusqlite::params![
369                    order_id.map(|u| u.to_string()),
370                    invoice_id.map(|u| u.to_string()),
371                    status.to_string(),
372                    effective_date.to_string(),
373                    &now,
374                    &id_str,
375                ],
376            )?;
377            Self::load_full(tx, &id_str)
378        })
379    }
380
381    fn list_obligations(&self, contract_id: Uuid) -> Result<Vec<PerformanceObligation>> {
382        let conn = self.conn()?;
383        Self::load_obligations(&conn, &contract_id.to_string()).map_err(map_db_error)
384    }
385
386    fn generate_schedule(&self, obligation_id: Uuid) -> Result<RevenueSchedule> {
387        let ob_str = obligation_id.to_string();
388        with_immediate_transaction(&self.pool, |tx| {
389            let obligation = Self::load_obligation(tx, &ob_str)?;
390            let recognized: i64 = tx.query_row(
391                "SELECT COUNT(*) FROM revenue_schedule_entries WHERE obligation_id = ? AND status = 'recognized'",
392                [&ob_str],
393                |r| r.get(0),
394            )?;
395            if recognized > 0 {
396                return Err(Self::conflict(
397                    "cannot regenerate a schedule with recognized revenue entries",
398                ));
399            }
400            let effective_date: String = tx.query_row(
401                "SELECT effective_date FROM revenue_contracts WHERE id = ?",
402                [obligation.contract_id.to_string()],
403                |r| r.get(0),
404            )?;
405            let recognition_date =
406                parse_date_row(&effective_date, "revenue_contract", "effective_date")?;
407            let entries = generate_revenue_schedule(
408                obligation.recognition_method,
409                obligation.allocated_amount,
410                recognition_date,
411            );
412            if entries.is_empty() {
413                return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
414                    CommerceError::ValidationError(
415                        "cannot generate a time-based revenue schedule for this obligation".into(),
416                    ),
417                )));
418            }
419            tx.execute("DELETE FROM revenue_schedule_entries WHERE obligation_id = ?", [&ob_str])?;
420            for e in &entries {
421                tx.execute(
422                    "INSERT INTO revenue_schedule_entries (obligation_id, period, period_start, amount, status)
423                     VALUES (?, ?, ?, ?, ?)",
424                    rusqlite::params![
425                        &ob_str,
426                        i64::from(e.period),
427                        e.period_start.to_string(),
428                        e.amount.to_string(),
429                        e.status.to_string(),
430                    ],
431                )?;
432            }
433            Ok(RevenueSchedule {
434                obligation_id,
435                method: obligation.recognition_method,
436                total_amount: obligation.allocated_amount,
437                entries,
438            })
439        })
440    }
441
442    fn get_schedule(&self, obligation_id: Uuid) -> Result<Option<RevenueSchedule>> {
443        let conn = self.conn()?;
444        let ob_str = obligation_id.to_string();
445        let obligation = match Self::load_obligation(&conn, &ob_str) {
446            Ok(o) => o,
447            Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
448            Err(e) => return Err(map_db_error(e)),
449        };
450        let entries = Self::load_entries(&conn, &ob_str).map_err(map_db_error)?;
451        if entries.is_empty() {
452            return Ok(None);
453        }
454        Ok(Some(RevenueSchedule {
455            obligation_id,
456            method: obligation.recognition_method,
457            total_amount: entries.iter().map(|e| e.amount).sum(),
458            entries,
459        }))
460    }
461
462    fn recognize_period(&self, obligation_id: Uuid, through: NaiveDate) -> Result<RevenueSchedule> {
463        let ob_str = obligation_id.to_string();
464        let now = Utc::now().to_rfc3339();
465        let through_str = through.to_string();
466        let (schedule, newly_recognized) = with_immediate_transaction(&self.pool, |tx| {
467            let obligation = Self::load_obligation(tx, &ob_str)?;
468            let entries = Self::load_entries(tx, &ob_str)?;
469            if entries.is_empty() {
470                return Err(Self::conflict(
471                    "no revenue schedule has been generated for this obligation",
472                ));
473            }
474            let newly_recognized: Decimal = entries
475                .iter()
476                .filter(|e| e.status == RevenueEntryStatus::Deferred && e.period_start <= through)
477                .map(|e| e.amount)
478                .sum();
479            tx.execute(
480                "UPDATE revenue_schedule_entries SET status = 'recognized'
481                 WHERE obligation_id = ? AND status = 'deferred' AND period_start <= ?",
482                rusqlite::params![&ob_str, &through_str],
483            )?;
484            let recognized_amount = obligation.recognized_amount + newly_recognized;
485            tx.execute(
486                "UPDATE performance_obligations SET recognized_amount = ?, updated_at = ? WHERE id = ?",
487                rusqlite::params![recognized_amount.to_string(), &now, &ob_str],
488            )?;
489            // Complete the contract when every obligation is fully recognized.
490            let contract_str = obligation.contract_id.to_string();
491            let contract = Self::load_full(tx, &contract_str)?;
492            if contract.status == RevenueContractStatus::Active && contract.is_fully_recognized() {
493                tx.execute(
494                    "UPDATE revenue_contracts SET status = 'completed', updated_at = ? WHERE id = ?",
495                    rusqlite::params![&now, &contract_str],
496                )?;
497            }
498            let entries = Self::load_entries(tx, &ob_str)?;
499            Ok((
500                RevenueSchedule {
501                    obligation_id,
502                    method: obligation.recognition_method,
503                    total_amount: entries.iter().map(|e| e.amount).sum(),
504                    entries,
505                },
506                newly_recognized,
507            ))
508        })?;
509        self.auto_post_recognition_entry(obligation_id, newly_recognized, through)?;
510        Ok(schedule)
511    }
512}
513
514impl SqliteRevenueRecognitionRepository {
515    /// Create and post a balanced revenue-recognition journal entry
516    /// (debit deferred/unearned revenue, credit sales revenue) when the active
517    /// GL auto-posting config has `auto_post_revenue_recognition` enabled.
518    ///
519    /// Mirrors the invoice auto-posting pattern: config-gated, posted via the
520    /// existing general-ledger repository. The deferred-revenue account is the
521    /// config's `unearned_revenue_account_id`, falling back to the first active
522    /// posting account with the `unearned_revenue` sub-type; revenue is credited
523    /// to the config's `sales_revenue_account_id`.
524    fn auto_post_recognition_entry(
525        &self,
526        obligation_id: Uuid,
527        amount: Decimal,
528        through: NaiveDate,
529    ) -> Result<()> {
530        use stateset_core::GeneralLedgerRepository;
531        let gl = super::general_ledger::SqliteGeneralLedgerRepository::new(self.pool.clone());
532        let Some(config) = gl.get_auto_posting_config()? else { return Ok(()) };
533        if !config.auto_post_revenue_recognition || amount <= Decimal::ZERO {
534            return Ok(());
535        }
536        let deferred_account_id = match config.unearned_revenue_account_id {
537            Some(id) => id,
538            None => gl
539                .list_accounts(stateset_core::GlAccountFilter {
540                    account_sub_type: Some(stateset_core::AccountSubType::UnearnedRevenue),
541                    status: Some(stateset_core::AccountStatus::Active),
542                    is_posting: Some(true),
543                    limit: Some(1),
544                    ..Default::default()
545                })?
546                .first()
547                .map(|a| a.id)
548                .ok_or_else(|| {
549                    CommerceError::ValidationError(
550                        "auto-post revenue recognition requires an unearned_revenue account in the auto-posting config or chart of accounts"
551                            .to_string(),
552                    )
553                })?,
554        };
555        gl.create_journal_entry(stateset_core::CreateJournalEntry {
556            entry_date: through,
557            entry_type: Some(stateset_core::JournalEntryType::Standard),
558            description: format!("Revenue recognition for obligation {obligation_id}"),
559            lines: vec![
560                stateset_core::CreateJournalEntryLine::debit(
561                    deferred_account_id,
562                    amount,
563                    Some("Deferred Revenue".to_string()),
564                ),
565                stateset_core::CreateJournalEntryLine::credit(
566                    config.sales_revenue_account_id,
567                    amount,
568                    Some("Sales Revenue".to_string()),
569                ),
570            ],
571            source_document_type: Some("revenue_recognition".to_string()),
572            source_document_id: Some(obligation_id),
573            auto_post: Some(true),
574        })?;
575        Ok(())
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use crate::DatabaseConfig;
583    use crate::sqlite::SqliteDatabase;
584    use rust_decimal_macros::dec;
585    use stateset_core::{CreatePerformanceObligation, RevenueRecognitionRepository};
586
587    fn test_repo() -> SqliteRevenueRecognitionRepository {
588        let db = SqliteDatabase::new(&DatabaseConfig::in_memory()).expect("in-memory db");
589        SqliteRevenueRecognitionRepository::new(db.pool().clone())
590    }
591
592    fn date(y: i32, m: u32, d: u32) -> NaiveDate {
593        NaiveDate::from_ymd_opt(y, m, d).unwrap()
594    }
595
596    fn create_input() -> CreateRevenueContract {
597        CreateRevenueContract {
598            contract_number: None,
599            customer_id: Uuid::new_v4(),
600            order_id: None,
601            invoice_id: None,
602            transaction_price: dec!(1200),
603            currency: None,
604            effective_date: date(2026, 1, 15),
605            obligations: vec![
606                CreatePerformanceObligation {
607                    description: "Annual support".into(),
608                    standalone_selling_price: Some(dec!(1000)),
609                    allocated_amount: dec!(1000),
610                    recognition_method: RecognitionMethod::RatableOverTime {
611                        start: date(2026, 1, 1),
612                        end: date(2026, 12, 31),
613                    },
614                },
615                CreatePerformanceObligation {
616                    description: "Onboarding".into(),
617                    standalone_selling_price: None,
618                    allocated_amount: dec!(200),
619                    recognition_method: RecognitionMethod::PointInTime,
620                },
621            ],
622        }
623    }
624
625    #[test]
626    fn create_get_contract_with_obligations() {
627        let repo = test_repo();
628        let c = repo.create_contract(create_input()).expect("create");
629        assert_eq!(c.status, RevenueContractStatus::Draft);
630        assert!(c.contract_number.starts_with("RC-"));
631        assert_eq!(c.obligations.len(), 2);
632        assert_eq!(c.total_allocated(), dec!(1200));
633        assert_eq!(c.total_recognized(), dec!(0));
634        let fetched = repo.get_contract(c.id).expect("get").expect("found");
635        assert_eq!(fetched.deferred_balance(), dec!(1200));
636    }
637
638    #[test]
639    fn create_rejects_misallocated_contract() {
640        let repo = test_repo();
641        let mut input = create_input();
642        input.obligations[0].allocated_amount = dec!(900);
643        assert!(repo.create_contract(input).is_err());
644    }
645
646    #[test]
647    fn recognize_flow_completes_contract() {
648        let repo = test_repo();
649        let c = repo.create_contract(create_input()).expect("create");
650        let c = repo
651            .update_contract(
652                c.id,
653                UpdateRevenueContract {
654                    status: Some(RevenueContractStatus::Active),
655                    ..Default::default()
656                },
657            )
658            .expect("activate");
659        assert_eq!(c.status, RevenueContractStatus::Active);
660
661        let ratable =
662            c.obligations.iter().find(|o| o.description == "Annual support").expect("ratable");
663        let point = c.obligations.iter().find(|o| o.description == "Onboarding").expect("point");
664
665        let schedule = repo.generate_schedule(ratable.id).expect("ratable schedule");
666        assert_eq!(schedule.entries.len(), 12);
667        let total: Decimal = schedule.entries.iter().map(|e| e.amount).sum();
668        assert_eq!(total, dec!(1000));
669
670        let point_schedule = repo.generate_schedule(point.id).expect("point schedule");
671        assert_eq!(point_schedule.entries.len(), 1);
672        assert_eq!(point_schedule.entries[0].amount, dec!(200));
673
674        // Recognize the first quarter of the ratable obligation.
675        let after_q1 = repo.recognize_period(ratable.id, date(2026, 3, 31)).expect("recognize");
676        assert_eq!(after_q1.recognized_total(), dec!(249.99));
677        assert_eq!(after_q1.deferred_total(), dec!(750.01));
678        let c = repo.get_contract(c.id).expect("get").expect("found");
679        assert_eq!(c.total_recognized(), dec!(249.99));
680        assert_eq!(c.status, RevenueContractStatus::Active);
681
682        // Recognize everything.
683        repo.recognize_period(ratable.id, date(2026, 12, 31)).expect("recognize rest");
684        repo.recognize_period(point.id, date(2026, 12, 31)).expect("recognize point");
685        let c = repo.get_contract(c.id).expect("get").expect("found");
686        assert!(c.is_fully_recognized());
687        assert_eq!(c.deferred_balance(), dec!(0));
688        assert_eq!(c.status, RevenueContractStatus::Completed);
689    }
690
691    #[test]
692    fn recognize_is_idempotent_for_recognized_entries() {
693        let repo = test_repo();
694        let c = repo.create_contract(create_input()).expect("create");
695        let ratable =
696            c.obligations.iter().find(|o| o.description == "Annual support").expect("ratable");
697        repo.generate_schedule(ratable.id).expect("schedule");
698        repo.recognize_period(ratable.id, date(2026, 2, 28)).expect("recognize");
699        let again = repo.recognize_period(ratable.id, date(2026, 2, 28)).expect("recognize again");
700        assert_eq!(again.recognized_total(), dec!(166.66));
701        let ob = repo
702            .list_obligations(c.id)
703            .expect("obligations")
704            .into_iter()
705            .find(|o| o.id == ratable.id)
706            .expect("found");
707        assert_eq!(ob.recognized_amount, dec!(166.66));
708    }
709
710    #[test]
711    fn regenerate_after_recognition_conflicts() {
712        let repo = test_repo();
713        let c = repo.create_contract(create_input()).expect("create");
714        let ratable =
715            c.obligations.iter().find(|o| o.description == "Annual support").expect("ratable");
716        repo.generate_schedule(ratable.id).expect("schedule");
717        repo.recognize_period(ratable.id, date(2026, 1, 31)).expect("recognize");
718        assert!(repo.generate_schedule(ratable.id).is_err());
719    }
720
721    #[test]
722    fn recognize_without_schedule_conflicts() {
723        let repo = test_repo();
724        let c = repo.create_contract(create_input()).expect("create");
725        assert!(repo.recognize_period(c.obligations[0].id, date(2026, 6, 30)).is_err());
726    }
727
728    #[test]
729    fn invalid_status_transition_conflicts() {
730        let repo = test_repo();
731        let c = repo.create_contract(create_input()).expect("create");
732        assert!(
733            repo.update_contract(
734                c.id,
735                UpdateRevenueContract {
736                    status: Some(RevenueContractStatus::Completed),
737                    ..Default::default()
738                },
739            )
740            .is_err()
741        );
742    }
743
744    #[test]
745    fn list_filters_by_customer_and_status() {
746        let repo = test_repo();
747        let a = repo.create_contract(create_input()).expect("create a");
748        repo.create_contract(create_input()).expect("create b");
749        repo.update_contract(
750            a.id,
751            UpdateRevenueContract {
752                status: Some(RevenueContractStatus::Active),
753                ..Default::default()
754            },
755        )
756        .expect("activate");
757
758        let active = repo
759            .list_contracts(RevenueContractFilter {
760                status: Some(RevenueContractStatus::Active),
761                ..Default::default()
762            })
763            .expect("list");
764        assert_eq!(active.len(), 1);
765        assert_eq!(active[0].id, a.id);
766        assert_eq!(active[0].obligations.len(), 2);
767
768        let by_customer = repo
769            .list_contracts(RevenueContractFilter {
770                customer_id: Some(a.customer_id),
771                ..Default::default()
772            })
773            .expect("list customer");
774        assert_eq!(by_customer.len(), 1);
775    }
776
777    mod gl_auto_posting {
778        use super::*;
779        use crate::sqlite::general_ledger::SqliteGeneralLedgerRepository;
780        use stateset_core::{
781            AccountSubType, AccountType, CreateAutoPostingConfig, CreateGlAccount, CreateGlPeriod,
782            GeneralLedgerRepository, JournalEntryFilter, JournalEntryStatus,
783        };
784
785        fn test_repos() -> (SqliteRevenueRecognitionRepository, SqliteGeneralLedgerRepository) {
786            let db = SqliteDatabase::new(&DatabaseConfig::in_memory()).expect("in-memory db");
787            (
788                SqliteRevenueRecognitionRepository::new(db.pool().clone()),
789                SqliteGeneralLedgerRepository::new(db.pool().clone()),
790            )
791        }
792
793        fn setup_gl(
794            gl: &SqliteGeneralLedgerRepository,
795            auto_post_revenue_recognition: bool,
796        ) -> Uuid {
797            gl.initialize_chart_of_accounts().expect("init chart");
798            let unearned_id = gl
799                .create_account(CreateGlAccount {
800                    account_number: "2100".into(),
801                    name: "Unearned Revenue".into(),
802                    description: None,
803                    account_type: AccountType::Liability,
804                    account_sub_type: Some(AccountSubType::UnearnedRevenue),
805                    parent_account_id: None,
806                    is_header: Some(false),
807                    is_posting: Some(true),
808                    currency: None,
809                })
810                .expect("create unearned account")
811                .id;
812            let period = gl
813                .create_period(CreateGlPeriod {
814                    period_name: "All".into(),
815                    fiscal_year: 2026,
816                    period_number: 1,
817                    start_date: NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
818                    end_date: NaiveDate::from_ymd_opt(2030, 12, 31).unwrap(),
819                })
820                .expect("create period");
821            gl.open_period(period.id).expect("open period");
822            let by_number = |n: &str| {
823                gl.get_account_by_number(n).expect("get account").expect("account exists").id
824            };
825            gl.set_auto_posting_config(CreateAutoPostingConfig {
826                config_name: "Test".into(),
827                cash_account_id: by_number("1010"),
828                accounts_receivable_account_id: by_number("1100"),
829                inventory_account_id: by_number("1200"),
830                accounts_payable_account_id: by_number("2010"),
831                unearned_revenue_account_id: Some(unearned_id),
832                sales_revenue_account_id: by_number("4010"),
833                shipping_revenue_account_id: None,
834                cogs_account_id: by_number("5010"),
835                bad_debt_expense_account_id: None,
836                fx_gain_loss_account_id: None,
837                auto_post_depreciation: false,
838                auto_post_revenue_recognition,
839            })
840            .expect("set config");
841            unearned_id
842        }
843
844        #[test]
845        fn recognize_period_auto_posts_balanced_journal_entry_when_enabled() {
846            let (repo, gl) = test_repos();
847            let unearned_id = setup_gl(&gl, true);
848
849            let c = repo.create_contract(create_input()).expect("create");
850            let ratable =
851                c.obligations.iter().find(|o| o.description == "Annual support").expect("ratable");
852            repo.generate_schedule(ratable.id).expect("schedule");
853            repo.recognize_period(ratable.id, date(2026, 3, 31)).expect("recognize");
854
855            let entries = gl
856                .list_journal_entries(JournalEntryFilter {
857                    source_document_id: Some(ratable.id),
858                    ..Default::default()
859                })
860                .expect("list entries");
861            assert_eq!(entries.len(), 1);
862            let entry = &entries[0];
863            assert_eq!(entry.status, JournalEntryStatus::Posted);
864            assert!(entry.is_balanced);
865            assert_eq!(entry.total_debits, dec!(249.99));
866            assert_eq!(entry.total_credits, dec!(249.99));
867            assert_eq!(entry.source_document_type.as_deref(), Some("revenue_recognition"));
868            assert_eq!(entry.lines.len(), 2);
869            let debit = entry.lines.iter().find(|l| l.debit_amount > dec!(0)).expect("debit");
870            let credit = entry.lines.iter().find(|l| l.credit_amount > dec!(0)).expect("credit");
871            assert_eq!(debit.account_id, unearned_id);
872            assert_eq!(debit.debit_amount, dec!(249.99));
873            let sales_id =
874                gl.get_account_by_number("4010").expect("get").expect("sales account").id;
875            assert_eq!(credit.account_id, sales_id);
876            assert_eq!(credit.credit_amount, dec!(249.99));
877
878            // Re-recognizing the same window recognizes nothing new and posts
879            // no additional journal entry.
880            repo.recognize_period(ratable.id, date(2026, 3, 31)).expect("recognize again");
881            let entries = gl
882                .list_journal_entries(JournalEntryFilter {
883                    source_document_id: Some(ratable.id),
884                    ..Default::default()
885                })
886                .expect("list entries again");
887            assert_eq!(entries.len(), 1);
888        }
889
890        #[test]
891        fn recognize_period_creates_no_journal_entry_when_disabled() {
892            let (repo, gl) = test_repos();
893            setup_gl(&gl, false);
894
895            let c = repo.create_contract(create_input()).expect("create");
896            let ratable =
897                c.obligations.iter().find(|o| o.description == "Annual support").expect("ratable");
898            repo.generate_schedule(ratable.id).expect("schedule");
899            let s = repo.recognize_period(ratable.id, date(2026, 3, 31)).expect("recognize");
900            assert_eq!(s.recognized_total(), dec!(249.99));
901
902            let entries = gl
903                .list_journal_entries(JournalEntryFilter {
904                    source_document_id: Some(ratable.id),
905                    ..Default::default()
906                })
907                .expect("list entries");
908            assert!(entries.is_empty());
909        }
910
911        #[test]
912        fn recognize_period_without_config_creates_no_journal_entry() {
913            let (repo, gl) = test_repos();
914            let c = repo.create_contract(create_input()).expect("create");
915            let ratable =
916                c.obligations.iter().find(|o| o.description == "Annual support").expect("ratable");
917            repo.generate_schedule(ratable.id).expect("schedule");
918            repo.recognize_period(ratable.id, date(2026, 3, 31)).expect("recognize");
919            let entries = gl
920                .list_journal_entries(JournalEntryFilter {
921                    source_document_id: Some(ratable.id),
922                    ..Default::default()
923                })
924                .expect("list entries");
925            assert!(entries.is_empty());
926        }
927    }
928
929    #[test]
930    fn get_schedule_returns_none_before_generation() {
931        let repo = test_repo();
932        let c = repo.create_contract(create_input()).expect("create");
933        let ratable =
934            c.obligations.iter().find(|o| o.description == "Annual support").expect("ratable");
935        assert!(repo.get_schedule(ratable.id).expect("get").is_none());
936        repo.generate_schedule(ratable.id).expect("generate");
937        let s = repo.get_schedule(ratable.id).expect("get").expect("some");
938        assert_eq!(s.total_amount, dec!(1000));
939    }
940
941    #[test]
942    fn list_contracts_after_cursor_paginates_without_overlap() {
943        let repo = test_repo();
944        for _ in 0..3 {
945            repo.create_contract(create_input()).expect("create");
946        }
947        let all = repo.list_contracts(RevenueContractFilter::default()).expect("list all");
948        assert_eq!(all.len(), 3);
949
950        let first_page = repo
951            .list_contracts(RevenueContractFilter { limit: Some(2), ..Default::default() })
952            .expect("page 1");
953        assert_eq!(first_page.len(), 2);
954        assert_eq!(first_page[0].id, all[0].id);
955
956        let last = &first_page[1];
957        let second_page = repo
958            .list_contracts(RevenueContractFilter {
959                after_cursor: Some((last.created_at.to_rfc3339(), last.id.to_string())),
960                ..Default::default()
961            })
962            .expect("page 2");
963        assert_eq!(second_page.len(), 1);
964        assert_eq!(second_page[0].id, all[2].id);
965    }
966}