@oliviermtlbali/product-selections
v0.3.0
Published
Shared validation, pricing and HTML rendering for DB-driven product selection groups
Readme
@oliviermtlbali/product-selections
Shared validation, pricing and HTML rendering for DB-driven product selection groups.
A parent product exposes one or more selection groups ("Hausgemachte Limonade", "Hausgemachter Eistee"); the customer picks options with quantities, and the parent's cart price is derived from those picks. This package is the single source of truth for all four consumers: the dashboard, both customer sites, and the PDF/quote servers.
Pure functions only — no I/O, no network, no Supabase, no React. Dual CJS/ESM build with types, so it imports from a JS Next.js app, a TS Vite app, and a Babel/CommonJS Express app alike.
Design: punsch-taxi-dashboard/product-selection-groups.md.
Install
pnpm add @oliviermtlbali/product-selectionsAPI
validateSelections(config: SelectionConfig, selections: Selection[]): ValidationResult
deriveComposite(
parent: { ust: number },
selections: Selection[],
groups: SelectionGroupConfig[],
): CompositeDerivation
buildSelectionHtml(groups: SelectionGroupConfig[], selections: Selection[]): string
// Is this configuration satisfiable at all, independent of any customer selection?
auditConfig(config: SelectionConfig): AuditResult
// Same question for a group with no parent in view (the group manager)
auditGroup(group: SelectionGroupConfig): AuditResult
// VAT checks, for admin UI warnings outside the full validator
assertUniformVat(parentUst: number, options: SelectionOption[]): SelectionError[]
mixedVatErrorsForGroup(parentUst: number, group: SelectionGroupConfig): SelectionError[]
mixedVatErrorsForSelections(parentUst: number, selections: Selection[]): SelectionError[]
// Limit interpretation, exported so a form can show the same thing the validator enforces
isUnbounded(limit: number | null | undefined): boolean
resolveMaxLimit(limit: number | null | undefined): number | null
escapeHtml(value: string | null | undefined): string
SELECTION_ERROR_CODES // const object, 11 codes
AUDIT_CODES // const object, 12 codesassertUniformVat takes a bare option list and emits no groupId;
mixedVatErrorsForGroup is the same check scoped to a configured group, so its errors carry one.
validateSelections
Runs every rule and returns all violations — it never short-circuits, so a UI can show the
complete blocking reason in one pass. Errors are ordered: per-selection structural issues (in
selection order), then per-group variety limits (in config order), then the parent's limits, then
NO_SELECTIONS, then VAT mismatches. Messages are German, customer-facing.
Limit semantics. null means "unconstrained" everywhere. On the two maximum limits —
parent.maxVarieties and a group's maxVarieties — any value <= 0 means the same thing,
because the dashboard's shared number input renders a null column as 0 and persists it on any
interaction, and products.min_quantity already uses 0 for "unset" across the catalog. A
maximum of zero or less is never a legitimate configuration: a parent that should accept nothing
has no group attached. maxVarieties: 1 is still the tightest real bound and is enforced.
Minimums are untouched, and need no equivalent rule: minVarieties: 0 and minQuantity: 0 are
no-ops because count >= 0 always holds, and minQtyPerOption: 0 is satisfied by definition —
a quantity must already be a positive integer to reach that check, so the per-option floor only
bites from 2 upward.
The single decision point is isUnbounded / resolveMaxLimit in src/limits.ts, both exported
so a form can show the same thing the validator enforces.
| Code | Raised when |
|---|---|
| UNKNOWN_GROUP | A selection references a groupId that is not in the config. |
| UNKNOWN_OPTION | A selection references a productId that is not in that group's options. |
| INVALID_QUANTITY | quantity is not a whole number of at least 1. |
| DUPLICATE_SELECTION | The same (groupId, productId) appears more than once. |
| OPTION_QTY_BELOW_MIN | quantity is below the group's minQtyPerOption. |
| GROUP_VARIETIES_BELOW_MIN | Fewer distinct options in a group than its minVarieties. A group with minVarieties >= 1 is mandatory, so leaving it empty violates this. |
| GROUP_VARIETIES_ABOVE_MAX | More distinct options in a group than its maxVarieties. |
| PARENT_VARIETIES_BELOW_MIN / _ABOVE_MAX | Distinct options across all groups outside the parent's range. |
| TOTAL_QUANTITY_BELOW_MIN | Σ quantity across all selections is below parent.minQuantity. |
| NO_SELECTIONS | Nothing usable is selected while at least one group offers at least one option. See below. |
| MIXED_VAT | An option's ust differs from the parent's. Tax correctness, not cosmetics — calculateVatBreakdown reads one rate per cart item. |
Distinctness is by productId within a group and by (groupId, productId) across groups, so a
duplicated line does not inflate a variety count. Non-finite quantities contribute 0 to the
total. MIXED_VAT is checked against both the configured options and the frozen selection
snapshots, de-duplicated per group/option pair; no other code is de-duplicated.
NO_SELECTIONS
An invariant, not a limit. A composite parent contributes no price of its own, so an empty
selection set is a €0 cart line. The limits only prevent that by accident — an admin who leaves
min_quantity and every min_varieties blank would otherwise ship a free product.
- Structurally broken lines (unknown group or option, non-integer quantity) do not count, so a cart holding only garbage is still empty.
- It never fires when no group has any option — a non-composite, or a half-authored product. Telling the customer to choose when there is nothing to choose would be wrong.
- It is a fallback: it stays silent when
TOTAL_QUANTITY_BELOW_MIN,GROUP_VARIETIES_BELOW_MINorPARENT_VARIETIES_BELOW_MINalready fired, since those name the concrete threshold ("Mindestens 50 Portionen erforderlich") and are strictly more actionable than "Bitte treffen Sie mindestens eine Auswahl". Unrelated codes such asMIXED_VATdo not suppress it.
deriveComposite
price is the brutto Σ of price × quantity over the frozen snapshots, priceNetto is that
total converted at the parent's rate via @oliviermtlbali/ust-calc. Both use decimal.js and
round once, half-up, to 2 DP. Empty selections give 0 / 0 / "".
buildSelectionHtml
The HTML table stored as formattedProduct and consumed only by the quote PDF through
react-pdf-html. Groups render in sortOrder; groups with no selections are omitted; rows follow
the configured option sortOrder. Options or groups that have since been removed from the config
still render from their snapshot, so a sent quote never loses content. Markup is limited to
table / tr / td / strong and all interpolated text is escaped.
auditConfig
validateSelections asks "are these picks valid for this config". auditConfig asks "can this
config be satisfied by any picks". Both production failures so far were of the second kind — a
configuration no selection could satisfy, or one that silently produced a €0 line — and no amount
of selection-level validation surfaces those, because nothing is wrong with the picks.
It returns AuditFinding[], not SelectionError[]. Different audience, different language:
SelectionError.message is German copy for a customer about their own choices; AuditFinding
is English copy for an admin about the configuration, and names database columns. Findings carry
a severity:
error— no selection can satisfy this, or it yields a €0 line. Setssatisfiable: false.warning— it works, but a setting is dead or a row is silently ignored.
| Code | Severity | Raised when |
|---|---|---|
| NO_GROUPS_ATTACHED | error | The parent has no groups. Derives €0 and passes validateSelections. |
| NO_SELECTABLE_OPTIONS | error | Groups exist but none offers an option. Same €0 outcome. |
| GROUP_HAS_NO_OPTIONS | error / warning | A group has no options. Error when mandatory (minVarieties >= 1), warning otherwise. |
| GROUP_MIN_VARIETIES_EXCEEDS_OPTIONS | error | A group demands more varieties than it offers. |
| GROUP_MAX_BELOW_MIN | error | A group's maximum is below its minimum. |
| DUPLICATE_OPTION_IN_GROUP | warning | A productId is listed twice; only the first is used. |
| PARENT_MAX_BELOW_MIN | error | The parent's maximum is below its own minimum. |
| PARENT_MIN_VARIETIES_UNREACHABLE | error | The parent demands more varieties than the groups can supply, whether from too few options or from group maximums capping it. |
| PARENT_MAX_BELOW_GROUP_MINIMUMS | error | The parent's maximum is below the sum of its groups' minimums. |
| MIN_QUANTITY_NEVER_BINDS | warning | The group minimums already force at least minQuantity, so the setting does nothing. |
| MIXED_VAT | error | An option's rate differs from the parent's. Detection reuses mixedVatErrorsForGroup; only the wording differs. |
| GROUP_VAT_NOT_UNIFORM | error | auditGroup only — the group's own options disagree on a rate. |
A maximum of <= 0 is unset throughout, so "max below min" never fires on a blank maximum.
auditGroup — the parentless case
The group manager edits a group before, or independently of, attaching it to a product. There is
no parent rate there, so auditConfig cannot be used: "is this group internally consistent" and
"does this group fit this parent" are different questions.
auditGroup runs the four structural checks above plus GROUP_VAT_NOT_UNIFORM, and never emits
the parent-relative MIXED_VAT. The two VAT codes are deliberately distinct:
MIXED_VATsays this option disagrees with the parent. It names the culprit option, and the fix is either the option's rate or the attachment.GROUP_VAT_NOT_UNIFORMsays these options disagree with each other. It names no culprit, because without a parent there is no correct rate to measure against — but it is the stronger statement, since such a group cannot be attached to any parent without mis-taxing.
Collapsing them into one code would leave a consumer unable to tell which situation it is in.
Note that minVarieties, maxVarieties and minQtyPerOption live on the parent link row rather
than the group, so a genuinely unattached group carries null for all three and the variety
checks are no-ops. They still run, for callers that have link values in hand.
Development
pnpm install
pnpm build # tsup → dist (cjs + esm + d.ts)
pnpm test # vitest
pnpm typecheck # tsc --noEmit