Skip to main content

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}