Skip to main content

Ticket System

The ticket system in upp-data manages the complete lifecycle of sales tickets — from creation, through product selection, pricing, payment, and fiscal document generation. The core classes are Ticket (DataObject), _TicketView (ViewObject), TicketCommit (commit orchestrator), and TicketPrint (printing orchestrator).

Ticket Lifecycle

Status Codes

StatusNameDescription
PRPreparationInitial state when a ticket is created but not yet activated
ACActiveTicket is open and accepting modifications
RDReadyKitchen/preparation is complete, ticket awaits payment
PDPaidTicket has been paid (cash, card, or online)
PPPaid (account)Paid via account (pending settlement)
CCCancelledTicket has been cancelled
DEDeletedTicket has been soft-deleted
SPSplitterVisual separator line in the product list (not a real ticket status)
RCRecycledInternal status used during offer recalculation
UNUndoLegacy line status; no longer used in the product-return flow (returns go directly to DE)

Flags (Bit String)

Tickets carry a bit string in the flags field, with each flag tracking a specific lifecycle event:

FlagMeaning
OrderPrintedThe order slip has been dispatched to at least one destination printer (PrintedFlag; see Order Printing)
ReadyNotifiedA "ready" notification has been sent
TicketReadyThe ticket is marked as ready for pickup/delivery
TicketToPayPayment has been requested
TicketPaidPayment has been confirmed
TicketReopenedThe ticket was reopened after being paid

State Diagram

Status Getters on TicketView

The _TicketView exposes convenient boolean getters that combine status and flags:

get IsRecent(): boolean   // updated within AppConstants.recentMs
get IsEmpty(): boolean // no valid products
get IsOpened(): boolean // active, not cancelled, not ready (or ready but unpaid without prepayment)
get IsClosed(): boolean // inverse of IsOpened
get IsReady(): boolean // ReadyFlag is set
get IsToPay(): boolean // ToPayFlag is set
get IsPaid(): boolean // PaidFlag is set
get IsCancel(): boolean // ticket.IsCancelled && no refundable lines pending selection

IsOpened has special logic for places with prepayment enabled: a ticket in RD status remains "open" only if place.TicketPrepayment is false.

Adding Products (ToCart)

The ToCart method on _TicketView is the primary API for adding products to a ticket:

ToCart(product: ProductView, options: Array<ProductOptView>, amount: number, info: ToCartInfo | null)

The flow:

  1. Create TicketProduct: A new TicketProduct is instantiated with ticket, product, amount, taxrate (from the product), and status='AC'. The line always points to the base catalog product; no per-line Product is created.
  2. Create TicketOptions: For each selected option, a TicketOption is created with product (the TicketProduct) and option (the ProductOpt).
  3. Auto-grouping: Before inserting, the method checks if an identical product already exists in the ticket (same product, same options, same offer, same effective price, same routing type). If found, the existing product's amount is incremented instead of creating a new line.
  4. Sort position: New products receive a sort value placing them at the end of the list; existing products are shifted to make room.

The ToCartInfo interface allows passing optional market price, weight, comments, a fixed (manual) price and a routing type:

interface ToCartInfo {
market?: number | null;
weight?: number | null;
comments?: string | null;
fixed?: number | null; // manual fixed price for the line
type?: 'BAR' | 'KIT' | null; // routing type for generic/various products
}

Generic / various products and routing type

Generic and "various" products (sold with a custom price and a chosen routing type) do not create a dedicated Product in the place catalog. Instead:

  • The fixed price is stored on the line (ticketproduct.fixed = true, ticketproduct.charge = info.fixed).
  • The routing type (BAR/KIT) is stored on the line via the ticketproduct.type field, but only when the base product has no type of its own (info.type && !product.type).
  • TicketProductView.type resolves the effective type as product?.type ?? ticketproduct.type — the catalog product wins when it defines a type; the line-level type is the fallback used for generic/various items.

This keeps the catalog free of throwaway per-line products while preserving the routing information needed for future kitchen commands.

MethodDescription
AddOneOf(ticketproduct)Copies a product (with its options) and adds 1 unit — used for "+1" buttons
DelOneOf(ticketproduct)Finds the grouped product with the highest amount and decrements it
AddSplit()Inserts a visual splitter (status='SP') to separate product groups
AddDiscount(discount)Applies a DiscountView to the ticket, creating a TicketDiscount

Price Calculation (_PriceInfo)

The priceinfo getter triggers a full price recalculation when the cached value is invalidated (any product, offer, extra, or discount changes). The _PriceInfo(recalculate) method orchestrates the entire pricing pipeline.

