Skip to main content

Document Print Module

Backend module for queuing and dispatching thermal receipt print jobs to the Print Bridge agent.


Purpose

The document-print module provides a REST API that:

  1. Accepts print job creation requests from the PWA (Firebase-authenticated).
  2. Fetches and serialises the full document payload from the database.
  3. Persists the job with status: pending.
  4. Exposes a poll endpoint and emits events so the Print Bridge agent can pick up and process jobs.

Architecture

The module follows strict Hexagonal Architecture.

document-print/
├── domain/
│ ├── document-print-job-repository.domain.ts ← IDocumentPrintJobRepository (port)
│ └── document-print-payload-port.domain.ts ← IDocumentPrintPayloadPort (port)
├── application/
│ ├── document-print-job.service.ts ← use cases (depends only on ports)
│ └── events/
│ └── document-print-job.events.ts ← domain events
├── infrastructure/
│ ├── document-print-job.repository.ts ← Kysely adapter for jobs table
│ └── document-print-payload.repository.ts ← Kysely adapter for payload assembly
└── interfaces/
├── document-print.controller.ts
└── dtos/
├── create-document-print-job.dto.ts
├── update-document-print-job.dto.ts
└── document-print-job-response.dto.ts

Dependency rule

interfaces → application → domain ← infrastructure

The application service depends only on two domain ports:

  • IDocumentPrintJobRepository — CRUD on document_print_job table.
  • IDocumentPrintPayloadPort — assembles typed print payloads from business documents.

The application service never touches KyselyDatabase directly.


Domain concepts

DocumentPrintJob

A queued print request stored in document_print_job. Fields:

FieldDescription
jobKindsale_receipt | order_receipt | fel_invoice
statuspendingsentprinted | failed
payloadSerialised print data (varies by jobKind)
sourceType / sourceIdLinks job to origin document
targetPrinterIdOptional — routes to a specific printer
claimedByPrinter ID that claimed the job
attemptsIncremented atomically on each failure

Job lifecycle

pending  →(bridge claims)→  sent  →(bridge prints)→  printed
→(bridge fails)→ failed

Claiming is optimistic — the claimJob repository method uses a conditional UPDATE (WHERE status = 'pending') that atomically prevents two bridge instances from claiming the same job.

Payload types

Defined in @flowpos-workspace/global/types/document-print.types:

  • SaleReceiptPayload — retail sale receipt
  • OrderReceiptPayload — restaurant order receipt
  • FelInvoicePayload extends SaleReceiptPayload — FEL electronic invoice

Authentication model

The module uses two distinct auth modes depending on the caller:

CallerAuthEndpoints
PWA frontendFirebase ID token (flowpos-id-token cookie or Authorization: Bearer)POST /document-print-jobs, GET thermal-availability
Print Bridge agentDevice token (validated by DeviceGuard)GET /document-print-jobs, GET /document-print-jobs/:id, PATCH /document-print-jobs/:id

Bridge-facing endpoints are marked @IsPublic() (skip Firebase guard) and protected by @UseGuards(DeviceGuard) instead.


API Endpoints

GET /document-print-jobs/thermal-availability

Check if a paired print_bridge device exists for a location.

Auth: Firebase
Query: locationId (required)
Response: { "available": true | false }


POST /document-print-jobs

Enqueue a print job.

Auth: Firebase
Query: locationId, businessId (both required)
Body:

{
"jobKind": "sale_receipt",
"sourceId": "<saleId>",
"targetPrinterId": "printer-usb-001"
}

Responses:

CodeReason
201Job created { id, status: "pending" }
400No printer paired, invalid jobKind, missing params
404Source document not found
409Job already in-flight for this document

GET /document-print-jobs

List pending jobs for a location (bridge poll fallback).

Auth: Device token
Query: locationId (required), jobKind (optional, repeatable)
Response: Array of DocumentPrintJob


GET /document-print-jobs/:id

Get a single job by ID.

Auth: Device token
Response: DocumentPrintJob or 404


PATCH /document-print-jobs/:id

Update job status. Used by the bridge to claim and report outcome.

Auth: Device token
Body:

// Claim (pending → sent):
{ "status": "sent", "printerId": "printer-usb-001" }

// Report success:
{ "status": "printed" }

// Report failure:
{ "status": "failed", "error": "USB timeout after 5s" }

Responses:

CodeBody
200{ id, status }
200{ id, status: "already_claimed" } when optimistic claim loses
400Invalid status
404Job not found

Events

The service emits domain events via EventEmitter2:

Event nameClassPayload
document_print_job.newOnDocumentPrintJobNewEventfull job + businessId, locationId, jobKind, targetPrinterId
document_print_job.updatedOnDocumentPrintJobUpdatedEventfull job + businessId, locationId, new status

Other modules (e.g., WebSocket gateway) listen to document_print_job.new to push jobs to the bridge in real time, reducing latency vs. polling.


Important design decisions

Why IDocumentPrintPayloadPort is a separate port

Payload assembly requires joining multiple tables (sale, business, location, orderItem, product, diningTable, employee, felCertifierConfig). This complexity belongs in infrastructure, not the application service. The port keeps the service free of Kysely and framework imports.

Optimistic job claiming

claimJob uses a conditional UPDATE (WHERE status = 'pending') rather than a SELECT-then-UPDATE. This prevents race conditions when multiple bridge instances poll simultaneously. The service treats a null return as "already claimed" and responds with { status: "already_claimed" }.

Atomic attempt counter

updateStatus on failed uses sql\attempts + 1`` — a single atomic SQL expression — instead of a read-modify-write. This ensures correctness under retries.

FEL invoice re-uses sale receipt payload

buildFelInvoicePayload calls buildSaleReceiptPayload internally and extends it with FEL-specific fields (felCertifierName, felCertifierNit, felDteNumber, felEmissionDate). If the sale lacks FEL authorization data, it throws 400 before creating the job.