# Print Subsystem — Specification v1.0

**Retail Pharmacy Platform · Caresoft Systems Private Limited**
**Date:** 27 August 2026
**Companion files:** `print_engine.php`, `test_print.php`, `schema-phase0-core-v1.sql` (§K.1)

---

## 1. Why this is specified in detail

This is the component that kills cloud POS products at Indian chemist counters. The chemist's judgment of the entire platform is formed in the two seconds between pressing F10 and tearing off the slip. Everything else in the product is negotiable. This is not.

**The rule:** the browser never prints. Ever. No `window.print()`, no print dialog, no PDF render in the hot path. A byte stream goes from PHP to the Counter Helper to the printer port.

Measured on the reference implementation: **0.13 ms** average render for a 5-line bill against a 5 ms budget. The remaining latency is entirely spooler and printer mechanics, which is where the incumbent's latency sits too.

---

## 2. Data flow

```
  Billing screen (browser, 127.0.0.1)
        │  F10
        ▼
  Counter Node — PHP 8.3
        │  1. commit sale_bill + lines + stock_ledger (local, ≤200 ms)
        │  2. append event_log row
        │  3. PrintEngine::render(template, data)   ← 0.13 ms
        ▼  POST http://127.0.0.1:7331/v1/print   (raw bytes, base64)
  Counter Helper — .NET Windows service
        │  4. write raw to printer handle (no dialog, no GDI)
        ▼
  Epson LX-300 / 80 mm thermal
```

Steps 1–3 are synchronous. Step 4 is fire-and-forget with a local spool queue, so a jammed printer never blocks the next customer.

---

## 3. Counter Helper — service contract

A small .NET Windows service. Deliberately thin: **it moves bytes and manages processes. No business logic.** Written in .NET to match the existing Caresoft HIS team's skills.

### 3.1 Binding and authentication

- Listens on `http://127.0.0.1:7331`, loopback only. Never binds a routable interface.
- Each install generates a 32-byte token at setup, stored in the local node's `setting` table (`scope=COUNTER`, key `helper_token`) and in the service's protected config.
- Every request carries `X-Helper-Token`. Mismatch returns 401. This stops any other page in the chemist's browser from driving his printer or cash drawer.

### 3.2 Endpoints

| Method | Path | Purpose |
|---|---|---|
| `GET`  | `/v1/health` | Service, printer and node status |
| `GET`  | `/v1/printers` | Enumerate installed printers and ports |
| `POST` | `/v1/print` | Send a raw byte stream |
| `POST` | `/v1/testprint` | Emit the built-in alignment pattern |
| `POST` | `/v1/drawer` | Kick the cash drawer |
| `GET`  | `/v1/spool` | Pending and failed jobs |
| `POST` | `/v1/spool/{id}/retry` | Retry a failed job |
| `POST` | `/v1/update/check` | Check for a signed agent update |

### 3.3 Print request

```json
{
  "job_id":       "01J8ZC4K9QW7X2N5PQRSTUVWXY",
  "printer":      "EPSON LX-300+II",
  "device_type":  "DOTMATRIX",
  "copies":       1,
  "payload_b64":  "GxtAGzMY...",
  "doc_type":     "SALE",
  "doc_no":       "C1/2026-27/004821",
  "priority":     "IMMEDIATE"
}
```

`job_id` is the ULID of the originating `event_log` row, which makes reprints traceable and makes the endpoint idempotent — the same `job_id` twice prints once.

**Response (immediate, do not block on the printer):**

```json
{ "accepted": true, "job_id": "01J8ZC...", "queued_at": "2026-08-27T14:12:11.482Z" }
```

### 3.4 Failure handling

- The helper holds a local spool queue on disk. A printer that is off, out of paper or jammed does not block billing.
- On failure the helper retries three times at 2 s, 5 s and 15 s, then marks the job `FAILED` and raises a `node_telemetry` row (`kind=ERROR`, `code=PRINT_FAIL`).
- The billing screen shows a small non-modal printer indicator. **It never shows a blocking dialog.** A modal error at a busy counter is worse than a missing slip.
- Reprint is always available from the bill list and increments `sale_bill.printed_count`.

### 3.5 Other responsibilities

