@kernhq/module-inventory 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/README.md +134 -9
  2. package/dist/contract/capabilities.d.ts +49 -0
  3. package/dist/contract/capabilities.d.ts.map +1 -0
  4. package/dist/contract/capabilities.js +94 -0
  5. package/dist/contract/capabilities.js.map +1 -0
  6. package/dist/contract/events.d.ts +76 -0
  7. package/dist/contract/events.d.ts.map +1 -0
  8. package/dist/contract/events.js +62 -0
  9. package/dist/contract/events.js.map +1 -0
  10. package/dist/contract/index.d.ts +16 -0
  11. package/dist/contract/index.d.ts.map +1 -0
  12. package/dist/contract/index.js +16 -0
  13. package/dist/contract/index.js.map +1 -0
  14. package/dist/contract/models.d.ts +468 -0
  15. package/dist/contract/models.d.ts.map +1 -0
  16. package/dist/contract/models.js +320 -0
  17. package/dist/contract/models.js.map +1 -0
  18. package/dist/contract/notifications.d.ts +24 -0
  19. package/dist/contract/notifications.d.ts.map +1 -0
  20. package/dist/contract/notifications.js +72 -0
  21. package/dist/contract/notifications.js.map +1 -0
  22. package/dist/contract/permissions.d.ts +72 -0
  23. package/dist/contract/permissions.d.ts.map +1 -0
  24. package/dist/contract/permissions.js +79 -0
  25. package/dist/contract/permissions.js.map +1 -0
  26. package/dist/contract/router.d.ts +1378 -0
  27. package/dist/contract/router.d.ts.map +1 -0
  28. package/dist/contract/router.js +266 -0
  29. package/dist/contract/router.js.map +1 -0
  30. package/dist/contract/settings.d.ts +20 -0
  31. package/dist/contract/settings.d.ts.map +1 -0
  32. package/dist/contract/settings.js +49 -0
  33. package/dist/contract/settings.js.map +1 -0
  34. package/dist/server/index.d.ts +8 -1
  35. package/dist/server/index.d.ts.map +1 -1
  36. package/dist/server/index.js +288 -8
  37. package/dist/server/index.js.map +1 -1
  38. package/dist/server/jobs.d.ts +48 -0
  39. package/dist/server/jobs.d.ts.map +1 -0
  40. package/dist/server/jobs.js +358 -0
  41. package/dist/server/jobs.js.map +1 -0
  42. package/dist/server/router.d.ts +1789 -0
  43. package/dist/server/router.d.ts.map +1 -0
  44. package/dist/server/router.js +439 -0
  45. package/dist/server/router.js.map +1 -0
  46. package/dist/server/schema.d.ts +156 -9
  47. package/dist/server/schema.d.ts.map +1 -1
  48. package/dist/server/schema.js +183 -11
  49. package/dist/server/schema.js.map +1 -1
  50. package/dist/server/services/assets.d.ts +139 -0
  51. package/dist/server/services/assets.d.ts.map +1 -0
  52. package/dist/server/services/assets.js +429 -0
  53. package/dist/server/services/assets.js.map +1 -0
  54. package/dist/server/services/attachments.d.ts +80 -0
  55. package/dist/server/services/attachments.d.ts.map +1 -0
  56. package/dist/server/services/attachments.js +182 -0
  57. package/dist/server/services/attachments.js.map +1 -0
  58. package/dist/server/services/audience.d.ts +15 -0
  59. package/dist/server/services/audience.d.ts.map +1 -0
  60. package/dist/server/services/audience.js +64 -0
  61. package/dist/server/services/audience.js.map +1 -0
  62. package/dist/server/services/categories.d.ts +57 -0
  63. package/dist/server/services/categories.d.ts.map +1 -0
  64. package/dist/server/services/categories.js +124 -0
  65. package/dist/server/services/categories.js.map +1 -0
  66. package/dist/server/services/cursor.d.ts +68 -0
  67. package/dist/server/services/cursor.d.ts.map +1 -0
  68. package/dist/server/services/cursor.js +39 -0
  69. package/dist/server/services/cursor.js.map +1 -0
  70. package/dist/server/services/custody.d.ts +175 -0
  71. package/dist/server/services/custody.d.ts.map +1 -0
  72. package/dist/server/services/custody.js +367 -0
  73. package/dist/server/services/custody.js.map +1 -0
  74. package/dist/server/services/db-errors.d.ts +7 -0
  75. package/dist/server/services/db-errors.d.ts.map +1 -0
  76. package/dist/server/services/db-errors.js +32 -0
  77. package/dist/server/services/db-errors.js.map +1 -0
  78. package/dist/server/services/index.d.ts +26 -0
  79. package/dist/server/services/index.d.ts.map +1 -0
  80. package/dist/server/services/index.js +39 -0
  81. package/dist/server/services/index.js.map +1 -0
  82. package/dist/server/services/members.d.ts +27 -0
  83. package/dist/server/services/members.d.ts.map +1 -0
  84. package/dist/server/services/members.js +39 -0
  85. package/dist/server/services/members.js.map +1 -0
  86. package/dist/server/services/notify.d.ts +105 -0
  87. package/dist/server/services/notify.d.ts.map +1 -0
  88. package/dist/server/services/notify.js +147 -0
  89. package/dist/server/services/notify.js.map +1 -0
  90. package/dist/server/services/offboarding.d.ts +70 -0
  91. package/dist/server/services/offboarding.d.ts.map +1 -0
  92. package/dist/server/services/offboarding.js +116 -0
  93. package/dist/server/services/offboarding.js.map +1 -0
  94. package/dist/server/services/repairs.d.ts +204 -0
  95. package/dist/server/services/repairs.d.ts.map +1 -0
  96. package/dist/server/services/repairs.js +476 -0
  97. package/dist/server/services/repairs.js.map +1 -0
  98. package/dist/server/services/search.d.ts +85 -0
  99. package/dist/server/services/search.d.ts.map +1 -0
  100. package/dist/server/services/search.js +142 -0
  101. package/dist/server/services/search.js.map +1 -0
  102. package/dist/server/services/stats.d.ts +42 -0
  103. package/dist/server/services/stats.d.ts.map +1 -0
  104. package/dist/server/services/stats.js +80 -0
  105. package/dist/server/services/stats.js.map +1 -0
  106. package/dist/server/services/status.d.ts +102 -0
  107. package/dist/server/services/status.d.ts.map +1 -0
  108. package/dist/server/services/status.js +71 -0
  109. package/dist/server/services/status.js.map +1 -0
  110. package/migrations/0000_init.sql +12 -3
  111. package/migrations/0001_rls.sql +24 -0
  112. package/migrations/0002_custody_and_categories.sql +23 -0
  113. package/migrations/0003_repairs.sql +23 -0
  114. package/migrations/0004_platform_surfaces.sql +51 -0
  115. package/migrations/0005_repair_dates.sql +35 -0
  116. package/migrations/0006_workspace_registry_read.sql +50 -0
  117. package/migrations/0007_history_sequence.sql +83 -0
  118. package/migrations/meta/0000_snapshot.json +40 -13
  119. package/migrations/meta/0002_snapshot.json +1054 -0
  120. package/migrations/meta/0003_snapshot.json +1070 -0
  121. package/migrations/meta/0004_snapshot.json +1130 -0
  122. package/migrations/meta/0005_snapshot.json +1135 -0
  123. package/migrations/meta/_journal.json +44 -2
  124. package/package.json +5 -4
  125. package/src/client/api-instance.ts +27 -2
  126. package/src/client/api.ts +1 -1
  127. package/src/client/bidi.test.ts +148 -0
  128. package/src/client/bidi.ts +85 -0
  129. package/src/client/components/AssetDetailPanel.svelte +614 -0
  130. package/src/client/components/AssetFormDialog.svelte +191 -59
  131. package/src/client/components/AssetPhoto.svelte +178 -0
  132. package/src/client/components/AttachmentsSection.svelte +327 -0
  133. package/src/client/components/CustodyDialog.svelte +201 -0
  134. package/src/client/components/RepairDialog.svelte +271 -0
  135. package/src/client/components/RepairsSection.svelte +318 -0
  136. package/src/client/components/Timeline.svelte +347 -0
  137. package/src/client/components/TimelineText.svelte +124 -0
  138. package/src/client/core-api.ts +71 -0
  139. package/src/client/custody.test.ts +31 -0
  140. package/src/client/custody.ts +34 -0
  141. package/src/client/errors.test.ts +365 -0
  142. package/src/client/errors.ts +201 -0
  143. package/src/client/i18n.ts +11 -166
  144. package/src/client/index.ts +8 -1
  145. package/src/client/links.test.ts +74 -0
  146. package/src/client/links.ts +44 -0
  147. package/src/client/members.test.ts +132 -0
  148. package/src/client/members.ts +116 -0
  149. package/src/client/messages.test.ts +296 -0
  150. package/src/client/messages.ts +1424 -0
  151. package/src/client/mock.test.ts +555 -0
  152. package/src/client/mock.ts +1261 -52
  153. package/src/client/module.ts +76 -2
  154. package/src/client/pages/AssetsPage.svelte +638 -145
  155. package/src/client/permissions.ts +8 -1
  156. package/src/client/price.test.ts +254 -0
  157. package/src/client/price.ts +279 -0
  158. package/src/client/query.test.ts +58 -0
  159. package/src/client/query.ts +51 -2
  160. package/src/client/repairs.test.ts +38 -0
  161. package/src/client/repairs.ts +38 -0
  162. package/src/client/settings/CategoriesSettings.svelte +421 -0
  163. package/src/client/settings/GeneralSettings.svelte +403 -0
  164. package/src/client/status.ts +29 -0
  165. package/src/client/timeline.test.ts +175 -0
  166. package/src/client/timeline.ts +206 -0
  167. package/src/client/widgets/OverviewWidget.svelte +140 -26
  168. package/src/client/widgets/RepairsWidget.svelte +124 -0
  169. package/src/contract/capabilities.ts +99 -0
  170. package/src/contract/events.ts +83 -0
  171. package/src/contract/index.ts +16 -0
  172. package/src/contract/models.ts +360 -0
  173. package/src/contract/notifications.ts +73 -0
  174. package/src/contract/permissions.ts +79 -0
  175. package/src/contract/router.ts +300 -0
  176. package/src/contract/settings.ts +50 -0
  177. package/src/module.test.ts +330 -7
  178. package/src/server/index.ts +318 -8
  179. package/src/server/inventory.int.test.ts +4374 -0
  180. package/src/server/jobs.ts +444 -0
  181. package/src/server/migrations.test.ts +251 -0
  182. package/src/server/router.ts +574 -0
  183. package/src/server/schema.ts +184 -10
  184. package/src/server/services/assets.ts +528 -0
  185. package/src/server/services/attachments.ts +215 -0
  186. package/src/server/services/audience.ts +77 -0
  187. package/src/server/services/categories.ts +136 -0
  188. package/src/server/services/cursor.ts +104 -0
  189. package/src/server/services/custody.ts +471 -0
  190. package/src/server/services/db-errors.ts +42 -0
  191. package/src/server/services/index.ts +56 -0
  192. package/src/server/services/members.ts +54 -0
  193. package/src/server/services/notify.ts +196 -0
  194. package/src/server/services/offboarding.ts +150 -0
  195. package/src/server/services/repairs.ts +567 -0
  196. package/src/server/services/search.ts +166 -0
  197. package/src/server/services/stats.ts +88 -0
  198. package/src/server/services/status.test.ts +34 -0
  199. package/src/server/services/status.ts +143 -0
  200. package/tsconfig.base.json +22 -0
  201. package/tsconfig.client.json +1 -1
  202. package/tsconfig.json +1 -1
  203. package/vitest.config.ts +18 -3
  204. package/dist/contract.d.ts +0 -387
  205. package/dist/contract.d.ts.map +0 -1
  206. package/dist/contract.js +0 -119
  207. package/dist/contract.js.map +0 -1
  208. package/dist/server/_impl.d.ts +0 -427
  209. package/dist/server/_impl.d.ts.map +0 -1
  210. package/dist/server/_impl.js +0 -204
  211. package/dist/server/_impl.js.map +0 -1
  212. package/src/contract.ts +0 -143
  213. package/src/server/_impl.ts +0 -275
