backbone_pos/application/service/pos_tender.rs
1//! Taking payment at the counter (hand-authored, user-owned).
2//!
3//! An `impl PosWriteService` chunk over the vocabulary in [`super::pos_write_service`]: add a tender
4//! line, recompute the ticket's `paid_total`/`change_due` in the SAME transaction, and emit
5//! `PosTenderCompleted` exactly on the tender that crosses full payment.
6//!
7//! Per the module's 4-layer rule this file holds no SQL — the statements live on
8//! `PosPaymentRepository` / `PosInvoiceRepository`, and the tender-insert + re-sum + header-update repo
9//! methods take THIS service's transaction so the tender and the totals it implies commit together.
10
11use rust_decimal::Decimal;
12use uuid::Uuid;
13
14use crate::infrastructure::persistence::NewTenderRow;
15
16use super::pos_events::{PosEvent, PosTenderCompleted};
17use super::pos_write_service::{legacy_company_echo, money, relay_ambient_scope, PosError, PosWriteService, TenderOutcome};
18
19impl PosWriteService {
20 /// Add a tender line; recompute `paid_total` + `change_due` (overpayment). Ticket must be draft.
21 pub async fn add_tender(&self, pos_invoice_id: Uuid, method: &str, amount: Decimal, reference_no: Option<String>) -> Result<TenderOutcome, PosError> {
22 if amount <= Decimal::ZERO { return Err(PosError::NegativeAmount); }
23 // Tenancy (ADR-0029), ID-only pattern: this method is identified by the ticket id alone —
24 // there is no unit argument to scope from up front. The verb therefore opens its OWN
25 // transaction, re-binds the caller's ambient org scope onto it, and runs the header read on
26 // that same connection: under a composed fence another unit's ticket simply isn't found
27 // (fail-closed), and the tender insert rides exactly the fence it read through.
28 let mut tx = self.rpool().begin().await?;
29 relay_ambient_scope(&mut tx).await?;
30 let hdr = self.invoices
31 .fetch_tender_header(&mut *tx, pos_invoice_id).await?
32 .ok_or(PosError::InvoiceNotFound(pos_invoice_id))?;
33 if hdr.status != "draft" { return Err(PosError::NotDraft); }
34 let rounded_total = hdr.rounded_total;
35 // Change is only ever given in cash: any other method may cover at most what is still due.
36 // The header lock above makes this sum the one the tender lands on.
37 let paid_before = self.payments.sum_paid_total_on(&mut tx, pos_invoice_id).await?;
38 check_change_is_cash(method, money(amount), rounded_total - paid_before)?;
39 self.payments.insert_tender(&mut tx, &NewTenderRow {
40 id: Uuid::new_v4(),
41 pos_invoice_id,
42 client_uuid: None,
43 payment_method: method,
44 amount: money(amount),
45 reference_no: reference_no.as_deref(),
46 }).await?;
47 let paid_total = self.payments.sum_paid_total_on(&mut tx, pos_invoice_id).await?;
48 let change_due = if paid_total > rounded_total { paid_total - rounded_total } else { Decimal::ZERO };
49 self.invoices.update_tender_totals(&mut tx, pos_invoice_id, paid_total, change_due).await?;
50 // Durable event: if this tender crosses full payment AND an outbox schema is configured,
51 // stage PosTenderCompleted INSIDE this transaction (atomic with the tender). A relay
52 // drains it to recognition — surviving a crash between commit and the in-process spawn.
53 // The fire-and-forget `sink.publish` below remains the fast path; double-delivery is a
54 // no-op because recognition is replay-safe (billing reuses billing_invoice_id; settle
55 // carries a payment_id skip-gate). Unset schema → historical fire-and-forget only.
56 let fully_tendered = paid_total >= rounded_total;
57 if fully_tendered && (paid_total - money(amount)) < rounded_total {
58 if let Some(schema) = &self.outbox_schema {
59 // Outbox fenced by `company_id` (ADR-0011, mirrored from backbone-payment) — see
60 // `tender_completed_outbox_record` for the fence contract. Under composition the
61 // fence column carries the ambient scope's legacy company echo (the acting org
62 // unit; the spine mirrored company ids into org_units verbatim).
63 let rec = tender_completed_outbox_record(pos_invoice_id, legacy_company_echo(), chrono::Utc::now());
64 backbone_outbox::outbox::stage(&mut *tx, schema, &rec)
65 .await
66 .map_err(|e| PosError::Db(sqlx::Error::Protocol(e.to_string())))?;
67 }
68 }
69 tx.commit().await?;
70 // Emit PosTenderCompleted exactly on the tender that crosses full payment, so a subscriber can
71 // recognise the sale. Guarding on the crossing (prev < total <= now) avoids a re-emit on any
72 // extra tender added before recognition flips the ticket to paid. The `company_id` field is
73 // the documented legacy twin (ADR-0029) — the acting unit id from the ambient scope's echo.
74 if fully_tendered && (paid_total - money(amount)) < rounded_total {
75 self.sink.publish(PosEvent::PosTenderCompleted(PosTenderCompleted {
76 pos_invoice_id, company_id: legacy_company_echo(),
77 }));
78 }
79 Ok(TenderOutcome { paid_total, change_due, fully_tendered })
80 }
81}
82
83/// Refuse a tender other than cash that would pay more than `remaining`. Change comes out of the
84/// cash drawer, so only cash may overpay; a card overpayment would have the drawer hand back cash it
85/// never took.
86pub(super) fn check_change_is_cash(method: &str, amount: Decimal, remaining: Decimal) -> Result<(), PosError> {
87 let remaining = remaining.max(Decimal::ZERO);
88 if method != "cash" && amount > remaining {
89 return Err(PosError::NonCashOverpayment { method: method.to_string(), remaining });
90 }
91 Ok(())
92}
93
94/// The fenced outbox record for a just-completed tender (ADR-0011, mirrored from backbone-payment).
95///
96/// `company_id` is what backbone-outbox v2.7.4's `multi_tenant` feature fences
97/// `<schema>.outbox_events` by — a backfilled `company_id` column + a fail-closed RLS policy —
98/// so a tenant-scoped relay cannot read another tenant's staged event. Under composition
99/// (ADR-0029) the caller sources it from the ambient org scope's legacy company echo: the
100/// acting org unit, which the spine mirrored from the company id verbatim. `OutboxRecord::new`
101/// sets the top-level field; the payload keeps a copy for consumers that read it from the JSON.
102pub(super) fn tender_completed_outbox_record(
103 pos_invoice_id: Uuid,
104 company_id: Uuid,
105 now: chrono::DateTime<chrono::Utc>,
106) -> backbone_outbox::OutboxRecord {
107 let payload = serde_json::json!({
108 "pos_invoice_id": pos_invoice_id,
109 "company_id": company_id,
110 });
111 backbone_outbox::OutboxRecord::new(
112 "PosTenderCompleted",
113 "PosSale",
114 pos_invoice_id.to_string(),
115 company_id,
116 payload,
117 now,
118 )
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 #[test]
126 fn only_cash_may_pay_past_what_is_due() {
127 let d = |s: &str| s.parse::<Decimal>().unwrap();
128 assert!(check_change_is_cash("cash", d("150"), d("100")).is_ok(), "cash overpays and takes change");
129 assert!(check_change_is_cash("card", d("100"), d("100")).is_ok(), "a card may cover exactly what is due");
130 assert!(matches!(
131 check_change_is_cash("card", d("100.01"), d("100")),
132 Err(PosError::NonCashOverpayment { .. })
133 ));
134 assert!(matches!(
135 check_change_is_cash("qris", d("1"), d("-5")),
136 Err(PosError::NonCashOverpayment { .. })
137 ), "nothing is due on an already paid-up ticket");
138 }
139
140 /// Regression guard for the ADR-0011 outbox fence (mirrored from backbone-payment): the staged
141 /// `PosTenderCompleted` record MUST carry the owning tenant as its top-level `company_id` — the
142 /// column the RLS policy fences on. If someone reverts to the pre-fence struct literal
143 /// (company_id only in the payload, outbox v2.7.1), this fails.
144 #[test]
145 fn tender_completed_record_is_fenced_by_company() {
146 let company = Uuid::new_v4();
147 let ticket = Uuid::new_v4();
148 let rec = tender_completed_outbox_record(ticket, company, chrono::Utc::now());
149 assert_eq!(
150 rec.company_id, company,
151 "PosTenderCompleted outbox record must carry the owning tenant (ADR-0011 fence)"
152 );
153 assert_eq!(rec.event_type, "PosTenderCompleted");
154 assert_eq!(rec.aggregate_type, "PosSale");
155 assert_eq!(rec.aggregate_id, ticket.to_string());
156 }
157}