commit feb1b676ef62094c52f9b1bc9c83eaf60517bd04
parent 0d4ce0e1b6e62655552be8de29d90b9e3a5677f0
Author: bohdan-potuzhnyi <bohdan.potuzhnyi@gmail.com>
Date: Wed, 29 Oct 2025 14:33:22 +0100
dd072
Diffstat:
2 files changed, 378 insertions(+), 1 deletion(-)
diff --git a/design-documents/072-products-units.rst b/design-documents/072-products-units.rst
@@ -0,0 +1,376 @@
+DD 72: Products Units
+#####################
+
+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.
+
+Motivation
+==========
+
+Bug reports revealed two conflicting requirements:
+
+* 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
+code base.
+
+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.
+
+Proposed Solution
+=================
+
+1. **Extend product schemas** with optional metadata:
+
+ * ``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.
+
+2. **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.
+
+3. **Return both representations** in all read APIs so integrators can migrate
+ at their own pace.
+
+4. **Default backend policies**
+
+ Any backend unit identifier that is **not** listed in the table below SHALL
+ be treated as *Custom*: ``unit_allow_fraction`` = false,
+ ``unit_precision_level`` = 0, and both default labels (long/short) echo the
+ merchant-supplied string.
+
+ .. list-table:: Default backend policies
+ :widths: 20 10 10 30 30
+ :header-rows: 1
+
+ * - 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
+ * - **Custom**
+ - int
+ - 0
+ - *merchant string*
+ - *merchant string*
+
+5. **Quantity presentation in wallets and orders**
+
+ When displaying order details or cart lines, wallet and POS front-ends
+ **MUST use the short unit label** from the table above, appended 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.5 kg
+ 3.00 pc → shown as 3 pc
+
+ For precision 0 units the fractional part is omitted entirely.
+
+6. **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):
+
+ .. list-table:: Supported automatic conversions and factors
+ :widths: 40 30 30
+ :header-rows: 1
+
+ * - kilogram (``kg``)
+ - pound (``lb``)
+ - 2.204 62
+ * - gram (``g``)
+ - ounce (``oz``)
+ - 0.035 274
+ * - litre (``L``)
+ - fluid ounce (``fl oz``)
+ - 33.814
+ * - metre (``m``)
+ - foot (``ft``)
+ - 3.280 84
+ * - square metre (``m²``)
+ - square foot (``ft²``)
+ - 10.763 9
+ * - cubic metre (``m³``)
+ - cubic foot (``ft³``)
+ - 35.314 7
+
+ * 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 quantities in SI units (no conversion if the merchant already uses SI).
+
+ * **imperial** – always display quantities converted to imperial/US‑customary units (no conversion if the merchant already uses imperial).
+
+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 SPAA has updated screen for product creation, which facilitates the
+ default units.
+* POS and wallet reference implementations render fractional quantities
+ according to ``unit_allow_fraction`` / ``unit_precision_level``.
+* Legacy clients continue to function using the integer fields, with
+ automated tests ensuring that canonical and legacy values stay in sync.
+* Wallets follows points 5 and 6 of Proposed Solution.
+
+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.
+
+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.
+
+Discussion / Q&A
+===============
diff --git a/design-documents/index.rst b/design-documents/index.rst
@@ -82,5 +82,6 @@ Design documents that start with "XX" are considered deprecated.
068-tokens-roadmap
069-exchange-base-url-completion
070-alias-directory-mailbox
- 070-auto-refresh
+ 071-auto-refresh
+ 072-products-units
999-template