npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-selections

API

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 codes

assertUniformVat 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_MIN or PARENT_VARIETIES_BELOW_MIN already 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 as MIXED_VAT do 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. Sets satisfiable: 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_VAT says 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_UNIFORM says 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