backbone_pos/application/service/pos_ports.rs
1//! Outbound orchestration ports (hand-authored, user-owned) — how POS drives billing + payment +
2//! inventory, resolves document-grade tax, and books the session-close variance.
3//!
4//! POS owns NO GL path and does NOT import billing/payment/tax. On `recognize_sale` it hands a
5//! serialized request to a `BillingPort` (raise + post the real Sales Invoice — revenue) and a
6//! `PaymentPort` (settle the tender). A composition layer implements both over the real
7//! `backbone-billing` / `backbone-payment` services; the shipped POS library has ZERO normal Cargo
8//! edge to either. The ports are the wire contract — the same envelope+ACL discipline every seam
9//! uses, here for the downstream emitters instead of one. The same posture carries the newer seams:
10//! `PosTaxComputePort` (the register's templates resolved document-grade) and `PosCashVariancePort`
11//! (the one new GL surface a session close produces).
12//!
13//! **Tenancy on the wires (ADR-0029):** the module is tenant-agnostic. Requests to stripped
14//! consumer modules (billing, payment, tax) carry no company field — the composing adapter binds
15//! the ambient org scope around those verbs. Two wires keep a `company_id` as the documented
16//! legacy twin for consumers that still read one: `StockIssueRequest` (inventory keeps the
17//! company axis until its own strip) and `CashVarianceRequest` (the GL-post envelope's legacy
18//! twin). Both are sourced from the ambient org scope's legacy company echo at the call site.
19
20use rust_decimal::Decimal;
21use serde::{Deserialize, Serialize};
22use uuid::Uuid;
23
24/// One sale line for the invoice request.
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
26pub struct SaleLine {
27 pub item_id: Uuid,
28 pub revenue_account_id: Uuid,
29 pub quantity: Decimal,
30 pub unit_price: Decimal,
31}
32
33/// The request POS hands billing: raise the Sales Invoice for this sale + post its revenue.
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35pub struct SaleInvoiceRequest {
36 pub customer_id: Uuid,
37 pub currency: String,
38 /// The POS ticket id (the invoice's `source` reference).
39 pub source_pos_id: Uuid,
40 pub receivable_account_id: Uuid,
41 pub posting_date: chrono::NaiveDate,
42 pub lines: Vec<SaleLine>,
43 /// Supplied PPN (tax) total + the account it credits (0 / None for a tax-free sale).
44 pub tax_total: Decimal,
45 pub tax_account_id: Option<Uuid>,
46 /// The PPN rate that produced `tax_total` (e.g. 0.11 for 11%); 0 for a tax-free sale. Informational
47 /// for the billing tax line + the receipt — billing books GL off `tax_total`, not this.
48 pub tax_rate: Decimal,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
52pub struct InvoiceAck {
53 pub invoice_id: Uuid,
54 pub journal_id: Uuid,
55 pub grand_total: Decimal,
56}
57
58/// The request POS hands payment: settle `amount` against the raised invoice (cash/card tender).
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60pub struct SettlementRequest {
61 pub customer_id: Uuid,
62 pub currency: String,
63 /// The billing invoice being settled (from the `InvoiceAck`).
64 pub invoice_ref: Uuid,
65 pub bank_account_id: Uuid,
66 pub party_account_id: Uuid,
67 pub posting_date: chrono::NaiveDate,
68 pub amount: Decimal,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
72pub struct SettlementAck {
73 pub payment_id: Uuid,
74 pub journal_id: Uuid,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
78pub struct PosRejected {
79 pub code: String,
80 pub message: String,
81}
82
83/// A line to credit on a partial return — mirrors the [`SaleLine`] shape POS handed billing on the
84/// sale (`qty · unit_price` is the line's net; `item_id` is the logical catalog FK).
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
86pub struct CreditLine {
87 pub item_id: Uuid,
88 pub quantity: Decimal,
89 pub unit_price: Decimal,
90 pub revenue_account_id: Uuid,
91}
92
93/// Optional partial-credit payload for [`CreditNoteRequest`].
94///
95/// **Forward-compatible seam (council 2026-07-26, #5 slice):** added now so POS can request a
96/// line-level credit note the moment `backbone-billing`'s `credit_note` / `reverse_sales_invoice`
97/// honors a line subset — without a later wire ABI break. `amount`, when set, overrides the summed
98/// line total (e.g. a header-level partial). POS does not construct this yet (full-ticket returns
99/// only — ADR-001 park); `return_sale` gates `Some(_)` as `PartialReturnsNotImplemented`.
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
101pub struct PartialCredit {
102 /// The subset of sale lines to reverse (item + qty). Empty = amount-only partial.
103 pub lines: Vec<CreditLine>,
104 /// Optional override for the total to credit; otherwise the sum of `lines`.
105 pub amount: Option<Decimal>,
106}
107
108/// The request POS hands billing to CREDIT-NOTE a sale (reverse the revenue) on a return.
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
110pub struct CreditNoteRequest {
111 /// The billing Sales Invoice to credit-note (`Dr Revenue · Cr A/R`, invoice → cancelled).
112 pub invoice_ref: Uuid,
113 /// Optional line-level / amount partial. `None` = whole-invoice credit note (today's behavior).
114 /// `#[serde(default)]` keeps the wire backward-compatible with adapters that don't send it.
115 #[serde(default)]
116 pub partial: Option<PartialCredit>,
117}
118
119/// The request POS hands payment to REFUND a settled sale (reverse the tender) on a return.
120///
121/// Carries the `payment_id` of the settling `PaymentEntry` (ADR-001 addendum 2026-07-14): the refund
122/// contract must be self-contained so a `PaymentPort` impl can reverse the tender **without reading
123/// another module's private tables**. Before this field, the seam adapter resolved the payment by a
124/// cross-schema `SELECT ... FROM payment.payment_allocations`, which only works when POS, billing,
125/// payment, and accounting co-locate in one database — it is unsatisfiable over a bus. POS persists the
126/// settlement `payment_id` on the ticket at recognition (see `PosInvoice.payment_entry_id`) and hands it
127/// back here, which also discharges the ADR-001 `settle`-idempotency park (payment_id becomes a durable
128/// skip-gate).
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
130pub struct RefundRequest {
131 /// The billing invoice whose settlement is being reversed (`Dr A/R · Cr Cash`).
132 pub invoice_ref: Uuid,
133 /// The `PaymentEntry` that settled this sale — the tender being reversed. Nil only for a legacy
134 /// ticket recognised before `payment_entry_id` was persisted (pre-2026-07-14).
135 pub payment_id: Uuid,
136 pub amount: Decimal,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
140pub struct ReversalAck {
141 pub journal_id: Uuid,
142}
143
144/// Billing seam: a composing service implements this over billing's Sales Invoice create+post and its
145/// credit-note (`reverse_sales_invoice`).
146#[async_trait::async_trait]
147pub trait BillingPort: Send + Sync {
148 async fn raise_and_post(&self, req: &SaleInvoiceRequest) -> Result<InvoiceAck, PosRejected>;
149 async fn credit_note(&self, req: &CreditNoteRequest) -> Result<ReversalAck, PosRejected>;
150}
151
152/// Payment seam: a composing service implements this over payment's create+post + apply_settlement,
153/// and its refund (`reverse_payment`).
154#[async_trait::async_trait]
155pub trait PaymentPort: Send + Sync {
156 async fn settle(&self, req: &SettlementRequest) -> Result<SettlementAck, PosRejected>;
157 async fn refund(&self, req: &RefundRequest) -> Result<ReversalAck, PosRejected>;
158}
159
160/// One sale line to issue out of stock (item + quantity moved off the shelf).
161#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
162pub struct StockIssueLine {
163 pub item_id: Uuid,
164 pub quantity: Decimal,
165}
166
167/// The request POS hands inventory to ISSUE (decrement) stock for a recognised sale — an outward
168/// Delivery Note. Inventory relieves on-hand and books `Dr COGS · Cr Inventory` at the item's
169/// moving-average cost. Driven only when the register has a warehouse + COGS/inventory accounts.
170///
171/// `company_id` is the legacy tenancy twin (ADR-0029): inventory keeps the company axis until its
172/// own strip, so the composing adapter still needs the owning unit on this wire. The service
173/// sources it from the ambient org scope's legacy company echo.
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
175pub struct StockIssueRequest {
176 pub company_id: Uuid,
177 pub branch_id: Option<Uuid>,
178 pub customer_id: Uuid,
179 /// The POS ticket id (the delivery note's `source` reference).
180 pub source_pos_id: Uuid,
181 pub warehouse_id: Uuid,
182 pub cogs_account_id: Uuid,
183 pub inventory_account_id: Uuid,
184 pub posting_date: chrono::NaiveDate,
185 pub lines: Vec<StockIssueLine>,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
189pub struct StockIssueAck {
190 pub delivery_note_id: Uuid,
191 pub journal_id: Option<Uuid>,
192}
193
194/// Inventory seam: a composing service implements this over inventory's delivery-note issue (create +
195/// submit). Zero normal Cargo edge to inventory — the DTOs are the wire contract.
196#[async_trait::async_trait]
197pub trait InventoryPort: Send + Sync {
198 async fn issue(&self, req: &StockIssueRequest) -> Result<StockIssueAck, PosRejected>;
199}
200
201// ---------------------------------------------------------------------------
202// Document-grade tax compute
203// ---------------------------------------------------------------------------
204
205/// One taxed line handed to the tax compute: `line_ref` identifies the TICKET line (a POS-side
206/// correlation id, not a persisted id), `template_id` is the register-configured template applying to
207/// it, and `net_amount` is the line's tax-excluded net (quantity × unit_price − discount) in the
208/// currency's smallest accounted unit. When a register carries several templates, POS emits one
209/// `PosTaxLineIn` per (line, template) pair — the implementer expands them the same way the tax
210/// engine expands its own input lines.
211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
212pub struct PosTaxLineIn {
213 pub line_ref: Uuid,
214 pub template_id: Uuid,
215 pub net_amount: Decimal,
216}
217
218/// Whether the document being taxed is a sale or a refund of one — the repartition family (and the
219/// sign of withholding components) differs between them.
220#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
221pub enum PosTaxDocumentType {
222 Invoice,
223 Refund,
224}
225
226/// The request POS hands the document-grade tax compute: the register's templates applied to the
227/// ticket's line nets, on the ticket's date.
228#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
229pub struct PosTaxComputeRequest {
230 pub document_type: PosTaxDocumentType,
231 pub on_date: chrono::NaiveDate,
232 pub lines: Vec<PosTaxLineIn>,
233}
234
235/// One routed tax split of one ticket line. `account_id` is the posting account (the cash-basis
236/// transition account when the template defers — `real_account_id` then carries the account the
237/// amount flips to as payments reconcile).
238#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
239pub struct PosTaxComponent {
240 pub line_ref: Uuid,
241 pub template_id: Uuid,
242 pub account_id: Option<Uuid>,
243 pub real_account_id: Option<Uuid>,
244 pub rate: Decimal,
245 pub tax_amount: Decimal,
246 pub description: Option<String>,
247}
248
249/// The document-grade result. `net_amounts` is keyed per ticket line (`line_ref`) and OVERWRITES
250/// POS's own per-line rounding — a globally-rounding tax policy redistributes per-line cents so the
251/// journal balances, and callers MUST adopt these nets as the line nets. `excluded_total` is the
252/// Σ of those nets, `tax_total` the Σ of the signed components, `included_total` their sum.
253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
254pub struct PosTaxComputeResult {
255 pub net_amounts: Vec<(Uuid, Decimal)>,
256 pub components: Vec<PosTaxComponent>,
257 pub excluded_total: Decimal,
258 pub tax_total: Decimal,
259 pub included_total: Decimal,
260}
261
262/// Tax seam: a composing service implements this over the tax module's document engine
263/// (`calculate_document`). POS holds ZERO normal Cargo edge to the tax module — the template refs
264/// live on the register profile, and this port is the only thing that resolves them. A register with
265/// no configured templates never reaches this port: the ring path refuses first (fail-closed).
266#[async_trait::async_trait]
267pub trait PosTaxComputePort: Send + Sync {
268 async fn compute_document(&self, req: &PosTaxComputeRequest) -> Result<PosTaxComputeResult, PosRejected>;
269}
270
271// ---------------------------------------------------------------------------
272// Session-close cash variance
273// ---------------------------------------------------------------------------
274
275/// The direction of a booked drawer variance at session close.
276#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
277pub enum CashVarianceDirection {
278 /// The drawer holds MORE than the journals say (`Dr Cash · Cr difference account`).
279 Over,
280 /// The drawer holds LESS than the journals say (`Dr difference account · Cr Cash`).
281 Short,
282}
283
284/// The request POS hands the variance seam when a session close counts a non-zero drawer
285/// difference: book it against the register's cash account and its difference/write-off account.
286/// POS posts no GL itself — this is the ONE new GL surface a close produces (per-ticket posting
287/// stays the posting path). `company_id` is the legacy tenancy twin (ADR-0029): the GL-post
288/// envelope still reads one, so the service sources it from the ambient org scope's legacy
289/// company echo.
290#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
291pub struct CashVarianceRequest {
292 pub company_id: Uuid,
293 /// The session being closed and its closing entry (source references for the journal).
294 pub opening_entry_id: Uuid,
295 pub closing_entry_id: Uuid,
296 pub posting_date: chrono::NaiveDate,
297 pub currency: String,
298 /// The register's cash account (the drawer side of the correction).
299 pub cash_account_id: Uuid,
300 /// The register's difference / write-off account (the absorption side).
301 pub difference_account_id: Uuid,
302 /// Absolute variance magnitude; `direction` carries the sign.
303 pub amount: Decimal,
304 pub direction: CashVarianceDirection,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
308pub struct CashVarianceAck {
309 pub journal_id: Uuid,
310}
311
312/// Cash-variance seam: a composing service implements this over its ledger. The booking must be
313/// idempotent on `opening_entry_id` — a session closes exactly once (the one-open-session unique plus
314/// the close's open-state guard), so one session can never legitimately book two variances; a retried
315/// close whose first attempt died between booking and commit must REUSE the journal, not double it.
316/// `closing_entry_id` is the source reference for the journal's traceability.
317#[async_trait::async_trait]
318pub trait PosCashVariancePort: Send + Sync {
319 async fn book_cash_variance(&self, req: &CashVarianceRequest) -> Result<CashVarianceAck, PosRejected>;
320}