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
| Status | Name | Description |
|---|---|---|
PR | Preparation | Initial state when a ticket is created but not yet activated |
AC | Active | Ticket is open and accepting modifications |
RD | Ready | Kitchen/preparation is complete, ticket awaits payment |
PD | Paid | Ticket has been paid (cash, card, or online) |
PP | Paid (account) | Paid via account (pending settlement) |
CC | Cancelled | Ticket has been cancelled |
DE | Deleted | Ticket has been soft-deleted |
SP | Splitter | Visual separator line in the product list (not a real ticket status) |
RC | Recycled | Internal status used during offer recalculation |
UN | Undo | Legacy 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:
| Flag | Meaning |
|---|---|
OrderPrinted | The order slip has been dispatched to at least one destination printer (PrintedFlag; see Order Printing) |
ReadyNotified | A "ready" notification has been sent |
TicketReady | The ticket is marked as ready for pickup/delivery |
TicketToPay | Payment has been requested |
TicketPaid | Payment has been confirmed |
TicketReopened | The 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:
- Create TicketProduct: A new
TicketProductis instantiated withticket,product,amount,taxrate(from the product), andstatus='AC'. The line always points to the base catalog product; no per-lineProductis created. - Create TicketOptions: For each selected option, a
TicketOptionis created withproduct(the TicketProduct) andoption(the ProductOpt). - 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'samountis incremented instead of creating a new line. - Sort position: New products receive a
sortvalue 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 theticketproduct.typefield, but only when the base product has notypeof its own (info.type && !product.type). TicketProductView.typeresolves the effective type asproduct?.type ?? ticketproduct.type— the catalog product wins when it defines a type; the line-leveltypeis 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.
Related Product Operations
| Method | Description |
|---|---|
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
SPsplitter 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.
OfferCalcsplits the offers into groups that can dispute a line, and hands each group toOfferSearch, 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,
SlotFillerfills 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
TicketOfferper 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 × amountat itstaxrate. - Each extra contributes its
chargeat itstaxrate. - Each discount is subtracted from the total.
- The result updates
ticket.price(total with tax) andticket.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 asRCand stores it for potential reuse.GetRecycle(product): Finds a recycled product matching the requested one (viaIsJoin), restores its amount and status, and returns it.DelRecycle(): After recalculation, any remaining recycled products are markedDE.
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:
!IsPaid— commercial payment not yet closed (includes tickets inPPwith a pending guest payment request):- If
ToInvoice, stores the requested mode inpendpayand defers the rest until invoice is created (RN-CAJA-49). - Otherwise schedules a print (deferred to commit) when the cash drawer should open (
OpenOnCash/OpenOnCardfrom place config), unless paying an account invoice (invceacset). - Sets
paidby,paidin(current session), andpaidon(current date). - Sets
paymentand status:PPforPAYACCOUNT,PDfor all other modes (cash, card, mixed, etc.). - When status becomes
PD, thestatussetter resolves the cash register internally and assignstillif not already set (cash attribution — RN-CAJA-31).
- If
IsPaid— ticket already commercially closed (paid at till or sent to account): only updatesticket.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-state | payment | PaidFlag | ToPayFlag | Closed by |
|---|---|---|---|---|
| Account pending | PAYACCOUNT | true | false | Settlement (ACT-CAJA-LIQUIDAR-TICKET) |
| GUEST request (till) | PAYCASH / PAYCARD | false | true | Worker (ACT-CAJA-EJECUTAR-COBRO, status = 'PD') |
| GUEST request (online) | PAYLINE | false | true | Server 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:
| Phase | Status | paidby | paidin | paidon | till |
|---|---|---|---|---|---|
| Send to account | PP | who sends | session that registers the charge | registration timestamp | null |
| Settlement / cash intake | PD | unchanged | liquidator (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), andpayment. - 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 ifticket.tillis still null (RN-CAJA-62). Liquidation may presettillbeforePD(task 06).- Internal resolution on
PD: private getter inTicketView(not part of the public API). Evaluated afterpaidinis set in the payment flow. Order: sole entry inplace.TillAreas, thenpaidin.deviceifIsPos, then ticket creator device for bar (ticket.session.device— RN-CAJA-53). Withoutpaidin, multi-TPV table tickets stay unresolved until payment assigns an executor.QRCODE.tillassigns the table to aTILLfor multi-TPV routing; it does not overrideTicketView.tillon payment (RN-CAJA-39). - Real cash intake (
PD) always gets a till via thestatussetter;PAYACCOUNTinPPkeepstillnull until settlement even thoughpaidin/paidonare 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
TicketView.OnReturn(selections, { cash, card })— marks selected units as returned (DEwithrefundon paid lines), adjustsmixed[]by method, recalculates net price, and cancels the ticket if nothing valid remains.OnCommit(null)— persists the change; when the net total drops on a fiscally registered ticket,_TicketChangecreates a rectificativeTicketChangewith reasonP(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. AskWaiterwithreason = 'REFUND'— not created.OnRefund()— removed; returns are complete afterOnReturn+ 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
-
Status adjustment: If the ticket is in
PR, it moves toAC. If the ticket is empty (no products), it moves toCC. -
_OrderNo()(async): Requests a unique order number from the server. Runs in parallel with subsequent steps. -
_OpenDrawer(): If the print module has a pending drawer-open request (ToOpen), it fires theOpenDrawer()event immediately. -
_TicketChange()(async): Creates the fiscalTicketChangerecord. See TicketChange Numbering below. -
Await order number: Waits for the server to return the order number before proceeding to print.
-
_PrintTicket(): If the print module has a pending print request (ToPrint), it fires thePrintTicket()event. -
_CommitTicket()(async):- If the ticket is a copy (
CopyOfis set), callsOverwrite()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.
- If the ticket is a copy (
-
_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)
| Value | Meaning | When used |
|---|---|---|
X | Provisional | Unpaid tickets |
S | Ticket | Paid tickets without invoice |
C | Ticket + Invoice | Paid tickets with invoice at creation |
F | Invoice | Invoice created after payment |
R | Rectification | Price changes on reopened tickets |
InvoiceType (Invoice Number)
| Value | Meaning | When used |
|---|---|---|
X | Provisional | Unpaid tickets |
T | Ticket | Standard ticket number |
C | Ticket + Invoice | Combined ticket/invoice number |
F | Invoice | Standalone invoice number |
R | Rectification | Rectification number |
A | Recapitulative | Recapitulative invoice |
Numbering Logic
The _TicketChange() method determines what fiscal action is needed based on the ticket's state:
First Commit (no previous TicketChange)
| Scenario | Series | Invoice | TicketChange? | Reason |
|---|---|---|---|---|
| Unpaid ticket | X | X | No | Provisional numbering only; no fiscal document |
| Paid without invoice | S | T | Yes | Reason 'F' (first) |
| Paid with invoice | C | C | Yes | Reason 'F' (first) |
Subsequent Commits (ticket reopened)
| Scenario | Series | Invoice | Reason |
|---|---|---|---|
| Price changed | R | R | 'P' (price changed) |
| Invoice created after payment | F | F | '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:
| Field | Source |
|---|---|
ticket | The parent ticket |
prev | Previous TicketChange (null for first) |
session | Current session |
series | Serial number (from NextDeviceTicket) |
invoice | Invoice number (from NextDeviceTicket) |
total | priceinfo.tax.total |
base | priceinfo.tax.base |
taxes | priceinfo.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'):
- A new
Verifactuobject is created. - Its
changeproperty is set to the newly createdTicketChange. verifactu.Refresh()is called (async) to populate the fiscal data.- If successful, the Verifactu object is added as a child of the
TicketChange.
TicketBAI
If place.SendBAIEnabled is true (i.e. OptionSend === 'BAI'):
- A new
Ticketbaiobject is created. - Its
changeproperty is set to theTicketChange. ticketbai.Refresh()is called to generate the TicketBAI signature.- 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
| Method | Parameters | Description |
|---|---|---|
DoPrint(drawer, isauto, oncommit) | drawer: boolean, isauto: boolean, oncommit: boolean | Schedules 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
| Observable | Event | Payload |
|---|---|---|
OnPrint | Print requested | PrintInfo { printer?, isauto, drawer } |
OnDrawer | Drawer open requested | PrintInfo { 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:
- alias of that shortcut, if set;
- otherwise alias of the product, if set;
- 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.
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
| Action | Method | Description |
|---|---|---|
| Create ticket | new Ticket(null, data) | Creates a ticket in PR status |
| Add product | ticketView.ToCart(product, options, amount, info) | Adds product with auto-grouping |
| Add one more | ticketView.AddOneOf(ticketproduct) | Copies and adds 1 unit |
| Remove one | ticketView.DelOneOf(ticketproduct) | Decrements the highest-amount group |
| Add splitter | ticketView.AddSplit() | Inserts a visual separator |
| Add discount | ticketView.AddDiscount(discount) | Applies a discount |
| Mark ready | ticketView.OnReady() | Sets status to RD |
| Cancel | ticketView.OnCancel() | Sets status to CC |
| Reopen | ticketView.OnOpen() | Clears payment, account, invoice, till, paidby/paidin/paidon; sets ReopenedFlag, status AC |
| Return products | ticketView.OnReturn(selections, refund) | Single-step return on a paid ticket; UI then OnCommit(null) |
| Merge tickets | ticketView.OnMerge(other) | Moves products and discounts from another ticket, deletes it |
| Commit | ticketView.OnCommit(payment, transaction?) | Full commit flow via TicketCommit |