Skip to main content

backbone_accounting/application/service/
check_printing_service.rs

1//! Check printing — the per-bank-journal check-number sequence and the
2//! printed-check registry.
3//!
4//! Hand-authored (user-owned; see `metaphor.codegen.yaml`). The Odoo
5//! `account_check_printing` shape ported onto this estate: a "bank journal" is
6//! a bank account (`banking.bank_accounts` owns the row; carried
7//! here as a logical reference — no cross-schema foreign key by contract).
8//!
9//! Numbering modes:
10//! - `auto`    — the verb allocates the next number from
11//!   `bank_check_sequences.next_number`. The allocation is a single
12//!   `UPDATE ... RETURNING` inside the verb's own transaction: the row lock IS
13//!   the serialization point, so two concurrent allocations block each other
14//!   and always receive distinct numbers — there is no read-then-write gap.
15//! - `manual`  — the officer supplies the number (prenumbered stock); the
16//!   registry's unique `(bank journal, number)` constraint refuses a
17//!   reuse with a typed error.
18//!
19//! Numbers are capped at 2147483647 (MAX_INT32): sequence columns upstream are
20//! 32-bit and printed numbers must stay portable. An allocation that would
21//! cross the cap refuses with `check_number_overflow` and the whole
22//! transaction rolls back — no number is consumed.
23//!
24//! Tenancy (ADR-0029): the module carries no tenancy of its own — the composing
25//! service's tenancy decorator owns org scoping. The `company_id` lanes here are
26//! the documented legacy twin: request shapes, acks, and verb signatures keep
27//! them so unstripped callers compile and run unchanged, but no statement keys
28//! on a tenant column. Every verb relays the ambient org scope onto its
29//! transaction; an undecorated deployment has no ambient scope and skips the
30//! relay entirely (unfenced by design).
31
32use rust_decimal::Decimal;
33use serde::Serialize;
34use sqlx::PgPool;
35use uuid::Uuid;
36
37/// Upstream's 32-bit sequence ceiling.
38pub const MAX_CHECK_NUMBER: i64 = 2_147_483_647;
39
40#[derive(Debug, Clone)]
41pub struct RegisterSequence {
42    pub bank_account_id: Uuid,
43    /// "auto" or "manual".
44    pub numbering_mode: String,
45    /// First number the sequence will hand out (auto mode).
46    pub next_number: i64,
47}
48
49#[derive(Debug, Clone)]
50pub struct RecordCheck {
51    pub bank_account_id: Uuid,
52    pub payment_id: Uuid,
53    pub payment_number: Option<String>,
54    pub amount: Decimal,
55    pub payee_name: Option<String>,
56    /// Required under `manual` numbering; must be absent under `auto`.
57    pub check_number: Option<String>,
58    /// Officer recording the print.
59    pub actor: Option<Uuid>,
60}
61
62#[derive(Debug, Clone, Serialize)]
63pub struct SequenceAck {
64    pub sequence_id: Uuid,
65    pub bank_account_id: Uuid,
66    pub numbering_mode: String,
67    pub next_number: i64,
68}
69
70#[derive(Debug, Clone, Serialize)]
71pub struct AllocationAck {
72    pub first: i64,
73    pub last: i64,
74    pub numbers: Vec<i64>,
75}
76
77#[derive(Debug, Clone, Serialize)]
78pub struct PrintedCheckAck {
79    pub printed_check_id: Uuid,
80    pub bank_account_id: Uuid,
81    pub payment_id: Uuid,
82    pub check_number: String,
83    pub status: String,
84}
85
86#[derive(Debug)]
87pub enum CheckPrintingError {
88    /// No sequence registered for the bank journal.
89    SequenceNotRegistered(Uuid),
90    /// A number was supplied to an auto sequence, or missing under manual.
91    NumberingModeConflict { mode: String, detail: String },
92    /// A manual number is malformed (empty, non-digit, over length, over cap).
93    InvalidCheckNumber(String),
94    /// The number is already in the registry for this bank journal.
95    DuplicateCheckNumber(String),
96    /// The next allocation would cross MAX_INT32.
97    CheckNumberOverflow,
98    /// Registry row not found.
99    CheckNotFound(Uuid),
100    /// Voiding a check that is already voided.
101    AlreadyVoided(Uuid),
102    /// Check amounts must be strictly positive.
103    InvalidAmount,
104    /// Storage failure.
105    Internal(String),
106}
107
108impl CheckPrintingError {
109    pub fn code(&self) -> &'static str {
110        match self {
111            CheckPrintingError::SequenceNotRegistered(_) => "sequence_not_registered",
112            CheckPrintingError::NumberingModeConflict { .. } => "numbering_mode_conflict",
113            CheckPrintingError::InvalidCheckNumber(_) => "invalid_check_number",
114            CheckPrintingError::DuplicateCheckNumber(_) => "duplicate_check_number",
115            CheckPrintingError::CheckNumberOverflow => "check_number_overflow",
116            CheckPrintingError::CheckNotFound(_) => "check_not_found",
117            CheckPrintingError::AlreadyVoided(_) => "check_already_voided",
118            CheckPrintingError::InvalidAmount => "invalid_amount",
119            CheckPrintingError::Internal(_) => "internal_error",
120        }
121    }
122
123    pub fn http_status(&self) -> u16 {
124        match self {
125            CheckPrintingError::SequenceNotRegistered(_) => 404,
126            CheckPrintingError::CheckNotFound(_) => 404,
127            CheckPrintingError::Internal(_) => 500,
128            _ => 422,
129        }
130    }
131}
132
133impl std::fmt::Display for CheckPrintingError {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        match self {
136            CheckPrintingError::SequenceNotRegistered(bank) => write!(
137                f,
138                "sequence_not_registered: no check sequence for bank account {bank}"
139            ),
140            CheckPrintingError::NumberingModeConflict { mode, detail } => write!(
141                f,
142                "numbering_mode_conflict: sequence is {mode}; {detail}"
143            ),
144            CheckPrintingError::InvalidCheckNumber(n) => write!(
145                f,
146                "invalid_check_number: {n:?} (want 1..=10 digits, value <= {MAX_CHECK_NUMBER})"
147            ),
148            CheckPrintingError::DuplicateCheckNumber(n) => write!(
149                f,
150                "duplicate_check_number: {n} is already registered for this bank journal"
151            ),
152            CheckPrintingError::CheckNumberOverflow => write!(
153                f,
154                "check_number_overflow: allocation would cross {MAX_CHECK_NUMBER}"
155            ),
156            CheckPrintingError::CheckNotFound(id) => {
157                write!(f, "check_not_found: printed check {id}")
158            }
159            CheckPrintingError::AlreadyVoided(id) => {
160                write!(f, "check_already_voided: printed check {id}")
161            }
162            CheckPrintingError::InvalidAmount => {
163                write!(f, "invalid_amount: check amount must be strictly positive")
164            }
165            CheckPrintingError::Internal(e) => write!(f, "internal_error: {e}"),
166        }
167    }
168}
169impl std::error::Error for CheckPrintingError {}
170
171fn internal(e: impl std::fmt::Display) -> CheckPrintingError {
172    CheckPrintingError::Internal(e.to_string())
173}
174
175fn validate_manual_number(raw: &str) -> Result<String, CheckPrintingError> {
176    let n = raw.trim();
177    if n.is_empty()
178        || n.chars().count() > 10
179        || !n.chars().all(|c| c.is_ascii_digit())
180    {
181        return Err(CheckPrintingError::InvalidCheckNumber(raw.to_string()));
182    }
183    // All-digits ≤10 chars always parses; treat any parse failure as invalid.
184    let value = n.parse::<i64>().map_err(|_| CheckPrintingError::InvalidCheckNumber(raw.to_string()))?;
185    if value > MAX_CHECK_NUMBER {
186        return Err(CheckPrintingError::InvalidCheckNumber(raw.to_string()));
187    }
188    Ok(n.to_string())
189}
190
191#[derive(Clone)]
192pub struct CheckPrintingService {
193    pool: PgPool,
194}
195
196#[derive(sqlx::FromRow)]
197struct SequenceRow {
198    id: Uuid,
199    numbering_mode: String,
200    next_number: i64,
201}
202
203impl CheckPrintingService {
204    pub fn new(pool: PgPool) -> Self {
205        Self { pool }
206    }
207
208    /// Register (or replace) the check sequence for one bank journal.
209    pub async fn register_sequence(
210        &self,
211        req: RegisterSequence,
212    ) -> Result<SequenceAck, CheckPrintingError> {
213        if req.numbering_mode != "auto" && req.numbering_mode != "manual" {
214            return Err(CheckPrintingError::NumberingModeConflict {
215                mode: req.numbering_mode.clone(),
216                detail: "numbering_mode must be \"auto\" or \"manual\"".into(),
217            });
218        }
219        if req.next_number < 1 || req.next_number > MAX_CHECK_NUMBER {
220            return Err(CheckPrintingError::InvalidCheckNumber(
221                req.next_number.to_string(),
222            ));
223        }
224
225        let mut tx = self.pool.begin().await.map_err(internal)?;
226        // Tenancy posture (ADR-0029): relay the AMBIENT request scope onto this
227        // transaction when the caller bound one. An undecorated deployment has no
228        // ambient scope and skips this entirely (unfenced by design).
229        if let Some(scope) = backbone_orm::org_scope::current_org_scope() {
230            backbone_orm::org_scope::bind_org_scope_on(&mut tx, &scope)
231                .await
232                .map_err(internal)?;
233            backbone_orm::audit_context::relay_ambient_audit_on(&mut tx)
234                .await
235                .map_err(internal)?;
236        }
237
238        let row = sqlx::query_as::<_, SequenceRow>(
239            r#"INSERT INTO accounting.bank_check_sequences
240                 (bank_account_id, numbering_mode, next_number, updated_at)
241               VALUES ($1,$2,$3,NOW())
242               ON CONFLICT (bank_account_id) DO UPDATE SET
243                 numbering_mode = EXCLUDED.numbering_mode,
244                 next_number   = EXCLUDED.next_number,
245                 updated_at    = NOW()
246               RETURNING id, numbering_mode, next_number"#,
247        )
248        .bind(req.bank_account_id)
249        .bind(&req.numbering_mode)
250        .bind(req.next_number)
251        .fetch_one(&mut *tx)
252        .await
253        .map_err(internal)?;
254
255        tx.commit().await.map_err(internal)?;
256        Ok(SequenceAck {
257            sequence_id: row.id,
258            bank_account_id: req.bank_account_id,
259            numbering_mode: row.numbering_mode,
260            next_number: row.next_number,
261        })
262    }
263
264    /// Allocate `count` consecutive numbers from an AUTO sequence.
265    ///
266    /// Single-statement atomic allocation: the UPDATE takes the row lock and
267    /// returns the post-bump cursor in one step, so a concurrent allocator
268    /// blocks on the lock and continues from the bumped value — there is no
269    /// window in which two callers read the same cursor. An overflow refuses
270    /// and the transaction rolls back, leaving the cursor untouched.
271    pub async fn allocate_check_numbers(
272        &self,
273        bank_account_id: Uuid,
274        count: i64,
275    ) -> Result<AllocationAck, CheckPrintingError> {
276        if count < 1 {
277            return Err(CheckPrintingError::InvalidCheckNumber(
278                "count must be >= 1".into(),
279            ));
280        }
281        let mut tx = self.pool.begin().await.map_err(internal)?;
282        let ack = self
283            .allocate_check_numbers_on(&mut tx, bank_account_id, count)
284            .await?;
285        tx.commit().await.map_err(internal)?;
286        Ok(ack)
287    }
288
289    /// In-transaction allocation core, shared with the record verb so a check
290    /// number is allocated and its registry row written atomically. The caller's
291    /// transaction already carries the ambient org scope relay; `company_id` is
292    /// the legacy twin parameter, accepted for call-shape stability and unused.
293    async fn allocate_check_numbers_on(
294        &self,
295        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
296        bank_account_id: Uuid,
297        count: i64,
298    ) -> Result<AllocationAck, CheckPrintingError> {
299        let bumped: Option<i64> = sqlx::query_scalar(
300            r#"UPDATE accounting.bank_check_sequences
301                  SET next_number = next_number + $2, updated_at = NOW()
302                WHERE bank_account_id = $1
303                  AND numbering_mode = 'auto'
304                RETURNING next_number"#,
305        )
306        .bind(bank_account_id)
307        .bind(count)
308        .fetch_optional(&mut **tx)
309        .await
310        .map_err(internal)?;
311
312        let Some(new_cursor) = bumped else {
313            // Distinguish "no row" from "wrong mode" for the refusal message.
314            let mode: Option<String> = sqlx::query_scalar(
315                r#"SELECT numbering_mode FROM accounting.bank_check_sequences
316                    WHERE bank_account_id=$1"#,
317            )
318            .bind(bank_account_id)
319            .fetch_optional(&mut **tx)
320            .await
321            .map_err(internal)?;
322            return Err(match mode {
323                None => CheckPrintingError::SequenceNotRegistered(bank_account_id),
324                Some(m) => CheckPrintingError::NumberingModeConflict {
325                    mode: m,
326                    detail: "auto allocation requires numbering_mode = \"auto\"".into(),
327                },
328            });
329        };
330
331        let first = new_cursor - count;
332        if new_cursor - 1 > MAX_CHECK_NUMBER {
333            // Refuse and roll the whole transaction back — the bump above dies
334            // with it, so no number is consumed.
335            return Err(CheckPrintingError::CheckNumberOverflow);
336        }
337        Ok(AllocationAck {
338            first,
339            last: new_cursor - 1,
340            numbers: (first..new_cursor).collect(),
341        })
342    }
343
344    /// Record one printed check: allocate (auto) or validate (manual) the
345    /// number and write the registry row in the SAME transaction. The unique
346    /// `(bank journal, number)` constraint is the cross-payment
347    /// uniqueness guard — a violation maps to `duplicate_check_number`.
348    pub async fn record_printed_check(
349        &self,
350        req: RecordCheck,
351    ) -> Result<PrintedCheckAck, CheckPrintingError> {
352        if req.amount <= Decimal::ZERO {
353            return Err(CheckPrintingError::InvalidAmount);
354        }
355
356        let mut tx = self.pool.begin().await.map_err(internal)?;
357        // Tenancy posture (ADR-0029): relay the AMBIENT request scope onto this
358        // transaction when the caller bound one. An undecorated deployment has no
359        // ambient scope and skips this entirely (unfenced by design).
360        if let Some(scope) = backbone_orm::org_scope::current_org_scope() {
361            backbone_orm::org_scope::bind_org_scope_on(&mut tx, &scope)
362                .await
363                .map_err(internal)?;
364            backbone_orm::audit_context::relay_ambient_audit_on(&mut tx)
365                .await
366                .map_err(internal)?;
367        }
368
369        let seq = sqlx::query_as::<_, SequenceRow>(
370            r#"SELECT id, numbering_mode, next_number
371                 FROM accounting.bank_check_sequences
372                WHERE bank_account_id=$1
373                FOR UPDATE"#,
374        )
375        .bind(req.bank_account_id)
376        .fetch_optional(&mut *tx)
377        .await
378        .map_err(internal)?;
379
380        let check_number = match seq {
381            None => {
382                return Err(CheckPrintingError::SequenceNotRegistered(
383                    req.bank_account_id,
384                ))
385            }
386            Some(row) => match row.numbering_mode.as_str() {
387                "auto" => {
388                    if req.check_number.is_some() {
389                        return Err(CheckPrintingError::NumberingModeConflict {
390                            mode: "auto".into(),
391                            detail: "auto sequences allocate; do not supply a check number"
392                                .into(),
393                        });
394                    }
395                    // One-number allocation through the shared atomic core —
396                    // same transaction as the registry insert.
397                    self.allocate_check_numbers_on(
398                        &mut tx,
399                        req.bank_account_id,
400                        1,
401                    )
402                    .await?
403                    .first
404                    .to_string()
405                }
406                "manual" => validate_manual_number(
407                    req.check_number
408                        .as_deref()
409                        .ok_or(CheckPrintingError::NumberingModeConflict {
410                            mode: "manual".into(),
411                            detail: "manual sequences require the officer's check number"
412                                .into(),
413                        })?,
414                )?,
415                other => {
416                    return Err(CheckPrintingError::NumberingModeConflict {
417                        mode: other.to_string(),
418                        detail: "unknown numbering mode".into(),
419                    })
420                }
421            },
422        };
423
424        let id: Uuid = match sqlx::query_scalar(
425            r#"INSERT INTO accounting.printed_checks
426                 (bank_account_id, payment_id, payment_number,
427                  check_number, amount, payee_name, printed_by, updated_at)
428               VALUES ($1,$2,$3,$4,$5,$6,$7,NOW())
429               RETURNING id"#,
430        )
431        .bind(req.bank_account_id)
432        .bind(req.payment_id)
433        .bind(&req.payment_number)
434        .bind(&check_number)
435        .bind(req.amount)
436        .bind(&req.payee_name)
437        .bind(req.actor)
438        .fetch_one(&mut *tx)
439        .await
440        {
441            Ok(id) => id,
442            Err(sqlx::Error::Database(db)) if db.is_unique_violation() => {
443                return Err(CheckPrintingError::DuplicateCheckNumber(check_number))
444            }
445            Err(e) => return Err(internal(e)),
446        };
447
448        tx.commit().await.map_err(internal)?;
449        Ok(PrintedCheckAck {
450            printed_check_id: id,
451            bank_account_id: req.bank_account_id,
452            payment_id: req.payment_id,
453            check_number,
454            status: "printed".into(),
455        })
456    }
457
458    /// Void a printed check. The number stays consumed (the registry row and
459    /// its uniqueness survive) — reversing the payment is the GL-side action
460    /// and never frees a check number for reuse. `company_id` is the legacy
461    /// tenancy twin (ADR-0029): kept in the signature for unstripped callers and
462    /// echoed into the ack; no statement keys on it.
463    pub async fn void_printed_check(
464        &self,
465        printed_check_id: Uuid,
466        actor: Option<Uuid>,
467    ) -> Result<PrintedCheckAck, CheckPrintingError> {
468        let mut tx = self.pool.begin().await.map_err(internal)?;
469        // Tenancy posture (ADR-0029): relay the AMBIENT request scope onto this
470        // transaction when the caller bound one. An undecorated deployment has no
471        // ambient scope and skips this entirely (unfenced by design).
472        if let Some(scope) = backbone_orm::org_scope::current_org_scope() {
473            backbone_orm::org_scope::bind_org_scope_on(&mut tx, &scope)
474                .await
475                .map_err(internal)?;
476            backbone_orm::audit_context::relay_ambient_audit_on(&mut tx)
477                .await
478                .map_err(internal)?;
479        }
480
481        let voided = sqlx::query_as::<_, VoidRow>(
482            r#"UPDATE accounting.printed_checks
483                  SET status='voided', updated_at=NOW(),
484                      metadata = metadata
485                          || jsonb_build_object(
486                               'voided_by', $2,
487                               'voided_at', to_jsonb(NOW()))
488                WHERE id=$1 AND status='printed'
489                RETURNING id, bank_account_id, payment_id,
490                          check_number, status"#,
491        )
492        .bind(printed_check_id)
493        .bind(actor.map(|a| a.to_string()))
494        .fetch_optional(&mut *tx)
495        .await
496        .map_err(internal)?;
497
498        let row = match voided {
499            Some(r) => r,
500            None => {
501                let exists: Option<String> = sqlx::query_scalar(
502                    "SELECT status FROM accounting.printed_checks WHERE id=$1",
503                )
504                .bind(printed_check_id)
505                .fetch_optional(&mut *tx)
506                .await
507                .map_err(internal)?;
508                return Err(match exists.as_deref() {
509                    Some("voided") => CheckPrintingError::AlreadyVoided(printed_check_id),
510                    _ => CheckPrintingError::CheckNotFound(printed_check_id),
511                });
512            }
513        };
514
515        tx.commit().await.map_err(internal)?;
516        Ok(PrintedCheckAck {
517            printed_check_id: row.id,
518            bank_account_id: row.bank_account_id,
519            payment_id: row.payment_id,
520            check_number: row.check_number,
521            status: row.status,
522        })
523    }
524}
525
526#[derive(sqlx::FromRow)]
527struct VoidRow {
528    id: Uuid,
529    bank_account_id: Uuid,
530    payment_id: Uuid,
531    check_number: String,
532    status: String,
533}