taler-docs

Documentation for GNU Taler components, APIs and protocols
Log | Files | Refs | README | LICENSE

072-products-units.rst (17849B)


      1 DD 72: Products Units
      2 #####################
      3 
      4 :Design status: Accepted
      5 :Implementation status: Partial
      6 :DD shepherd: TBD
      7 :Historical contributors: Bohdan Potuzhnyi, Vlada Svirsh
      8 :First published: 2025-10-29
      9 :Last substantive change: 2025-11-02
     10 :Implementation evidence: ``merchant`` (2025-10-18); backend and API support landed, client coverage remains incomplete
     11 :Normative references: ``core/merchant/post-private-products.rst``, ``core/merchant/patch-private-units-UNIT.rst``, and ``core/merchant/get-templates-TEMPLATE_ID.rst``
     12 
     13 Summary
     14 =======
     15 
     16 Introduce canonical ``unit_*`` metadata for merchant inventory so prices and
     17 stock levels can be expressed with fractional precision while retaining legacy
     18 integer fields for backwards compatibility. Provide guidance to wallets, PoS
     19 terminals, and merchant tooling to keep UX coherent across integrations.
     20 
     21 Motivation
     22 ==========
     23 
     24 Field feedback highlighted several gaps in the existing product catalogue flow:
     25 
     26 * Conflicting requirements coexist:
     27 
     28   * Products sold by measurable attributes (for example potatoes by kilogram)
     29     need fractional support so customers can order 1.5 kg without hacks.
     30   * Discrete products (for example “pieces” of cheese) must remain integer-only;
     31     allowing 1.2 pc would break inventory management.
     32 
     33 * The existing API exposes only integer fields (``quantity``, ``total_stock``,
     34   ``price``). Simply switching to floating-point values would enable nonsensical
     35   orders and introduce rounding issues. After team discussion it was decided
     36   that explicit ``unit_*`` metadata can be introduced for overall cleanliness of
     37   the API surface.
     38 * The merchant SPA currently requires operators to type a ``unit`` string for
     39   every product, creating room for typos and inconsistent spellings across the
     40   same instance.
     41 * Product descriptions already support translations, but the ``unit`` label is
     42   fixed, limiting the ability to localise inventory for customers.
     43 * Some end customers, especially when travelling or having grown up with a
     44   different measurement system than the merchant uses, might have difficulties
     45   understanding the quantities; a predefined list of units enables conversions
     46   that support informed buying decisions.
     47 
     48 Requirements
     49 ============
     50 
     51 * **Preserve compatibility:** accept and emit the legacy integer fields while
     52   marking them deprecated once ``unit_*`` alternatives exist. When both are
     53   supplied the backend must check that values match.
     54 * **Use a predictable format:** fixed-point decimal strings
     55   ``INTEGER[.FRACTION]`` with up to six fractional digits; reject scientific
     56   notation and special floating-point tokens.
     57 * **Provide backend-chosen defaults per unit identifier** so new front-ends
     58   can present appropriate UI without manual configuration.
     59 * **Allow merchants to override** the default policy through explicit fields.
     60 * **Update every affected endpoint** (GET/POST/PATCH products, PoS inventory,
     61   lock, order creation, contract products) to expose and accept the new
     62   metadata.
     63 * **Document expectations** for merchant back-ends, PoS clients, and wallets
     64   to ensure consistent behaviour across the ecosystem.
     65 
     66 Proposed Solution
     67 =================
     68 
     69 1. **Introduce unit catalog endpoints**
     70 
     71    The merchant backend exposes ``/private/units`` so operators can manage the
     72    measurement units available to an instance. Payloads follow the
     73    ``InternationalizedString`` pattern already used across the API (maps of
     74    BCP 47 language tags to translated strings).
     75 
     76    Since merchant protocol **v44**, wallets can retrieve all active definitions
     77    through the public :http:get:`[/instances/$INSTANCE]/units` endpoint without
     78    credentials. It returns built-in units with the selected instance's overrides
     79    and that instance's custom units in one response. The unprefixed ``/units``
     80    path selects the default (``admin``) instance. Inactive units are omitted;
     81    operators can still inspect them through the authenticated private endpoints.
     82 
     83    .. http:get:: /private/units
     84 
     85       Return the catalogue for the current instance.
     86 
     87       :http:statuscode:`200 OK`:
     88         The response body is a ``MerchantUnitsResponse``.
     89 
     90       **Details:**
     91 
     92       .. ts:def:: MerchantUnitsResponse
     93 
     94          interface MerchantUnitsResponse {
     95            // Units available to the instance (built-in and custom).
     96            units: MerchantUnit[];
     97          }
     98 
     99       .. ts:def:: MerchantUnit
    100 
    101          interface MerchantUnit {
    102            // Backend identifier used in product payloads.
    103            unit: Slug;
    104 
    105            // Localised long label.
    106            unit_name_long: string;
    107            unit_name_long_i18n: InternationalizedString | null;
    108 
    109            // Localised short label (preferred for UI display).
    110            unit_name_short: string;
    111            unit_name_short_i18n: InternationalizedString | null;
    112 
    113            // Whether fractional quantities are permitted by default.
    114            unit_allow_fraction: boolean;
    115 
    116            // Maximum number of fractional digits to honour.
    117            unit_precision_level: number;
    118 
    119            // Toggle for hiding the unit from selection lists.
    120            unit_active: boolean;
    121 
    122            // True for catalogue entries shipped with the backend.
    123            unit_builtin: boolean;
    124          }
    125 
    126       ``unit_builtin`` marks records that ship with the backend and therefore
    127       cannot be deleted.
    128 
    129    .. ts:def:: InternationalizedString
    130 
    131       type InternationalizedString = {
    132         [lang_tag: string]: string;
    133       };
    134 
    135    .. http:get:: /private/units/$UNIT
    136 
    137       Return a single unit definition.
    138 
    139       :http:statuscode:`200 OK`:
    140         The response body is a ``MerchantUnit``.
    141       :http:statuscode:`404 Not Found`:
    142         The identifier is unknown or belongs to a deleted record.
    143 
    144    .. http:post:: /private/units
    145 
    146       Create a new custom unit.
    147 
    148       :http:statuscode:`204 No Content`:
    149         The unit was created successfully.
    150 
    151       **Request body:** ``MerchantUnitCreateRequest``
    152 
    153       **Details:**
    154 
    155       .. ts:def:: MerchantUnitCreateRequest
    156 
    157          interface MerchantUnitCreateRequest {
    158            unit: Slug;
    159            unit_name_long: string;
    160            // Optional translations for the long label (defaults to null).
    161            unit_name_long_i18n?: InternationalizedString | null;
    162            unit_name_short: string;
    163            // Optional translations for the short label (defaults to null).
    164            unit_name_short_i18n?: InternationalizedString | null;
    165            // Defaults to false.
    166            unit_allow_fraction?: boolean;
    167            // Defaults to 0 (ignored when unit_allow_fraction is false).
    168            unit_precision_level?: number;
    169            // Defaults to true.
    170            unit_active?: boolean;
    171          }
    172 
    173    .. http:patch:: /private/units/$UNIT
    174 
    175       Update an existing unit.
    176 
    177       :http:statuscode:`204 No Content`:
    178         The update was applied.
    179       :http:statuscode:`409 Conflict`:
    180         Attempted to modify immutable fields on a built-in unit.
    181 
    182       **Request body:** ``MerchantUnitPatchRequest``
    183 
    184       **Details:**
    185 
    186       .. ts:def:: MerchantUnitPatchRequest
    187 
    188          interface MerchantUnitPatchRequest {
    189            unit_name_long?: string;
    190            unit_name_long_i18n?: InternationalizedString | null;
    191            unit_name_short?: string;
    192            unit_name_short_i18n?: InternationalizedString | null;
    193            unit_allow_fraction?: boolean;
    194            unit_precision_level?: number;
    195            unit_active?: boolean;
    196          }
    197 
    198       Built-in units accept changes only to ``unit_allow_fraction``,
    199       ``unit_precision_level``, and ``unit_active``. Custom units may update
    200       every attribute except ``unit``.
    201 
    202    .. http:delete:: /private/units/$UNIT
    203 
    204       Remove a custom unit.
    205 
    206       :http:statuscode:`204 No Content`:
    207         The unit was deleted.
    208       :http:statuscode:`409 Conflict`:
    209         Attempted to delete a built-in unit.
    210 
    211    Product payloads continue to accept the ``unit`` string. The backend resolves
    212    that value against this catalogue; when no entry is found the fallback rules
    213    from step 6 apply.
    214 
    215 2. **Extend product schemas** with optional metadata:
    216 
    217    * ``unit`` (string; existing field, now validated against the catalogue)
    218    * ``unit_allow_fraction`` (boolean)
    219    * ``unit_precision_level`` (integer 0–6)
    220    * ``unit_price`` (fixed-point decimal string)
    221    * ``unit_total_stock`` (fixed-point decimal string, ``-1`` keeps the
    222      “infinite” semantics)
    223 
    224    Legacy ``price`` and ``total_stock`` remain, but become compatibility shims and
    225    must match the new values whenever present. Every product record continues to
    226    emit the legacy ``unit`` string so existing clients can operate unchanged.
    227 
    228 3. **Accept** ``unit_quantity`` wherever clients submit quantities (inventory
    229    locks, ``inventory_products``). The backend converts the decimal string into
    230    the legacy ``quantity`` and new ``quantity_frac`` pair for storage so existing
    231    clients keep working.
    232 
    233 4. **Return both representations** in all read APIs so integrators can migrate
    234    at their own pace.
    235 
    236 5. **Seed default units**
    237 
    238    During instance provisioning the backend populates the units table with the
    239    following built-in entries. Built-in entries start active with
    240    ``unit_builtin`` = true and cannot be deleted, although their fractional policy
    241    may be tuned as described above.
    242 
    243 .. list-table:: Default backend policies
    244     :widths: 20 10 10 30 30
    245     :header-rows: 1
    246 
    247     * - BackendStr
    248       - Type
    249       - Precision
    250       - Default label (long)
    251       - Default label (short)
    252     * - Piece
    253       - int
    254       - 0
    255       - piece
    256       - pc
    257     * - Set
    258       - int
    259       - 0
    260       - set
    261       - set
    262     * - SizeUnitCm
    263       - float
    264       - 1
    265       - centimetre
    266       - cm
    267     * - SizeUnitDm
    268       - float
    269       - 3
    270       - decimetre
    271       - dm
    272     * - SizeUnitFoot
    273       - float
    274       - 3
    275       - foot
    276       - ft
    277     * - SizeUnitInch
    278       - float
    279       - 2
    280       - inch
    281       - in
    282     * - SizeUnitM
    283       - float
    284       - 3
    285       - metre
    286       - m
    287     * - SizeUnitMm
    288       - int
    289       - 0
    290       - millimetre
    291       - mm
    292     * - SurfaceUnitCm2
    293       - float
    294       - 2
    295       - square centimetre
    296       - cm²
    297     * - SurfaceUnitDm2
    298       - float
    299       - 3
    300       - square decimetre
    301       - dm²
    302     * - SurfaceUnitFoot2
    303       - float
    304       - 3
    305       - square foot
    306       - ft²
    307     * - SurfaceUnitInch2
    308       - float
    309       - 4
    310       - square inch
    311       - in²
    312     * - SurfaceUnitM2
    313       - float
    314       - 4
    315       - square metre
    316       - m²
    317     * - SurfaceUnitMm2
    318       - float
    319       - 1
    320       - square millimetre
    321       - mm²
    322     * - TimeUnitDay
    323       - float
    324       - 3
    325       - day
    326       - d
    327     * - TimeUnitHour
    328       - float
    329       - 2
    330       - hour
    331       - h
    332     * - TimeUnitMinute
    333       - float
    334       - 3
    335       - minute
    336       - min
    337     * - TimeUnitMonth
    338       - float
    339       - 2
    340       - month
    341       - mo
    342     * - TimeUnitSecond
    343       - float
    344       - 3
    345       - second
    346       - s
    347     * - TimeUnitWeek
    348       - float
    349       - 3
    350       - week
    351       - wk
    352     * - TimeUnitYear
    353       - float
    354       - 4
    355       - year
    356       - yr
    357     * - VolumeUnitCm3
    358       - float
    359       - 3
    360       - cubic centimetre
    361       - cm³
    362     * - VolumeUnitDm3
    363       - float
    364       - 5
    365       - cubic decimetre
    366       - dm³
    367     * - VolumeUnitFoot3
    368       - float
    369       - 5
    370       - cubic foot
    371       - ft³
    372     * - VolumeUnitGallon
    373       - float
    374       - 3
    375       - gallon
    376       - gal
    377     * - VolumeUnitInch3
    378       - float
    379       - 2
    380       - cubic inch
    381       - in³
    382     * - VolumeUnitLitre
    383       - float
    384       - 3
    385       - litre
    386       - L
    387     * - VolumeUnitM3
    388       - float
    389       - 6
    390       - cubic metre
    391       - m³
    392     * - VolumeUnitMm3
    393       - float
    394       - 1
    395       - cubic millimetre
    396       - mm³
    397     * - VolumeUnitOunce
    398       - float
    399       - 2
    400       - fluid ounce
    401       - fl oz
    402     * - WeightUnitG
    403       - float
    404       - 1
    405       - gram
    406       - g
    407     * - WeightUnitKg
    408       - float
    409       - 3
    410       - kilogram
    411       - kg
    412     * - WeightUnitMg
    413       - int
    414       - 0
    415       - milligram
    416       - mg
    417     * - WeightUnitOunce
    418       - float
    419       - 2
    420       - ounce
    421       - oz
    422     * - WeightUnitPound
    423       - float
    424       - 3
    425       - pound
    426       - lb
    427     * - WeightUnitTon
    428       - float
    429       - 3
    430       - metric tonne
    431       - t
    432 
    433 6. **Handle legacy and ad-hoc units gracefully**
    434 
    435    Older clients may still submit arbitrary ``unit`` strings in API requests. The
    436    backend accepts those values by treating them as custom units with
    437    ``unit_allow_fraction`` = false and ``unit_precision_level`` = 0. The merchant
    438    SPA limits merchants to the drop-down populated via ``GET /private/units`` so
    439    newly created products stay consistent. This fallback path is considered
    440    deprecated; clients SHOULD obtain unit strings from the catalogue.
    441 
    442 7. **Quantity presentation in wallets and orders**
    443 
    444    When displaying order details or cart lines, wallet and POS front-ends
    445    **MUST use the short unit label embedded in the public template response**
    446    for the referenced ``unit``. When no label is embedded, wallets can resolve
    447    it using the merchant instance's public ``GET /units`` catalogue. Wallets
    448    cannot call the authenticated ``/private/units`` endpoints. If the unit is
    449    absent or the catalogue cannot be fetched and no label is otherwise
    450    available, clients fall back to the raw ``unit`` string.
    451 
    452    Append the selected label to the numeric value with a non-breaking thin space
    453    (U+202F). Trailing zeros
    454    *up to* the declared ``unit_precision_level`` **MUST be trimmed**, but the
    455    displayed precision **MUST NOT** exceed the declared level. Examples::
    456 
    457      1.500 kg → shown as 1.5 kg
    458      3.00 pc  → shown as 3 pc
    459 
    460    For precision 0 units the fractional part is omitted entirely.
    461 
    462 8. **Locale-aware unit translation rules for wallets**
    463 
    464    Wallets **SHOULD** offer users the option to view quantities in familiar
    465    measurement systems. The following guidance applies:
    466 
    467    * Detect the buyer locale using the platform-standard mechanism (e.g.
    468      ``navigator.language`` in browsers or OS locale on mobile). Only when the
    469      locale **primary region** is in the CLDR “IU-customary group”
    470      (``US``, ``LR``, ``MM``, ``GB``) **SHALL** conversions default to
    471      imperial/US-customary, and vice-versa when the merchant lists imperial
    472      units but the buyer locale is SI-centred.
    473 
    474    * Supported automatic conversions and factors (SI -> US and US -> SI):
    475 
    476      .. list-table:: Supported automatic conversions and factors
    477         :widths: 40 30 30
    478         :header-rows: 1
    479 
    480         * - SI unit
    481           - US/imperial unit
    482           - factor
    483         * - kilogram (``kg``)
    484           - pound (``lb``)
    485           - 2.20462
    486         * - gram (``g``)
    487           - ounce (``oz``)
    488           - 0.035274
    489         * - litre (``L``)
    490           - fluid ounce (``fl oz``)
    491           - 33.814
    492         * - metre (``m``)
    493           - foot (``ft``)
    494           - 3.28084
    495         * - square metre (``m²``)
    496           - square foot (``ft²``)
    497           - 10.7639
    498         * - cubic metre (``m³``)
    499           - cubic foot (``ft³``)
    500           - 35.3147
    501 
    502    * Conversions **MUST** round to the wallet's target
    503      ``unit_precision_level`` using bankers-rounding to minimise cumulative
    504      error.
    505 
    506    * When a converted value is displayed it **SHOULD** be prefixed with
    507      “ca.” (or ``≈`` symbol) and rendered in a visually subdued style (e.g. 60% opacity) to
    508      signal approximation; the merchant-provided unit remains the authoritative
    509      primary value.
    510 
    511    * The original backend value **MUST** be preserved in the contract terms;
    512      conversions are *presentation-only*.
    513 
    514    * Wallets **SHOULD** expose a global *numeric-system* setting in their
    515      preferences with the values ``off``, ``automatic``, ``SI``, and ``imperial``.
    516 
    517      - **off** – never perform unit conversions; display exactly the merchant-supplied units.
    518      - **automatic** – apply the locale heuristic described above (imperial for ``US``, ``GB``, ``LR``, ``MM``; SI otherwise).
    519      - **SI** – always display conversion of quantities in SI units (no conversion if the merchant already uses SI).
    520      - **imperial** – always display conversion of quantities converted to imperial/US-customary units (no conversion if the merchant already uses imperial).
    521 
    522 Definition of Done
    523 ==================
    524 
    525 (Only applicable to design documents that describe a new feature. While the
    526 DoD is not satisfied yet, a user-facing feature **must** be behind a feature
    527 flag or dev-mode flag.)
    528 
    529 * [x] Merchant backend accepts and emits the new metadata for product CRUD,
    530   inventory locks, and order creation.
    531 * [ ] Merchant SPA surfaces a unit drop-down populated from ``GET /private/units``,
    532   uses ``unit_total_stock`` in product listings, allows fractional orders where
    533   permitted, and provides a management screen for the unit catalogue.
    534 * [ ] POS and wallet reference implementations render fractional quantities
    535   according to ``unit_allow_fraction`` / ``unit_precision_level``, allows
    536   to create orders with fractional quantities of products.
    537 * [ ] Legacy clients continue to function using the integer fields, with
    538   automated tests ensuring that canonical and legacy values stay in sync.
    539 * [ ] Wallets implement the presentation and localisation guidance described in
    540   steps 7 and 8 of this section.
    541 
    542 Alternatives
    543 ============
    544 
    545 * Replace integers with floating-point numbers. This was ruled out because it
    546   cannot prevent semantically invalid requests (for example 1.2 pieces) and
    547   reintroduces floating-point rounding issues into price calculations.
    548 
    549 Drawbacks
    550 =========
    551 
    552 * Payloads grow slightly because responses include both canonical decimal
    553   strings and legacy integers.
    554 * Integrations must update their tooling to emit and validate decimal strings,
    555   which adds complexity compared to sending plain integers.
    556 
    557 Discussion / Q&A
    558 ================