docs/reference/tax-packs.md
Rama main · réplica de lectura del repositorio original.
# Tax pack reference
Schema, trust model, and install mechanics for country tax packs. How the engine applies a pack is
in [taxation](../architecture/taxation.md); why packs are data rather than plugins is in
[0004: data-only tax packs](../decisions/0004-data-only-tax-packs.md). The authoring procedure is
in [adding a tax pack](../guides/adding-a-tax-pack.md).
The types below are declared in [`main/tax-packs/types.ts`](../../main/tax-packs/types.ts).
`main/tax-packs/generic.json` is the reference instance and the only bundled pack.
## `CountryPack`
A pack is a data object conforming to `schemaVersion: 1`.
| Field | Type | Notes |
| --- | --- | --- |
| `schemaVersion` | `1` | Only `1` is accepted at activation. |
| `id` | `string` | Catalog ids match `^[a-z0-9][a-z0-9-]*$`. |
| `publisher` | `string` | `local` is reserved; see below. |
| `sourceType` | `'official' \| 'community'` | Optional; defaults to `official`. |
| `version` | `string` | Must match `^\d+\.\d+\.\d+$`. |
| `country` | `string` | Two-letter code, or `*` for a multi-country pack. |
| `jurisdiction` | `string` | Free text, `*` for country-wide. |
| `currency` | `string` | ISO 4217, or `XXX` for a placeholder. |
| `effectiveFrom` | `string` | A parseable date. |
| `effectiveTo` | `string` | Optional; must be at or after `effectiveFrom`. |
| `publishedAt` | `string` | |
| `minFloVersion` | `string` | Minimum FloCafe version that may activate it. |
| `taxPoint` | `'order_created' \| 'finalized_at'` | Declared, but nothing reads it. |
| `inclusivePricingDefault` | `boolean` | |
| `registrationNumberLabel` | `string` | Merchant-facing label. |
| `registrationNumberFormat` | `TaxIdFormat` | Optional pattern plus description. |
| `categories` | `TaxCategory[]` | |
| `defaultCategories` | `Record<TaxLineKind, string>` | A category id for every line kind. |
| `unclassifiedCategoryId` | `string` | Must name a category in `categories`. |
| `rules` | `TaxRule[]` | |
| `taxRounding` | `TaxRounding` | |
| `payableRounding` | `PayableRounding` | |
`TaxCategory` is `{ id, label, ruleIds, defaultBehavior? }`, where `defaultBehavior` is one of
`country_default`, `inclusive`, `exclusive`, or `exempt`.
`TaxLineKind` is `product`, `packaging`, `delivery`, `service_charge`, or `addon`. Every one of
those needs an entry in `defaultCategories`.
`TaxRounding` is `{ scope, method, decimalPlaces, remainderAllocation }`:
- `scope`: `unit`, `line`, or `document`.
- `method`: `half_up`, `half_even`, `floor`, or `ceiling`.
- `decimalPlaces`: an integer from 0 to 6.
- `remainderAllocation`: `largest_remainder`. It is the only accepted value.
`PayableRounding` is `{ increment, method }`, where `increment` is a positive decimal string at most
1000.
## `TaxRule`
`TaxRule` is `{ id, label, type, categoryIds, rate?, amount?, appliesPer?, baseRuleIds?,
conditions? }`.
- `type` is `percent` or `fixed`. A `percent` rule carries `rate`; a `fixed` rule carries `amount`
and `appliesPer`.
- `appliesPer` is `unit` or `line`. A `fixed` rule must declare it.
- `baseRuleIds` makes a `percent` rule compound, applying to the base rules' result instead of the
line gross. A `fixed` rule may not declare it.
- `categoryIds` names the categories the rule applies to.
`TaxRuleConditions` is `{ businessTypes?, customerStateRelation?, customerExempt? }`.
`customerStateRelation` is `interstate` or `intra_or_unspecified`.
Amounts are strings, not numbers. A rate or amount that is not finite, or is negative, fails
activation.
## Artifact form
A catalog artifact may be either a bare `CountryPack` or a `CountryTaxPackPluginArtifact`:
```ts
interface CountryTaxPackPluginArtifact {
schemaVersion: 1;
artifactType: 'country-tax-pack-plugin';
id: string;
displayName: string;
publisher: string;
version: string;
country: string;
jurisdiction: string;
publishedAt: string;
minFloVersion: string;
taxPack: CountryPack;
printTemplates?: PluginPrintTemplate[];
}
```
Despite the name, the artifact carries no plugin code. `printTemplates` is a list of declarative
`escpos-line-template-v1` templates rendered by FloCafe code; see
[print templates compliance](print-templates-compliance.md). The templates are data, validated on
install by `validateTemplateChargeRows()` and `validateTemplateLabelsMap()` in
[`main/tax-packs/catalog.ts`](../../main/tax-packs/catalog.ts), and persisted to
`installed_print_templates` keyed by the installed pack version.
The plugin artifact is what is signed and digested, so a pack and its print templates are
distributed and verified as one unit.
## Install path
`installCatalogEntry()` in [`main/routes/tax-packs.ts`](../../main/routes/tax-packs.ts) runs
`downloadAndVerifyTaxPack()` in [`main/tax-packs/catalog.ts`](../../main/tax-packs/catalog.ts)
first. The steps run in this order; each is a hard gate.
1. **Catalog fetch.** `fetchRemoteTaxPackCatalog()` pages the GitHub Releases API for
`FreeOpenSourcePOS/FloCafe-Plugins`, 100 per page, at most `MAX_RELEASE_PAGES` (10) pages. A
request that exceeds `MAX_CATALOG_BYTES` (1,000,000) is rejected.
2. **Release filter.** Draft releases are skipped, and a release is considered only when its tag
matches `^tax-pack-[a-z0-9][a-z0-9-]*-v\d+\.\d+\.\d+$`. A `catalog.json` asset is then located
on that release.
3. **Download URL guard.** `trustedReleaseDownloadUrl()` accepts a URL only when the protocol is
`https`, the host is exactly `github.com`, there are no embedded credentials, and the path
starts with `/FreeOpenSourcePOS/FloCafe-Plugins/releases/download/`.
4. **Size caps.** The pack artifact is capped at `MAX_PACK_BYTES` (2,000,000) and the detached
signature at `MAX_SIGNATURE_BYTES` (4,096).
5. **Digest.** The SHA-256 of the pack JSON must equal the catalog entry's `digest`.
6. **Signature.** The detached signature must verify as Ed25519 against
`TRUSTED_TAX_PACK_SIGNING_PUBLIC_KEY`.
7. **Identity cross-check.** If a pack with that id is already installed, its `publisher`,
`country`, and `jurisdiction` must match the downloaded artifact, or install fails with HTTP
409. Installing the same id and version twice also fails with HTTP 409.
8. **Local publisher rejection.** A downloaded artifact claiming `publisher: 'local'` is refused.
9. **Staging.** The pack, its rules, and its print templates are written to the version, its rules,
and `installed_print_templates` tables, with a `status` of `installed` rather than `active`.
10. **Activation.** Activation runs the 26 checks in
[taxation](../architecture/taxation.md#activation-checks) and then switches the pack's
`active_version_id`, flipping the version and its template rows to `active`.
A catalog request uses a 15 second timeout and honours an abort signal.
## Trust
`publisher: 'local'` is reserved for packs the app synthesizes, which today means only manual tax
configuration. A local pack must carry no signature. The catalog path refuses a downloaded artifact
that claims to be local, so local status cannot be used to skip signing.
`sourceType: 'community'` marks a pack as built from public information rather than an official
source. It requires merchant acknowledgement of a disclaimer, and a local or manual pack may never
declare it.
The Singapore community profile in
[`main/tax-packs/community-singapore.json`](../../main/tax-packs/community-singapore.json) applies
9% GST from 2024-01-01 to standard-rated restaurant products, packaging, delivery, service charges,
add-ons, and unclassified lines. Its default pricing behavior is GST-inclusive. Activation does not
verify GST registration with IRAS; only GST-registered businesses charge GST. The registration field
accepts UENs and IRAS-issued GST registration numbers, which may be separate from a UEN. Its format
check validates syntax only, not registration status.
Hotels and food-and-beverage establishments that impose a genuine service charge may display
GST-exclusive prices for goods and services subject to that charge, with a prominent notice. The
service charge remains part of the amount on which GST is calculated. The community profile does
not model the price-display concession, rate-change transition rules, or exempt and zero-rated
supplies. It rounds document GST to cents using half-up rounding. See IRAS guidance on the
[prevailing 9% rate](https://www.iras.gov.sg/taxes/goods-services-tax-%28gst%29/charging-gst-%28output-tax%29/when-to-charge-goods-and-services-tax-%28gst%29/prevailing-rate-of-9),
[hotel and F&B treatment](https://www.iras.gov.sg/taxes/goods-services-tax-%28gst%29/specific-business-sectors/hotel-and-food-beverage),
and [GST registration numbers on invoices](https://www.iras.gov.sg/taxes/goods-services-tax-%28gst%29/basics-of-gst/invoicing-price-display-and-record-keeping/invoicing-customers).
A JSON source file in FloCafe does not make a pack bundled or available in the public catalog. The
catalog contains only signed releases that have been published in FloCafe-Plugins.
There is one trusted signing public key, in
[`main/tax-packs/trusted-signing-key.ts`](../../main/tax-packs/trusted-signing-key.ts).
`LEGACY_TRUSTED_PACK_DIGESTS` in `main/routes/tax-packs.ts` pins four SHA-256 digests of
pre-signing artifacts, for `official-india`, `official-thailand`, `official-in`, and
`official-th`. An unsigned artifact matching one of them is accepted. This is a compatibility
exception pinned by `tests/legacy-tax-pack-digest.test.ts`, not a way to publish a new unsigned
pack.
## Manual tax configuration
`buildManualPack()` in `main/routes/tax-packs.ts` turns settings-entered tax configuration into a
`CountryPack` with `publisher: 'local'`, id `manual-<country>`, and jurisdiction `*`. It installs
through the same path and is calculated by the same `TaxEngine.calculate()`, so there is no second
calculation path. `POST /manual-config` is owner-only.
## Verification
```sh
npm run test:tax-pack-catalog
npm run test:tax-pack-management
npm run test:manual-tax-config
npm run test:legacy-tax-pack-digest
npm run test:community-tax-packs
```