1use rust_decimal::Decimal;
33use serde::Serialize;
34use sqlx::PgPool;
35use uuid::Uuid;
36
37pub const MAX_CHECK_NUMBER: i64 = 2_147_483_647;
39
40#[derive(Debug, Clone)]
41pub struct RegisterSequence {
42 pub bank_account_id: Uuid,
43 pub numbering_mode: String,
45 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 pub check_number: Option<String>,
58 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 SequenceNotRegistered(Uuid),
90 NumberingModeConflict { mode: String, detail: String },
92 InvalidCheckNumber(String),
94 DuplicateCheckNumber(String),
96 CheckNumberOverflow,
98 CheckNotFound(Uuid),
100 AlreadyVoided(Uuid),
102 InvalidAmount,
104 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 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 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 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 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 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 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 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 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 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 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 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 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}