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