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 explicit unit_* metadata can be introduced for overall cleanliness of the API surface.

  • The merchant SPA currently requires operators to type a unit string for every product, creating room for typos and inconsistent spellings across the same instance.

  • Product descriptions already support translations, but the unit label 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#

  1. Introduce unit catalog endpoints

    The merchant backend exposes /private/units so operators can manage the measurement units available to an instance. Payloads follow the InternationalizedString pattern 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_builtin marks 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: MerchantUnitCreateRequest

    Details:

    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: MerchantUnitPatchRequest

    Details:

    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_fraction and unit_precision_level. Custom units may update every attribute except unit.

    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 unit string. The backend resolves that value against this catalogue; when no entry is found the fallback rules from step 6 apply.

  2. 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, -1 keeps the “infinite” semantics)

    Legacy price and total_stock remain, but become compatibility shims and must match the new values whenever present. Every product record continues to emit the legacy unit string so existing clients can operate unchanged.

  3. Accept unit_quantity wherever clients submit quantities (inventory locks, inventory_products). The backend converts the decimal string into the legacy quantity and new quantity_frac pair for storage so existing clients keep working.

  4. Return both representations in all read APIs so integrators can migrate at their own pace.

  5. 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.

Default backend policies#

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

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

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

  1. Handle legacy and ad-hoc units gracefully

    Older clients may still submit arbitrary unit strings in API requests. The backend accepts those values by treating them as custom units with unit_allow_fraction = false and unit_precision_level = 0. The merchant SPA limits merchants to the drop-down populated via GET /private/units so newly created products stay consistent. This fallback path is considered deprecated; clients SHOULD obtain unit strings from the catalogue.

  2. 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 (or GET /private/units/$UNIT) for the referenced unit. When the unit catalogue does not contain the identifier, clients fall back to the raw unit string. Append the selected label to the numeric value with a non-breaking thin space (U+202F). Trailing zeros up to the declared unit_precision_level MUST 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.

  3. 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.language in 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 ()

      square foot (ft²)

      10.7639

      cubic metre ()

      cubic foot (ft³)

      35.3147

    • Conversions MUST round to the wallet’s target unit_precision_level using 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, and imperial.

      • 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, uses unit_total_stock in 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.

14.72.8. Discussion / Q&A#