Grocery / supermarket POS (weighted barcodes, tax, EBT)
This page describes a supermarket-style product and checkout layer: weighted internal barcodes, configurable parsing rules, tax exemption, and EBT (SNAP) eligibility. Shared parsing helpers live in @indochina/shared under lib/retail-grocery.
Product types
Section titled “Product types”| Type | Meaning |
|---|---|
NORMAL | Fixed barcode; quantity from cashier entry or default 1. |
WEIGHT | Sold by weight; quantity often comes from scale label (embedded) or manual entry. |
PRICE_EMBEDDED | Barcode encodes total sale amount for the line (random weight / price). |
QTY_EMBEDDED | Barcode encodes packaged quantity (e.g. 6-pack). |
Use a single TypeScript const object (already in shared) as the source of truth for APIs and forms.
Custom internal barcode (EAN-13)
Section titled “Custom internal barcode (EAN-13)”Custom barcode = store-internal 13-digit EAN-13: prefix(2) + PLU(4) + separator check(1) + value(5) + GS1 check(1)` (13 digits, indices 0–12).
| Index (0-based) | Role | Included in PLU / value parse? |
|---|---|---|
| 0–1 | Prefix 02 (price) or 22 (weight) | No |
| 2–5 | PLU (4 digits) | PLU only |
| 6 | Separator check (GS1 on prefix + PLU(4)) | No — same algorithm family as index 12 |
| 7–11 | Embedded price or weight (5 digits) | Value only |
| 12 | GS1 check on digits 0–11 | No |
| Prefix | Variant | item_type | Separator check (index 6) | Variable field (7–11) |
|---|---|---|---|---|
02 | Price-embedded | PRICE_EMBEDDED, PER_UNIT (shelf) | GS1 on 02 + PLU(4) | Cents in USD (÷ 100 → dollars, max $999.99) |
22 | Weight-embedded | WEIGHT | GS1 on 22 + PLU(4) | Thousandths of lb (÷ 1000, max 99.999 lb) |
PLU at indices 2–5 (four digits). When the catalog PLU has more than four digits, encoding uses the last four for 02 and the first four for 22 (scale legacy). POS scan parse is positional for PLU/value; some deli scales use a different digit at index 6 than in-app GS1 encode (lenient parse). Helpers: encodeCatalogCustomShelfEan13, encodeCatalogCustomEan13, internalCustomPluSeparatorCheckOk, isInternalCustomEan13Digits, getInternalCustomEan13Variant, parseInternalCustomEan13 in @indochina/shared (internal-custom-ean13.ts). Catalog Generate barcode and POS workbench generation call the same encode helpers via apps/vendor-web/src/lib/generate-catalog-variant-barcode.ts.
Example 0212342000109 (custom price): 02 + PLU 1234 + separator check 2 (GS1 on 021234) + 00010 → $0.10 + GS1 check 9. For PLU 1234 at $1.00, encode yields 0212342001007. Scale label 0221376000102 still parses as PLU 2137 / $0.10 when index 6 is 6 (not our GS1 5 on 022137).
Weighted internal barcode (prefix 22)
Section titled “Weighted internal barcode (prefix 22)”Layout (EAN-13, digits only): 22 + PLU (4) + separator check (1) + weight (5) + GS1 check (1).
Example scan: 2210001008259
| Segment | Digits | Meaning |
|---|---|---|
| Prefix | 22 | Internal weighted class (configurable per chain). |
| PLU | 1000 | Four-digit PLU on the label (catalog 10001 → leading four). |
| Separator check | 1 | GS1 check on 22 + 1000 (index 6; not part of PLU/weight). |
| Weight | 00825 | Integer weight field; scale by 10^-decimal_places (default 3 → 0.825 lb). |
| GS1 check | 9 | Check digit over all 12 preceding digits. |
Encoding new internal weighted labels (catalog + POS workbench)
Section titled “Encoding new internal weighted labels (catalog + POS workbench)”Generation uses the same slice layout as parsing (defaultInternalWeightedEan13Rule). Implementations live in @indochina/shared:
| Helper | Role |
|---|---|
encodeCatalogCustomShelfEan13(itemType, plu) | Shelf label: 22 + PLU + 00000 (weight) or 02 + PLU + 00000 ($0.00) from item_type. |
encodeCatalogCustomEan13(itemType, plu, { weightLb | priceDollars }) | Variable label on the same layout (workbench / scale). |
encodeInternalWeightedShelfEan13(plu) / encodeInternalPriceEmbeddedShelfEan13(plu) | Low-level shelf encode per prefix (used by the helpers above). |
parseInternalWeightedEan13 / parseWeightLbFromInternalWeightedEan13 | Decode scans for PLU + lb quantity. |
isInternalWeightedEan13Digits(digits) | Prefer this over hand-written length === 13 && startsWith('22') — it delegates to the rule matcher (exact length, slices, types). |
Vendor UI wraps shelf + weighted encode with a random valid EAN-13 fallback where legacy flows expected a non-null string (apps/vendor-web/src/lib/generate-catalog-variant-barcode.ts: generateCatalogVariantBarcode for new variant rows, encodeInternalWeightedEan13Lb with fallback for the barcode workbench).
Scan → cart flow
Section titled “Scan → cart flow”- Normalize scan (digits-only for GS1).
- Run ordered rule list — first match wins (
parseRetailBarcodein shared). - Resolve catalog row by PLU (and
store_idin multi-store POS). extended_subtotal = scaled_weight × unit_price(after unit conversion if needed).- Apply discounts on eligible bases (policy-specific).
- Apply sales tax only on taxable, non-exempt bases (see Tax).
- Track EBT-eligible subtotal separately for tender limits (see EBT).
Units (lb / kg)
Section titled “Units (lb / kg)”Store weight_unit on the product or on the barcode rule. Conversion factors should be centralized (e.g. lb → kg) so scale labels and back-office inventory agree.
Product fields (target schema)
Section titled “Product fields (target schema)”Existing Laravel-backed items rows should gain new nullable columns (use project fp(...) naming when adding columns to legacy tables). Suggested model:
| Field | Type | Notes |
|---|---|---|
id | PK | Existing. |
sku | string | Existing. |
plu | varchar nullable | Primary key for scale labels + weighted lookup. |
name | string | Existing. |
barcode | string | Shelf / case barcode; may differ from scale label. |
item_type | enum string | NORMAL | WEIGHT | … (POS scan / qty resolution; not the item_types join labels) |
unit | string | ea, lb, kg, … |
unit_price | decimal | Price per unit. |
barcode_rule_id | FK nullable | Points at store/chain rule row when not using defaults. |
is_tax_exempt | bool | When true, no sales-tax merchandise base on the line (includes legacy rows that had is_taxable = 0 before that column was removed). |
is_ebt_eligible | bool | SNAP-eligible food flag. |
Tax vs scan mode: item_type only affects how quantity is derived from barcodes (by weight, embedded price, etc.). Tax treatment is a separate catalog flag: use is_tax_exempt for zero sales-tax base.
Barcode rule system
Section titled “Barcode rule system”Table pos_barcode_rules (or JSON blob on stores for MVP), keyed by store_id + sort_order:
| Column | Purpose |
|---|---|
prefix | Leading literal match. |
product_code_start, product_code_length | 0-based slice into normalized barcode. |
value_start, value_length | Variable segment (weight / price / qty). |
decimal_places | Divide integer value by 10^decimal_places. |
value_type | WEIGHT | PRICE | QUANTITY. |
weight_unit | lb | kg when value_type = WEIGHT. |
Shared type: RetailBarcodeRule — see parseRetailBarcode and defaultInternalWeightedEan13Rule.
- Per line:
taxable_base = extended_subtotalonly if!is_tax_exempt(seegroceryTaxableBasein shared). - Rate: resolve from store jurisdiction (zip / state) — not hardcoded in the parser.
- Prepared food: model as category flags or tax codes on the item; hot food often taxable even when raw ingredients are exempt (state-specific).
EBT (SNAP)
Section titled “EBT (SNAP)”- Per line: if
is_ebt_eligible, accumulate to EBT subtotal; else non-EBT (splitEbtEligibleSubtotal). - Tender:
capEbtTenderAmount(ebtSubtotal, requestedEbt)— never allocate EBT above eligible food subtotal. - Split checkout: remainder after EBT must use cash, debit, credit, etc.
- Compliance: alcohol, tobacco, hot prepared foods, and general merchandise are typically ineligible — enforce with catalog flags, not cashier memory.
Inventory
Section titled “Inventory”- Decimal quantity: order lines and stock movements must use decimal (or integer smallest weight increment, e.g. milligrams) — migrating from integer-only quantity is a breaking contract change across POS DTOs and
order_details. - Shrink / waste: post separate adjustment transactions so retail books stay aligned with scale sales.
Scale integration
Section titled “Scale integration”Operator workflow: select PLU on scale → weigh → print EAN label → POS scan. No wire protocol is required in v1; optional future OPOS / JavaPOS or vendor SDK integration can push weight directly for stores without labels.
Edge cases
Section titled “Edge cases”- Multiple rules same prefix: keep deterministic sort order; first match wins.
- UPC-A vs EAN-13: normalize to 13 digits with lead zero for GS1 algorithms.
- Invalid check digit: reject or warn; do not silently change price.
- Zero weight field: reject line or prompt manual weight.
- Price-embedded totals: may bypass unit price × qty — reconcile with anti-fraud limits (max % off PLU).
- Split packages:
QTY_EMBEDDEDmay encode fractional cases — same decimal pipeline as weight.
LionPOS implementation status
Section titled “LionPOS implementation status”- Shipped: shared
lib/retail-grocery— custom EAN-13 (02/22),parseRetailBarcode,internal-custom-ean13,internal-weighted-ean13,internal-price-embedded-ean13, GS1 check helpers, tax base + EBT split helpers, unit tests. - Vendor:
generate-catalog-variant-barcodeuses shared shelf encode foritem_type = WEIGHT; POS weighted scan resolution usesisInternalWeightedEan13Digits+ shared parsers (apps/vendor-web/src/lib/pos-weighted-barcode-scan.ts). Barcode workbench embeds weight via shared encode with vendor fallback (apps/vendor-web/src/app/point-of-sale/barcode-page.tsx). - Planned: DB migrations on
items, store-scoped ordered rule lists beyond the default internal weighted rule, payment tender types for EBT (where not already wired).
Best practices
Section titled “Best practices”- Keep rules in data, not in cashier training.
- Single parser in shared for web POS, desktop bridge, and server validation.
- Audit trail for overridden tax/EBT flags (manager PIN).
- Performance: pre-index catalog by
plu+store_id; cache active rule list per store at shift open.