Price Quotes
A generic quote engine for any product type — software licences, subscriptions, physical goods, services, domains. Describe a product, its variants, its prices and its tax treatment in config, then ask for a quote in a given currency, interval and quantity.
The output is also smart: it tells you when a different but equivalent purchase shape would cost less — annual instead of monthly, 5 units instead of 1 — without ever suggesting something that isn't actually the same thing.
Install
npm i price-quotes
Quick start
import { Quotes, formatMoney } from 'price-quotes';
const q = new Quotes({
catalog: {
products: [{ sku: 'pro', variants: [{ id: 'sub' }], intervals: [{ unit: 'month' }, { unit: 'year' }] }],
rules: [
{ sku: 'pro', interval: 'month', amount: { USD: 10 } },
{ sku: 'pro', interval: 'year', amount: { USD: 100 } }, // 2 months free
{ sku: 'pro', minQuantity: 5, amount: { USD: 8 } }, // volume tier
],
},
currencies: [{ code: 'USD', symbol: '
The model
Products and variants
A product is a thing you can buy, identified by a SKU. A variant is a mode of
buying that same product: subscription / academic for software, standard / expedited
for a service, new / upgrade for a licence.
The three axes
"Buying more" means three different things, and savings can come from any of them
independently:
Axis
Meaning
Example
interval
the billing unit
once, day, week, month, year
term
how many intervals bought at once
a 2-year commitment
quantity
how many units
5 seats, 3 licences
Pre-tax subtotal is unitPrice × quantity × term, modulo tier pricing on either axis.
Price rules
Prices are a flat list of rules with optional selectors. An omitted selector matches
anything, and the most specific rule wins.
rules: [
{ amount: { USD: 30 } }, // catch-all
{ group: 'seats', amount: { USD: 20 } }, // any product tagged 'seats'
{ sku: 'pro', amount: { USD: 10 } }, // just 'pro'
{ sku: 'pro', variant: 'academic', amount: { USD: 4 } }, // academic pricing
{ sku: 'pro', interval: 'year', minTerm: 3, amount: { USD: 75 } }, // 3-year commit
{ sku: 'pro', minQuantity: 10, amount: { USD: 6 } }, // 10+ seats
]
Precedence ladder, highest first:
- exact
sku beats group beats wildcard
- narrower quantity range
- narrower term range
- explicit
variant beats wildcard
- explicit
interval beats wildcard
- declaration order — later wins
The winning rule is always reported in quote.explain.matchedRule.
Money
Amounts are integer minor units ({ currency: 'USD', minor: 1299 } = $12.99), so JPY
(no minor unit) and KWD (three) work correctly. Each currency carries its own rounding
policy:
{ code: 'NGN', symbol: '₦', exponent: 2, roundingIncrement: 100 } // whole naira
{ code: 'JPY', symbol: '¥', exponent: 0 } // whole yen
Use toMajor(meta, minor) for a number and formatMoney(meta, money) for display.
Tax
taxes: [
{ id: 'gst', name: 'GST', rate: 0.05 },
{ id: 'pst', name: 'PST', rate: 0.10, compound: true }, // stacks on GST
{ id: 'vat', name: 'VAT', rate: 0.20, inclusive: true }, // already in the price
{ id: 'x', name: 'X', rate: 0.02, basis: 'base' }, // pre-discount
{ id: 'y', name: 'Y', rate: 0.05, appliesTo: { groups: ['standard'] } },
]
Or a resolver, for anything that depends on the request:
taxes: (ctx) => ctx.context?.country === 'NG' ? [{ id: 'vat', name: 'VAT', rate: 0.075 }] : []
quote.taxes itemizes every line; quote.tax is the total tax charged (including
inclusive tax, which is reported but does not raise quote.total).
Discounts
discounts: {
WELCOME: { rate: 0.1, skus: ['pro', 'team'], startAt: '2024-01-01T00:00:00Z', endAt: '2024-12-31T23:59:59Z' },
NEWUSER: { rate: 0.2, variants: ['new'] },
CORP: { rate: 0.3, isEligible: (ctx) => String(ctx.context?.email ?? '').endsWith('@acme.com') },
}
await q.quote({ sku: 'pro', currency: 'USD', discountCodes: ['WELCOME', 'NEWUSER'] });
// discountPolicy: 'max' (default) applies the highest; 'stack' sums them
QuoteRequest.context is opaque caller data forwarded to every eligibility callback and
to the tax resolver — that's where email domain, country code, or referrer live. Discounts
are always clamped to the subtotal, so a total can never go negative. isEligible must be
side-effect-free: with explore on it may be consulted for several candidates per call
(results are memoized within a call).
The smart layer
Pass { explore: true } to price counterfactuals and get insights.
import { formatInsight } from 'price-quotes';
const quote = await q.quote({ sku: 'pro', currency: 'USD', interval: 'month' }, { explore: true });
for (const i of quote.insights) {
console.log(i.strength, formatInsight(i));
}
// strong Switch to yearly billing and save $20.00 (17%)
// (assumes you keep this for 365 days, you buy 335 more days of cover than you asked for)
Kind
Meaning
interval-upgrade
a different billing interval costs less over the horizon
term-upgrade
committing to more intervals costs less
volume-tier
buying more units costs less
tier-threshold
buying more costs more, but drops the unit price
variant-swap
a declared-substitutable variant costs less
discount-available
an unrequested code applies (opt-in only)
strength is dominant (costs less outright), strong (≥10% saving) or info.
It only compares like with like
This is the important part. Naively reporting "anything cheaper" produces confident
nonsense: an academic licence is cheaper than a retail one, but suggesting it to a
customer who can't use it is a category error, not a saving.
So:
interval, term and quantity are safely comparable. More of the same thing is
still the same thing.
- Variants are never compared unless config says so. A variant only enters the
comparison set if it declares a
substitutionGroup, meaning it delivers genuinely
equivalent value.
- Ineligible variants are never dangled at customers who can't use them.
- Shorter terms and smaller quantities are never suggested — that's less, not cheaper.
- One-time and recurring options are never compared. A perpetual licence has no natural
horizon, so whichever one you picked would decide the answer by itself.
It's honest about assumptions
Comparing annual to monthly×12 assumes the customer stays a year. That's an assumption,
not a fact, so it's in the output:
insight.savings.horizonDays; // 365
insight.assumes; // ['you keep this for 365 days', 'you buy 335 more days of cover...']
insight.providesExtra; // { days: 335 }
insight.dominant; // false — the annual plan costs more up front
A tier-threshold reports savings.amount of zero, because paying more to get more
is not a saving. What it costs is in threshold.extraCost.
Cost control
{ explore: {
intervals: true, terms: true, quantities: true, variants: true,
discounts: false, // opt-in: reveals codes the customer wasn't offered
horizonDays: undefined, // defaults to the longest candidate's duration
maxCandidates: 24,
minSavingsPercent: 0.01,
} }
Candidates come only from breakpoints the catalog actually declares — never a synthesized
sweep.
Presets
A preset is just a helper that builds a QuotesConfig for a common shape. The engine has
no built-in knowledge of any of them.
Software
import { softwareQuotes } from 'price-quotes/presets/software';
const q = softwareQuotes({
plans: [{
id: 'pro',
monthly: 10, annual: 100, perpetual: 400,
seatTiers: [{ minSeats: 5, monthly: 8 }, { minSeats: 20, monthly: 6 }],
termTiers: [{ minYears: 3, annual: 75 }],
}],
taxes: [{ id: 'vat', name: 'VAT', rate: 0.2 }],
});
const quote = await q.quote({ sku: 'pro', currency: 'USD', interval: 'month', quantity: 3 }, { explore: true });
Domains
An example preset that prices domain registrations across ~1,800 TLDs from a public
registrar dataset, loaded lazily. Importing does no I/O; nothing is fetched until you
call load().
import { domainsPreset } from 'price-quotes/presets/domains';
const q = await domainsPreset().quotes();
const quote = await q.quote({ sku: 'com', currency: 'NGN' });
const preset = domainsPreset({
fetch: myFetch, // injectable HTTP; defaults to global fetch
cache: myCache, // or `false` to disable
data: preloadedData, // skip the network entirely
vatRate: 0.075,
currencies: ['USD', 'NGN'],
});
await preset.load();
const q = new Quotes(preset.config);
Errors
Typed, with stable code values so you can branch without string-matching messages.
Error
Code
UnknownSkuError
ERR_UNKNOWN_SKU
UnknownVariantError
ERR_UNKNOWN_VARIANT
VariantNotEligibleError
ERR_VARIANT_NOT_ELIGIBLE
UnsupportedCurrencyError
ERR_UNSUPPORTED_CURRENCY
NoPriceError
ERR_NO_PRICE
InvalidRequestError
ERR_INVALID_REQUEST
BelowMinimumChargeError
ERR_BELOW_MINIMUM_CHARGE
All extend QuoteError. Zero totals are legal (free tiers, 100%-off promos) unless you set
a floor:
minChargeableTotal: { USD: 0.5, NGN: 100 } // per currency, in major units
Design
See design-docs/design.md for
the architecture, the reasoning behind the comparability rules, and the open questions.
Core invariant: src/core/ imports nothing from src/presets/ and does no I/O. It
takes an in-memory catalog and returns quotes. Every product-specific assumption lives
behind a preset.
Testing
npm test
Node's built-in node:test runner; builds first.
, exponent: 2 }],
taxes: [{ id: 'vat', name: 'VAT', rate: 0.2 }],
});
const quote = await q.quote({ sku: 'pro', currency: 'USD', interval: 'month' }, { explore: true });
formatMoney(quote.currency, quote.total); // '$12.00'
quote.rate.perUnitPerYear; // 144
quote.insights[0].kind; // 'interval-upgrade'
quote.insights[0].savings.amount; // { currency: 'USD', minor: 2000 } -> $20/yr
The model
Products and variants
A product is a thing you can buy, identified by a SKU. A variant is a mode of buying that same product: __INLINE_CODE_0__ / __INLINE_CODE_1__ for software, __INLINE_CODE_2__ / __INLINE_CODE_3__ for a service, __INLINE_CODE_4__ / __INLINE_CODE_5__ for a licence.
The three axes
"Buying more" means three different things, and savings can come from any of them independently:
| Axis | Meaning | Example |
|---|---|---|
| __INLINE_CODE_6__ | the billing unit | __INLINE_CODE_7__, __INLINE_CODE_8__, __INLINE_CODE_9__, __INLINE_CODE_10__, __INLINE_CODE_11__ |
| __INLINE_CODE_12__ | how many intervals bought at once | a 2-year commitment |
| __INLINE_CODE_13__ | how many units | 5 seats, 3 licences |
Pre-tax subtotal is __INLINE_CODE_14__, modulo tier pricing on either axis.
Price rules
Prices are a flat list of rules with optional selectors. An omitted selector matches anything, and the most specific rule wins.
__CODE_BLOCK_2__Precedence ladder, highest first:
- exact __INLINE_CODE_15__ beats __INLINE_CODE_16__ beats wildcard
- narrower quantity range
- narrower term range
- explicit __INLINE_CODE_17__ beats wildcard
- explicit __INLINE_CODE_18__ beats wildcard
- declaration order — later wins
The winning rule is always reported in __INLINE_CODE_19__.
Money
Amounts are integer minor units (__INLINE_CODE_20__ = $12.99), so JPY (no minor unit) and KWD (three) work correctly. Each currency carries its own rounding policy:
__CODE_BLOCK_3__Use __INLINE_CODE_21__ for a number and __INLINE_CODE_22__ for display.
Tax
__CODE_BLOCK_4__Or a resolver, for anything that depends on the request:
__CODE_BLOCK_5____INLINE_CODE_23__ itemizes every line; __INLINE_CODE_24__ is the total tax charged (including inclusive tax, which is reported but does not raise __INLINE_CODE_25__).
Discounts
__CODE_BLOCK_6____INLINE_CODE_26__ is opaque caller data forwarded to every eligibility callback and to the tax resolver — that's where email domain, country code, or referrer live. Discounts are always clamped to the subtotal, so a total can never go negative. __INLINE_CODE_27__ must be side-effect-free: with __INLINE_CODE_28__ on it may be consulted for several candidates per call (results are memoized within a call).
The smart layer
Pass __INLINE_CODE_29__ to price counterfactuals and get __INLINE_CODE_30__.
__CODE_BLOCK_7__| Kind | Meaning |
|---|---|
| __INLINE_CODE_31__ | a different billing interval costs less over the horizon |
| __INLINE_CODE_32__ | committing to more intervals costs less |
| __INLINE_CODE_33__ | buying more units costs less |
| __INLINE_CODE_34__ | buying more costs more, but drops the unit price |
| __INLINE_CODE_35__ | a declared-substitutable variant costs less |
| __INLINE_CODE_36__ | an unrequested code applies (opt-in only) |
__INLINE_CODE_37__ is __INLINE_CODE_38__ (costs less outright), __INLINE_CODE_39__ (≥10% saving) or __INLINE_CODE_40__.
It only compares like with like
This is the important part. Naively reporting "anything cheaper" produces confident nonsense: an __INLINE_CODE_41__ licence is cheaper than a __INLINE_CODE_42__ one, but suggesting it to a customer who can't use it is a category error, not a saving.
So:
- __INLINE_CODE_43__, __INLINE_CODE_44__ and __INLINE_CODE_45__ are safely comparable. More of the same thing is still the same thing.
- Variants are never compared unless config says so. A variant only enters the comparison set if it declares a __INLINE_CODE_46__, meaning it delivers genuinely equivalent value.
- Ineligible variants are never dangled at customers who can't use them.
- Shorter terms and smaller quantities are never suggested — that's less, not cheaper.
- One-time and recurring options are never compared. A perpetual licence has no natural horizon, so whichever one you picked would decide the answer by itself.
It's honest about assumptions
Comparing annual to monthly×12 assumes the customer stays a year. That's an assumption, not a fact, so it's in the output:
__CODE_BLOCK_8__A __INLINE_CODE_47__ reports __INLINE_CODE_48__ of zero, because paying more to get more is not a saving. What it costs is in __INLINE_CODE_49__.
Cost control
__CODE_BLOCK_9__Candidates come only from breakpoints the catalog actually declares — never a synthesized sweep.
Presets
A preset is just a helper that builds a __INLINE_CODE_50__ for a common shape. The engine has no built-in knowledge of any of them.
Software
__CODE_BLOCK_10__Domains
An example preset that prices domain registrations across ~1,800 TLDs from a public registrar dataset, loaded lazily. Importing does no I/O; nothing is fetched until you call __INLINE_CODE_51__.
__CODE_BLOCK_11__ __CODE_BLOCK_12__Errors
Typed, with stable __INLINE_CODE_52__ values so you can branch without string-matching messages.
| Error | Code |
|---|---|
| __INLINE_CODE_53__ | __INLINE_CODE_54__ |
| __INLINE_CODE_55__ | __INLINE_CODE_56__ |
| __INLINE_CODE_57__ | __INLINE_CODE_58__ |
| __INLINE_CODE_59__ | __INLINE_CODE_60__ |
| __INLINE_CODE_61__ | __INLINE_CODE_62__ |
| __INLINE_CODE_63__ | __INLINE_CODE_64__ |
| __INLINE_CODE_65__ | __INLINE_CODE_66__ |
All extend __INLINE_CODE_67__. Zero totals are legal (free tiers, 100%-off promos) unless you set a floor:
__CODE_BLOCK_13__Design
See design-docs/design.md for the architecture, the reasoning behind the comparability rules, and the open questions.
Core invariant: __INLINE_CODE_68__ imports nothing from __INLINE_CODE_69__ and does no I/O. It takes an in-memory catalog and returns quotes. Every product-specific assumption lives behind a preset.
Testing
__CODE_BLOCK_14__Node's built-in __INLINE_CODE_70__ runner; builds first.