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(SyncOutcome {
67                    pos_invoice_id: existing.id,
68                    action: SyncAction::ReplayFinalized,
69                    totals: TicketTotals {
70                        net_total: existing.net_total,
71                        tax_total: existing.tax_total,
72                        grand_total: existing.grand_total,
73                        rounding_adjustment: existing.rounding_adjustment,
74                        rounded_total: existing.rounded_total,
75                        paid_total: existing.paid_total,
76                        change_due: existing.change_due,
77                    },
78                });
79            }
80            return self.sync_update_draft(s, existing, tax).await;
81        }
82
83        // No live ticket carries the uuid. A refund replay never creates a draft — it drives the
84        // (privileged, idempotent) full-return flow against its parent.
85        if let Some(parent_uuid) = s.refund_of_client_uuid {
86            // Privileged: the manager PIN is verified BEFORE anything is read or written.
87            let manager = s.manager.as_ref().ok_or(PosError::ManagerAuthRequired)?;
88            self.verify_manager_internal(manager, s.source_ip.as_deref()).await?;
89            let parent = self.invoices
90                .find_by_client_uuid(&self.rpool(), parent_uuid)
91                .await?
92                .ok_or(PosError::RefundParentNotFound(parent_uuid))?;
93            // The replay's own totals are discarded by contract: a full refund reverses the
94            // parent's persisted money, whatever the client says it handed back.
95            let out = self
96                .return_sale(parent.id, billing, payment, None)
97                .await?;
98            // return_sale is idempotent on the parent's paid→returned flip; whether THIS call
99            // recorded the return ticket or replayed an existing one is observable from the
100            // parent's state we already read.
101            let action = if parent.status == "paid" { SyncAction::Created } else { SyncAction::ReplayFinalized };
102            return Ok(SyncOutcome {
103                pos_invoice_id: out.return_ticket_id,
104                action,
105                totals: TicketTotals {
106                    net_total: parent.net_total,
107                    tax_total: parent.tax_total,
108                    grand_total: parent.grand_total,
109                    rounding_adjustment: Decimal::ZERO,
110                    rounded_total: parent.rounded_total,
111                    paid_total: parent.rounded_total,
112                    change_due: Decimal::ZERO,
113                },
114            });
115        }
116
117        self.sync_create_draft(s, tax).await
118    }
119
120    /// The CREATE half: a fresh offline ticket, rung through the shared compute core into a draft
121    /// carrying the sync identity. One scope-relayed transaction carries every read and write.
122    async fn sync_create_draft(&self, s: NewSyncSale, tax: &dyn PosTaxComputePort) -> Result<SyncOutcome, PosError> {
123        let mut tx = self.rpool().begin().await?;
124        relay_ambient_scope(&mut tx).await?;
125        // Session validation (rescue-or-refuse): the session the client rang under must be open, or
126        // the replay must name an open rescue session to attribute the ticket to.
127        let session = self.resolve_session_for_create(&mut *tx, &s).await?;
128        // Tender methods are validated against the enum up front — a typo'd method is a typed 422,
129        // not a DB cast failure mid-transaction.
130        self.validate_tender_methods(&mut *tx, &s).await?;
131        // Restaurant lane — seating, same contract as the online ring: the named table must exist
132        // and hold no other draft (the DB partial unique is the race backstop).
133        if let Some(table) = s.pos_table_id {
134            if self.tables.fetch_table(&mut *tx, table).await?.is_none() {
135                return Err(PosError::TableNotFound(table));
136            }
137            if let Some(occupant) = self
138                .invoices
139                .find_draft_on_table(&mut *tx, table, None)
140                .await?
141            {
142                return Err(PosError::TableOccupied { pos_table_id: table, draft_invoice_id: occupant });
143            }
144        }
145        // Order-level discount: rate resolved from the master, folded before the compute.
146        let order_discount = self
147            .resolve_order_discount(&mut tx, s.pos_profile_id, s.discount_id)
148            .await?;
149
150        let mut compute_lines: Vec<ComputeLineIn> = s
151            .lines
152            .iter()
153            .map(|l| ComputeLineIn {
154                item_id: l.item_id,
155                revenue_account_id: l.revenue_account_id,
156                description: l.description.clone(),
157                course: l.course,
158                quantity: l.quantity,
159                unit_price: l.unit_price,
160                discount_amount: l.discount_amount,
161            })
162            .collect();
163        if let Some(d) = &order_discount {
164            Self::fold_order_discount(&mut compute_lines, d.percentage)?;
165        }
166        let computed = self
167            .compute_ticket(
168                &mut *tx,
169                s.pos_profile_id,
170                s.posting_at.date(),
171                PosTaxDocumentType::Invoice,
172                compute_lines,
173                tax,
174            )
175            .await?;
176        let paid = s.tenders.iter().map(|t| t.amount).sum::<Decimal>();
177        let totals = Self::totals_with_tenders(&computed, paid);
178
179        // The server mints the receipt number, deterministically from the sync identity (scope
180        // fragment + uuid fragment): a retried create collides on it (and on the uuid) instead of
181        // double-ringing, while two tenants replaying the same device uuid never collide — the
182        // receipt-number unique index is global. The scope fragment is the ambient scope's legacy
183        // company echo (the acting unit; nil only when undecorated).
184        let receipt_number = format!(
185            "SYNC-{}-{}",
186            &legacy_company_echo().simple().to_string()[..4],
187            &s.client_uuid.simple().to_string()[..12]
188        );
189        let id = Uuid::new_v4();
190        let r = self.invoices.insert_draft(&mut tx, &NewDraftInvoiceRow {
191            id,
192            client_uuid: Some(s.client_uuid),
193            pos_profile_id: s.pos_profile_id,
194            opening_entry_id: session,
195            branch_id: s.branch_id,
196            customer_id: s.customer_id,
197            pos_table_id: s.pos_table_id,
198            receipt_number: &receipt_number,
199            posting_at: s.posting_at,
200            net_total: computed.net_total,
201            tax_total: computed.tax_total,
202            grand_total: computed.grand_total,
203            rounding_adjustment: computed.rounding_adjustment,
204            rounded_total: computed.rounded_total,
205        }).await;
206        if let Err(e) = r {
207            return Err(map_invoice_dup(
208                e, &receipt_number, Some(s.client_uuid), s.pos_table_id,
209                &self.invoices, &self.rpool(), None,
210            ).await);
211        }
212        self.sync_write_children(&mut tx, id, &computed, &s).await?;
213        // The replay can carry tenders from the first moment (the offline client already took
214        // payment). Re-sum what was just written onto the header so paid/change can never disagree
215        // with the tender rows — same contract `add_tender` holds on the online path.
216        let paid_total = self.payments.sum_paid_total_on(&mut tx, id).await?;
217        let change_due = if paid_total > computed.rounded_total {
218            paid_total - computed.rounded_total
219        } else {
220            Decimal::ZERO
221        };
222        self.invoices.update_tender_totals(&mut tx, id, paid_total, change_due).await?;
223        // Durable recognition event, staged INSIDE this (scope-relayed) transaction — atomic with
224        // the ticket, the same contract the online `add_tender` path holds. A staging failure
225        // fails the replay: a silently lost event would leave the ticket draft forever with no
226        // trace of why.
227        self.stage_tender_completed_in_tx(&mut tx, id, computed.rounded_total, paid_total).await?;
228        tx.commit().await?;
229        self.emit_sync_events(id, totals.rounded_total, paid_total).await;
230        Ok(SyncOutcome {
231            pos_invoice_id: id,
232            action: SyncAction::Created,
233            totals: TicketTotals { paid_total, change_due, ..totals },
234        })
235    }
236
237    /// The UPDATE half: rewrite a live DRAFT ticket from its replay (full-snapshot semantics — the
238    /// replay carries the complete basket + tender set). Partner, session, and refund lineage are
239    /// validated before anything is written. One scope-relayed transaction carries every read and
240    /// write.
241    async fn sync_update_draft(
242        &self,
243        s: NewSyncSale,
244        existing: crate::infrastructure::persistence::SyncLookupRow,
245        tax: &dyn PosTaxComputePort,
246    ) -> Result<SyncOutcome, PosError> {
247        let mut tx = self.rpool().begin().await?;
248        relay_ambient_scope(&mut tx).await?;
249        // Partner is validated, never re-assigned silently.
250        if existing.customer_id != s.customer_id {
251            return Err(PosError::SyncPartnerMismatch);
252        }
253        // Refund lineage: a ticket that was never a refund cannot become one on replay (the refund
254        // path is the privileged full-return flow, not a draft rewrite), and a refund ticket cannot
255        // be re-pointed at a different parent.
256        if existing.is_return || existing.return_against.is_some() || s.refund_of_client_uuid.is_some() {
257            return Err(PosError::RefundLineageConflict);
258        }
259        // Session: equal names must both be open; a different name is only honored when the
260        // ORIGINAL session closed and the payload's session (or named rescue) is open — and the
261        // target must be the same register's session.
262        let session = self.resolve_session_for_update(&mut *tx, &s, &existing).await?;
263        self.validate_tender_methods(&mut *tx, &s).await?;
264        // Restaurant lane — table TRANSFER: the replay's table is honored as the ticket's new seat
265        // (diners move; a draft is not pinned to its birth table the way it is to its register).
266        // The named table must exist, and if it differs from the current seat it must hold no
267        // OTHER draft — the occupancy check excludes THIS ticket.
268        if let Some(table) = s.pos_table_id {
269            if self.tables.fetch_table(&mut *tx, table).await?.is_none() {
270                return Err(PosError::TableNotFound(table));
271            }
272            if Some(table) != existing.pos_table_id {
273                if let Some(occupant) = self
274                    .invoices
275                    .find_draft_on_table(&mut *tx, table, Some(existing.id))
276                    .await?
277                {
278                    return Err(PosError::TableOccupied { pos_table_id: table, draft_invoice_id: occupant });
279                }
280            }
281        }
282        // Order-level discount: rate resolved from the master, folded before the compute.
283        let order_discount = self
284            .resolve_order_discount(&mut tx, existing.pos_profile_id, s.discount_id)
285            .await?;
286
287        // The ticket keeps the register it was born on — its tax + rounding config — even when the
288        // session is rescued to a new shift of that same register.
289        let mut compute_lines: Vec<ComputeLineIn> = s
290            .lines
291            .iter()
292            .map(|l| ComputeLineIn {
293                item_id: l.item_id,
294                revenue_account_id: l.revenue_account_id,
295                description: l.description.clone(),
296                course: l.course,
297                quantity: l.quantity,
298                unit_price: l.unit_price,
299                discount_amount: l.discount_amount,
300            })
301            .collect();
302        if let Some(d) = &order_discount {
303            Self::fold_order_discount(&mut compute_lines, d.percentage)?;
304        }
305        let computed = self
306            .compute_ticket(
307                &mut *tx,
308                existing.pos_profile_id,
309                s.posting_at.date(),
310                PosTaxDocumentType::Invoice,
311                compute_lines,
312                tax,
313            )
314            .await?;
315        let paid = s.tenders.iter().map(|t| t.amount).sum::<Decimal>();
316        let totals = Self::totals_with_tenders(&computed, paid);
317
318        let id = existing.id;
319        let now = chrono::Utc::now();
320        // Full snapshot: retire the prior lines + tenders (soft delete — audit trail kept, sync
321        // uuid uniqueness freed), then write the replay's.
322        self.items.soft_delete_lines_for_ticket(&mut tx, id, now).await?;
323        self.payments.soft_delete_tenders_for_ticket(&mut tx, id, now).await?;
324        let r = self.invoices
325            .update_draft_from_sync(
326                &mut tx, id, session, s.branch_id, s.customer_id, s.pos_table_id,
327                computed.net_total, computed.tax_total, computed.grand_total,
328                computed.rounding_adjustment, computed.rounded_total,
329                totals.paid_total, totals.change_due,
330            )
331            .await;
332        if let Err(e) = r {
333            // A unique violation here is the table partial unique racing the occupancy pre-check
334            // (a transfer onto a table another draft just claimed) — map it to the typed 409.
335            return Err(map_invoice_dup(
336                e, "", Some(s.client_uuid), s.pos_table_id,
337                &self.invoices, &self.rpool(), Some(id),
338            ).await);
339        }
340        self.sync_write_children(&mut tx, id, &computed, &s).await?;
341        // Durable recognition event, staged INSIDE this (scope-relayed) transaction — atomic with
342        // the rewrite, the same contract the online `add_tender` path holds. A staging failure
343        // fails the replay: a silently lost event would leave the ticket draft forever with no
344        // trace of why.
345        self.stage_tender_completed_in_tx(&mut tx, id, computed.rounded_total, totals.paid_total).await?;
346        tx.commit().await?;
347        self.emit_sync_events(id, totals.rounded_total, totals.paid_total).await;
348        Ok(SyncOutcome { pos_invoice_id: id, action: SyncAction::Updated, totals })
349    }
350
351    /// Write the replay's lines + tenders onto a ticket inside the caller's transaction. The tenders
352    /// re-sum to the header's paid/change (they were computed from the same sum), so the header and
353    /// its tender lines can never disagree.
354    async fn sync_write_children(
355        &self,
356        tx: &mut sqlx::PgConnection,
357        pos_invoice_id: Uuid,
358        computed: &super::pos_compute::ComputedTicket,
359        s: &NewSyncSale,
360    ) -> Result<(), PosError> {
361        for (l, src) in computed.lines.iter().zip(&s.lines) {
362            self.items.insert_line(tx, &NewInvoiceItemRow {
363                id: Uuid::new_v4(),
364                pos_invoice_id,
365                client_uuid: Some(src.client_uuid),
366                item_id: l.item_id,
367                description: l.description.as_deref(),
368                course: l.course,
369                quantity: l.quantity,
370                unit_price: l.unit_price,
371                discount_amount: l.discount_amount,
372                net_amount: l.net_amount,
373                revenue_account_id: l.revenue_account_id,
374            }).await?;
375        }
376        for t in &s.tenders {
377            if t.amount <= Decimal::ZERO {
378                return Err(PosError::NegativeAmount);
379            }
380            self.payments.insert_tender(tx, &NewTenderRow {
381                id: Uuid::new_v4(),
382                pos_invoice_id,
383                client_uuid: Some(t.client_uuid),
384                payment_method: &t.method,
385                amount: money(t.amount),
386                reference_no: t.reference_no.as_deref(),
387            }).await?;
388        }
389        Ok(())
390    }
391
392    /// Durable staging half of a replay's recognition triggers: when the replay crosses full
393    /// payment AND an outbox schema is configured, stage `PosTenderCompleted` INSIDE the replay's
394    /// own transaction — atomic with the ticket, the same contract the online `add_tender` path
395    /// holds. The caller's transaction is scope-relayed, so the fenced outbox INSERT passes the
396    /// row-level-security WITH CHECK under a restricted app role; a second, unbound transaction is
397    /// exactly what the fence rejects (and what this staging must never revert to). A staging
398    /// failure propagates and fails the whole replay — the durable event must never be lost
399    /// silently behind a reported sync success. The fence column carries the ambient scope's
400    /// legacy company echo (the acting unit; ADR-0029).
401    async fn stage_tender_completed_in_tx(
402        &self,
403        tx: &mut sqlx::PgConnection,
404        pos_invoice_id: Uuid,
405        rounded_total: Decimal,
406        paid_total: Decimal,
407    ) -> Result<(), PosError> {
408        if paid_total >= rounded_total && rounded_total > Decimal::ZERO {
409            if let Some(schema) = &self.outbox_schema {
410                let rec = super::pos_tender::tender_completed_outbox_record(pos_invoice_id, legacy_company_echo(), chrono::Utc::now());
411                backbone_outbox::outbox::stage(&mut *tx, schema, &rec)
412                    .await
413                    .map_err(|e| PosError::Db(sqlx::Error::Protocol(e.to_string())))?;
414            }
415        }
416        Ok(())
417    }
418
419    /// Fire-and-forget recognition trigger for a replay that (now) crosses full payment — the
420    /// in-process half of the SAME contract as `add_tender`: durable outbox staging first (inside
421    /// the replay's transaction, before its commit — see [`Self::stage_tender_completed_in_tx`]),
422    /// fire-and-forget sink second. Double delivery is safe: recognition is replay-safe end to end.
423    /// The `company_id` field is the documented legacy twin (ADR-0029).
424    async fn emit_sync_events(&self, pos_invoice_id: Uuid, rounded_total: Decimal, paid_total: Decimal) {
425        if paid_total >= rounded_total && rounded_total > Decimal::ZERO {
426            self.sink.publish(PosEvent::PosTenderCompleted(PosTenderCompleted {
427                pos_invoice_id, company_id: legacy_company_echo(),
428            }));
429        }
430    }
431
432    /// CREATE-session resolution: the named session must be open, or closed WITH an open rescue.
433    /// Refuses with the typed rescue-refusal otherwise. Reads ride the caller's scope-relayed
434    /// connection.
435    async fn resolve_session_for_create(&self, conn: &mut sqlx::PgConnection, s: &NewSyncSale) -> Result<Uuid, PosError> {
436        let named = self.openings
437            .fetch_status_on(&mut *conn, s.opening_entry_id)
438            .await?
439            .ok_or(PosError::SessionNotFound(s.opening_entry_id))?;
440        if named == "open" {
441            // Open, but still the ticket's OWN register: the plain-open path owes the same
442            // register pairing the rescue arms enforce — no mixing configs and drawers.
443            return self.same_register_open_session(&mut *conn, s.opening_entry_id, s.pos_profile_id).await;
444        }
445        // Closed: honor the rescue only when it is an OPEN session on the SAME register the ticket
446        // names — a rescue onto another register's drawer would move money between tills.
447        if let Some(rescue) = s.rescue_opening_entry_id {
448            return self.same_register_open_session(&mut *conn, rescue, s.pos_profile_id).await;
449        }
450        Err(PosError::SessionClosedRescueRequired(s.opening_entry_id))
451    }
452
453    /// UPDATE-session resolution: an unchanged name must be open; a changed name is honored only
454    /// when the ORIGINAL closed and the new session is open on the SAME register as the ticket.
455    /// Reads ride the caller's scope-relayed connection.
456    async fn resolve_session_for_update(
457        &self,
458        conn: &mut sqlx::PgConnection,
459        s: &NewSyncSale,
460        existing: &crate::infrastructure::persistence::SyncLookupRow,
461    ) -> Result<Uuid, PosError> {
462        if s.opening_entry_id == existing.opening_entry_id {
463            let st = self.openings
464                .fetch_status_on(&mut *conn, existing.opening_entry_id)
465                .await?
466                .ok_or(PosError::SessionNotFound(existing.opening_entry_id))?;
467            if st != "open" {
468                // The original closed between the create replay and this one: fall through to the
469                // rescue rules below using the payload's rescue, not a silent re-attribution.
470                if let Some(rescue) = s.rescue_opening_entry_id {
471                    return self.same_register_open_session(&mut *conn, rescue, existing.pos_profile_id).await;
472                }
473                return Err(PosError::SessionClosedRescueRequired(existing.opening_entry_id));
474            }
475            return Ok(existing.opening_entry_id);
476        }
477        // Different session name: only a CLOSED original + an OPEN target on the ticket's register.
478        let orig = self.openings
479            .fetch_status_on(&mut *conn, existing.opening_entry_id)
480            .await?
481            .ok_or(PosError::SessionNotFound(existing.opening_entry_id))?;
482        if orig == "open" {
483            return Err(PosError::SyncSessionMismatch);
484        }
485        let target = s.rescue_opening_entry_id.unwrap_or(s.opening_entry_id);
486        self.same_register_open_session(&mut *conn, target, existing.pos_profile_id).await
487    }
488
489    /// The rescue must be an OPEN session on the ticket's own register — a rescue onto another
490    /// register's drawer would move money between tills. Reads ride the caller's scope-relayed
491    /// connection.
492    async fn same_register_open_session(
493        &self,
494        conn: &mut sqlx::PgConnection,
495        session: Uuid,
496        pos_profile_id: Uuid,
497    ) -> Result<Uuid, PosError> {
498        let st = self.openings
499            .fetch_status_on(&mut *conn, session)
500            .await?
501            .ok_or(PosError::SessionNotFound(session))?;
502        if st != "open" {
503            return Err(PosError::SessionClosedRescueRequired(session));
504        }
505        let profile = self.openings
506            .fetch_profile_id_on(&mut *conn, session)
507            .await?
508            .ok_or(PosError::SessionNotFound(session))?;
509        if profile != pos_profile_id {
510            return Err(PosError::SessionRegisterMismatch);
511        }
512        Ok(session)
513    }
514
515    /// Every replayed tender method must be a live enum variant. Reads ride the caller's
516    /// scope-relayed connection.
517    async fn validate_tender_methods(&self, conn: &mut sqlx::PgConnection, s: &NewSyncSale) -> Result<(), PosError> {
518        let valid = self.payments.valid_methods(&mut *conn).await?;
519        for t in &s.tenders {
520            if !valid.iter().any(|m| m == &t.method) {
521                return Err(PosError::InvalidTenderMethod(t.method.clone()));
522            }
523        }
524        Ok(())
525    }
526}