- Silent signed auto-update of the local node. Unsigned packages are rejected.
- Staged rollout, and **working hours only, lunch slot preferred — never overnight**, per Caresoft operating practice.
- Supervises the local PHP and MySQL services, restarts on failure.
- Ships health and error telemetry (support-cost rule S8: a bug reaches engineering once, not 500 chemists).
- Barcode scanner passthrough where the scanner is not already an HID keyboard device.

---

## 4. Hardware profiles

| Profile | Device | Command set | Columns | Notes |
|---|---|---|---|---|
| `DM80_STD` | 80-col dot matrix | ESC/P | 80 normal / **137 condensed** | Default. Epson LX-300 series and compatibles. |
| `DM132_DETAIL` | 132-col dot matrix | ESC/P | 132 / 233 condensed | Larger stores wanting a fuller table. |
| `TH80_STD` | 80 mm thermal | ESC/POS | 48 normal / **64 small** | Second most common. |
| `TH58_COMPACT` | 58 mm thermal | ESC/POS | 32 / 42 small | Small counters, token slips. |
| `A5_GST` | Laser / inkjet A5 | PDF | — | Not queue-pressured; PDF acceptable. |
| `A4_GST` | Laser / inkjet A4 | PDF | — | Institutional and credit customers. |

**Six profiles. That is the complete list, permanently.** Support-cost rule S1: no per-customer bill formats, at any price, for any partner. New rows here are platform releases, not customer work. This single policy is the largest difference between our support economics and the incumbent's.

### 4.1 Dot matrix specifics

- Text mode only. No graphics, no bitmap rendering. This is the entire reason the slip is instant on the hardware chemists own.
- Line endings are `CRLF`. Some LX-300 clones drop lines on bare `LF`.
- Alignment is done by **space padding**, not by ESC commands. Centre and right commands are unreliable across clones.
- `ESC 3 24` sets tight line spacing; `ESC @` then form feed at the end clears the tractor.
- No cutter exists. `CUT` is a no-op on this emitter.

### 4.2 Thermal specifics

- `ESC t 0` selects code page 437 at init.
- Partial cut is `GS V 66 0`.
- Feed 3 lines before cutting or the cut lands mid-text.
- Drawer kick is `ESC p 0 25 250` on both command sets.

---

## 5. Template language

Templates live in `print_profile.template_body` (§K.1 of the schema) and are rendered by `PrintEngine`. Full directive reference is in the header comment of `print_engine.php`. Summary:

**Layout:** `INIT` `FONT` `BOLD` `UNDERLINE` `TEXT` `CENTER` `RIGHT` `PAIR` `RULE` `BLANK` `FEED` `CUT` `DRAWER`
**Tables:** `COLS <w><align>,...` then `ROW a,b,c`. Cells wrap to continuation lines rather than truncating — long drug names are the norm, and truncating a name is a dispensing-error risk.
**Totals:** `TOTALW <labelW> <valueW>` then `TOTAL <label> | <value>`. Right-anchored to the item table's edge, so labels and decimal points line up. `PAIR` cannot do this because every label is a different length.
**Control:** `REPEAT lines` / `END` (with `{line._sr}` auto serial), `IF {token}` / `ENDIF`
**Filters:** `|money` (Indian grouping, 12,34,567.89) `|words` (statutory amount in words, lakh/crore) `|qty` `|mmyy` `|date` `|dt` `|upper`

### 5.1 Template validation is a CI gate

`PrintEngine::validate()` statically checks a template against the target emitter's column count and reports overflow before it reaches a counter.

```
BAD_WIDE   line 2: COLS width 82 exceeds 42 columns for font SMALL — figures will be truncated
```

**Run this over every row in `print_profile` in CI.** A template that overflows its carriage silently truncates the amount column — the chemist sees `425.` instead of `425.00`. That defect found its way into the first draft of both reference templates during development, which is precisely why the check exists.

---

## 6. Seed data

