Skip to main content

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}