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 self.payments.insert_tender(&mut tx, &NewTenderRow {
36 id: Uuid::new_v4(),
37 pos_invoice_id,
38 client_uuid: None,
39 payment_method: method,
40 amount: money(amount),
41 reference_no: reference_no.as_deref(),
42 }).await?;
43 let paid_total = self.payments.sum_paid_total_on(&mut tx, pos_invoice_id).await?;
44 let change_due = if paid_total > rounded_total { paid_total - rounded_total } else { Decimal::ZERO };
45 self.invoices.update_tender_totals(&mut tx, pos_invoice_id, paid_total, change_due).await?;
46 // Durable event: if this tender crosses full payment AND an outbox schema is configured,
47 // stage PosTenderCompleted INSIDE this transaction (atomic with the tender). A relay
48 // drains it to recognition — surviving a crash between commit and the in-process spawn.
49 // The fire-and-forget `sink.publish` below remains the fast path; double-delivery is a
50 // no-op because recognition is replay-safe (billing reuses billing_invoice_id; settle
51 // carries a payment_id skip-gate). Unset schema → historical fire-and-forget only.
52 let fully_tendered = paid_total >= rounded_total;
53 if fully_tendered && (paid_total - money(amount)) < rounded_total {
54 if let Some(schema) = &self.outbox_schema {
55 // Outbox fenced by `company_id` (ADR-0011, mirrored from backbone-payment) — see
56 // `tender_completed_outbox_record` for the fence contract. Under composition the
57 // fence column carries the ambient scope's legacy company echo (the acting org
58 // unit; the spine mirrored company ids into org_units verbatim).
59 let rec = tender_completed_outbox_record(pos_invoice_id, legacy_company_echo(), chrono::Utc::now());
60 backbone_outbox::outbox::stage(&mut *tx, schema, &rec)
61 .await
62 .map_err(|e| PosError::Db(sqlx::Error::Protocol(e.to_string())))?;
63 }
64 }
65 tx.commit().await?;
66 // Emit PosTenderCompleted exactly on the tender that crosses full payment, so a subscriber can
67 // recognise the sale. Guarding on the crossing (prev < total <= now) avoids a re-emit on any
68 // extra tender added before recognition flips the ticket to paid. The `company_id` field is
69 // the documented legacy twin (ADR-0029) — the acting unit id from the ambient scope's echo.
70 if fully_tendered && (paid_total - money(amount)) < rounded_total {
71 self.sink.publish(PosEvent::PosTenderCompleted(PosTenderCompleted {
72 pos_invoice_id, company_id: legacy_company_echo(),
73 }));
74 }
75 Ok(TenderOutcome { paid_total, change_due, fully_tendered })
76 }
77}
78
79/// The fenced outbox record for a just-completed tender (ADR-0011, mirrored from backbone-payment).
80///
81/// `company_id` is what backbone-outbox v2.7.4's `multi_tenant` feature fences
82/// `<schema>.outbox_events` by — a backfilled `company_id` column + a fail-closed RLS policy —
83/// so a tenant-scoped relay cannot read another tenant's staged event. Under composition
84/// (ADR-0029) the caller sources it from the ambient org scope's legacy company echo: the
85/// acting org unit, which the spine mirrored from the company id verbatim. `OutboxRecord::new`
86/// sets the top-level field; the payload keeps a copy for consumers that read it from the JSON.
87pub(super) fn tender_completed_outbox_record(
88 pos_invoice_id: Uuid,
89 company_id: Uuid,
90 now: chrono::DateTime<chrono::Utc>,
91) -> backbone_outbox::OutboxRecord {
92 let payload = serde_json::json!({
93 "pos_invoice_id": pos_invoice_id,
94 "company_id": company_id,
95 });
96 backbone_outbox::OutboxRecord::new(
97 "PosTenderCompleted",
98 "PosSale",
99 pos_invoice_id.to_string(),
100 company_id,
101 payload,
102 now,
103 )
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109
110 /// Regression guard for the ADR-0011 outbox fence (mirrored from backbone-payment): the staged
111 /// `PosTenderCompleted` record MUST carry the owning tenant as its top-level `company_id` — the
112 /// column the RLS policy fences on. If someone reverts to the pre-fence struct literal
113 /// (company_id only in the payload, outbox v2.7.1), this fails.
114 #[test]
115 fn tender_completed_record_is_fenced_by_company() {
116 let company = Uuid::new_v4();
117 let ticket = Uuid::new_v4();
118 let rec = tender_completed_outbox_record(ticket, company, chrono::Utc::now());
119 assert_eq!(
120 rec.company_id, company,
121 "PosTenderCompleted outbox record must carry the owning tenant (ADR-0011 fence)"
122 );
123 assert_eq!(rec.event_type, "PosTenderCompleted");
124 assert_eq!(rec.aggregate_type, "PosSale");
125 assert_eq!(rec.aggregate_id, ticket.to_string());
126 }
127}