Calculation Pipeline

Step Details

1. ClearOffers: Removes the offer reference from all ticket products, resetting them to unaffiliated state.

2. GroupProducts(false) — Merge equal products:

  • Splits products into sections (delimited by SP splitter products).
  • Within each section, finds products that are "equal" (same underlying product, same options, same offer) via IsEqual().
  • Merges equal products by summing their amounts and marking the duplicate as RC (recycled).

3. ApplyOffers: Asks OfferCalc (co-located in views/ticket.ts) which offers leave the ticket cheapest, then binds the applications it resolves:

  • Competing offers fight for the same units, so no ranking is good enough on its own: a 3x2 on soft drinks saves more on its first application than one menu, yet applying it consumes the drinks two menus need and leaves a more expensive ticket. Choosing offers is a set packing problem and has to be resolved as a whole.
  • The savings of one application are the base price of the units it takes minus the offer price (option charges are added to both sides and cancel out), so a plan is worth the price of every unit it covers minus the price of every offer it uses.
  • OfferCalc splits the offers into groups that can dispute a line, and hands each group to OfferSearch, which weighs how many times every offer is applied. A branch is dropped as soon as even the best each remaining offer could ever do on its own can not beat the combination already found.
  • For a given number of applications, SlotFiller fills the slots from the most expensive lines with alternating paths, so committing an expensive line moves an already placed cheaper unit to another slot instead of blocking the assignment.
  • Creates a TicketOffer per application and assigns to it the exact units the resolution chose.
  • If an application takes only part of a line's amount, the line is split: the original keeps the applied portion, and a new (or recycled) product holds the remainder for the applications still to come.
  • The search settles first on a combination grown one application at a time, giving each offer the head start of being applied on its own. That combination bounds the search, and it is also the answer for a ticket with more combinations than the budget (OFFER_BUDGET) allows to weigh. Real tickets stay far below that budget and are therefore resolved exactly.

4. GroupProducts(true) — Display grouping: After offers are applied, products are regrouped for display. Products with the same visual identity are linked via DisplayTo for the UI to show them as a single line.

5. ApplyExtras: Checks which extras from the place apply to this ticket:

  • Removes extras that no longer apply (e.g., period expired, product removed).
  • Adds new extras that now apply (e.g., table-specific extra, all-product extra).

6. ApplyDiscounts: Triggers calcinfo on each TicketDiscountView, which computes the discount charge.

7. Final summation: Using PriceInfo from upp-defs:

  • Each product contributes charge × amount at its taxrate.
  • Each extra contributes its charge at its taxrate.
  • Each discount is subtracted from the total.
  • The result updates ticket.price (total with tax) and ticket.taxes.

Product Recycling

During offer recalculation, products may be split and recombined. To avoid unnecessary creation/deletion cycles, the system maintains a _torecycle array:

  • AddRecycle(product): Marks a product as RC and stores it for potential reuse.
  • GetRecycle(product): Finds a recycled product matching the requested one (via IsJoin), restores its amount and status, and returns it.
  • DelRecycle(): After recalculation, any remaining recycled products are marked DE.

Payment

Setting the payment property on a TicketView triggers the payment flow:

set payment(value: TicketPayment | null)

Payment types (TicketPayment): 'PAYCASH', 'PAYCARD', 'PAYLINE', 'PAYMIXED', 'PAYACCOUNT'.

When payment is set (non-null)

The setter branches on IsPaid (PaidFlag), not on IsToPay:

  1. !IsPaid — commercial payment not yet closed (includes tickets in PP with a pending guest payment request):
    • If ToInvoice, stores the requested mode in pendpay and defers the rest until invoice is created (RN-CAJA-49).
    • Otherwise schedules a print (deferred to commit) when the cash drawer should open (OpenOnCash / OpenOnCard from place config), unless paying an account invoice (invceac set).
    • Sets paidby, paidin (current session), and paidon (current date).
    • Sets payment and status: PP for PAYACCOUNT, PD for all other modes (cash, card, mixed, etc.).
    • When status becomes PD, the status setter resolves the cash register internally and assigns till if not already set (cash attribution — RN-CAJA-31).
  2. IsPaid — ticket already commercially closed (paid at till or sent to account): only updates ticket.payment (change payment method on a closed ticket — RN-CAJA-29).

Account charges use the same path: TicketView.account setter delegates to payment = 'PAYACCOUNT'. Account-pending tickets stay in PP with PaidFlag true and ToPayFlag false; pending account is detected by payment === 'PAYACCOUNT' and invceac == null, not by IsToPay (RN-CAJA-74).

