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        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}