Skip to main content

An unrecognised folio transaction code posts with ZERO GST and bypasses the posting limit

Found: 2026-08-07 · Environment: TEST (deployed pod) · Severity: proposed P1 — compliance + safeguard, no data corruption Found by: pms-f-inhouse TC-387, which only became visible after the uat room-contention fix stopped that suite dying in beforeAll.

Measured, with a working positive control​

POST /api/folios/:id/charges, same folio, same amount (₹300 — deliberately under the role posting limit so the control could actually run):

transactionCodeHTTPtaxAmountsacCode
PARKING (canonical) — POSITIVE CONTROL20154 (18%)996743
MISCELLANEOUS2010—
ZZ_NOT_A_REAL_CODE2010—

A first attempt at ₹10,000 is worth recording because the control FAILED there and the failure was informative: PARKING was refused 409 FOLIO_CHARGE_EXCEEDS_ROLE_LIMIT, while MISCELLANEOUS and ZZ_NOT_A_REAL_CODE both posted 201 at the same amount.

The two findings​

  1. A charge on an unrecognised code is billed with no GST and no SAC/HSN. The canonical set is TRANSACTION_CODE_TAX_MAP in hmp-frontdesk-service/src/folios/folio-tax-resolver.service.ts (ROOM … PARKING). MISCELLANEOUS is not in it — it exists only as a PettyCashCategory. An Indian tax invoice line requires a SAC; a line with neither tax nor SAC is not compliant.

  2. It also appears to bypass the role charge-posting limit. ← THIS WAS WRONG. Corrected 2026-08-07 by hospitality-sme, then verified in code by me.

    The limit is role-keyed, not category-derived: const totalAmount = dto.amount * (dto.quantity || 1) + (dto.taxAmount || 0) then roleLimits[roleKey] (folios.service.ts:3356-3366). The comparison base is tax-INCLUSIVE, so the ₹10,000 observation has an arithmetic explanation, not a bypass:

    codenettaxcomparedvs ₹10,000 staff limit
    PARKING10,0001,80011,800> limit → refused
    unknown10,000010,000NOT > limit → allowed

    So the erosion is real but bounded at exactly the tax fraction (~15.25% more net headroom for an 18% code), and "a staff member could post any amount by inventing a code" is false — the role-independent per-line ceiling (folio-line-ceiling.util.ts, ₹10,00,000) still applies. Severity accordingly P1, not P0.

The service's own type already states the intent: FrontDeskPostableChargeCode = keyof typeof TRANSACTION_CODE_TAX_MAP (src/folios/front-desk-charge-codes.ts:61) — only canonical codes are postable. The DTO does not enforce what the type asserts.

THE ACTUAL DEFECT — a better characterisation than mine (SME)​

The zero tax is deliberate, not a bug. An unmapped code resolves isUnclassified: true (folio-tax-resolver.service.ts:952) and the platform refuses to invent a rate or a SAC (AB#9920). The invoice is then correctly held: assertLinesClassified throws 409 FOLIO_LINE_SAC_UNCLASSIFIED and guards both the PDF and the IRN path.

So the defect is not "wrong tax". It is:

The front desk gets a 201 for a line the platform has ALREADY decided it cannot invoice, and finds out at checkout that the guest's bill is refused.

Posted at 23:40, refused at 07:15 with a queue at the desk. Every incumbent PMS fails FAST at post.

And the guard to prevent it was written and never wired: FRONT_DESK_POSTABLE_CODE_SET (front-desk-charge-codes.ts:115), whose own docblock says "Fast membership check (e.g. to validate a client-supplied code)", has zero production consumers — only its own contract spec references it. An unwired guard with a green test is worse than no guard.

SME ruling on the fix: REFUSE (400), do not default. There is no safe default — a rate over/under-charges, a residual SAC (999799) is worse than the bug because the line would then pass assertLinesClassified and file a code nobody identified, and zero silently under-collects tax the hotel owes under §73/74.

Zero-GST-no-SAC IS legitimate — but only as a NAMED treatment. Three states must stay distinguishable: taxable (SAC + rate) · exempt/nil-rated (still a supply, still carries a SAC, still reported) · non-supply (damage recovery — no SAC, not reported). Today an unmapped code accidentally produces the shape of the third while being none of them. The vocabulary already exists (taxTreatment: 'NO_SUPPLY'). ⚠ A latent bug blocks it: assertLinesClassified's docblock claims exempt/non-supply lines are exempt from the gate, but the code exempts only alcohol — so a legitimate NO_SUPPLY line would still 409.

What this is NOT​

  • Not data corruption; the charge posts correctly, it is simply untaxed and uncategorised.
  • Not yet confirmed as reachable from the dashboard — the UI may only offer canonical codes. It is reachable by any API client with charge-posting rights, which includes integrations.

The test half​

pms-f-inhouse TC-387 posts transactionCode: 'MISCELLANEOUS' and asserts 18% GST = ₹1,800. The spec is ALSO wrong — it uses a code the platform does not define, so it can never pass as written. Do not "fix" it by asserting taxAmount === 0: that would encode the defect. It should either post a canonical code (and keep the 18% assertion) or assert that an unknown code is REFUSED, once the product decides which. That decision belongs with hospitality-sme / hospitality-ba.

Cleanup​

Five probe charges were posted to a shared TEST folio and all five were voided, verified by a separate-request read-back (still-live probe charges: 0).


The enumeration the fix needs (2026-08-07) — "enumerate them, do not guess"​

The SME's item 1 is refuse an unrecognised code, with the validation set built as a union rather than guessed. Enumerated from the service (all non-spec .ts, literal transactionCode: '…' emissions):

  • canonical codes in TRANSACTION_CODE_TAX_MAP: 33
  • distinct codes actually EMITTED by writers: 22
  • EMITTED but NOT canonical: 16 — a refusal built on the tax map alone would break every one:
ACCOMMODATION  ADJ  ADVANCE  CASH  CHARGEBACK_LOSS  CITY_LEDGER  CITY_LEDGER_SETTLEMENT
CREDIT_NOTE DISCOUNT REFUND ROUNDING SPONSORED STAY_SHORTEN_CREDIT TDS
UPI_PAYMENT WRITE_OFF

The design constraint this exposes, which a code-list union alone does NOT capture​

Most of those 16 are not charge lines at all — they are money movements and credits: CASH, UPI_PAYMENT, ADVANCE, REFUND, CREDIT_NOTE, WRITE_OFF, DISCOUNT, ROUNDING, CITY_LEDGER, CITY_LEDGER_SETTLEMENT, SPONSORED, STAY_SHORTEN_CREDIT, CHARGEBACK_LOSS, TDS.

They carry no GST correctly, because a payment or a credit is not a supply. So:

The discriminator is transactionType (CHARGE vs PAYMENT/CREDIT), not the code list. A refusal must gate CHARGE lines only. Applied to every line it would refuse cash, refunds, discounts, city-ledger settlements and TDS — i.e. most of the ledger.

Genuinely unclassified CHARGE-type emissions worth a per-code decision before the guard ships: ACCOMMODATION (authorization.service.ts — Vishramgruh), ADJ (folios.service.ts).

Why this was not shipped in this session​

Building the refusal without this list would have broken 16 code paths, including cash posting. The same hazard class as the day-not-ended gate that would have wedged 34 properties: a correct rule, shipped without the pre-deploy enumeration, becomes an outage. The list above is the prerequisite; the guard is a small change once it is agreed.