commit 4b9341f99484d28dbd8e82323d5d468a4237fb5e
parent e2436d60143ec7cb530188145916f02b018a7483
Author: bohdan-potuzhnyi <bohdan.potuzhnyi@gmail.com>
Date: Sun, 2 Nov 2025 23:18:34 +0100
update of the dd72
Diffstat:
1 file changed, 194 insertions(+), 35 deletions(-)
diff --git a/design-documents/072-products-units.rst b/design-documents/072-products-units.rst
@@ -12,18 +12,29 @@ 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.
+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.
Requirements
============
@@ -46,8 +57,148 @@ Requirements
Proposed Solution
=================
-1. **Extend product schemas** with optional metadata:
+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).
+
+ .. http:get:: /private/units
+
+ Return the catalogue for the current instance.
+
+ :http:statuscode:`200 OK`:
+ The response body is a :ts:ref:`MerchantUnitsResponse`.
+
+ **Details:**
+
+ .. ts:def:: MerchantUnitsResponse
+
+ interface MerchantUnitsResponse {
+ // Units available to the instance (built-in and custom).
+ units: MerchantUnit[];
+ }
+
+ .. ts:def:: 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.
+
+ .. ts:def:: InternationalizedString
+
+ type InternationalizedString = {
+ [lang_tag: string]: string;
+ };
+
+ .. http:get:: /private/units/$UNIT
+
+ Return a single unit definition.
+
+ :http:statuscode:`200 OK`:
+ The response body is a :ts:ref:`MerchantUnit`.
+ :http:statuscode:`404 Not Found`:
+ The identifier is unknown or belongs to a deleted record.
+
+ .. http:post:: /private/units
+ Create a new custom unit.
+
+ :http:statuscode:`204 No Content`:
+ The unit was created successfully.
+
+ **Request body:** :ts:ref:`MerchantUnitCreateRequest`
+
+ **Details:**
+
+ .. ts:def:: MerchantUnitCreateRequest
+
+ interface MerchantUnitCreateRequest {
+ unit: string;
+ unit_name_long: string;
+ //defaults to null
+ unit_name_long_i18n?: InternationalizedString ;
+ unit_name_short: string;
+ //defaults to null
+ unit_name_short_i18n?: InternationalizedString;
+ //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;
+ }
+
+ .. http:patch:: /private/units/$UNIT
+
+ Update an existing unit.
+
+ :http:statuscode:`204 No Content`:
+ The update was applied.
+ :http:statuscode:`409 Conflict`:
+ Attempted to modify immutable fields on a built-in unit.
+
+ **Request body:** :ts:ref:`MerchantUnitPatchRequest`
+
+ **Details:**
+
+ .. ts:def:: MerchantUnitPatchRequest
+
+ 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``.
+
+ .. http:delete:: /private/units/$UNIT
+
+ Remove a custom unit.
+
+ :http:statuscode:`204 No Content`:
+ The unit was deleted.
+ :http:statuscode:`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)
@@ -55,22 +206,23 @@ Proposed Solution
“infinite” semantics)
Legacy ``price`` and ``total_stock`` remain, but become compatibility shims and
- must match the new values whenever present.
+ must match the new values whenever present. Every product record continues to
+ emit the legacy ``unit`` string so existing clients can operate unchanged.
-2. **Accept** ``unit_quantity`` wherever clients submit quantities (inventory
+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.
-3. **Return both representations** in all read APIs so integrators can migrate
+4. **Return both representations** in all read APIs so integrators can migrate
at their own pace.
-4. **Default backend policies**
+5. **Seed default units**
- 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.
+ 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.
.. list-table:: Default backend policies
:widths: 20 10 10 30 30
@@ -261,26 +413,32 @@ Proposed Solution
- 3
- metric tonne
- t
- * - **Custom**
- - int
- - 0
- - *merchant string*
- - *merchant string*
-5. **Quantity presentation in wallets and orders**
+6. **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 merchant to the drop-down populated via
+ ``GET /private/units`` so newly created products stay consistent.
+
+7. **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
+ **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.5 kg
+ 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.
-6. **Locale-aware unit translation rules for wallets**
+8. **Locale-aware unit translation rules for wallets**
Wallets **SHOULD** offer users the option to view quantities in familiar
measurement systems. The following guidance applies:
@@ -349,15 +507,16 @@ 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, updated products table to use new ``unit_total_stock`` string,
- order can be created with fractional quantities.
+* 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 follows points 5 and 6 of Proposed Solution.
+* Wallets implement the presentation and localisation guidance described in
+ steps 7 and 8 of this section.
Alternatives
============