taler-docs

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

072-products-units.rst (17179B)


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