@@ -1,5 +1,5 @@
1
1
  import { session } from '@kernhq/ui'
2
- import { inventoryPermissions, MODULE_ID } from '../contract.js'
2
+ import { inventoryPermissions, MODULE_ID } from '../contract/index.js'
3
3
 
4
4
  /**
5
5
  * What this module lets somebody do.
@@ -21,6 +21,13 @@ const key = (suffix: string) => {
21
21
  export const INVENTORY_PERMISSIONS = {
22
22
  view: key('asset.view'),
23
23
  manage: key('asset.manage'),
24
+ /** Handing an item over and taking it back. Reading who holds what rides `view` — see the
25
+ * contract's `permissions.ts` for why that is a decision rather than an omission. */
26
+ custody: key('custody.manage'),
27
+ /** Sending an item away, correcting the record and logging it back. */
28
+ repairs: key('repair.manage'),
29
+ /** The categories settings page, and nothing else. */
30
+ categories: key('category.manage'),
24
31
  } as const
25
32
 
26
33
  export type InventoryPermission = keyof typeof INVENTORY_PERMISSIONS
@@ -0,0 +1,254 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ CURRENCIES,
4
+ currencyOptions,
5
+ formatPrice,
6
+ minorUnitExponent,
7
+ parsePrice,
8
+ priceExample,
9
+ } from './price.js'
10
+
11
+ /**
12
+ * The regression this file exists for.
13
+ *
14
+ * `raw.replace(',', '.')` replaced the first comma only, so `1.234,56` — how German, Turkish and
15
+ * Persian write twelve hundred — parsed as `1.23456` and was stored as €1.23. Three of the five
16
+ * languages this module ships, silently, with no error anywhere. `abc` and `-5` were worse: they
17
+ * came back `null` and the asset saved with no price at all.
18
+ */
19
+ const minor = (raw: string, locale: string, currency?: string | null) => {
20
+ const result = parsePrice(raw, locale, currency)
21
+ return result.ok ? result.minor : 'invalid'
22
+ }
23
+
24
+ describe('parsePrice', () => {
25
+ it('reads a grouped price in each locale the module ships', () => {
26
+ expect(minor('1,234.56', 'en')).toBe(123456)
27
+ expect(minor('1.234,56', 'de')).toBe(123456)
28
+ expect(minor('1.234,56', 'tr')).toBe(123456)
29
+ // Persian groups with ٬ and points with ٫, in Persian digits.
30
+ expect(minor('۱٬۲۳۴٫۵۶', 'fa')).toBe(123456)
31
+ expect(minor('1,234.56', 'ar')).toBe(123456)
32
+ })
33
+
34
+ it('reads an ungrouped price in each locale', () => {
35
+ expect(minor('1234.56', 'en')).toBe(123456)
36
+ expect(minor('1234,56', 'de')).toBe(123456)
37
+ expect(minor('1234,56', 'tr')).toBe(123456)
38
+ expect(minor('۱۲۳۴٫۵۶', 'fa')).toBe(123456)
39
+ })
40
+
41
+ it('folds Persian and Arabic digits, so a person may type their own numerals', () => {
42
+ expect(minor('۱۹٫۹۹', 'fa')).toBe(1999)
43
+ expect(minor('١٩.٩٩', 'ar')).toBe(1999)
44
+ expect(minor('۲۵', 'fa')).toBe(2500)
45
+ })
46
+
47
+ it('treats an empty field as no price rather than a bad one', () => {
48
+ expect(minor('', 'en')).toBe(null)
49
+ expect(minor(' ', 'de')).toBe(null)
50
+ })
51
+
52
+ it('rejects what it cannot read instead of saving nothing', () => {
53
+ // Both of these used to return null, and the asset was filed with no price.
54
+ expect(minor('abc', 'en')).toBe('invalid')
55
+ expect(minor('-5', 'en')).toBe('invalid')
56
+ expect(minor('1.2.3', 'en')).toBe('invalid')
57
+ expect(minor('12,34', 'en')).toBe('invalid')
58
+ expect(minor('$12.00', 'en')).toBe('invalid')
59
+ })
60
+
61
+ /**
62
+ * The 100× that has no visible symptom: `.` is German's *group* separator, so stripping it would
63
+ * store 123456 for somebody who meant 1234.56. There is no way to tell which they meant, so the
64
+ * field asks rather than guesses.
65
+ */
66
+ it('rejects a group separator that does not group three digits', () => {
67
+ expect(minor('1234.56', 'de')).toBe('invalid')
68
+ expect(minor('1234.56', 'tr')).toBe('invalid')
69
+ expect(minor('1.23.456', 'de')).toBe('invalid')
70
+ expect(minor('1,2345', 'en')).toBe('invalid')
71
+ })
72
+
73
+ it('keeps a bare decimal, with or without its leading zero', () => {
74
+ expect(minor('0.5', 'en')).toBe(50)
75
+ expect(minor('.5', 'en')).toBe(50)
76
+ expect(minor(',5', 'de')).toBe(50)
77
+ })
78
+
79
+ it('does not lose a cent to floating point', () => {
80
+ expect(minor('19.99', 'en')).toBe(1999)
81
+ expect(minor('0.07', 'en')).toBe(7)
82
+ expect(minor('8.29', 'en')).toBe(829)
83
+ })
84
+ })
85
+
86
+ /**
87
+ * The second silent 100×, and the one the picker itself created.
88
+ *
89
+ * `parsePrice` and `formatPrice` both hard-coded 1/100 while the currency list offers **JPY**,
90
+ * which has no minor unit at all, and **KWD**, which has three decimal places. So ¥1000 typed into
91
+ * the field was stored as 100000 minor units — ¥100 000 to anything reading the column honestly —
92
+ * and every dinar amount was out by a factor of ten. **IRR is the one that stings**: the rial has
93
+ * no minor unit either, and it is the currency of a language this module ships.
94
+ */
95
+ describe('the currency decides the minor unit', () => {
96
+ it('asks the currency rather than assuming hundredths', () => {
97
+ expect(minorUnitExponent('USD')).toBe(2)
98
+ expect(minorUnitExponent('EUR')).toBe(2)
99
+ expect(minorUnitExponent('JPY')).toBe(0)
100
+ expect(minorUnitExponent('IRR')).toBe(0)
101
+ expect(minorUnitExponent('KWD')).toBe(3)
102
+ // No currency on an amount is not a reason to make the field unusable, and two decimals is
103
+ // what ISO 4217 assigns by default.
104
+ expect(minorUnitExponent(null)).toBe(2)
105
+ expect(minorUnitExponent('')).toBe(2)
106
+ // The contract takes any three letters, so a code `Intl` refuses must not throw out of a form.
107
+ expect(minorUnitExponent('nonsense')).toBe(2)
108
+ })
109
+
110
+ it('stores ¥1000 as 1000, not as 10', () => {
111
+ expect(minor('1000', 'en', 'JPY')).toBe(1000)
112
+ expect(minor('1,000', 'en', 'JPY')).toBe(1000)
113
+ expect(formatPrice(1000, 'en', 'JPY')).toBe('1000')
114
+ // What it did before: hundredths, so the amount read back a hundred times too small.
115
+ expect(formatPrice(1000, 'en')).toBe('10.00')
116
+ })
117
+
118
+ it('keeps all three of a dinar’s decimal places', () => {
119
+ expect(minor('1.234', 'en', 'KWD')).toBe(1234)
120
+ expect(minor('1,234.567', 'en', 'KWD')).toBe(1234567)
121
+ expect(formatPrice(1234567, 'en', 'KWD')).toBe('1234.567')
122
+ // The old behaviour dropped the third place and was ten times out.
123
+ expect(minor('1.234', 'en')).toBe('invalid')
124
+ })
125
+
126
+ it('still reads a two-decimal currency exactly as before', () => {
127
+ expect(minor('19.99', 'en', 'USD')).toBe(1999)
128
+ expect(minor('1.234,56', 'de', 'EUR')).toBe(123456)
129
+ expect(formatPrice(123456, 'de', 'EUR')).toBe('1234,56')
130
+ })
131
+
132
+ /**
133
+ * The point of the whole fix: there is no path where a wrong number is stored quietly. `19.99`
134
+ * in a yen field is somebody who has not noticed which field they are in, and rounding it to
135
+ * ¥20 for them is the same class of silence as the `1.234,56` → €1.23 bug this file began with.
136
+ */
137
+ it('refuses more decimal places than the currency has, rather than rounding them away', () => {
138
+ expect(minor('19.99', 'en', 'JPY')).toBe('invalid')
139
+ expect(minor('0.5', 'en', 'JPY')).toBe('invalid')
140
+ expect(minor('.5', 'en', 'IRR')).toBe('invalid')
141
+ expect(minor('1.2345', 'en', 'KWD')).toBe('invalid')
142
+ expect(minor('1.234', 'en', 'USD')).toBe('invalid')
143
+ })
144
+
145
+ it('round-trips every shipped locale against a currency of each shape', () => {
146
+ for (const locale of ['en', 'de', 'tr', 'fa', 'ar']) {
147
+ for (const currency of ['USD', 'JPY', 'KWD', 'IRR']) {
148
+ for (const value of [0, 7, 2500, 123456, 99999999]) {
149
+ expect({
150
+ locale,
151
+ currency,
152
+ value,
153
+ back: minor(formatPrice(value, locale, currency), locale, currency),
154
+ }).toEqual({ locale, currency, value, back: value })
155
+ }
156
+ }
157
+ }
158
+ })
159
+
160
+ it('holds up an example the field would actually accept', () => {
161
+ // It said "Enter an amount like 1234.56" in a yen field and then refused 1234.56.
162
+ expect(priceExample('en', 'JPY')).toBe('1234')
163
+ expect(priceExample('en', 'KWD')).toBe('1234.567')
164
+ expect(priceExample('en', 'USD')).toBe('1234.56')
165
+ expect(priceExample('de', null)).toBe('1234,56')
166
+ for (const currency of ['USD', 'JPY', 'KWD', 'IRR', 'EUR']) {
167
+ const example = priceExample('en', currency)
168
+ expect({ currency, example, ok: parsePrice(example, 'en', currency).ok }).toEqual({
169
+ currency,
170
+ example,
171
+ ok: true,
172
+ })
173
+ }
174
+ })
175
+ })
176
+
177
+ describe('formatPrice', () => {
178
+ it('has no price to show for no price', () => {
179
+ expect(formatPrice(null, 'en')).toBe('')
180
+ expect(formatPrice(undefined, 'de')).toBe('')
181
+ })
182
+
183
+ /**
184
+ * What the edit form seeds itself with has to be something the same locale can parse back, or
185
+ * opening an asset and saving it unchanged would report the stored price as invalid.
186
+ */
187
+ it('round-trips through parsePrice in every locale', () => {
188
+ for (const locale of ['en', 'de', 'tr', 'fa', 'ar']) {
189
+ for (const value of [0, 7, 2500, 123456, 99999999]) {
190
+ expect({ locale, value, back: minor(formatPrice(value, locale), locale) }).toEqual({
191
+ locale,
192
+ value,
193
+ back: value,
194
+ })
195
+ }
196
+ }
197
+ })
198
+
199
+ it('writes the reader’s own digits', () => {
200
+ expect(formatPrice(123456, 'fa')).toContain('۴')
201
+ expect(formatPrice(123456, 'de')).toBe('1234,56')
202
+ expect(formatPrice(123456, 'en')).toBe('1234.56')
203
+ })
204
+ })
205
+
206
+ /**
207
+ * The list a money field offers.
208
+ *
209
+ * It was `['USD', 'EUR', 'IRR', 'AED']` in a product whose language menu offers Turkish — so a
210
+ * workspace in Istanbul could not record what it paid for anything. The contract takes any ISO
211
+ * 4217 code (`z.string().length(3)`) and the server stores whatever arrives, so nothing but the
212
+ * picker was ever short: four options, one of them missing for one of the five shipped languages.
213
+ */
214
+ describe('CURRENCIES', () => {
215
+ it('has a currency for every language this module ships', () => {
216
+ // The point of the whole fix. `tr` is on the language menu and TRY was not on this list.
217
+ for (const code of ['USD', 'EUR', 'TRY', 'IRR', 'AED']) expect(CURRENCIES).toContain(code)
218
+ })
219
+
220
+ it('offers codes `Intl` recognises, so a price is never labelled with something invented', () => {
221
+ for (const code of CURRENCIES) {
222
+ const formatted = new Intl.NumberFormat('en', { style: 'currency', currency: code }).format(1)
223
+ // An unknown code comes back as the literal code; a real one gets a symbol or a name, and
224
+ // either way `Intl` accepts it rather than throwing on a malformed one.
225
+ expect({ code, formatted: typeof formatted }).toEqual({ code, formatted: 'string' })
226
+ expect(code).toMatch(/^[A-Z]{3}$/)
227
+ }
228
+ })
229
+
230
+ it('lists each code once', () => {
231
+ expect([...new Set(CURRENCIES)]).toEqual([...CURRENCIES])
232
+ })
233
+
234
+ /**
235
+ * The order the comment above the list claims, which the list did not have.
236
+ *
237
+ * It said "the currency of each shipped language first, in the order the locales are listed
238
+ * everywhere else in this package (en, ar, de, fa, tr)" while the list read `USD, EUR, AED, TRY,
239
+ * IRR` — en, de, ar, tr, fa, which is no order at all. A rule nothing checks is a rule the next
240
+ * person appends underneath and quietly breaks, so it is checked.
241
+ */
242
+ it('opens with the currency of each shipped language, in the locale order', () => {
243
+ expect(CURRENCIES.slice(0, 5)).toEqual(['USD', 'AED', 'EUR', 'IRR', 'TRY'])
244
+ })
245
+
246
+ it('puts the empty option first, because an asset with no price has no currency', () => {
247
+ const options = currencyOptions('—')
248
+ expect(options[0]).toEqual({ value: '', label: '—' })
249
+ expect(options.slice(1).map((o) => o.value)).toEqual([...CURRENCIES])
250
+ // Codes rather than names: ISO 4217 is what the column stores, and a name is one more thing to
251
+ // translate five times for no gain over what somebody reads on their own bank statement.
252
+ expect(options.slice(1).every((o) => o.label === o.value)).toBe(true)
253
+ })
254
+ })
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Reading and writing a price the way the person in front of the form writes it.
3
+ *
4
+ * **This is the file that shipped a silent 100× data loss.** The dialog parsed a price with
5
+ * `Number.parseFloat(raw.replace(',', '.'))`, and `String.replace` with a string pattern replaces
6
+ * only the *first* match — so a German, Turkish or Persian reader typing `1.234,56`, which is how
7
+ * three of the five languages this module ships write twelve hundred, got `1.23456` parsed to
8
+ * `1.23` and stored as €1.23. `1,234.56` in English became the same. Neither said anything: the
9
+ * old parser returned `null` for garbage and the asset was simply saved with no price at all, so
10
+ * `abc` and `-5` both vanished without an error.
11
+ *
12
+ * So: no separator is assumed, the locale is asked what its own are, and anything that is not a
13
+ * plain non-negative number in *that* locale comes back as a rejection the form can show. There is
14
+ * no third outcome where a wrong number is stored quietly.
15
+ *
16
+ * Pure on purpose — the locale arrives as an argument rather than being read from `@kernhq/ui`.
17
+ * That entry point reaches Svelte components, and a parser that drags a compiler behind it is a
18
+ * parser nothing can unit-test. The dialog passes `messageLocale()`.
19
+ */
20
+
21
+ /** `minor: null` is a real answer — an empty field means "no price", not a bad one. */
22
+ export type PriceResult = { ok: true; minor: number | null } | { ok: false }
23
+
24
+ /**
25
+ * The currencies a money field offers, in one place because two forms ask for one.
26
+ *
27
+ * The asset form and the repair form put the same question — what is this amount in — and a second
28
+ * copy of the list is a list that gains a currency in one form and not the other. Codes rather than
29
+ * names: ISO 4217 is what the column stores, and a currency name is one more thing to translate
30
+ * five times for no gain over the code somebody already reads on their own bank statement.
31
+ *
32
+ * **This list was `['USD', 'EUR', 'IRR', 'AED']` in a product that ships Turkish.** A Turkish
33
+ * workspace could not record what it paid for anything in lira: the contract takes any three-letter
34
+ * code (`z.string().length(3)`) and the server stores whatever arrives, so the shortage was the
35
+ * picker's alone — four options, one of which was missing for one of the five languages on the
36
+ * language menu. TRY, and the currency of every other locale this module ships, is now here.
37
+ *
38
+ * The order is deliberate and is not alphabetical: the currency of each shipped language first, in
39
+ * the order the locales are listed everywhere else in this package (en, ar, de, fa, tr) — so USD,
40
+ * AED, EUR, IRR, TRY — then the ones a company in those places is most likely to have paid an
41
+ * invoice in. A picker sorted A–Z puts AED above USD for a reader in Berlin, which is tidy and
42
+ * useless.
43
+ *
44
+ * **The comment said that while the list said `USD, EUR, AED, TRY, IRR`**, which is no order at
45
+ * all — en, de, ar, tr, fa — so the rule a sixth currency was supposed to be filed under described
46
+ * something that had never been true. `price.test.ts` now asserts the first five, because a stated
47
+ * order nothing checks drifts back the first time somebody appends a code.
48
+ *
49
+ * It is still a list, not the whole of ISO 4217: all 180 codes in a `<select>` is not a kindness.
50
+ *
51
+ * **What a new asset defaults to is deliberately nothing.** The obvious improvement on "the first
52
+ * code in the list" is "whatever this workspace uses", and that is right — but a workspace currency
53
+ * does not exist: `InventorySettings` holds the tag prefix, the tag padding and the two notice
54
+ * windows, and nothing else, so `defaultCurrency` is a contract change rather than something a
55
+ * picker can invent. The tempting shortcut is to read it off the reader's locale, and that is
56
+ * *worse* than no default: two colleagues in one workspace, one reading German and one reading
57
+ * Turkish, would be handed different pre-selected currencies for the same register, notice nothing,
58
+ * and store EUR against one laptop and TRY against the next. A currency is a fact about the
59
+ * company, not about the language somebody happens to read the screen in. So the forms seed from
60
+ * the *record* — a repair inherits its asset's currency, an edit keeps its own — and a new asset
61
+ * starts empty, which asks the question instead of answering it wrongly.
62
+ */
63
+ export const CURRENCIES = [
64
+ 'USD',
65
+ 'AED',
66
+ 'EUR',
67
+ 'IRR',
68
+ 'TRY',
69
+ 'GBP',
70
+ 'SAR',
71
+ 'QAR',
72
+ 'KWD',
73
+ 'EGP',
74
+ 'CHF',
75
+ 'SEK',
76
+ 'NOK',
77
+ 'DKK',
78
+ 'PLN',
79
+ 'CAD',
80
+ 'AUD',
81
+ 'JPY',
82
+ 'CNY',
83
+ 'INR',
84
+ ] as const
85
+
86
+ /**
87
+ * The options both money fields offer, built once.
88
+ *
89
+ * The empty option is first and is a real answer: an asset with no price has no currency either,
90
+ * and a picker with no way back to "none" is a field somebody can fill in and never clear.
91
+ */
92
+ export function currencyOptions(noneLabel: string): Array<{ value: string; label: string }> {
93
+ return [{ value: '', label: noneLabel }, ...CURRENCIES.map((code) => ({ value: code, label: code }))]
94
+ }
95
+
96
+ const EXPONENTS: Map<string, number> = new Map()
97
+
98
+ /**
99
+ * How many decimal places this currency has — asked of the currency, never assumed to be two.
100
+ *
101
+ * **This module stored ¥1000 as ¥10 and every KWD amount ten times too small.** `parsePrice` and
102
+ * `formatPrice` both hard-coded 1/100, and the picker offers JPY, which has *no* minor unit, and
103
+ * KWD, which has *three* decimal places. So `1000` typed into a yen field became 100000 minor
104
+ * units, read back as `1000.00`, and was ¥100 000 to anything that read the column honestly — a
105
+ * silent 100× on exactly the shape of field this file was rewritten to stop silently mangling.
106
+ *
107
+ * **IRR is the one that matters most here**, because it is the currency of a language this module
108
+ * ships: the rial has no minor unit either, so a Persian workspace was the default victim.
109
+ *
110
+ * CLDR knows each currency's exponent and `Intl` will hand it over, so nothing is tabulated: a
111
+ * currency added to `CURRENCIES` next year gets the right answer without anybody remembering this
112
+ * function exists. A code `Intl` refuses, or one it has never heard of, falls back to two — which
113
+ * is what ISO 4217 assigns by default, and is the only guess available.
114
+ *
115
+ * The locale is fixed at `'en'` deliberately: the exponent is a fact about the *currency*, and
116
+ * asking under the reader's locale would make the same money parse differently for two colleagues.
117
+ */
118
+ export function minorUnitExponent(currency: string | null | undefined): number {
119
+ if (!currency) return 2
120
+ const cached = EXPONENTS.get(currency)
121
+ if (cached !== undefined) return cached
122
+ let exponent = 2
123
+ try {
124
+ // `maximumFractionDigits` is optional in the DOM lib and always present in practice for a
125
+ // currency formatter; `??` is what keeps the two type-checks agreeing about that.
126
+ exponent =
127
+ new Intl.NumberFormat('en', { style: 'currency', currency }).resolvedOptions().maximumFractionDigits ??
128
+ 2
129
+ } catch {
130
+ // A malformed code — the contract takes any three letters and the picker is not the only way
131
+ // in. Two decimals is ISO 4217's own default and keeps the field usable.
132
+ }
133
+ if (!Number.isInteger(exponent) || exponent < 0 || exponent > 4) exponent = 2
134
+ EXPONENTS.set(currency, exponent)
135
+ return exponent
136
+ }
137
+
138
+ interface Separators {
139
+ group: string
140
+ decimal: string
141
+ }
142
+
143
+ const SEPARATORS: Map<string, Separators> = new Map()
144
+
145
+ /**
146
+ * What this locale's own numbers look like, asked of `Intl` rather than tabulated here.
147
+ *
148
+ * A table of separators per language is a table that is wrong for the sixth language somebody adds.
149
+ * Measured for the five shipped today: en and ar group with `,` and point with `.`, de and tr are
150
+ * the other way round, and fa uses `٬` and `٫`.
151
+ */
152
+ function separatorsFor(locale: string): Separators {
153
+ const cached = SEPARATORS.get(locale)
154
+ if (cached) return cached
155
+ const found: Separators = { group: ',', decimal: '.' }
156
+ try {
157
+ const parts = new Intl.NumberFormat(locale).formatToParts(12345.6)
158
+ found.group = parts.find((part) => part.type === 'group')?.value ?? found.group
159
+ found.decimal = parts.find((part) => part.type === 'decimal')?.value ?? found.decimal
160
+ } catch {
161
+ // A runtime that cannot name this locale must not make the field unusable; English separators
162
+ // are the fallback, and a wrong guess shows an error rather than storing a wrong number.
163
+ }
164
+ SEPARATORS.set(locale, found)
165
+ return found
166
+ }
167
+
168
+ /**
169
+ * A Persian keyboard produces ۱۲۳ and an Arabic one ١٢٣, and `Number` reads neither.
170
+ *
171
+ * Same fold HR's employee counter does, and for the same reason: a person typing the digits of
172
+ * their own language into their own language's form must not be told their input is not a number.
173
+ */
174
+ const toLatinDigits = (value: string) =>
175
+ value
176
+ .replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660))
177
+ .replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
178
+
179
+ /** Bidi controls ride along on anything copied out of an RTL document and are never separators. */
180
+ const BIDI = /[‎‏؜⁦-⁩]/g
181
+
182
+ /**
183
+ * A group separator always precedes exactly three digits.
184
+ *
185
+ * This is the rule that stops the quiet 100×: in German `.` groups, so `1234.56` is not "one
186
+ * thousand two hundred and thirty-four point five six" — it is not a well-formed German number at
187
+ * all. Stripping the dot would store €12,345.60 for a €1,234.56 laptop and nothing would say so.
188
+ * Rejecting it puts the question back to the person, which is the only honest answer available.
189
+ */
190
+ function isGroupedInteger(text: string, group: string): boolean {
191
+ if (/^\d+$/.test(text)) return true
192
+ if (!group || !text.includes(group)) return false
193
+ const [first, ...rest] = text.split(group)
194
+ if (first === undefined || !/^\d{1,3}$/.test(first)) return false
195
+ return rest.every((chunk) => /^\d{3}$/.test(chunk))
196
+ }
197
+
198
+ /**
199
+ * Minor units are the wire format; the form takes what somebody reads off the receipt.
200
+ *
201
+ * Returns `{ ok: false }` for anything that is not a non-negative number in `locale` — including a
202
+ * negative one, which the contract has no way to mean and the old parser dropped in silence.
203
+ *
204
+ * `currency` decides how many decimal places the amount is allowed and what it is scaled by. More
205
+ * fractional digits than the currency has is a **rejection, not a rounding**: `19.99` in a yen
206
+ * field is not ¥20, it is somebody who has not noticed which field they are in, and quietly
207
+ * storing a number nobody typed is the whole class of failure this file exists to end. Omitting
208
+ * `currency` keeps two decimals, which is what an amount with no currency on it has always meant.
209
+ *
210
+ * The scaled integer is built by **concatenating digits**, never by multiplying: `19.99 * 100` is
211
+ * `1998.9999999999998` in a double, and the `Math.round` that used to hide it would have had to
212
+ * hide `1234.567 * 1000` too. Digits in, digits out, and no float on the path at all.
213
+ */
214
+ export function parsePrice(raw: string, locale: string, currency?: string | null): PriceResult {
215
+ const text = toLatinDigits(raw).replace(BIDI, '').trim()
216
+ if (!text) return { ok: true, minor: null }
217
+
218
+ const { group, decimal } = separatorsFor(locale)
219
+ const pieces = text.split(decimal)
220
+ if (pieces.length > 2) return { ok: false }
221
+
222
+ let whole = pieces[0] ?? ''
223
+ const frac = pieces[1]
224
+ // "‚50" is how somebody writes half a unit; the leading zero is implied, not missing.
225
+ if (whole === '' && frac !== undefined) whole = '0'
226
+
227
+ if (!isGroupedInteger(whole, group)) return { ok: false }
228
+ if (frac !== undefined && !/^\d+$/.test(frac)) return { ok: false }
229
+
230
+ const exponent = minorUnitExponent(currency)
231
+ // A currency with no minor unit has no decimal point either, so `frac` of any length fails here.
232
+ if (frac !== undefined && frac.length > exponent) return { ok: false }
233
+
234
+ const digits = group ? whole.split(group).join('') : whole
235
+ const minor = Number(`${digits}${(frac ?? '').padEnd(exponent, '0')}`)
236
+ // A price that cannot survive the round trip through a double is not a price anybody typed.
237
+ if (!Number.isSafeInteger(minor)) return { ok: false }
238
+ return { ok: true, minor }
239
+ }
240
+
241
+ /**
242
+ * The stored price, back in the field, in the reader's own numbers.
243
+ *
244
+ * The edit form has to seed itself from `priceMinor`, and seeding it with `1234.56` for a German
245
+ * reader would make them look at a number their own locale says is €123,456 — then `parsePrice`
246
+ * would reject it on the grouping rule and they would be told their own data is invalid. What comes
247
+ * out of here is exactly what goes back in, for the currency the amount is in.
248
+ */
249
+ export function formatPrice(
250
+ minor: number | null | undefined,
251
+ locale: string,
252
+ currency?: string | null,
253
+ ): string {
254
+ if (minor === null || minor === undefined) return ''
255
+ const exponent = minorUnitExponent(currency)
256
+ const value = minor / 10 ** exponent
257
+ try {
258
+ return new Intl.NumberFormat(locale, {
259
+ minimumFractionDigits: exponent,
260
+ maximumFractionDigits: exponent,
261
+ useGrouping: false,
262
+ }).format(value)
263
+ } catch {
264
+ return String(value)
265
+ }
266
+ }
267
+
268
+ /**
269
+ * The amount the "enter an amount like…" error holds up as an example.
270
+ *
271
+ * It has to be an amount the field would *accept*, or the error tells somebody to type something
272
+ * that is also rejected. `1234.56` was hard-coded, so a yen field said "Enter an amount like
273
+ * 1234.56" and then refused it. Built from the exponent instead: `1234` in yen, `1234.567` in
274
+ * dinars, and the reader's own separators and digits around it either way.
275
+ */
276
+ export function priceExample(locale: string, currency?: string | null): string {
277
+ const exponent = minorUnitExponent(currency)
278
+ return formatPrice(Number(`1234${'567'.slice(0, exponent).padEnd(exponent, '0')}`), locale, currency)
279
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { inventoryKeys } from './query.js'
3
+
4
+ /**
5
+ * The realtime contract, held to by a test because nothing else can hold it.
6
+ *
7
+ * `realtime.svelte.ts` calls `invalidateQueries({ queryKey: [module, entity] })` with whatever the
8
+ * server put in the change event, and `src/server/router.ts` emits `entity: 'asset'`. A key whose
9
+ * second segment is anything else is simply never invalidated — no error, no warning, just a list
10
+ * that stops updating. That is what `['inventory', 'assets', …]` did: the detail panel refreshed
11
+ * and the list behind it did not, and the only way to see it was to write to the module from a
12
+ * second browser.
13
+ *
14
+ * Proven against `partialMatchKey` from `@tanstack/query-core`, which is what
15
+ * `invalidateQueries` filters with; asserted here on the segment itself, so the test does not need
16
+ * the query runtime to state the rule it is protecting.
17
+ */
18
+ const WS = '01920000-0000-7000-8000-000000000001'
19
+
20
+ /** Must equal the `entity` string in `src/server/router.ts`'s `notify.change` call. */
21
+ const ENTITY = 'asset'
22
+
23
+ describe('inventoryKeys', () => {
24
+ it('names the entity exactly as the server emits it, on every key', () => {
25
+ for (const [name, key] of [
26
+ ['assets', inventoryKeys.assets(WS)],
27
+ ['assets(filtered)', inventoryKeys.assets(WS, { archived: false })],
28
+ ['asset', inventoryKeys.asset(WS, 'some-id')],
29
+ ] as const)
30
+ expect({ name, module: key[0], entity: key[1] }).toEqual({
31
+ name,
32
+ module: 'inventory',
33
+ entity: ENTITY,
34
+ })
35
+ })
36
+
37
+ it('puts the list and the row under one prefix, so one change event reaches both', () => {
38
+ const list = inventoryKeys.assets(WS, { archived: false })
39
+ const row = inventoryKeys.asset(WS, 'some-id')
40
+ expect(list.slice(0, 2)).toEqual(row.slice(0, 2))
41
+ })
42
+
43
+ it('makes a filtered list a different question from an unfiltered one', () => {
44
+ expect(inventoryKeys.assets(WS, { archived: true })).not.toEqual(
45
+ inventoryKeys.assets(WS, { archived: false }),
46
+ )
47
+ expect(inventoryKeys.assets(WS, { archived: false })).not.toEqual(inventoryKeys.assets(WS))
48
+ })
49
+
50
+ it('does not let two workspaces share a cache entry', () => {
51
+ expect(inventoryKeys.assets('ws-a')).not.toEqual(inventoryKeys.assets('ws-b'))
52
+ })
53
+
54
+ it('starts every key with the module, so one invalidation can clear all of Inventory', () => {
55
+ for (const key of [inventoryKeys.all, inventoryKeys.assets(WS), inventoryKeys.asset(WS, 'id')])
56
+ expect(key[0]).toBe('inventory')
57
+ })
58
+ })