1use 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 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 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 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 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 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 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 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}