1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
//! The retail seam: recognising and returning a sale (hand-authored, user-owned).
//!
//! An `impl PosWriteService` chunk over the vocabulary in [`super::pos_write_service`]. POS posts no
//! GL itself — it drives `backbone-billing` and `backbone-payment` through their ports, and both
//! directions net cleanly: recognise books `Dr A/R · Cr Revenue` then `Dr Cash · Cr A/R`; return
//! reverses both. Every state flip is gated on its own `UPDATE ... WHERE status=<prior>` so a retry
//! recognises/returns at most once.
//!
//! Per the module's 4-layer rule this file holds no SQL — the statements live on
//! `PosInvoiceRepository` / `PosInvoiceItemRepository`. These paths are ID-only (no unit argument),
//! so their ambient-scope contract is documented on each method and on the repo methods they call.
//!
//! Tenancy is composition-installed (ADR-0029): the module is tenant-agnostic, and every read and
//! write here rides the composing caller's ambient org scope (under HTTP the request-dedicated
//! connection carries it). The `company_id` fields still on the outbound wires — the stock-issue
//! request to inventory (which keeps the company axis until its own strip) and the event twins —
//! are sourced from the ambient scope's legacy company echo.
use rust_decimal::Decimal;
use uuid::Uuid;
use crate::infrastructure::persistence::NewReturnInvoiceRow;
use super::pos_events::{PosEvent, PosInvoicePaid, PosInvoiceReturned};
use super::pos_ports::{
BillingPort, CreditNoteRequest, InventoryPort, PartialCredit, PaymentPort, RefundRequest,
SaleInvoiceRequest, SaleLine, SettlementRequest, StockIssueLine, StockIssueRequest,
};
use super::pos_write_service::{legacy_company_echo, PosError, PosWriteService, RecognizeOutcome, ReturnOutcome};
impl PosWriteService {
/// Recognise a fully-tendered sale: drive billing (raise + post the Sales Invoice → revenue) then
/// payment (settle the `rounded_total` against it → `Dr Cash · Cr A/R`), link the results, mark the
/// ticket `paid`, and emit `PosInvoicePaid`. Idempotent on `status='paid'`.
pub async fn recognize_sale(&self, pos_invoice_id: Uuid, billing: &dyn BillingPort, payment: &dyn PaymentPort, inventory: Option<&dyn InventoryPort>) -> Result<RecognizeOutcome, PosError> {
// Short-circuit if already recognised.
if let Some(o) = self.short_circuit_paid(pos_invoice_id).await? { return Ok(o); }
// Tenancy (ADR-0029), ID-only pattern: identified by ticket id alone. Under HTTP the
// request-dedicated connection carries the ambient org scope. When driven by an EVENT (the
// recognition sink subscribing to PosTenderCompleted), the caller must wrap this in the
// composing service's org request scope — bound from the event's `company_id` twin (the
// acting unit) — otherwise these reads fail closed.
let inv = self.invoices
.fetch_for_recognition(&self.rpool(), pos_invoice_id).await?
.ok_or(PosError::InvoiceNotFound(pos_invoice_id))?;
let rounded_total = inv.rounded_total;
let paid_total = inv.paid_total;
if paid_total < rounded_total {
return Err(PosError::NotFullyTendered { rounded_total, paid_total });
}
let tax_total = inv.tax_total;
let tax_account_id = inv.tax_account_id;
let tax_rate = inv.tax_rate;
// A ticket carrying PPN must have a tax liability account on its register to credit it to.
if tax_total > Decimal::ZERO && tax_account_id.is_none() {
return Err(PosError::MissingAccount("tax_account_id (register charges PPN but has no tax account)"));
}
let company_echo = legacy_company_echo();
let currency = inv.currency.clone();
let receivable: Uuid = inv.receivable_account_id.ok_or(PosError::MissingAccount("receivable_account_id"))?;
let income: Option<Uuid> = inv.income_account_id;
let cash: Uuid = inv.cash_account_id.ok_or(PosError::MissingAccount("cash_account_id"))?;
let customer: Uuid = inv.customer_id
.or(inv.default_customer_id)
.ok_or(PosError::MissingAccount("customer_id (no ticket or profile default)"))?;
let posting_at = inv.posting_at;
let posting_date = posting_at.date_naive();
// Billing at-most-once (council 2026-07-05): raise the Sales Invoice only if it was not already
// raised on a prior attempt. Persisting `billing_invoice_id` on the still-draft ticket the
// instant `raise_and_post` returns makes a retry after a settle failure (declined card / closed
// period) REUSE the invoice instead of raising a second one — otherwise the counter's ordinary
// decline-then-retry doubles the revenue journal (billing does not dedup on the POS ticket).
let existing_billing: Option<Uuid> = inv.billing_invoice_id;
let invoice_id = if let Some(id) = existing_billing {
id
} else {
// Sale lines → invoice request (revenue = line account, else profile income).
let item_rows = self.items.fetch_revenue_lines(&self.rpool(), pos_invoice_id).await?;
let mut lines = Vec::with_capacity(item_rows.len());
for r in &item_rows {
let rev = r.revenue_account_id.or(income).ok_or(PosError::MissingAccount("revenue account (line or profile income)"))?;
// Pass the NET as the unit_price at qty 1 so billing's line net == POS net (discounts already applied).
lines.push(SaleLine { item_id: r.item_id, revenue_account_id: rev, quantity: Decimal::ONE, unit_price: r.net_amount });
}
let ack = billing.raise_and_post(&SaleInvoiceRequest {
customer_id: customer, currency: currency.clone(), source_pos_id: pos_invoice_id,
receivable_account_id: receivable, posting_date, lines, tax_total, tax_account_id, tax_rate,
}).await.map_err(|e| PosError::BillingRejected { code: e.code, message: e.message })?;
// Persist the link WHILE STILL DRAFT — before settle — so any retry reuses this invoice.
self.invoices.link_billing_invoice(&self.rpool(), pos_invoice_id, ack.invoice_id).await?;
ack.invoice_id
};
// Settle the rounded_total against the raised invoice (cash sale → A/R nets to zero).
let sack = payment.settle(&SettlementRequest {
customer_id: customer, currency, invoice_ref: invoice_id,
bank_account_id: cash, party_account_id: receivable, posting_date, amount: rounded_total,
}).await.map_err(|e| PosError::PaymentRejected { code: e.code, message: e.message })?;
// Gate the event on THIS invocation performing the draft→paid transition. Persist the settling
// `payment_entry_id` in the same flip so a later return refunds the tender from the ticket (no
// cross-schema lookup) and a crash-retry has a durable skip-gate (ADR-001 `settle`-idempotency).
let rows_affected = self.invoices.mark_paid(&self.rpool(), pos_invoice_id, invoice_id, sack.payment_id).await?;
if rows_affected == 1 {
self.sink.publish(PosEvent::PosInvoicePaid(PosInvoicePaid {
pos_invoice_id, company_id: company_echo, grand_total: inv.grand_total, rounded_total,
billing_invoice_id: invoice_id, payment_id: sack.payment_id,
}));
// Decrement stock (an outward Delivery Note: on-hand relieved, `Dr COGS · Cr Inventory`)
// when the register is stock-tracking. Gated on this once-only flip, so it issues exactly
// once even if recognise is retried. Non-stock registers (no warehouse) skip it.
if let (Some(inv_port), Some(warehouse), Some(cogs), Some(inv_acct)) = (
inventory,
inv.warehouse_id,
inv.cogs_account_id,
inv.inventory_account_id,
) {
let qty_rows = self.items.fetch_quantity_lines(&self.rpool(), pos_invoice_id).await?;
let lines: Vec<StockIssueLine> = qty_rows.iter()
.map(|r| StockIssueLine { item_id: r.item_id, quantity: r.quantity })
.filter(|l| l.quantity > Decimal::ZERO)
.collect();
if !lines.is_empty() {
inv_port.issue(&StockIssueRequest {
company_id: company_echo, branch_id: inv.branch_id, customer_id: customer,
source_pos_id: pos_invoice_id, warehouse_id: warehouse, cogs_account_id: cogs,
inventory_account_id: inv_acct, posting_date, lines,
}).await.map_err(|e| PosError::InventoryRejected { code: e.code, message: e.message })?;
}
}
}
Ok(RecognizeOutcome { pos_invoice_id, billing_invoice_id: invoice_id, payment_id: sack.payment_id })
}
async fn short_circuit_paid(&self, pos_invoice_id: Uuid) -> Result<Option<RecognizeOutcome>, PosError> {
let row = self.invoices
.fetch_paid_state(&self.rpool(), pos_invoice_id).await?
.ok_or(PosError::InvoiceNotFound(pos_invoice_id))?;
if row.status == "paid" {
if let Some(bid) = row.billing_invoice_id {
// Return the real persisted payment_id on replay (was fabricated as Uuid::nil() before
// payment_entry_id was persisted — nil only for pre-2026-07-14 legacy tickets).
let payment_id = row.payment_entry_id.unwrap_or(Uuid::nil());
return Ok(Some(RecognizeOutcome { pos_invoice_id, billing_invoice_id: bid, payment_id }));
}
}
Ok(None)
}
/// Return a recognised sale (council 2026-07-05, the KEEP returns flow). Drives billing (credit
/// note — `Dr Revenue · Cr A/R`, invoice → cancelled) and payment (refund — `Dr A/R · Cr Cash`),
/// so revenue, cash, and A/R all net back to zero. Records an `is_return` ticket linked via
/// `return_against`, flips the original → `returned`, emits `PosInvoiceReturned`. **Idempotent:**
/// both downstream reversals are idempotent, and the ticket/event are gated on the original's
/// `paid→returned` transition — a repeat return refunds/credits at most once. POS posts no GL.
pub async fn return_sale(&self, original_pos_invoice_id: Uuid, billing: &dyn BillingPort, payment: &dyn PaymentPort, partial: Option<PartialCredit>) -> Result<ReturnOutcome, PosError> {
// Forward-compatible seam (council 2026-07-26, #5): the optional line-subset is accepted but
// not yet honored — partial returns need backbone-billing's credit note to take a line subset
// (ADR-001 park; gate: merchant demand). Today only full-ticket returns are supported.
if partial.is_some() {
return Err(PosError::PartialReturnsNotImplemented);
}
// Tenancy (ADR-0029), ID-only pattern — see `recognize_sale`: the lookup rides the ambient
// org scope, so another unit's ticket is simply not found.
let o = self.invoices
.fetch_return_source(&self.rpool(), original_pos_invoice_id).await?
.ok_or(PosError::InvoiceNotFound(original_pos_invoice_id))?;
let status = o.status.clone();
if status != "paid" && status != "returned" {
return Err(PosError::NotReturnable(status));
}
let billing_invoice_id: Uuid = o.billing_invoice_id.ok_or(PosError::NotReturnable("no billing invoice".into()))?;
let company_echo = legacy_company_echo();
let rounded_total = o.rounded_total;
// The tender to reverse — persisted at recognition (nil only for pre-2026-07-14 legacy tickets).
let payment_id: Uuid = o.payment_entry_id.unwrap_or(Uuid::nil());
// Drive the two reversals (both idempotent downstream): refund the tender + credit-note the sale.
payment.refund(&RefundRequest { invoice_ref: billing_invoice_id, payment_id, amount: rounded_total })
.await.map_err(|e| PosError::PaymentRejected { code: e.code, message: e.message })?;
billing.credit_note(&CreditNoteRequest { invoice_ref: billing_invoice_id, partial: None })
.await.map_err(|e| PosError::BillingRejected { code: e.code, message: e.message })?;
// Gate the return ticket + event on the original's paid→returned transition (exactly-once).
let rows_affected = self.invoices.mark_returned(&self.rpool(), original_pos_invoice_id).await?;
let return_ticket_id = if rows_affected == 1 {
let rt = Uuid::new_v4();
self.invoices.insert_return_ticket(&self.rpool(), &NewReturnInvoiceRow {
id: rt,
pos_profile_id: o.pos_profile_id,
opening_entry_id: o.opening_entry_id,
branch_id: o.branch_id,
customer_id: o.customer_id,
receipt_number: format!("RET-{}", &original_pos_invoice_id.simple().to_string()[..12]),
posting_at: o.posting_at,
net_total: o.net_total,
tax_total: o.tax_total,
grand_total: o.grand_total,
rounded_total,
billing_invoice_id,
return_against: original_pos_invoice_id,
}).await?;
self.sink.publish(PosEvent::PosInvoiceReturned(PosInvoiceReturned {
pos_invoice_id: original_pos_invoice_id, return_ticket_id: rt, company_id: company_echo,
billing_invoice_id, amount: rounded_total,
}));
rt
} else {
// Already returned — reuse the existing return ticket (idempotent replay).
self.invoices
.find_return_ticket(&self.rpool(), original_pos_invoice_id).await?
.unwrap_or(original_pos_invoice_id)
};
Ok(ReturnOutcome { pos_invoice_id: original_pos_invoice_id, return_ticket_id, billing_invoice_id })
}
}