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 ================