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}