Catalog & pricing
Catalog & pricing
Catalog
A Stripe-style split of Product (what you sell) and Price (how much / how
often — a version):
interface Catalog
{
// resolve, list, and version products and prices
}
InMemoryCatalog is the default. Prices are versioned by effective date: a new
version supersedes the prior, new subscriptions use the most recent version, and
existing subscriptions are invoiced against the version in effect when the
subscription was created — price-pinning / grandfathering by default.
Pricing models
PricingModel selects how a quantity turns into an amount. Flat and per-unit are the
scalar models; the four tiered models price against an ordered list of
PriceTiers carried on the Price (and, for packages, a packageSize):
| Model | Rule |
|---|---|
Flat |
one fixed amount regardless of quantity (billable quantity is 1) |
PerUnit |
unitAmount × quantity |
Graduated |
the quantity is sliced across tiers; each slice is priced at its own tier's rate and the slices are summed (plus any per-tier flat entry fee reached) |
Volume |
all units are priced at the single tier the total quantity lands in (a retroactive volume discount) |
Package |
ceil(quantity ÷ packageSize) whole blocks, each charged the block's flat price (buy in packs of N) |
Stairstep |
a single flat amount for the whole bracket the quantity lands in |
A PriceTier is { ?int upTo, Money unitAmount, ?Money flatAmount }: upTo is the
tier's inclusive upper bound in units (null marks the final, unbounded tier),
unitAmount the per-unit rate within the tier, and flatAmount a tier fee whose
meaning is set by the model (a graduated/volume entry fee, the package block price,
or the stairstep bracket price).
// Graduated: 0–10 @ €1.00, 11–100 @ €0.80, 101+ @ €0.50
$price = new Price('metered-v1', 'metered', PricingModel::Graduated,
Money::ofMinor(0, 'EUR'), new DateTimeImmutable('2025-01-01'),
tiers: [
new PriceTier(10, Money::ofMinor(100, 'EUR')),
new PriceTier(100, Money::ofMinor(80, 'EUR')),
new PriceTier(null, Money::ofMinor(50, 'EUR')),
],
);
$catalog->priceQuantity('metered', 150, $now); // €107.00 → 10×100 + 90×80 + 50×50
Every tiered model is computed by Pricing\TierCalculator in integer minor units
— only unitAmount × wholeUnits and flatAmount terms, never a division — so no
minor unit is ever rounded away (remainder-safe by construction). It is
deny-by-default: an empty, mis-ordered, negatively-priced, or gap-having tier
set, a package with no positive size/block price, or a quantity that no tier covers
raises MalformedTierSet rather than silently returning zero. priceQuantity() is
the single catalog entry point the quote path uses, so a tiered product prices
through the same call as a flat one; Price::amountFor() does the same for a Price
in hand. Tiered pricing composes with metering: a meter's aggregated billable
quantity (see Metering → billable-metric aggregations)
feeds straight into amountFor().
Product shapes (ADR-0015)
A Product declares a ProductShape selecting its billing/fulfilment semantics:
Metered— a usage-metered plan (entitlement + real-time metering).Recurring— a rolling subscription: cycle-anchored, prorated, renewing indefinitely. This is the default, so pre-shape catalogs keep their exact meaning.FixedTerm— a registrar-style product bought for a committedTerm(1/2/5 yr) with distinct register/renewal/transfer/redemption pricing and a post-expiry lifecycle. See Subscriptions → fixed-term products.OneTime— a single non-recurring charge.
shape is the last constructor parameter and defaults to Recurring, so it is
backward-compatible.
Term × price-kind pricing (ADR-0015)
A FixedTerm product's catalog is a set of (term × kind) price points. A Term
is a {count, TermUnit} (Day | Month | Year) with calendar arithmetic (addTo,
toIso8601 → e.g. P2Y, equals). A PriceKind is Standard | Register | Renewal | Transfer | Redemption — a .com at P2Y/Register is a different
number than at P1Y/Renewal, and redemption carries a recovery premium.
$price = $catalog->termPriceFor('domain-com', new Term(2, TermUnit::Year), PriceKind::Register, $now);
termPriceFor grandfathers by effective date exactly like priceFor: an instance
registered before a price rise keeps the version effective at its registration.
priceFor resolves only the non-term (Standard) prices, so the two never collide
on a mixed catalog. Recurring/metered prices leave term null and kind at
Standard.
Registry/EPP/DNS provisioning (auth codes, actual transfers) is out of scope — a connector concern. The catalog owns only the commercial price grid.
Pricing operations
Coupon (DiscountType::Percentage or Fixed, with validity) and CouponApplier
discount the net before tax, so the tax base is the discounted amount:
$net = $couponApplier->apply($coupon, $net, $now);
Seller of record
Tax depends on who is selling. A SellerEntity carries its tax registrations
(TaxRegistration), and EntityRouter routes a buyer to the entity registered in
their country, falling back to a default:
interface EntityRouter
{
// route a buyer to the SellerEntity that should be the seller of record
}
DefaultEntityRouter is the multi-entity routing that drives tax resolution in the
quote, which composes cboxdk/laravel-tax for the
actual per-line VAT.