Pending payment sub-states in PP (RN-CAJA-73)

All pending-payment tickets use status = 'PP'; discriminate by payment and flags:

Sub-statepaymentPaidFlagToPayFlagClosed by
Account pendingPAYACCOUNTtruefalseSettlement (ACT-CAJA-LIQUIDAR-TICKET)
GUEST request (till)PAYCASH / PAYCARDfalsetrueWorker (ACT-CAJA-EJECUTAR-COBRO, status = 'PD')
GUEST request (online)PAYLINEfalsetrueServer webhook / sync (ACT-CAJA-PAYLINE-OK)

All GUEST requests share the same entry state (RN-CAJA-61): PP, payment set to the requested mode, ToPayFlag true, PaidFlag false, paidby = guest session. Only the closing actor differs: till worker sets paidin/paidon/till and keeps paidby unchanged (RN-CAJA-23); server for online pay.

v4 always sets payment when entering PP (no PP without payment). After server confirmation for online pay → PD + PAYLINE with payment unchanged (RN-CAJA-PAYLINE-02). Worker status = 'PD' must not close PAYLINE tickets (RV-CAJA-004).

Dual model for PAYACCOUNT (RN-CAJA-75)

Account tickets use paidin and paidon in two phases:

PhaseStatuspaidbypaidinpaidontill
Send to accountPPwho sendssession that registers the chargeregistration timestampnull
Settlement / cash intakePDunchangedliquidator (overwrites)cash intake timestamp (overwrites)batch till

The daily income report classifies pending account by payment + created (RN-INC-09); it does not require paidon to be null. SII and period closes use paidon with this dual semantics (phase A = account registration, phase B = cash intake).

PAYMIXED follows the same till attribution as atomic cash/card payment (RN-CAJA-51).

When payment is removed (null)

  • Clears paidby, given, paidon, paidin, till (directly on the DataObject), and payment.
  • Resets status to AC.

OnOpen() performs a full reopen cleanup: same payment fields plus account, invoice, and ReopenedFlag, then status AC (RN-CAJA-30).

Till attribution (till)

  • TicketView.till: public getter/setter for the persisted logical cash register (TILL). Setter assigns only if ticket.till is still null (RN-CAJA-62). Liquidation may preset till before PD (task 06).
  • Internal resolution on PD: private getter in TicketView (not part of the public API). Evaluated after paidin is set in the payment flow. Order: sole entry in place.TillAreas, then paidin.device if IsPos, then ticket creator device for bar (ticket.session.device — RN-CAJA-53). Without paidin, multi-TPV table tickets stay unresolved until payment assigns an executor. QRCODE.till assigns the table to a TILL for multi-TPV routing; it does not override TicketView.till on payment (RN-CAJA-39).
  • Real cash intake (PD) always gets a till via the status setter; PAYACCOUNT in PP keeps till null until settlement even though paidin/paidon are set at registration (RN-CAJA-75).

Product returns (single step)

Paid tickets can return selected products in one operation: the operator picks units, splits cash/card refund amounts on the same screen, and confirms. There is no intermediate pending-refund state and no deferred «confirm refund» step.

Domain flow

  1. TicketView.OnReturn(selections, { cash, card }) — marks selected units as returned (DE with refund on paid lines), adjusts mixed[] by method, recalculates net price, and cancels the ticket if nothing valid remains.
  2. OnCommit(null) — persists the change; when the net total drops on a fiscally registered ticket, _TicketChange creates a rectificative TicketChange with reason P (same pipeline as reopen/repay price changes).

Cash refund is always available regardless of the original payment method. Card refund cannot exceed the amount originally charged by card. When cash is refunded on a non-mixed ticket, the ticket becomes PAYMIXED with net amounts per method for till closing.

What is not part of the return flow

  • Line status UN (pending refund) — not emitted.
  • AskWaiter with reason = 'REFUND' — not created.
  • OnRefund() — removed; returns are complete after OnReturn + commit.

Returned lines stay visible on closed tickets with refund > 0 for history and printing.

TicketCommit Flow

The TicketCommit class orchestrates the full commit sequence. It is accessed via ticket.Commiter (lazy-initialized).

CommitTicket Sequence

