backbone_pos/application/service/pos_sync.rs
1//! The offline reconciliation verb (`sync_from_ui`) (hand-authored, user-owned).
2//!
3//! An `impl PosWriteService` chunk over the vocabulary in [`super::pos_write_service`]. An offline
4//! client (register app that sold without connectivity) replays a ticket here; the server is the
5//! book of record and TRUSTS THE REPLAY FOR IDENTITY ONLY:
6//!
7//! - **Identity** is the payload's `client_uuid` — a replay whose uuid already names a live ticket
8//! UPDATES that ticket (while it is still draft) instead of creating a second one. A finalized
9//! ticket short-circuits as [`SyncAction::ReplayFinalized`] — the server's state wins, nothing is
10//! rewritten.
11//! - **Money** is ALWAYS re-derived: the replay's lines + tenders run through the SAME compute core
12//! as `ring_sale` ([`super::pos_compute`]: register templates → document-grade tax →
13//! register-config cash rounding), and the tender sum implies paid/change. Any client-claimed
14//! total was discarded at the DTO boundary — this verb never sees one.
15//! - **Partner + session are validated, not trusted**: a replay cannot re-assign a ticket's
16//! customer, and it cannot ring under a session that has since closed unless it names an open
17//! RESCUE session (the typed refusal [`PosError::SessionClosedRescueRequired`] is the
18//! rescue-or-refuse contract: the client either re-attributes the ticket to the new shift or
19//! surfaces the mismatch to a human).
20//! - **Refund lineage is single-parent**: one refund names one parent (`refund_of_client_uuid`);
21//! re-pointing a refund at a DIFFERENT parent than it was first recorded against is refused
22//! ([`PosError::RefundLineageConflict`]), and a parent that does not resolve refuses
23//! ([`PosError::RefundParentNotFound`]). A refund replay is a privileged mutation — the manager
24//! PIN is verified first.
25//!
26//! Per the module's 4-layer rule this file holds no SQL — the statements live on
27//! `PosInvoiceRepository` / `PosInvoiceItemRepository` / `PosPaymentRepository` /
28//! `PosOpeningEntryRepository` / `PosProfileRepository`.
29//!
30//! Tenancy is composition-installed (ADR-0029): the identity pre-read rides the ambient org scope,
31//! and each write half opens its OWN transaction, re-binds the ambient scope onto it, and runs every
32//! validation read + the compute's register-config read on that same connection — so a replay sees
33//! through exactly the fence it writes under.
34
35use rust_decimal::Decimal;
36use uuid::Uuid;
37
38use crate::infrastructure::persistence::{NewDraftInvoiceRow, NewInvoiceItemRow, NewTenderRow};
39
40use super::pos_compute::ComputeLineIn;
41use super::pos_events::{PosEvent, PosTenderCompleted};
42use super::pos_ports::{BillingPort, PaymentPort, PosTaxComputePort, PosTaxDocumentType};
43use super::pos_write_service::{
44 legacy_company_echo, map_invoice_dup, money, relay_ambient_scope, NewSyncSale, PosError,
45 PosWriteService, SyncAction, SyncOutcome, TicketTotals,
46};
47
48impl PosWriteService {
49 /// Replay one offline ticket. See the module doc for the identity/trust contract.
50 /// `tax` resolves the document-grade compute (as on `ring_sale`); `billing`/`payment` are only
51 /// touched when the replay is a REFUND (they drive the same reversal pair `return_sale` does).
52 pub async fn sync_from_ui(
53 &self,
54 s: NewSyncSale,
55 tax: &dyn PosTaxComputePort,
56 billing: &dyn BillingPort,
57 payment: &dyn PaymentPort,
58 ) -> Result<SyncOutcome, PosError> {
59 // Identity first (ambient scope): does a live ticket already carry this client uuid?
60 // A uuid only namespaces inside the caller's scope — another unit's ticket carrying the
61 // same uuid is simply not found.
62 if let Some(existing) = self.invoices.find_by_client_uuid(&self.rpool(), s.client_uuid).await? {
63 // Finalized server-side (paid/returned): the replay changes nothing. The server's
64 // persisted money is the answer.
65 if existing.status != "draft" {
66 return Ok(finalized(&existing));
67 }
68 return self.sync_update_draft(s, existing, tax).await;
69 }
70
71 // No live ticket carries the uuid. A refund replay never creates a draft — it drives the
72 // (privileged, idempotent) full-return flow against its parent.
73 if let Some(parent_uuid) = s.refund_of_client_uuid {
74 // Privileged: the manager PIN is verified BEFORE anything is read or written.
75 let manager = s.manager.as_ref().ok_or(PosError::ManagerAuthRequired)?;
76 self.verify_manager_internal(manager, s.source_ip.as_deref()).await?;
77 let parent = self.invoices
78 .find_by_client_uuid(&self.rpool(), parent_uuid)
79 .await?
80 .ok_or(PosError::RefundParentNotFound(parent_uuid))?;
81 // The replay's own totals are discarded by contract: a full refund reverses the
82 // parent's persisted money, whatever the client says it handed back.
83 let out = self
84 .return_sale(parent.id, billing, payment, None)
85 .await?;
86 // return_sale is idempotent on the parent's paid→returned flip; whether THIS call
87 // recorded the return ticket or replayed an existing one is observable from the
88 // parent's state we already read.
89 let action = if parent.status == "paid" { SyncAction::Created } else { SyncAction::ReplayFinalized };
90 return Ok(SyncOutcome {
91 pos_invoice_id: out.return_ticket_id,
92 action,
93 totals: TicketTotals {
94 net_total: parent.net_total,
95 tax_total: parent.tax_total,
96 grand_total: parent.grand_total,
97 rounding_adjustment: Decimal::ZERO,
98 rounded_total: parent.rounded_total,
99 paid_total: parent.rounded_total,
100 change_due: Decimal::ZERO,
101 },
102 });
103 }
104
105 self.sync_create_draft(s, tax).await
106 }
107
108 /// The CREATE half: a fresh offline ticket, rung through the shared compute core into a draft
109 /// carrying the sync identity. One scope-relayed transaction carries every read and write.
110 async fn sync_create_draft(&self, s: NewSyncSale, tax: &dyn PosTaxComputePort) -> Result<SyncOutcome, PosError> {
111 let mut tx = self.rpool().begin().await?;
112 relay_ambient_scope(&mut tx).await?;
113 // Session validation (rescue-or-refuse): the session the client rang under must be open, or
114 // the replay must name an open rescue session to attribute the ticket to.
115 let session = self.resolve_session_for_create(&mut *tx, &s).await?;
116 // Tender methods are validated against the enum up front — a typo'd method is a typed 422,
117 // not a DB cast failure mid-transaction.
118 self.validate_tender_methods(&mut *tx, &s).await?;
119 // Restaurant lane — seating, same contract as the online ring: the named table must exist
120 // and hold no other draft (the DB partial unique is the race backstop).
121 if let Some(table) = s.pos_table_id {
122 if self.tables.fetch_table(&mut *tx, table).await?.is_none() {
123 return Err(PosError::TableNotFound(table));
124 }
125 if let Some(occupant) = self
126 .invoices
127 .find_draft_on_table(&mut *tx, table, None)
128 .await?
129 {
130 return Err(PosError::TableOccupied { pos_table_id: table, draft_invoice_id: occupant });
131 }
132 }
133 // Order-level discount: rate resolved from the master, folded before the compute.
134 let order_discount = self
135 .resolve_order_discount(&mut tx, s.pos_profile_id, s.discount_id)
136 .await?;
137
138 let mut compute_lines: Vec<ComputeLineIn> = s
139 .lines
140 .iter()
141 .map(|l| ComputeLineIn {
142 item_id: l.item_id,
143 revenue_account_id: l.revenue_account_id,
144 description: l.description.clone(),
145 course: l.course,
146 quantity: l.quantity,
147 unit_price: l.unit_price,
148 discount_amount: l.discount_amount,
149 })
150 .collect();
151 if let Some(d) = &order_discount {
152 Self::fold_order_discount(&mut compute_lines, d.percentage)?;
153 }
154 let computed = self
155 .compute_ticket(
156 &mut *tx,
157 s.pos_profile_id,
158 s.posting_at.date(),
159 PosTaxDocumentType::Invoice,
160 compute_lines,
161 tax,
162 )
163 .await?;
164 let paid = s.tenders.iter().map(|t| t.amount).sum::<Decimal>();
165 let totals = Self::totals_with_tenders(&computed, paid);
166
167 // The server mints the receipt number, deterministically from the sync identity (scope
168 // fragment + uuid fragment): a retried create collides on it (and on the uuid) instead of
169 // double-ringing, while two tenants replaying the same device uuid never collide — the
170 // receipt-number unique index is global. The scope fragment is the ambient scope's legacy
171 // company echo (the acting unit; nil only when undecorated).
172 let receipt_number = format!(
173 "SYNC-{}-{}",
174 &legacy_company_echo().simple().to_string()[..4],
175 &s.client_uuid.simple().to_string()[..12]
176 );
177 let id = Uuid::new_v4();
178 let r = self.invoices.insert_draft(&mut tx, &NewDraftInvoiceRow {
179 id,
180 client_uuid: Some(s.client_uuid),
181 pos_profile_id: s.pos_profile_id,
182 opening_entry_id: session,
183 branch_id: s.branch_id,
184 customer_id: s.customer_id,
185 pos_table_id: s.pos_table_id,
186 receipt_number: &receipt_number,
187 posting_at: s.posting_at,
188 net_total: computed.net_total,
189 tax_total: computed.tax_total,
190 grand_total: computed.grand_total,
191 rounding_adjustment: computed.rounding_adjustment,
192 rounded_total: computed.rounded_total,
193 }).await;
194 if let Err(e) = r {
195 return Err(map_invoice_dup(
196 e, &receipt_number, Some(s.client_uuid), s.pos_table_id,
197 &self.invoices, &self.rpool(), None,
198 ).await);
199 }
200 check_replay_change_is_cash(&s, computed.rounded_total)?;
201 self.sync_write_children(&mut tx, id, &computed, &s).await?;
202 // The replay can carry tenders from the first moment (the offline client already took
203 // payment). Re-sum what was just written onto the header so paid/change can never disagree
204 // with the tender rows — same contract `add_tender` holds on the online path.
205 let paid_total = self.payments.sum_paid_total_on(&mut tx, id).await?;
206 let change_due = if paid_total > computed.rounded_total {
207 paid_total - computed.rounded_total
208 } else {
209 Decimal::ZERO
210 };
211 self.invoices.update_tender_totals(&mut tx, id, paid_total, change_due).await?;
212 // Durable recognition event, staged INSIDE this (scope-relayed) transaction — atomic with
213 // the ticket, the same contract the online `add_tender` path holds. A staging failure
214 // fails the replay: a silently lost event would leave the ticket draft forever with no
215 // trace of why.
216 // A fresh ticket was paid nothing before this replay.
217 let crossed = crosses_full_payment(false, computed.rounded_total, paid_total);
218 if crossed {
219 self.stage_tender_completed_in_tx(&mut tx, id).await?;
220 }
221 tx.commit().await?;
222 if crossed {
223 self.emit_tender_completed(id);
224 }
225 Ok(SyncOutcome {
226 pos_invoice_id: id,
227 action: SyncAction::Created,
228 totals: TicketTotals { paid_total, change_due, ..totals },
229 })
230 }
231
232 /// The UPDATE half: rewrite a live DRAFT ticket from its replay (full-snapshot semantics — the
233 /// replay carries the complete basket + tender set). Partner, session, and refund lineage are
234 /// validated before anything is written. One scope-relayed transaction carries every read and
235 /// write.
236 async fn sync_update_draft(
237 &self,
238 s: NewSyncSale,
239 existing: crate::infrastructure::persistence::SyncLookupRow,
240 tax: &dyn PosTaxComputePort,
241 ) -> Result<SyncOutcome, PosError> {
242 let mut tx = self.rpool().begin().await?;
243 relay_ambient_scope(&mut tx).await?;
244 // Lock the ticket before anything else: recognition cannot flip it to paid between this
245 // check and the rewrite below. A ticket that left draft since the identity read is
246 // finalized, and the replay changes nothing.
247 let held = self.invoices
248 .lock_for_sync_on(&mut tx, existing.id).await?
249 .ok_or(PosError::InvoiceNotFound(existing.id))?;
250 if held.status != "draft" {
251 drop(tx);
252 let now = self.invoices
253 .find_by_client_uuid(&self.rpool(), s.client_uuid).await?
254 .ok_or(PosError::InvoiceNotFound(existing.id))?;
255 return Ok(finalized(&now));
256 }
257 let was_full = held.rounded_total > Decimal::ZERO && held.paid_total >= held.rounded_total;
258 // Partner is validated, never re-assigned silently.
259 if existing.customer_id != s.customer_id {
260 return Err(PosError::SyncPartnerMismatch);
261 }
262 // Refund lineage: a ticket that was never a refund cannot become one on replay (the refund
263 // path is the privileged full-return flow, not a draft rewrite), and a refund ticket cannot
264 // be re-pointed at a different parent.
265 if existing.is_return || existing.return_against.is_some() || s.refund_of_client_uuid.is_some() {
266 return Err(PosError::RefundLineageConflict);
267 }
268 // Session: equal names must both be open; a different name is only honored when the
269 // ORIGINAL session closed and the payload's session (or named rescue) is open — and the
270 // target must be the same register's session.
271 let session = self.resolve_session_for_update(&mut *tx, &s, &existing).await?;
272 self.validate_tender_methods(&mut *tx, &s).await?;
273 // Restaurant lane — table TRANSFER: the replay's table is honored as the ticket's new seat
274 // (diners move; a draft is not pinned to its birth table the way it is to its register).
275 // The named table must exist, and if it differs from the current seat it must hold no
276 // OTHER draft — the occupancy check excludes THIS ticket.
277 if let Some(table) = s.pos_table_id {
278 if self.tables.fetch_table(&mut *tx, table).await?.is_none() {
279 return Err(PosError::TableNotFound(table));
280 }
281 if Some(table) != existing.pos_table_id {
282 if let Some(occupant) = self
283 .invoices
284 .find_draft_on_table(&mut *tx, table, Some(existing.id))
285 .await?
286 {
287 return Err(PosError::TableOccupied { pos_table_id: table, draft_invoice_id: occupant });
288 }
289 }
290 }
291 // Order-level discount: rate resolved from the master, folded before the compute.
292 let order_discount = self
293 .resolve_order_discount(&mut tx, existing.pos_profile_id, s.discount_id)
294 .await?;
295
296 // The ticket keeps the register it was born on — its tax + rounding config — even when the
297 // session is rescued to a new shift of that same register.
298 let mut compute_lines: Vec<ComputeLineIn> = s
299 .lines
300 .iter()
301 .map(|l| ComputeLineIn {
302 item_id: l.item_id,
303 revenue_account_id: l.revenue_account_id,
304 description: l.description.clone(),
305 course: l.course,
306 quantity: l.quantity,
307 unit_price: l.unit_price,
308 discount_amount: l.discount_amount,
309 })
310 .collect();
311 if let Some(d) = &order_discount {
312 Self::fold_order_discount(&mut compute_lines, d.percentage)?;
313 }
314 let computed = self
315 .compute_ticket(
316 &mut *tx,
317 existing.pos_profile_id,
318 s.posting_at.date(),
319 PosTaxDocumentType::Invoice,
320 compute_lines,
321 tax,
322 )
323 .await?;
324 let paid = s.tenders.iter().map(|t| t.amount).sum::<Decimal>();
325 let totals = Self::totals_with_tenders(&computed, paid);
326 check_replay_change_is_cash(&s, computed.rounded_total)?;
327
328 let id = existing.id;
329 let now = chrono::Utc::now();
330 // Full snapshot: retire the prior lines + tenders (soft delete — audit trail kept, sync
331 // uuid uniqueness freed), then write the replay's.
332 self.items.soft_delete_lines_for_ticket(&mut tx, id, now).await?;
333 self.payments.soft_delete_tenders_for_ticket(&mut tx, id, now).await?;
334 let r = self.invoices
335 .update_draft_from_sync(
336 &mut tx, id, session, s.branch_id, s.customer_id, s.pos_table_id,
337 computed.net_total, computed.tax_total, computed.grand_total,
338 computed.rounding_adjustment, computed.rounded_total,
339 totals.paid_total, totals.change_due,
340 )
341 .await;
342 match r {
343 Ok(1) => {}
344 // Unreachable under the row lock taken above; refused rather than writing children
345 // onto a ticket the header update did not take.
346 Ok(_) => return Err(PosError::NotDraft),
347 Err(e) => {
348 // A unique violation here is the table partial unique racing the occupancy
349 // pre-check (a transfer onto a table another draft just claimed) — map it to the
350 // typed 409.
351 return Err(map_invoice_dup(
352 e, "", Some(s.client_uuid), s.pos_table_id,
353 &self.invoices, &self.rpool(), Some(id),
354 ).await);
355 }
356 }
357 self.sync_write_children(&mut tx, id, &computed, &s).await?;
358 // Durable recognition event, staged INSIDE this (scope-relayed) transaction — atomic with
359 // the rewrite, the same contract the online `add_tender` path holds. A staging failure
360 // fails the replay: a silently lost event would leave the ticket draft forever with no
361 // trace of why.
362 // Only the replay that completes the payment triggers recognition; a replay of a ticket
363 // that was already paid up re-emits nothing.
364 let crossed = crosses_full_payment(was_full, computed.rounded_total, totals.paid_total);
365 if crossed {
366 self.stage_tender_completed_in_tx(&mut tx, id).await?;
367 }
368 tx.commit().await?;
369 if crossed {
370 self.emit_tender_completed(id);
371 }
372 Ok(SyncOutcome { pos_invoice_id: id, action: SyncAction::Updated, totals })
373 }
374
375 /// Write the replay's lines + tenders onto a ticket inside the caller's transaction. The tenders
376 /// re-sum to the header's paid/change (they were computed from the same sum), so the header and
377 /// its tender lines can never disagree.
378 async fn sync_write_children(
379 &self,
380 tx: &mut sqlx::PgConnection,
381 pos_invoice_id: Uuid,
382 computed: &super::pos_compute::ComputedTicket,
383 s: &NewSyncSale,
384 ) -> Result<(), PosError> {
385 for (l, src) in computed.lines.iter().zip(&s.lines) {
386 self.items.insert_line(tx, &NewInvoiceItemRow {
387 id: Uuid::new_v4(),
388 pos_invoice_id,
389 client_uuid: Some(src.client_uuid),
390 item_id: l.item_id,
391 description: l.description.as_deref(),
392 course: l.course,
393 quantity: l.quantity,
394 unit_price: l.unit_price,
395 discount_amount: l.discount_amount,
396 net_amount: l.net_amount,
397 revenue_account_id: l.revenue_account_id,
398 }).await?;
399 }
400 for t in &s.tenders {
401 if t.amount <= Decimal::ZERO {
402 return Err(PosError::NegativeAmount);
403 }
404 self.payments.insert_tender(tx, &NewTenderRow {
405 id: Uuid::new_v4(),
406 pos_invoice_id,
407 client_uuid: Some(t.client_uuid),
408 payment_method: &t.method,
409 amount: money(t.amount),
410 reference_no: t.reference_no.as_deref(),
411 }).await?;
412 }
413 Ok(())
414 }
415
416 /// Durable staging half of a replay's recognition triggers: when the replay crosses full
417 /// payment AND an outbox schema is configured, stage `PosTenderCompleted` INSIDE the replay's
418 /// own transaction — atomic with the ticket, the same contract the online `add_tender` path
419 /// holds. The caller's transaction is scope-relayed, so the fenced outbox INSERT passes the
420 /// row-level-security WITH CHECK under a restricted app role; a second, unbound transaction is
421 /// exactly what the fence rejects (and what this staging must never revert to). A staging
422 /// failure propagates and fails the whole replay — the durable event must never be lost
423 /// silently behind a reported sync success. The fence column carries the ambient scope's
424 /// legacy company echo (the acting unit; ADR-0029).
425 async fn stage_tender_completed_in_tx(
426 &self,
427 tx: &mut sqlx::PgConnection,
428 pos_invoice_id: Uuid,
429 ) -> Result<(), PosError> {
430 if let Some(schema) = &self.outbox_schema {
431 let rec = super::pos_tender::tender_completed_outbox_record(pos_invoice_id, legacy_company_echo(), chrono::Utc::now());
432 backbone_outbox::outbox::stage(&mut *tx, schema, &rec)
433 .await
434 .map_err(|e| PosError::Db(sqlx::Error::Protocol(e.to_string())))?;
435 }
436 Ok(())
437 }
438
439 /// Fire-and-forget recognition trigger for a replay that (now) crosses full payment — the
440 /// in-process half of the SAME contract as `add_tender`: durable outbox staging first (inside
441 /// the replay's transaction, before its commit — see [`Self::stage_tender_completed_in_tx`]),
442 /// fire-and-forget sink second. Double delivery is safe: recognition is replay-safe end to end.
443 /// The `company_id` field is the documented legacy twin (ADR-0029).
444 fn emit_tender_completed(&self, pos_invoice_id: Uuid) {
445 self.sink.publish(PosEvent::PosTenderCompleted(PosTenderCompleted {
446 pos_invoice_id, company_id: legacy_company_echo(),
447 }));
448 }
449
450 /// CREATE-session resolution: the named session must be open, or closed WITH an open rescue.
451 /// Refuses with the typed rescue-refusal otherwise. Reads ride the caller's scope-relayed
452 /// connection.
453 async fn resolve_session_for_create(&self, conn: &mut sqlx::PgConnection, s: &NewSyncSale) -> Result<Uuid, PosError> {
454 let named = self.openings
455 .fetch_status_on(&mut *conn, s.opening_entry_id)
456 .await?
457 .ok_or(PosError::SessionNotFound(s.opening_entry_id))?;
458 if named == "open" {
459 // Open, but still the ticket's OWN register: the plain-open path owes the same
460 // register pairing the rescue arms enforce — no mixing configs and drawers.
461 return self.same_register_open_session(&mut *conn, s.opening_entry_id, s.pos_profile_id).await;
462 }
463 // Closed: honor the rescue only when it is an OPEN session on the SAME register the ticket
464 // names — a rescue onto another register's drawer would move money between tills.
465 if let Some(rescue) = s.rescue_opening_entry_id {
466 return self.same_register_open_session(&mut *conn, rescue, s.pos_profile_id).await;
467 }
468 Err(PosError::SessionClosedRescueRequired(s.opening_entry_id))
469 }
470
471 /// UPDATE-session resolution: an unchanged name must be open; a changed name is honored only
472 /// when the ORIGINAL closed and the new session is open on the SAME register as the ticket.
473 /// Reads ride the caller's scope-relayed connection.
474 async fn resolve_session_for_update(
475 &self,
476 conn: &mut sqlx::PgConnection,
477 s: &NewSyncSale,
478 existing: &crate::infrastructure::persistence::SyncLookupRow,
479 ) -> Result<Uuid, PosError> {
480 if s.opening_entry_id == existing.opening_entry_id {
481 let st = self.openings
482 .fetch_status_on(&mut *conn, existing.opening_entry_id)
483 .await?
484 .ok_or(PosError::SessionNotFound(existing.opening_entry_id))?;
485 if st != "open" {
486 // The original closed between the create replay and this one: fall through to the
487 // rescue rules below using the payload's rescue, not a silent re-attribution.
488 if let Some(rescue) = s.rescue_opening_entry_id {
489 return self.same_register_open_session(&mut *conn, rescue, existing.pos_profile_id).await;
490 }
491 return Err(PosError::SessionClosedRescueRequired(existing.opening_entry_id));
492 }
493 return Ok(existing.opening_entry_id);
494 }
495 // Different session name: only a CLOSED original + an OPEN target on the ticket's register.
496 let orig = self.openings
497 .fetch_status_on(&mut *conn, existing.opening_entry_id)
498 .await?
499 .ok_or(PosError::SessionNotFound(existing.opening_entry_id))?;
500 if orig == "open" {
501 return Err(PosError::SyncSessionMismatch);
502 }
503 let target = s.rescue_opening_entry_id.unwrap_or(s.opening_entry_id);
504 self.same_register_open_session(&mut *conn, target, existing.pos_profile_id).await
505 }
506
507 /// The rescue must be an OPEN session on the ticket's own register — a rescue onto another
508 /// register's drawer would move money between tills. Reads ride the caller's scope-relayed
509 /// connection.
510 async fn same_register_open_session(
511 &self,
512 conn: &mut sqlx::PgConnection,
513 session: Uuid,
514 pos_profile_id: Uuid,
515 ) -> Result<Uuid, PosError> {
516 let st = self.openings
517 .fetch_status_on(&mut *conn, session)
518 .await?
519 .ok_or(PosError::SessionNotFound(session))?;
520 if st != "open" {
521 return Err(PosError::SessionClosedRescueRequired(session));
522 }
523 let profile = self.openings
524 .fetch_profile_id_on(&mut *conn, session)
525 .await?
526 .ok_or(PosError::SessionNotFound(session))?;
527 if profile != pos_profile_id {
528 return Err(PosError::SessionRegisterMismatch);
529 }
530 Ok(session)
531 }
532
533 /// Every replayed tender method must be a live enum variant. Reads ride the caller's
534 /// scope-relayed connection.
535 async fn validate_tender_methods(&self, conn: &mut sqlx::PgConnection, s: &NewSyncSale) -> Result<(), PosError> {
536 let valid = self.payments.valid_methods(&mut *conn).await?;
537 for t in &s.tenders {
538 if !valid.iter().any(|m| m == &t.method) {
539 return Err(PosError::InvalidTenderMethod(t.method.clone()));
540 }
541 }
542 Ok(())
543 }
544}
545
546/// The answer to a replay of a ticket the server already finalized: its persisted money.
547fn finalized(t: &crate::infrastructure::persistence::SyncLookupRow) -> SyncOutcome {
548 SyncOutcome {
549 pos_invoice_id: t.id,
550 action: SyncAction::ReplayFinalized,
551 totals: TicketTotals {
552 net_total: t.net_total,
553 tax_total: t.tax_total,
554 grand_total: t.grand_total,
555 rounding_adjustment: t.rounding_adjustment,
556 rounded_total: t.rounded_total,
557 paid_total: t.paid_total,
558 change_due: t.change_due,
559 },
560 }
561}
562
563/// Whether a replay is the one that completes the payment: the ticket was not paid up before it
564/// and is now. Only that replay triggers recognition.
565fn crosses_full_payment(was_full: bool, rounded_total: Decimal, paid_total: Decimal) -> bool {
566 !was_full && rounded_total > Decimal::ZERO && paid_total >= rounded_total
567}
568
569/// A replay's tenders may overpay only in cash: the change the drawer hands back must be cash it
570/// took. Equivalently, the tenders other than cash cover at most the rounded total.
571fn check_replay_change_is_cash(s: &NewSyncSale, rounded_total: Decimal) -> Result<(), PosError> {
572 let non_cash = s.tenders.iter().filter(|t| t.method != "cash").map(|t| money(t.amount)).sum::<Decimal>();
573 if non_cash > rounded_total.max(Decimal::ZERO) {
574 let method = s.tenders.iter().find(|t| t.method != "cash").map(|t| t.method.clone()).unwrap_or_default();
575 return Err(PosError::NonCashOverpayment { method, remaining: rounded_total.max(Decimal::ZERO) });
576 }
577 Ok(())
578}
579
580#[cfg(test)]
581mod crossing_tests {
582 use super::crosses_full_payment;
583 use rust_decimal::Decimal;
584
585 #[test]
586 fn only_the_replay_that_completes_payment_triggers_recognition() {
587 let d = |s: &str| s.parse::<Decimal>().unwrap();
588 assert!(crosses_full_payment(false, d("100"), d("100")));
589 assert!(!crosses_full_payment(true, d("100"), d("120")), "already paid up: no re-trigger");
590 assert!(!crosses_full_payment(false, d("100"), d("99.99")));
591 assert!(!crosses_full_payment(false, d("0"), d("0")), "a zero ticket is not a sale to recognise");
592 }
593}