taler-docs

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

072-products-units.rst (16652B)


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