Step-by-Step

  1. Status adjustment: If the ticket is in PR, it moves to AC. If the ticket is empty (no products), it moves to CC.

  2. _OrderNo() (async): Requests a unique order number from the server. Runs in parallel with subsequent steps.

  3. _OpenDrawer(): If the print module has a pending drawer-open request (ToOpen), it fires the OpenDrawer() event immediately.

  4. _TicketChange() (async): Creates the fiscal TicketChange record. See TicketChange Numbering below.

  5. Await order number: Waits for the server to return the order number before proceeding to print.

  6. _PrintTicket(): If the print module has a pending print request (ToPrint), it fires the PrintTicket() event.

  7. _CommitTicket() (async):

    • If the ticket is a copy (CopyOf is set), calls Overwrite() to apply the copy's data back to the original.
    • Pushes the ticket to the transaction (if provided) or calls DoCommit() directly.
    • Adds the ticket to the place's and QR code's ticket collections.
  8. _NotifyTicket(): Notifies local activity (wslogger.LogTicketCommit) after the commit.

TicketChange Numbering

Each paid ticket generates a TicketChange record containing the fiscal series and invoice number. The numbering system uses two enums defined in place.ts:

SerialType (Series)

ValueMeaningWhen used
XProvisionalUnpaid tickets
STicketPaid tickets without invoice
CTicket + InvoicePaid tickets with invoice at creation
FInvoiceInvoice created after payment
RRectificationPrice changes on reopened tickets

InvoiceType (Invoice Number)

ValueMeaningWhen used
XProvisionalUnpaid tickets
TTicketStandard ticket number
CTicket + InvoiceCombined ticket/invoice number
FInvoiceStandalone invoice number
RRectificationRectification number
ARecapitulativeRecapitulative invoice

Numbering Logic

The _TicketChange() method determines what fiscal action is needed based on the ticket's state:

First Commit (no previous TicketChange)

ScenarioSeriesInvoiceTicketChange?Reason
Unpaid ticketXXNoProvisional numbering only; no fiscal document
Paid without invoiceSTYesReason 'F' (first)
Paid with invoiceCCYesReason 'F' (first)

Subsequent Commits (ticket reopened)

ScenarioSeriesInvoiceReason
Price changedRR'P' (price changed)
Invoice created after paymentFF'I' (invoice created)
Cancelled'C' (cancelled) — no new TicketChange

The _InvoiceNumber(reason) method calls place.NextDeviceTicket(serialType, invoiceType) which manages per-device sequential numbering stored in configService.

TicketChange Record Structure

When a TicketChange is created, it stores:

FieldSource
ticketThe parent ticket
prevPrevious TicketChange (null for first)
sessionCurrent session
seriesSerial number (from NextDeviceTicket)
invoiceInvoice number (from NextDeviceTicket)
totalpriceinfo.tax.total
basepriceinfo.tax.base
taxespriceinfo.tax.taxes

TicketBAI / Verifactu Integration

After creating a TicketChange, the commit flow checks if fiscal integrations are enabled on the place. The system to use is configured via place.OptionSend, an enumerated value: '' (none), 'BAI' (TicketBAI), 'VFCT' (Verifactu). Only one system can be active per place.

Verifactu

If place.SendVFTEnabled is true (i.e. OptionSend === 'VFCT'):

  1. A new Verifactu object is created.
  2. Its change property is set to the newly created TicketChange.
  3. verifactu.Refresh() is called (async) to populate the fiscal data.
  4. If successful, the Verifactu object is added as a child of the TicketChange.

TicketBAI

If place.SendBAIEnabled is true (i.e. OptionSend === 'BAI'):

  1. A new Ticketbai object is created.
  2. Its change property is set to the TicketChange.
  3. ticketbai.Refresh() is called to generate the TicketBAI signature.
  4. If successful, it is added as a child of the TicketChange.

Both integrations run sequentially within the _TicketChange() step, before the actual commit to the server.

TicketPrint

The TicketPrint class manages print requests and cash drawer operations with a deferred execution model.

API

MethodParametersDescription
DoPrint(drawer, isauto, oncommit)drawer: boolean, isauto: boolean, oncommit: booleanSchedules or executes a print. If oncommit=true, stores flags for deferred execution.
OpenDrawer()Fires the drawer-open event and resets the request flag
PrintTicket()Fires the print event and resets all request flags

Observables

ObservableEventPayload
OnPrintPrint requestedPrintInfo { printer?, isauto, drawer }
OnDrawerDrawer open requestedPrintInfo { isauto, drawer }

Deferred Printing

When oncommit=true in DoPrint(), the flags are stored:

  • _print_requested: A print is pending.
  • _dopen_requested: A drawer open is pending.
  • _onpay_requested: The print was triggered by a payment.

