@particle-academy/fancy-conformance 0.2.0 → 0.4.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.
@@ -0,0 +1,409 @@
1
+ {
2
+ "$schema": "../../../schema/case-table.schema.json",
3
+ "suite": "shared/money-minor-units",
4
+ "cases": [
5
+ {
6
+ "id": "0001-to-minor-classic-float-trap",
7
+ "title": "toMinorUnits of '19.99' at exponent 2 is 1999, not 1998",
8
+ "since": "0.4.0",
9
+ "tags": [
10
+ "toMinorUnits",
11
+ "hazard",
12
+ "float"
13
+ ],
14
+ "fn": "toMinorUnits",
15
+ "input": {
16
+ "amount": "19.99",
17
+ "exponent": 2
18
+ },
19
+ "expected": 1999,
20
+ "notes": "THE money bug. int(19.99 * 100) is 1998 in every IEEE-754 language: the nearest double to 19.99 is 19.98999999999999843..., and truncation takes the 1998. One cent, on every order."
21
+ },
22
+ {
23
+ "id": "0002-to-minor-round-rescues-this-one",
24
+ "title": "toMinorUnits of '0.07' at exponent 2 is 7",
25
+ "since": "0.4.0",
26
+ "tags": [
27
+ "toMinorUnits",
28
+ "float",
29
+ "control"
30
+ ],
31
+ "fn": "toMinorUnits",
32
+ "input": {
33
+ "amount": "0.07",
34
+ "exponent": 2
35
+ },
36
+ "expected": 7,
37
+ "notes": "0.07 * 100 is 7.000000000000001 as a double, so truncation gives 7 here by luck. The control row: an implementation that fixes 0001 by swapping trunc for round still passes this and still fails 0003."
38
+ },
39
+ {
40
+ "id": "0003-to-minor-round-does-not-rescue-this-one",
41
+ "title": "toMinorUnits of '8.615' at exponent 3 is 8615",
42
+ "since": "0.4.0",
43
+ "tags": [
44
+ "toMinorUnits",
45
+ "hazard",
46
+ "float"
47
+ ],
48
+ "fn": "toMinorUnits",
49
+ "input": {
50
+ "amount": "8.615",
51
+ "exponent": 3
52
+ },
53
+ "expected": 8615,
54
+ "notes": "8.615 * 1000 is 8614.999999999999 as a double, so BOTH truncation and a naive round give 8614. The only way through is not to use a float at all. This is the row that forces exact decimal arithmetic rather than a rounding tweak."
55
+ },
56
+ {
57
+ "id": "0004-to-minor-kuwaiti-dinar",
58
+ "title": "toMinorUnits of '1.005' at exponent 3 is 1005",
59
+ "since": "0.4.0",
60
+ "tags": [
61
+ "toMinorUnits",
62
+ "exponent"
63
+ ],
64
+ "fn": "toMinorUnits",
65
+ "input": {
66
+ "amount": "1.005",
67
+ "exponent": 3
68
+ },
69
+ "expected": 1005,
70
+ "notes": "KWD, BHD and JOD have three minor digits. A hard-coded 100 turns 1.005 dinars into 100 fils and undercharges by a factor of ten."
71
+ },
72
+ {
73
+ "id": "0005-to-minor-yen",
74
+ "title": "toMinorUnits of '1000' at exponent 0 is 1000",
75
+ "since": "0.4.0",
76
+ "tags": [
77
+ "toMinorUnits",
78
+ "exponent"
79
+ ],
80
+ "fn": "toMinorUnits",
81
+ "input": {
82
+ "amount": "1000",
83
+ "exponent": 0
84
+ },
85
+ "expected": 1000,
86
+ "notes": "JPY has no minor unit. A hard-coded 100 charges a hundred times the price."
87
+ },
88
+ {
89
+ "id": "0006-to-minor-negative",
90
+ "title": "toMinorUnits of '-19.99' at exponent 2 is -1999",
91
+ "since": "0.4.0",
92
+ "tags": [
93
+ "toMinorUnits",
94
+ "sign"
95
+ ],
96
+ "fn": "toMinorUnits",
97
+ "input": {
98
+ "amount": "-19.99",
99
+ "exponent": 2
100
+ },
101
+ "expected": -1999,
102
+ "notes": "A refund line. abs(f(-x)) == abs(f(x)) is the property that matters for money, and it is the first thing to break when an implementation reaches for floor()."
103
+ },
104
+ {
105
+ "id": "0007-to-minor-zero",
106
+ "title": "toMinorUnits of '0' at exponent 2 is 0",
107
+ "since": "0.4.0",
108
+ "tags": [
109
+ "toMinorUnits"
110
+ ],
111
+ "fn": "toMinorUnits",
112
+ "input": {
113
+ "amount": "0",
114
+ "exponent": 2
115
+ },
116
+ "expected": 0
117
+ },
118
+ {
119
+ "id": "0008-to-minor-one-decimal",
120
+ "title": "toMinorUnits of '0.1' at exponent 2 is 10",
121
+ "since": "0.4.0",
122
+ "tags": [
123
+ "toMinorUnits"
124
+ ],
125
+ "fn": "toMinorUnits",
126
+ "input": {
127
+ "amount": "0.1",
128
+ "exponent": 2
129
+ },
130
+ "expected": 10,
131
+ "notes": "Fewer decimals than the currency has: the value is scaled, not padded as text."
132
+ },
133
+ {
134
+ "id": "0009-to-minor-trailing-zero",
135
+ "title": "toMinorUnits of '8.20' at exponent 2 is 820",
136
+ "since": "0.4.0",
137
+ "tags": [
138
+ "toMinorUnits",
139
+ "parse"
140
+ ],
141
+ "fn": "toMinorUnits",
142
+ "input": {
143
+ "amount": "8.20",
144
+ "exponent": 2
145
+ },
146
+ "expected": 820,
147
+ "notes": "A trailing zero is significant to a text parser and irrelevant to the value. 82 is what a strip-the-dot-and-parse implementation returns."
148
+ },
149
+ {
150
+ "id": "0010-to-minor-leading-dot",
151
+ "title": "toMinorUnits of '.5' at exponent 2 is 50",
152
+ "since": "0.4.0",
153
+ "tags": [
154
+ "toMinorUnits",
155
+ "parse"
156
+ ],
157
+ "fn": "toMinorUnits",
158
+ "input": {
159
+ "amount": ".5",
160
+ "exponent": 2
161
+ },
162
+ "expected": 50,
163
+ "notes": "A leading dot is legal decimal notation and appears in hand-entered prices."
164
+ },
165
+ {
166
+ "id": "0011-to-minor-near-the-double-boundary",
167
+ "title": "toMinorUnits of '9007199254740.99' at exponent 2 is exact",
168
+ "since": "0.4.0",
169
+ "tags": [
170
+ "toMinorUnits",
171
+ "precision"
172
+ ],
173
+ "fn": "toMinorUnits",
174
+ "input": {
175
+ "amount": "9007199254740.99",
176
+ "exponent": 2
177
+ },
178
+ "expected": 900719925474099,
179
+ "notes": "Just under 2^53. Beyond this a JavaScript implementation needs BigInt; this row marks where the boundary is rather than crossing it, because a golden past it could not survive JSON.parse either."
180
+ },
181
+ {
182
+ "id": "0012-to-minor-leading-zeros",
183
+ "title": "toMinorUnits of '007.50' at exponent 2 is 750",
184
+ "since": "0.4.0",
185
+ "tags": [
186
+ "toMinorUnits",
187
+ "parse"
188
+ ],
189
+ "fn": "toMinorUnits",
190
+ "input": {
191
+ "amount": "007.50",
192
+ "exponent": 2
193
+ },
194
+ "expected": 750,
195
+ "notes": "Must NOT be read as octal -- a trap in several languages' integer-parse routines."
196
+ },
197
+ {
198
+ "id": "0013-format-plain",
199
+ "title": "formatMinorUnits of 1999 at exponent 2 is '19.99'",
200
+ "since": "0.4.0",
201
+ "tags": [
202
+ "formatMinorUnits"
203
+ ],
204
+ "fn": "formatMinorUnits",
205
+ "input": {
206
+ "minor": 1999,
207
+ "exponent": 2
208
+ },
209
+ "expected": "19.99"
210
+ },
211
+ {
212
+ "id": "0014-format-negative",
213
+ "title": "formatMinorUnits of -1999 at exponent 2 is '-19.99'",
214
+ "since": "0.4.0",
215
+ "tags": [
216
+ "formatMinorUnits",
217
+ "sign"
218
+ ],
219
+ "fn": "formatMinorUnits",
220
+ "input": {
221
+ "minor": -1999,
222
+ "exponent": 2
223
+ },
224
+ "expected": "-19.99"
225
+ },
226
+ {
227
+ "id": "0015-format-pads-the-fraction",
228
+ "title": "formatMinorUnits of 7 at exponent 2 is '0.07'",
229
+ "since": "0.4.0",
230
+ "tags": [
231
+ "formatMinorUnits",
232
+ "hazard"
233
+ ],
234
+ "fn": "formatMinorUnits",
235
+ "input": {
236
+ "minor": 7,
237
+ "exponent": 2
238
+ },
239
+ "expected": "0.07",
240
+ "notes": "The commonest formatting bug: '0.7'. The fraction is zero-PADDED on the left to the currency's width, and the integer part is 0 rather than absent."
241
+ },
242
+ {
243
+ "id": "0016-format-negative-sub-unit",
244
+ "title": "formatMinorUnits of -7 at exponent 2 is '-0.07'",
245
+ "since": "0.4.0",
246
+ "tags": [
247
+ "formatMinorUnits",
248
+ "sign",
249
+ "hazard"
250
+ ],
251
+ "fn": "formatMinorUnits",
252
+ "input": {
253
+ "minor": -7,
254
+ "exponent": 2
255
+ },
256
+ "expected": "-0.07",
257
+ "notes": "The sign belongs in front of the whole amount. Taking abs() to pad and forgetting to put it back gives '0.07'; applying it after the split gives '0.-07'."
258
+ },
259
+ {
260
+ "id": "0017-format-zero",
261
+ "title": "formatMinorUnits of 0 at exponent 2 is '0.00'",
262
+ "since": "0.4.0",
263
+ "tags": [
264
+ "formatMinorUnits"
265
+ ],
266
+ "fn": "formatMinorUnits",
267
+ "input": {
268
+ "minor": 0,
269
+ "exponent": 2
270
+ },
271
+ "expected": "0.00"
272
+ },
273
+ {
274
+ "id": "0018-format-yen",
275
+ "title": "formatMinorUnits of 1000 at exponent 0 is '1000'",
276
+ "since": "0.4.0",
277
+ "tags": [
278
+ "formatMinorUnits",
279
+ "exponent"
280
+ ],
281
+ "fn": "formatMinorUnits",
282
+ "input": {
283
+ "minor": 1000,
284
+ "exponent": 0
285
+ },
286
+ "expected": "1000",
287
+ "notes": "Exponent 0 means no decimal separator at all -- not '1000.' and not '1000.0'."
288
+ },
289
+ {
290
+ "id": "0019-format-near-the-double-boundary",
291
+ "title": "formatMinorUnits inverts 0011 exactly",
292
+ "since": "0.4.0",
293
+ "tags": [
294
+ "formatMinorUnits",
295
+ "precision"
296
+ ],
297
+ "fn": "formatMinorUnits",
298
+ "input": {
299
+ "minor": 900719925474099,
300
+ "exponent": 2
301
+ },
302
+ "expected": "9007199254740.99",
303
+ "notes": "The inverse of 0011. Together they pin the round trip at the largest amount every runtime can still represent exactly."
304
+ },
305
+ {
306
+ "id": "0020-format-kuwaiti-dinar",
307
+ "title": "formatMinorUnits of 1005 at exponent 3 is '1.005'",
308
+ "since": "0.4.0",
309
+ "tags": [
310
+ "formatMinorUnits",
311
+ "exponent"
312
+ ],
313
+ "fn": "formatMinorUnits",
314
+ "input": {
315
+ "minor": 1005,
316
+ "exponent": 3
317
+ },
318
+ "expected": "1.005"
319
+ },
320
+ {
321
+ "id": "0021-line-total-plain",
322
+ "title": "lineTotal of 1999 x 3 is 5997",
323
+ "since": "0.4.0",
324
+ "tags": [
325
+ "lineTotal"
326
+ ],
327
+ "fn": "lineTotal",
328
+ "input": {
329
+ "unitAmount": 1999,
330
+ "quantity": 3
331
+ },
332
+ "expected": 5997
333
+ },
334
+ {
335
+ "id": "0022-line-total-zero-quantity",
336
+ "title": "lineTotal of 1999 x 0 is 0",
337
+ "since": "0.4.0",
338
+ "tags": [
339
+ "lineTotal"
340
+ ],
341
+ "fn": "lineTotal",
342
+ "input": {
343
+ "unitAmount": 1999,
344
+ "quantity": 0
345
+ },
346
+ "expected": 0
347
+ },
348
+ {
349
+ "id": "0023-line-total-free-item",
350
+ "title": "lineTotal of 0 x 5 is 0",
351
+ "since": "0.4.0",
352
+ "tags": [
353
+ "lineTotal"
354
+ ],
355
+ "fn": "lineTotal",
356
+ "input": {
357
+ "unitAmount": 0,
358
+ "quantity": 5
359
+ },
360
+ "expected": 0
361
+ },
362
+ {
363
+ "id": "0024-line-total-large",
364
+ "title": "lineTotal of 2500 x 1000000 is exact",
365
+ "since": "0.4.0",
366
+ "tags": [
367
+ "lineTotal"
368
+ ],
369
+ "fn": "lineTotal",
370
+ "input": {
371
+ "unitAmount": 2500,
372
+ "quantity": 1000000
373
+ },
374
+ "expected": 2500000000
375
+ },
376
+ {
377
+ "id": "0025-line-total-refund-line",
378
+ "title": "lineTotal of -1999 x 2 is -3998",
379
+ "since": "0.4.0",
380
+ "tags": [
381
+ "lineTotal",
382
+ "sign"
383
+ ],
384
+ "fn": "lineTotal",
385
+ "input": {
386
+ "unitAmount": -1999,
387
+ "quantity": 2
388
+ },
389
+ "expected": -3998,
390
+ "notes": "A credit note is a negative line, and it must be the exact negation of the charge it reverses."
391
+ },
392
+ {
393
+ "id": "0026-line-total-zero-exponent-currency",
394
+ "title": "lineTotal of 150000000 x 100000 is exact",
395
+ "since": "0.4.0",
396
+ "tags": [
397
+ "lineTotal",
398
+ "precision"
399
+ ],
400
+ "fn": "lineTotal",
401
+ "input": {
402
+ "unitAmount": 150000000,
403
+ "quantity": 100000
404
+ },
405
+ "expected": 15000000000000,
406
+ "notes": "1.5e13 -- an ordinary invoice in a currency with no minor unit (IDR, VND). Well within a double, and a reminder that 'the numbers are small' is a USD assumption."
407
+ }
408
+ ]
409
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "$schema": "../../../schema/suite-manifest.schema.json",
3
+ "suite": "shared/money-minor-units",
4
+ "title": "Decimal money to integer minor units, and back",
5
+ "since": "0.4.0",
6
+ "caseFormat": "table",
7
+ "cases": "cases.json",
8
+ "contract": {
9
+ "summary": "The three operations every commerce backend performs on a price, and the one place a currency amount can silently become a float. Stripe stores money as an integer in the currency's minor unit; a human types a decimal string; the conversion between them is where the money is lost.",
10
+ "functions": {
11
+ "toMinorUnits": "toMinorUnits(amount: string, exponent: int) -> int - a plain decimal STRING to whole minor units. Never via a float.",
12
+ "formatMinorUnits": "formatMinorUnits(minor: int, exponent: int) -> string - the inverse, zero-padded, sign in front.",
13
+ "lineTotal": "lineTotal(unitAmount: int, quantity: int) -> int - exact integer multiplication."
14
+ },
15
+ "reference": "python",
16
+ "referenceNote": "Python is the reference only because it is the first runtime to implement these as functions: laravel-catalog and fancy-catalog-js both store unit_amount as an integer and never convert to or from a decimal string, so every consumer does the conversion by hand and gets it wrong on their own. The goldens are exact decimal arithmetic (Python's decimal.Decimal), not what any float happens to produce.",
17
+ "implementations": [
18
+ { "language": "python", "package": "fancy-catalog", "symbol": "fancy_catalog.money" }
19
+ ]
20
+ },
21
+ "notes": [
22
+ "The exponent is the ISO-4217 minor-unit exponent: 2 for USD/EUR, 0 for JPY, 3 for KWD/BHD/JOD. A hard-coded 100 is wrong for roughly a quarter of the world's currencies, and wrong in a direction that multiplies a Kuwaiti price by ten.",
23
+ "0001 is the whole reason this suite exists, and 0003 is why the fix is not a rounding tweak. int(19.99 * 100) is 1998 in every IEEE-754 language; round() rescues that case and NOT 0003. The reference converts through exact decimal arithmetic rather than through a float at all.",
24
+ "NOT COVERED, deliberately: amounts whose minor-unit value exceeds 2^53. A JavaScript implementation needs BigInt beyond it, but a golden for such a case could not survive JSON.parse either, so pinning one here would test the harness rather than the implementation. 0011 and 0019 sit just under the boundary to document where it is.",
25
+ "NOT COVERED: what happens when the input carries more precision than the currency has (toMinorUnits of '0.005' at exponent 2). The reference REFUSES it rather than rounding, because silently rounding a payment amount is how half a cent per transaction goes missing, and a raise has no representation in this table format. Each implementation asserts the refusal in its own suite.",
26
+ "PHP and Node do not implement these yet. That is a finding this suite records rather than an omission in it: laravel-catalog and fancy-catalog-js both receive unit_amount already converted, so the conversion happens in consumer code where nothing checks it."
27
+ ]
28
+ }