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