```sql
-- print_profile seed. Codes are referenced by store.bill_template_code.
INSERT INTO print_profile
  (id, code, name, device_type, columns, paper_width_mm, command_set, template_body, is_active, row_ver, created_at, updated_at)
VALUES
  ('<ulid>', 'DM80_STD',     '80-col Dot Matrix — Standard',  'DOTMATRIX', 80,  NULL, 'ESCP',   '<dm80 template>',  1, 0, NOW(3), NOW(3)),
  ('<ulid>', 'DM132_DETAIL', '132-col Dot Matrix — Detailed', 'DOTMATRIX', 132, NULL, 'ESCP',   '<dm132 template>', 1, 0, NOW(3), NOW(3)),
  ('<ulid>', 'TH80_STD',     '80mm Thermal — Standard',       'THERMAL',   NULL, 80,  'ESCPOS', '<th80 template>',  1, 0, NOW(3), NOW(3)),
  ('<ulid>', 'TH58_COMPACT', '58mm Thermal — Compact',        'THERMAL',   NULL, 58,  'ESCPOS', '<th58 template>',  1, 0, NOW(3), NOW(3)),
  ('<ulid>', 'A5_GST',       'A5 GST Invoice',                'LASER',     NULL, NULL, 'PDF',   '<a5 template>',    1, 0, NOW(3), NOW(3)),
  ('<ulid>', 'A4_GST',       'A4 GST Invoice',                'LASER',     NULL, NULL, 'PDF',   '<a4 template>',    1, 0, NOW(3), NOW(3));
```

Working `DM80_STD` and `TH80_STD` bodies are in `test_print.php`, verified end to end.

---

## 7. Statutory content on the slip

Every sale bill must carry, or the store cannot legally use it:

- Store name, full address, phone
- **GSTIN** and **both drug licence numbers** (DL-20 and DL-21)
- Invoice number and date-time
- Per line: item name, **batch number**, **expiry**, quantity, MRP, rate, HSN, GST%
- Tax split: CGST / SGST or IGST, plus cess where applicable
- Net payable and **amount in words**
- Pharmacist name where any Schedule H or H1 line is present
- Copy designation (Original for Recipient / Duplicate for Transporter / Triplicate for Supplier) on GST invoices

The engine's `IF {bill.rx_id}` block handles the pharmacist line conditionally. **Batch and expiry on the printed slip are not optional** — they are what allow a recall or an adverse-event trace back to a specific supply.

---

## 8. Acceptance tests

Must pass before any store goes live.

| # | Test | Pass condition |
|---|---|---|
| T1 | Render 10-line bill, DM80_STD | ≤ 5 ms; verified at 0.13 ms |
| T2 | Render 10-line bill, TH80_STD | ≤ 5 ms |
| T3 | F10 to first character printing | ≤ 500 ms on reference hardware |
| T4 | Full 10-line slip, LX-300 | ≤ 3 s |
| T5 | Full 10-line slip, 80 mm thermal | ≤ 2 s |
| T6 | 50-character drug name | Wraps; never truncated |
| T7 | Amount ₹1,23,45,678.00 | Correct Indian grouping and words |
| T8 | Printer off during print | Bill still commits; job spools; no modal dialog |
| T9 | Printer out of paper mid-slip | Retry succeeds; no duplicate bill number |
| T10 | Same `job_id` submitted twice | Prints once |
| T11 | Request without valid token | 401 |
| T12 | All six templates through `validate()` | Zero problems |
| T13 | WAN down | Print unaffected |
| T14 | Reprint from bill list | Identical bytes; `printed_count` increments |
| T15 | Cash drawer kick | Fires on both command sets |

### 8.1 Field validation before general availability

Automated tests do not cover the thing that actually matters. Before GA:

- Test on **at least five genuinely old LX-300 units** sourced from real shops, not new stock. Clone firmware behaviour varies and this is where surprises live.
- Test on the three cheapest 80 mm thermal units available on the local market, not the reference model.
- Have an actual counter assistant — not a developer — bill 50 real customers on it during an evening rush.
- Time it side by side against the incumbent on the same hardware, in the same shop. **If we are not faster, stop and fix it before anything else in the roadmap proceeds.**

---

## 9. Deliberately excluded

- PDF generation in the counter hot path. A5/A4 profiles render server-side, off the critical path.
- Any font embedding, logo bitmaps or graphics on dot matrix. They multiply print time by five to ten and are the single most common cause of "the new software is slow."
- Per-customer template editing (rule S1).
- Browser-based printing of any kind.
- A WYSIWYG template designer. It would be used to create the customisation we have refused, by a different route.
