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):
| transactionCode | HTTP | taxAmount | sacCode |
|---|---|---|---|
PARKING (canonical) — POSITIVE CONTROL | 201 | 54 (18%) | 996743 |
MISCELLANEOUS | 201 | 0 | — |
ZZ_NOT_A_REAL_CODE | 201 | 0 | — |
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
-
A charge on an unrecognised code is billed with no GST and no SAC/HSN. The canonical set is
TRANSACTION_CODE_TAX_MAPinhmp-frontdesk-service/src/folios/folio-tax-resolver.service.ts(ROOM … PARKING).MISCELLANEOUSis not in it — it exists only as aPettyCashCategory. An Indian tax invoice line requires a SAC; a line with neither tax nor SAC is not compliant. -
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)thenroleLimits[roleKey](folios.service.ts:3356-3366). The comparison base is tax-INCLUSIVE, so the ₹10,000 observation has an arithmetic explanation, not a bypass:code net tax compared vs ₹10,000 staff limit PARKING10,000 1,800 11,800 > limit → refused unknown 10,000 0 10,000 NOT > 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.