14.72. DD 72: Products Units#
14.72.1. Summary#
Introduce canonical unit_* metadata for merchant inventory so prices and
stock levels can be expressed with fractional precision while retaining legacy
integer fields for backwards compatibility. Provide guidance to wallets, PoS
terminals, and merchant tooling to keep UX coherent across integrations.
14.72.2. Motivation#
Field feedback highlighted several gaps in the existing product catalogue flow:
Conflicting requirements coexist:
Products sold by measurable attributes (for example potatoes by kilogram) need fractional support so customers can order 1.5 kg without hacks.
Discrete products (for example “pieces” of cheese) must remain integer-only; allowing 1.2 pc would break inventory management.
The existing API exposes only integer fields (
quantity,total_stock,price). Simply switching to floating-point values would enable nonsensical orders and introduce rounding issues. After team discussion it was decided that explicitunit_*metadata can be introduced for overall cleanliness of the API surface.The merchant SPA currently requires operators to type a
unitstring for every product, creating room for typos and inconsistent spellings across the same instance.Product descriptions already support translations, but the
unitlabel is fixed, limiting the ability to localise inventory for customers.Some end customers, especially when travelling or having grown up with a different measurement system than the merchant uses, might have difficulties understanding the quantities; a predefined list of units enables conversions that support informed buying decisions.
14.72.3. Requirements#
Preserve compatibility: accept and emit the legacy integer fields while marking them deprecated once
unit_*alternatives exist. When both are supplied the backend must check that values match.Use a predictable format: fixed-point decimal strings
INTEGER[.FRACTION]with up to eight fractional digits; reject scientific notation and special floating-point tokens.Provide backend-chosen defaults per unit identifier so new front-ends can present appropriate UI without manual configuration.
Allow merchants to override the default policy through explicit fields.
Update every affected endpoint (GET/POST/PATCH products, PoS inventory, lock, order creation, contract products) to expose and accept the new metadata.
Document expectations for merchant back-ends, PoS clients, and wallets to ensure consistent behaviour across the ecosystem.
14.72.4. Proposed Solution#
Introduce unit catalog endpoints
The merchant backend exposes
/private/unitsso operators can manage the measurement units available to an instance. Payloads follow theInternationalizedStringpattern already used across the API (maps of BCP 47 language tags to translated strings).- GET /private/units#
Return the catalogue for the current instance.
- 200 OK:
The response body is a
MerchantUnitsResponse.
Details:
interface MerchantUnitsResponse { // Units available to the instance (built-in and custom). units: MerchantUnit[]; }
interface MerchantUnit { // Backend identifier used in product payloads. unit: string; // Localised long label. unit_name_long: string; unit_name_long_i18n: InternationalizedString | null; // Localised short label (preferred for UI display). unit_name_short: string; unit_name_short_i18n: InternationalizedString | null; // Whether fractional quantities are permitted by default. unit_allow_fraction: boolean; // Maximum number of fractional digits to honour. unit_precision_level: number; // Toggle for hiding the unit from selection lists. unit_active: boolean; // True for catalogue entries shipped with the backend. unit_builtin: boolean; }
unit_builtinmarks records that ship with the backend and therefore cannot be deleted.
type InternationalizedString = { [lang_tag: string]: string; };
- GET /private/units/$UNIT#
Return a single unit definition.
- 200 OK:
The response body is a
MerchantUnit.- 404 Not Found:
The identifier is unknown or belongs to a deleted record.
- POST /private/units#
Create a new custom unit.
- 204 No Content:
The unit was created successfully.
Request body:
MerchantUnitCreateRequestDetails:
interface MerchantUnitCreateRequest { unit: string; unit_name_long: string; // Optional translations for the long label (defaults to null). unit_name_long_i18n?: InternationalizedString | null; unit_name_short: string; // Optional translations for the short label (defaults to null). unit_name_short_i18n?: InternationalizedString | null; // Defaults to false. unit_allow_fraction?: boolean; // Defaults to 0 (ignored when unit_allow_fraction is false). unit_precision_level?: number; // Defaults to true. unit_active?: boolean; }
- PATCH /private/units/$UNIT#
Update an existing unit.
- 204 No Content:
The update was applied.
- 409 Conflict:
Attempted to modify immutable fields on a built-in unit.
Request body:
MerchantUnitPatchRequestDetails:
interface MerchantUnitPatchRequest { unit_name_long?: string; unit_name_long_i18n?: InternationalizedString | null; unit_name_short?: string; unit_name_short_i18n?: InternationalizedString | null; unit_allow_fraction?: boolean; unit_precision_level?: number; unit_active?: boolean; }
Built-in units accept changes only to
unit_allow_fractionandunit_precision_level. Custom units may update every attribute exceptunit.
- DELETE /private/units/$UNIT#
Remove a custom unit.
- 204 No Content:
The unit was deleted.
- 409 Conflict:
Attempted to delete a built-in unit.
Product payloads continue to accept the
unitstring. The backend resolves that value against this catalogue; when no entry is found the fallback rules from step 6 apply.Extend product schemas with optional metadata:
unit(string; existing field, now validated against the catalogue)unit_allow_fraction(boolean)unit_precision_level(integer 0–6)unit_price(fixed-point decimal string)unit_total_stock(fixed-point decimal string,-1keeps the “infinite” semantics)
Legacy
priceandtotal_stockremain, but become compatibility shims and must match the new values whenever present. Every product record continues to emit the legacyunitstring so existing clients can operate unchanged.Accept
unit_quantitywherever clients submit quantities (inventory locks,inventory_products). The backend converts the decimal string into the legacyquantityand newquantity_fracpair for storage so existing clients keep working.Return both representations in all read APIs so integrators can migrate at their own pace.
Seed default units
During instance provisioning the backend populates the units table with the following built-in entries. Built-in entries start active with
unit_builtin= true and cannot be deleted, although their fractional policy may be tuned as described above.
BackendStr |
Type |
Precision |
Default label (long) |
Default label (short) |
|---|---|---|---|---|
Piece |
int |
0 |
piece |
pc |
Set |
int |
0 |
set |
set |
SizeUnitCm |
float |
1 |
centimetre |
cm |
SizeUnitDm |
float |
3 |
decimetre |
dm |
SizeUnitFoot |
float |
3 |
foot |
ft |
SizeUnitInch |
float |
2 |
inch |
in |
SizeUnitM |
float |
3 |
metre |
m |
SizeUnitMm |
int |
0 |
millimetre |
mm |
SurfaceUnitCm2 |
float |
2 |
square centimetre |
cm² |
SurfaceUnitDm2 |
float |
3 |
square decimetre |
dm² |
SurfaceUnitFoot2 |
float |
3 |
square foot |
ft² |
SurfaceUnitInch2 |
float |
4 |
square inch |
in² |
SurfaceUnitM2 |
float |
4 |
square metre |
m² |
SurfaceUnitMm2 |
float |
1 |
square millimetre |
mm² |
TimeUnitDay |
float |
3 |
day |
d |
TimeUnitHour |
float |
2 |
hour |
h |
TimeUnitMinute |
float |
3 |
minute |
min |
TimeUnitMonth |
float |
2 |
month |
mo |
TimeUnitSecond |
float |
3 |
second |
s |
TimeUnitWeek |
float |
3 |
week |
wk |
TimeUnitYear |
float |
4 |
year |
yr |
VolumeUnitCm3 |
float |
3 |
cubic centimetre |
cm³ |
VolumeUnitDm3 |
float |
5 |
cubic decimetre |
dm³ |
VolumeUnitFoot3 |
float |
5 |
cubic foot |
ft³ |
VolumeUnitGallon |
float |
3 |
gallon |
gal |
VolumeUnitInch3 |
float |
2 |
cubic inch |
in³ |
VolumeUnitLitre |
float |
3 |
litre |
L |
VolumeUnitM3 |
float |
6 |
cubic metre |
m³ |
VolumeUnitMm3 |
float |
1 |
cubic millimetre |
mm³ |
VolumeUnitOunce |
float |
2 |
fluid ounce |
fl oz |
WeightUnitG |
float |
1 |
gram |
g |
WeightUnitKg |
float |
3 |
kilogram |
kg |
WeightUnitMg |
int |
0 |
milligram |
mg |
WeightUnitOunce |
float |
2 |
ounce |
oz |
WeightUnitPound |
float |
3 |
pound |
lb |
WeightUnitTon |
float |
3 |
metric tonne |
t |
Handle legacy and ad-hoc units gracefully
Older clients may still submit arbitrary
unitstrings in API requests. The backend accepts those values by treating them as custom units withunit_allow_fraction= false andunit_precision_level= 0. The merchant SPA limits merchants to the drop-down populated viaGET /private/unitsso newly created products stay consistent. This fallback path is considered deprecated; clients SHOULD obtain unit strings from the catalogue.Quantity presentation in wallets and orders
When displaying order details or cart lines, wallet and POS front-ends MUST use the short unit label returned by
GET /private/units(orGET /private/units/$UNIT) for the referencedunit. When the unit catalogue does not contain the identifier, clients fall back to the rawunitstring. Append the selected label to the numeric value with a non-breaking thin space (U+202F). Trailing zeros up to the declaredunit_precision_levelMUST be trimmed, but the displayed precision MUST NOT exceed the declared level. Examples:1.500 kg → shown as 1.500 kg 3.00 pc → shown as 3 pc
For precision 0 units the fractional part is omitted entirely.
Locale-aware unit translation rules for wallets
Wallets SHOULD offer users the option to view quantities in familiar measurement systems. The following guidance applies:
Detect the buyer locale using the platform-standard mechanism (e.g.
navigator.languagein browsers or OS locale on mobile). Only when the locale primary region is in the CLDR “IU-customary group” (US,LR,MM,GB) SHALL conversions default to imperial/US-customary, and vice-versa when the merchant lists imperial units but the buyer locale is SI-centred.Supported automatic conversions and factors (SI -> US and US -> SI):
Supported automatic conversions and factors# SI unit
US/imperial unit
factor
kilogram (
kg)pound (
lb)2.20462
gram (
g)ounce (
oz)0.035274
litre (
L)fluid ounce (
fl oz)33.814
metre (
m)foot (
ft)3.28084
square metre (
m²)square foot (
ft²)10.7639
cubic metre (
m³)cubic foot (
ft³)35.3147
Conversions MUST round to the wallet’s target
unit_precision_levelusing bankers-rounding to minimise cumulative error.When a converted value is displayed it SHOULD be prefixed with “ca.” (or
≈symbol) and rendered in a visually subdued style (e.g. 60% opacity) to signal approximation; the merchant-provided unit remains the authoritative primary value.The original backend value MUST be preserved in the contract terms; conversions are presentation-only.
Wallets SHOULD expose a global numeric-system setting in their preferences with the values
off,automatic,SI, andimperial.off – never perform unit conversions; display exactly the merchant-supplied units.
automatic – apply the locale heuristic described above (imperial for
US,GB,LR,MM; SI otherwise).SI – always display conversion of quantities in SI units (no conversion if the merchant already uses SI).
imperial – always display conversion of quantities converted to imperial/US-customary units (no conversion if the merchant already uses imperial).
14.72.5. Definition of Done#
(Only applicable to design documents that describe a new feature. While the DoD is not satisfied yet, a user-facing feature must be behind a feature flag or dev-mode flag.)
Merchant backend accepts and emits the new metadata for product CRUD, inventory locks, and order creation.
Merchant SPA surfaces a unit drop-down populated from
GET /private/units, usesunit_total_stockin product listings, allows fractional orders where permitted, and provides a management screen for the unit catalogue.POS and wallet reference implementations render fractional quantities according to
unit_allow_fraction/unit_precision_level, allows to create orders with fractional quantities of products.Legacy clients continue to function using the integer fields, with automated tests ensuring that canonical and legacy values stay in sync.
Wallets implement the presentation and localisation guidance described in steps 7 and 8 of this section.
14.72.6. Alternatives#
Replace integers with floating-point numbers. This was ruled out because it cannot prevent semantically invalid requests (for example 1.2 pieces) and reintroduces floating-point rounding issues into price calculations.
14.72.7. Drawbacks#
Payloads grow slightly because responses include both canonical decimal strings and legacy integers.
Integrations must update their tooling to emit and validate decimal strings, which adds complexity compared to sending plain integers.