During CommitTicket, the _OpenDrawer() and _PrintTicket() steps check these flags and fire the events. This ensures the printer and drawer are only activated after the commit data is ready.

Order Printing (Comandas)

An order slip (comanda) notifies bar, kitchen, service point or general printers about the changes in a ticket — new lines, cancelled lines, or a full cancellation — independently of fiscal receipt printing (TicketPrint). It is not a fiscal document: it never creates a TicketChange and never touches SerialType/InvoiceType.

Pending delta per line (notified)

Each TicketProduct stores notified: the number of units already communicated to a printer. The pending delta for a line is:

delta = live - notified          // live = 0 when status is DE, otherwise amount

TicketProductView.GetNotified() exposes this delta; SetNotified() writes notified = live, marking the line fully communicated. Splitter lines (status='SP') never participate (delta always 0). Moving units between tickets (Remove/Merge) redistributes notified proportionally so a transfer never creates a false addition or removal.

TicketOrder — deciding when to notify

TicketView exposes an order orchestrator (via Commiter) with the same shape as TicketPrint:

get ToOrder(): boolean                 // true when there is pending delta, or the ticket was
// just cancelled after a prior successful order print
get ProductsToOrder(): TicketProductView[] // lines with GetNotified() !== 0
get OnOrder(): Observable<OrderInfo> // { action: 'new' | 'set' | 'del' }
DoOrder(): void // emits immediately if ToOrder (manual/retry path)

During OnCommit, right after _CommitTicket() resolves, the commit flow checks ToOrder and fires OnOrder — fire-and-forget, in parallel with (not blocking) the commit itself. TicketOrder only decides whether an order is due and which action applies ('new' for the first order on a ticket, 'set' for a later delta, 'del' when a cancelled ticket had already been ordered); it does not know about printers, does not build any document, and does not write PrintedFlag.

Destination resolution and physical dispatch (feature/ticket)

Everything downstream of OnOrder — resolving which printers should receive the slip (BAR/KIT/SPC/GEN), building one HTML document per destination, and sending it — lives in feature/ticket (UppTicketOrderComponent for rendering, queueService for routing, dispatch and persistence of notified / PrintedFlag), never in upp-data. Dispatch is best-effort per printer: a failure on one destination does not block the others nor the ticket commit. On a successful physical dispatch, queueService calls SetNotified() on the lines covered by that printer, sets PrintedFlag = true on the first success, and persists those fields with a follow-up DoCommit. A device print path that cannot open WS returns failure (no browser handoff that would fake kitchen success).

Line label on the order slip

The printed name of each product line is not chosen in the print class. TicketProductView.label is the single API; OrderToPrint only consumes label.tr(place.lang) (fallback 'ES').

The ticket line does not store which shortcut (Preselect) created it. TicketProductView.shortcut recovers it by option coincidence (IsDirect against product.selects). The label then follows this cascade:

  1. alias of that shortcut, if set;
  2. otherwise alias of the product, if set;
  3. otherwise the commercial product name in the place language (previous behaviour).

The cascade applies to every preparation destination (BAR / KIT / SPC / GEN), additions and removals. It does not change the customer receipt (TicketToPrint / ticketdata.registry), the on-screen ticket, the catalog, or code/barcode search. Alias is a single non-translatable string (null when unset, max 32); tr(lang) ignores language for an alias and uses it for the commercial name.

Known limitation

A successful dispatch only means the transport (deviceService.doPrint, see Networking) reported exec: SUCCESS — i.e. the message reached the local print service. It does not confirm that paper actually came out of the printer.

Ticket Actions Summary

ActionMethodDescription
Create ticketnew Ticket(null, data)Creates a ticket in PR status
Add productticketView.ToCart(product, options, amount, info)Adds product with auto-grouping
Add one moreticketView.AddOneOf(ticketproduct)Copies and adds 1 unit
Remove oneticketView.DelOneOf(ticketproduct)Decrements the highest-amount group
Add splitterticketView.AddSplit()Inserts a visual separator
Add discountticketView.AddDiscount(discount)Applies a discount
Mark readyticketView.OnReady()Sets status to RD
CancelticketView.OnCancel()Sets status to CC
ReopenticketView.OnOpen()Clears payment, account, invoice, till, paidby/paidin/paidon; sets ReopenedFlag, status AC
Return productsticketView.OnReturn(selections, refund)Single-step return on a paid ticket; UI then OnCommit(null)
Merge ticketsticketView.OnMerge(other)Moves products and discounts from another ticket, deletes it
CommitticketView.OnCommit(payment, transaction?)Full commit flow via TicketCommit