@classytic/primitives 0.26.4 → 0.28.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.
- package/CHANGELOG.md +1119 -1057
- package/dist/bank-transaction.d.mts +1 -1
- package/dist/csv.d.mts +36 -0
- package/dist/csv.mjs +50 -0
- package/dist/currency.d.mts +1 -1
- package/dist/monetization.d.mts +92 -6
- package/dist/monetization.mjs +68 -5
- package/dist/money-BBOARE5f.mjs +204 -0
- package/dist/{money-CfAvfNiM.d.mts → money-C9iLWiWR.d.mts} +21 -1
- package/dist/money.d.mts +2 -2
- package/dist/money.mjs +3 -177
- package/dist/payment-events.d.mts +1 -1
- package/dist/payment-gateway.d.mts +1 -1
- package/dist/proration.d.mts +1 -1
- package/dist/unit-cost-rate.d.mts +1 -1
- package/dist/unit-cost-rate.mjs +1 -1
- package/package.json +285 -281
package/dist/csv.d.mts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
//#region src/serialization/csv.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* `@classytic/primitives/csv` — make one value safe to put in a CSV cell.
|
|
4
|
+
*
|
|
5
|
+
* Two defences, deliberately separable because a consumer that already has a
|
|
6
|
+
* real CSV writer must NOT get its quoting done twice:
|
|
7
|
+
*
|
|
8
|
+
* - `neutralizeCsvFormula` — stops the cell being executed as a formula when
|
|
9
|
+
* the file is opened in Excel / Sheets / LibreOffice. Use this when a
|
|
10
|
+
* library (`csv-stringify`, `papaparse`, …) is doing the quoting.
|
|
11
|
+
* - `escapeCsvCell` — neutralize + RFC 4180 quoting. Use this in a
|
|
12
|
+
* hand-rolled `rows.map(...).join(',')` serializer.
|
|
13
|
+
*
|
|
14
|
+
* WHY quoting is not enough: a spreadsheet strips the quotes first and
|
|
15
|
+
* evaluates what is left, so `"=cmd|'/c calc'!A1"` still executes. Only
|
|
16
|
+
* changing the LEADING character stops it, which is why these are one module
|
|
17
|
+
* and not a quoting helper someone can use alone.
|
|
18
|
+
*
|
|
19
|
+
* Well-formed numbers are exempt. Prefixing `-1500` would make every negative
|
|
20
|
+
* amount text and break the reader's `SUM` — a fix that quietly corrupts every
|
|
21
|
+
* finance export is worse than the hole. The exemption is the WHOLE cell
|
|
22
|
+
* matching a number, so `-2+3+cmd|'/c calc'!A0` is still neutralized.
|
|
23
|
+
*
|
|
24
|
+
* NOT for a machine-to-machine upload contract with a fixed column spec (a
|
|
25
|
+
* courier's bulk-import CSV, say) — there the receiver is a parser, not a
|
|
26
|
+
* spreadsheet, and an added `'` corrupts the payload.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Prefix `'` when the cell would otherwise be evaluated as a formula.
|
|
30
|
+
* `null`/`undefined` become `''`. Numbers are returned unchanged.
|
|
31
|
+
*/
|
|
32
|
+
declare function neutralizeCsvFormula(value: unknown): string;
|
|
33
|
+
/** `neutralizeCsvFormula` + RFC 4180 quoting. The one-call form. */
|
|
34
|
+
declare function escapeCsvCell(value: unknown): string;
|
|
35
|
+
//#endregion
|
|
36
|
+
export { escapeCsvCell, neutralizeCsvFormula };
|
package/dist/csv.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//#region src/serialization/csv.ts
|
|
2
|
+
/**
|
|
3
|
+
* `@classytic/primitives/csv` — make one value safe to put in a CSV cell.
|
|
4
|
+
*
|
|
5
|
+
* Two defences, deliberately separable because a consumer that already has a
|
|
6
|
+
* real CSV writer must NOT get its quoting done twice:
|
|
7
|
+
*
|
|
8
|
+
* - `neutralizeCsvFormula` — stops the cell being executed as a formula when
|
|
9
|
+
* the file is opened in Excel / Sheets / LibreOffice. Use this when a
|
|
10
|
+
* library (`csv-stringify`, `papaparse`, …) is doing the quoting.
|
|
11
|
+
* - `escapeCsvCell` — neutralize + RFC 4180 quoting. Use this in a
|
|
12
|
+
* hand-rolled `rows.map(...).join(',')` serializer.
|
|
13
|
+
*
|
|
14
|
+
* WHY quoting is not enough: a spreadsheet strips the quotes first and
|
|
15
|
+
* evaluates what is left, so `"=cmd|'/c calc'!A1"` still executes. Only
|
|
16
|
+
* changing the LEADING character stops it, which is why these are one module
|
|
17
|
+
* and not a quoting helper someone can use alone.
|
|
18
|
+
*
|
|
19
|
+
* Well-formed numbers are exempt. Prefixing `-1500` would make every negative
|
|
20
|
+
* amount text and break the reader's `SUM` — a fix that quietly corrupts every
|
|
21
|
+
* finance export is worse than the hole. The exemption is the WHOLE cell
|
|
22
|
+
* matching a number, so `-2+3+cmd|'/c calc'!A0` is still neutralized.
|
|
23
|
+
*
|
|
24
|
+
* NOT for a machine-to-machine upload contract with a fixed column spec (a
|
|
25
|
+
* courier's bulk-import CSV, say) — there the receiver is a parser, not a
|
|
26
|
+
* spreadsheet, and an added `'` corrupts the payload.
|
|
27
|
+
*/
|
|
28
|
+
/** Leading characters a spreadsheet may treat as the start of a formula or DDE call. */
|
|
29
|
+
const FORMULA_LEAD = /^[=+\-@\t\r]/;
|
|
30
|
+
/** The WHOLE cell is a plain number — no separators, no currency symbol. */
|
|
31
|
+
const WHOLE_NUMBER = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?$/;
|
|
32
|
+
/** Characters that force RFC 4180 quoting. */
|
|
33
|
+
const NEEDS_QUOTING = /[",\r\n]/;
|
|
34
|
+
/**
|
|
35
|
+
* Prefix `'` when the cell would otherwise be evaluated as a formula.
|
|
36
|
+
* `null`/`undefined` become `''`. Numbers are returned unchanged.
|
|
37
|
+
*/
|
|
38
|
+
function neutralizeCsvFormula(value) {
|
|
39
|
+
const text = value == null ? "" : String(value);
|
|
40
|
+
if (!FORMULA_LEAD.test(text)) return text;
|
|
41
|
+
if (WHOLE_NUMBER.test(text)) return text;
|
|
42
|
+
return `'${text}`;
|
|
43
|
+
}
|
|
44
|
+
/** `neutralizeCsvFormula` + RFC 4180 quoting. The one-call form. */
|
|
45
|
+
function escapeCsvCell(value) {
|
|
46
|
+
const text = neutralizeCsvFormula(value);
|
|
47
|
+
return NEEDS_QUOTING.test(text) ? `"${text.replace(/"/g, "\"\"")}"` : text;
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
export { escapeCsvCell, neutralizeCsvFormula };
|
package/dist/currency.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { A as
|
|
1
|
+
import { A as FxRounding, B as reverseWithSnapshot, D as CURRENCY_PATTERN, E as CURRENCIES, F as currencyCode, I as isCurrencyCode, L as isFxSnapshot, M as InvalidCurrencyCodeError, N as MINOR_UNIT_FACTOR, O as CurrencyCode, P as convertWithSnapshot, R as isKnownCurrency, V as toCurrencyCode, j as FxSnapshot, k as CurrencyMismatchError, z as minorUnitFactor } from "./money-C9iLWiWR.mjs";
|
|
2
2
|
export { CURRENCIES, CURRENCY_PATTERN, CurrencyCode, CurrencyMismatchError, FxRounding, FxSnapshot, InvalidCurrencyCodeError, MINOR_UNIT_FACTOR, convertWithSnapshot, currencyCode, isCurrencyCode, isFxSnapshot, isKnownCurrency, minorUnitFactor, reverseWithSnapshot, toCurrencyCode };
|
package/dist/monetization.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as Money } from "./money-
|
|
1
|
+
import { n as Money } from "./money-C9iLWiWR.mjs";
|
|
2
2
|
//#region src/money/monetization.d.ts
|
|
3
3
|
/**
|
|
4
4
|
* A map of currency code → {@link Money} for explicit per-currency pricing.
|
|
@@ -17,8 +17,11 @@ type MoneyByCurrency = Record<string, Money>;
|
|
|
17
17
|
* - `bundle` — a composite priced from its members.
|
|
18
18
|
* - `usage` — metered / per-seat / consumption (billed per unit or in
|
|
19
19
|
* arrears). The extension point for rating models.
|
|
20
|
+
* - `pass` — paid once, grants a TERM, never renews (a course access
|
|
21
|
+
* pass, a gym or transit pass, a parking or day pass, a
|
|
22
|
+
* term licence). See {@link PassMonetization}.
|
|
20
23
|
*/
|
|
21
|
-
declare const MONETIZATION_KINDS: readonly ["free", "one_time", "subscription", "bundle", "usage"];
|
|
24
|
+
declare const MONETIZATION_KINDS: readonly ["free", "one_time", "subscription", "bundle", "usage", "pass"];
|
|
22
25
|
type MonetizationKind = (typeof MONETIZATION_KINDS)[number];
|
|
23
26
|
/** True when `value` is one of the canonical kinds. */
|
|
24
27
|
declare function isMonetizationKind(value: unknown): value is MonetizationKind;
|
|
@@ -103,6 +106,31 @@ interface SubscriptionPlan {
|
|
|
103
106
|
/** Optional auto-calculated discount vs the highest-frequency plan. */
|
|
104
107
|
readonly discount?: number;
|
|
105
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* One buyable term: a price that grants access for a fixed span.
|
|
111
|
+
*
|
|
112
|
+
* Shaped like {@link SubscriptionPlan} on purpose (same `duration` +
|
|
113
|
+
* `durationUnit` vocabulary, so a term means the same thing everywhere) and
|
|
114
|
+
* kept separate on purpose: a plan carries `trialDays` and a renewal discount,
|
|
115
|
+
* which are recurring-billing concepts a pass has none of. Reusing the plan
|
|
116
|
+
* would re-blur the very line this kind exists to draw.
|
|
117
|
+
*/
|
|
118
|
+
interface AccessPass {
|
|
119
|
+
/** Internal key ('pass-30d', 'season', 'day'). Stable across edits. */
|
|
120
|
+
readonly key: string;
|
|
121
|
+
/** Display label ("1 month", "Season pass", "Day pass"). */
|
|
122
|
+
readonly label: string;
|
|
123
|
+
/** Price for this term in the default currency. */
|
|
124
|
+
readonly price: Money;
|
|
125
|
+
/** Explicit per-currency overrides. */
|
|
126
|
+
readonly priceByCurrency?: MoneyByCurrency;
|
|
127
|
+
/** Optional strikethrough / "was" price for this term. */
|
|
128
|
+
readonly compareAtPrice?: Money;
|
|
129
|
+
/** Length of access this term buys. */
|
|
130
|
+
readonly duration: number;
|
|
131
|
+
/** Unit of that length. */
|
|
132
|
+
readonly durationUnit: DurationUnit;
|
|
133
|
+
}
|
|
106
134
|
/**
|
|
107
135
|
* One band of a metered rate table.
|
|
108
136
|
*
|
|
@@ -180,17 +208,63 @@ interface UsageMonetization {
|
|
|
180
208
|
readonly durationUnit: DurationUnit;
|
|
181
209
|
};
|
|
182
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* Paid once, grants a TERM, never renews.
|
|
213
|
+
*
|
|
214
|
+
* The gap this closes: `one_time` prices a thing but says nothing about how
|
|
215
|
+
* long it is yours, and `subscription` prices a term but means it RECURS. A
|
|
216
|
+
* prepaid term fits neither, so hosts selling one reached for `subscription`
|
|
217
|
+
* and then had to explain, wherever the value was read, that it does not
|
|
218
|
+
* actually renew — a classification that needs a comment to be understood has
|
|
219
|
+
* already failed, and `toRevenueMonetizationType` would have settled those
|
|
220
|
+
* sales as subscriptions in the ledger.
|
|
221
|
+
*
|
|
222
|
+
* Buying again EXTENDS rather than renewing: nothing schedules a charge, the
|
|
223
|
+
* buyer chooses when (or whether) to pay for the next term. A perpetual sale
|
|
224
|
+
* is `one_time`, not a pass with an enormous duration: "forever" is the
|
|
225
|
+
* absence of a term, not a long one.
|
|
226
|
+
*/
|
|
227
|
+
interface PassMonetization {
|
|
228
|
+
readonly type: 'pass';
|
|
229
|
+
/** At least one term. Several means the buyer picks (1 month / 1 year). */
|
|
230
|
+
readonly passes: readonly AccessPass[];
|
|
231
|
+
}
|
|
183
232
|
/**
|
|
184
233
|
* The canonical monetization value object. The `type` discriminant selects the
|
|
185
234
|
* pricing detail. Matches `@classytic/catalog`'s existing discriminants (so no
|
|
186
235
|
* data migration) plus `usage`.
|
|
187
236
|
*/
|
|
188
|
-
type Monetization = FreeMonetization | OneTimeMonetization | SubscriptionMonetization | BundleMonetization | UsageMonetization;
|
|
237
|
+
type Monetization = FreeMonetization | OneTimeMonetization | SubscriptionMonetization | BundleMonetization | UsageMonetization | PassMonetization;
|
|
189
238
|
declare const isFreeMonetization: (m: Monetization) => m is FreeMonetization;
|
|
190
239
|
declare const isOneTimeMonetization: (m: Monetization) => m is OneTimeMonetization;
|
|
191
240
|
declare const isSubscriptionMonetization: (m: Monetization) => m is SubscriptionMonetization;
|
|
192
241
|
declare const isBundleMonetization: (m: Monetization) => m is BundleMonetization;
|
|
193
242
|
declare const isUsageMonetization: (m: Monetization) => m is UsageMonetization;
|
|
243
|
+
declare const isPassMonetization: (m: Monetization) => m is PassMonetization;
|
|
244
|
+
/**
|
|
245
|
+
* A term's length in APPROXIMATE days, for ORDERING AND LABELS ONLY.
|
|
246
|
+
*
|
|
247
|
+
* A month is 30 here and a year 365, which is wrong as a date and right as a
|
|
248
|
+
* comparison: it answers "is this term longer than that one" and "call this
|
|
249
|
+
* one '6 months'", both of which only need a stable ranking.
|
|
250
|
+
*
|
|
251
|
+
* NEVER compute an expiry from it. A term that ENDS on a date must be added
|
|
252
|
+
* with calendar arithmetic (`addMonths` / `addYears`), or February and leap
|
|
253
|
+
* years quietly short-change the buyer. That belongs to the host's date
|
|
254
|
+
* library, not to a pure money value object.
|
|
255
|
+
*/
|
|
256
|
+
declare function approxTermDays(duration: number, unit: DurationUnit): number;
|
|
257
|
+
/**
|
|
258
|
+
* The term a storefront leads with: the CHEAPEST, ties broken by the shortest
|
|
259
|
+
* span so the answer is deterministic whatever order the passes were authored
|
|
260
|
+
* in. One definition, because a card reading the cheapest while a checkout
|
|
261
|
+
* defaulted to the first is the divergence {@link unitPriceOf} exists to stop.
|
|
262
|
+
*
|
|
263
|
+
* The tie-break compares SPANS, not the raw `duration`: "1 year" and "1 month"
|
|
264
|
+
* are both `duration: 1`, so a number-only comparison ranks them by whichever
|
|
265
|
+
* happened to be authored first.
|
|
266
|
+
*/
|
|
267
|
+
declare function defaultPassOf(m: PassMonetization): AccessPass | null;
|
|
194
268
|
/** The kind of a monetization value (its discriminant, narrowed to the union). */
|
|
195
269
|
declare const monetizationKindOf: (m: Monetization) => MonetizationKind;
|
|
196
270
|
/**
|
|
@@ -205,6 +279,8 @@ declare const monetizationKindOf: (m: Monetization) => MonetizationKind;
|
|
|
205
279
|
* - `bundle` → the FIXED bundle price; `null` in `dynamic` mode (that
|
|
206
280
|
* price is computed from the members, not stored here)
|
|
207
281
|
* - `free` → `null`
|
|
282
|
+
* - `pass` → the cheapest term's price (the "from" price a
|
|
283
|
+
* storefront leads with), see {@link defaultPassOf}
|
|
208
284
|
* - `usage` → `null` — a metered item has no single unit price; it is
|
|
209
285
|
* rated per consumption via {@link UsageRating}
|
|
210
286
|
*
|
|
@@ -212,7 +288,14 @@ declare const monetizationKindOf: (m: Monetization) => MonetizationKind;
|
|
|
212
288
|
* it prices, instead of silently returning `null`.
|
|
213
289
|
*/
|
|
214
290
|
declare function unitPriceOf(m: Monetization): Money | null;
|
|
215
|
-
/**
|
|
291
|
+
/**
|
|
292
|
+
* The strikethrough / MSRP price, for the kinds that carry one.
|
|
293
|
+
*
|
|
294
|
+
* Always paired with whatever {@link unitPriceOf} returned, so a card can
|
|
295
|
+
* never strike through the "was" of one term beside the price of another:
|
|
296
|
+
* `one_time` compares against its base price, `pass` against the term
|
|
297
|
+
* `defaultPassOf` picked.
|
|
298
|
+
*/
|
|
216
299
|
declare function compareAtPriceOf(m: Monetization): Money | null;
|
|
217
300
|
/**
|
|
218
301
|
* `@classytic/revenue`'s coarse wire enum. `purchase` is the paid-once case that
|
|
@@ -224,10 +307,13 @@ type RevenueMonetizationType = 'free' | 'purchase' | 'subscription';
|
|
|
224
307
|
/**
|
|
225
308
|
* Canonical kind → revenue wire type. Everything that is not `free` or
|
|
226
309
|
* `subscription` settles as a `purchase` in revenue's ledger vocabulary
|
|
227
|
-
* (one_time, bundle,
|
|
310
|
+
* (one_time, bundle, a usage charge and a pass are all "a purchase
|
|
311
|
+
* happened"). A `pass` in particular must NOT land on `subscription`: nothing
|
|
312
|
+
* schedules a renewal, and a ledger that thinks otherwise forecasts revenue
|
|
313
|
+
* that will never be billed.
|
|
228
314
|
*/
|
|
229
315
|
declare function toRevenueMonetizationType(kind: MonetizationKind): RevenueMonetizationType;
|
|
230
316
|
/** Revenue wire type → canonical kind (`purchase` → `one_time`). */
|
|
231
317
|
declare function fromRevenueMonetizationType(type: RevenueMonetizationType): MonetizationKind;
|
|
232
318
|
//#endregion
|
|
233
|
-
export { BundleMonetization, DurationUnit, FreeMonetization, MONETIZATION_KINDS, Monetization, MonetizationKind, MoneyByCurrency, OneTimeMonetization, OneTimePricing, PriceTier, RevenueMonetizationType, SubscriptionMonetization, SubscriptionPlan, UPFRONT_COLLECTION_MODES, UpfrontCollectionMode, UsageMonetization, UsageRating, UsageTier, compareAtPriceOf, fromRevenueMonetizationType, isBundleMonetization, isFreeMonetization, isMonetizationKind, isOneTimeMonetization, isSubscriptionMonetization, isUpfrontCollectionMode, isUsageMonetization, monetizationKindOf, toRevenueMonetizationType, unitPriceOf };
|
|
319
|
+
export { AccessPass, BundleMonetization, DurationUnit, FreeMonetization, MONETIZATION_KINDS, Monetization, MonetizationKind, MoneyByCurrency, OneTimeMonetization, OneTimePricing, PassMonetization, PriceTier, RevenueMonetizationType, SubscriptionMonetization, SubscriptionPlan, UPFRONT_COLLECTION_MODES, UpfrontCollectionMode, UsageMonetization, UsageRating, UsageTier, approxTermDays, compareAtPriceOf, defaultPassOf, fromRevenueMonetizationType, isBundleMonetization, isFreeMonetization, isMonetizationKind, isOneTimeMonetization, isPassMonetization, isSubscriptionMonetization, isUpfrontCollectionMode, isUsageMonetization, monetizationKindOf, toRevenueMonetizationType, unitPriceOf };
|
package/dist/monetization.mjs
CHANGED
|
@@ -8,13 +8,17 @@
|
|
|
8
8
|
* - `bundle` — a composite priced from its members.
|
|
9
9
|
* - `usage` — metered / per-seat / consumption (billed per unit or in
|
|
10
10
|
* arrears). The extension point for rating models.
|
|
11
|
+
* - `pass` — paid once, grants a TERM, never renews (a course access
|
|
12
|
+
* pass, a gym or transit pass, a parking or day pass, a
|
|
13
|
+
* term licence). See {@link PassMonetization}.
|
|
11
14
|
*/
|
|
12
15
|
const MONETIZATION_KINDS = [
|
|
13
16
|
"free",
|
|
14
17
|
"one_time",
|
|
15
18
|
"subscription",
|
|
16
19
|
"bundle",
|
|
17
|
-
"usage"
|
|
20
|
+
"usage",
|
|
21
|
+
"pass"
|
|
18
22
|
];
|
|
19
23
|
const MONETIZATION_KIND_SET = new Set(MONETIZATION_KINDS);
|
|
20
24
|
/** True when `value` is one of the canonical kinds. */
|
|
@@ -71,6 +75,50 @@ const isOneTimeMonetization = (m) => m.type === "one_time";
|
|
|
71
75
|
const isSubscriptionMonetization = (m) => m.type === "subscription";
|
|
72
76
|
const isBundleMonetization = (m) => m.type === "bundle";
|
|
73
77
|
const isUsageMonetization = (m) => m.type === "usage";
|
|
78
|
+
const isPassMonetization = (m) => m.type === "pass";
|
|
79
|
+
const APPROX_DAYS_PER_UNIT = {
|
|
80
|
+
day: 1,
|
|
81
|
+
week: 7,
|
|
82
|
+
month: 30,
|
|
83
|
+
year: 365
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* A term's length in APPROXIMATE days, for ORDERING AND LABELS ONLY.
|
|
87
|
+
*
|
|
88
|
+
* A month is 30 here and a year 365, which is wrong as a date and right as a
|
|
89
|
+
* comparison: it answers "is this term longer than that one" and "call this
|
|
90
|
+
* one '6 months'", both of which only need a stable ranking.
|
|
91
|
+
*
|
|
92
|
+
* NEVER compute an expiry from it. A term that ENDS on a date must be added
|
|
93
|
+
* with calendar arithmetic (`addMonths` / `addYears`), or February and leap
|
|
94
|
+
* years quietly short-change the buyer. That belongs to the host's date
|
|
95
|
+
* library, not to a pure money value object.
|
|
96
|
+
*/
|
|
97
|
+
function approxTermDays(duration, unit) {
|
|
98
|
+
return duration * APPROX_DAYS_PER_UNIT[unit];
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The term a storefront leads with: the CHEAPEST, ties broken by the shortest
|
|
102
|
+
* span so the answer is deterministic whatever order the passes were authored
|
|
103
|
+
* in. One definition, because a card reading the cheapest while a checkout
|
|
104
|
+
* defaulted to the first is the divergence {@link unitPriceOf} exists to stop.
|
|
105
|
+
*
|
|
106
|
+
* The tie-break compares SPANS, not the raw `duration`: "1 year" and "1 month"
|
|
107
|
+
* are both `duration: 1`, so a number-only comparison ranks them by whichever
|
|
108
|
+
* happened to be authored first.
|
|
109
|
+
*/
|
|
110
|
+
function defaultPassOf(m) {
|
|
111
|
+
let best = null;
|
|
112
|
+
let bestSpan = Number.POSITIVE_INFINITY;
|
|
113
|
+
for (const pass of m.passes) {
|
|
114
|
+
const span = approxTermDays(pass.duration, pass.durationUnit);
|
|
115
|
+
if (best === null || pass.price.amount < best.price.amount || pass.price.amount === best.price.amount && span < bestSpan) {
|
|
116
|
+
best = pass;
|
|
117
|
+
bestSpan = span;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return best;
|
|
121
|
+
}
|
|
74
122
|
/** The kind of a monetization value (its discriminant, narrowed to the union). */
|
|
75
123
|
const monetizationKindOf = (m) => m.type;
|
|
76
124
|
/**
|
|
@@ -85,6 +133,8 @@ const monetizationKindOf = (m) => m.type;
|
|
|
85
133
|
* - `bundle` → the FIXED bundle price; `null` in `dynamic` mode (that
|
|
86
134
|
* price is computed from the members, not stored here)
|
|
87
135
|
* - `free` → `null`
|
|
136
|
+
* - `pass` → the cheapest term's price (the "from" price a
|
|
137
|
+
* storefront leads with), see {@link defaultPassOf}
|
|
88
138
|
* - `usage` → `null` — a metered item has no single unit price; it is
|
|
89
139
|
* rated per consumption via {@link UsageRating}
|
|
90
140
|
*
|
|
@@ -96,18 +146,31 @@ function unitPriceOf(m) {
|
|
|
96
146
|
case "one_time": return m.pricing.basePrice;
|
|
97
147
|
case "subscription": return m.plans[0]?.price ?? null;
|
|
98
148
|
case "bundle": return m.basePrice ?? null;
|
|
149
|
+
case "pass": return defaultPassOf(m)?.price ?? null;
|
|
99
150
|
case "free":
|
|
100
151
|
case "usage": return null;
|
|
101
152
|
}
|
|
102
153
|
}
|
|
103
|
-
/**
|
|
154
|
+
/**
|
|
155
|
+
* The strikethrough / MSRP price, for the kinds that carry one.
|
|
156
|
+
*
|
|
157
|
+
* Always paired with whatever {@link unitPriceOf} returned, so a card can
|
|
158
|
+
* never strike through the "was" of one term beside the price of another:
|
|
159
|
+
* `one_time` compares against its base price, `pass` against the term
|
|
160
|
+
* `defaultPassOf` picked.
|
|
161
|
+
*/
|
|
104
162
|
function compareAtPriceOf(m) {
|
|
105
|
-
|
|
163
|
+
if (m.type === "one_time") return m.pricing.compareAtPrice ?? null;
|
|
164
|
+
if (m.type === "pass") return defaultPassOf(m)?.compareAtPrice ?? null;
|
|
165
|
+
return null;
|
|
106
166
|
}
|
|
107
167
|
/**
|
|
108
168
|
* Canonical kind → revenue wire type. Everything that is not `free` or
|
|
109
169
|
* `subscription` settles as a `purchase` in revenue's ledger vocabulary
|
|
110
|
-
* (one_time, bundle,
|
|
170
|
+
* (one_time, bundle, a usage charge and a pass are all "a purchase
|
|
171
|
+
* happened"). A `pass` in particular must NOT land on `subscription`: nothing
|
|
172
|
+
* schedules a renewal, and a ledger that thinks otherwise forecasts revenue
|
|
173
|
+
* that will never be billed.
|
|
111
174
|
*/
|
|
112
175
|
function toRevenueMonetizationType(kind) {
|
|
113
176
|
if (kind === "free") return "free";
|
|
@@ -121,4 +184,4 @@ function fromRevenueMonetizationType(type) {
|
|
|
121
184
|
return "one_time";
|
|
122
185
|
}
|
|
123
186
|
//#endregion
|
|
124
|
-
export { MONETIZATION_KINDS, UPFRONT_COLLECTION_MODES, compareAtPriceOf, fromRevenueMonetizationType, isBundleMonetization, isFreeMonetization, isMonetizationKind, isOneTimeMonetization, isSubscriptionMonetization, isUpfrontCollectionMode, isUsageMonetization, monetizationKindOf, toRevenueMonetizationType, unitPriceOf };
|
|
187
|
+
export { MONETIZATION_KINDS, UPFRONT_COLLECTION_MODES, approxTermDays, compareAtPriceOf, defaultPassOf, fromRevenueMonetizationType, isBundleMonetization, isFreeMonetization, isMonetizationKind, isOneTimeMonetization, isPassMonetization, isSubscriptionMonetization, isUpfrontCollectionMode, isUsageMonetization, monetizationKindOf, toRevenueMonetizationType, unitPriceOf };
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { CurrencyMismatchError, currencyCode, isCurrencyCode, minorUnitFactor } from "./currency.mjs";
|
|
2
|
+
//#region src/money/cash-destination.ts
|
|
3
|
+
/**
|
|
4
|
+
* WHERE received cash landed, as an INTENT — never a chart-of-accounts code.
|
|
5
|
+
*
|
|
6
|
+
* The one vocabulary a request body, a domain event, a client SDK and a
|
|
7
|
+
* settlement dialog all speak. The accounting tier maps an intent to whatever
|
|
8
|
+
* account the active chart assigns, so a chart renumber cannot reach any of
|
|
9
|
+
* them. A literal code in a client once named a bank account the chart had
|
|
10
|
+
* already renumbered, and every settlement it sent was refused.
|
|
11
|
+
*
|
|
12
|
+
* `cash` — the business's bank / current account, the default for a courier
|
|
13
|
+
* remittance, a card payout or a transfer.
|
|
14
|
+
* `petty_cash` — cash in hand, for an in-person handover at the till.
|
|
15
|
+
*/
|
|
16
|
+
const CASH_DESTINATIONS = ["cash", "petty_cash"];
|
|
17
|
+
function isCashDestination(value) {
|
|
18
|
+
return typeof value === "string" && CASH_DESTINATIONS.includes(value);
|
|
19
|
+
}
|
|
20
|
+
/** Human label for a destination — the ONE place the wording lives. */
|
|
21
|
+
function cashDestinationLabel(destination) {
|
|
22
|
+
switch (destination) {
|
|
23
|
+
case "cash": return "Bank account";
|
|
24
|
+
case "petty_cash": return "Cash in hand";
|
|
25
|
+
default: throw new Error(`unknown cash destination ${String(destination)}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/money/money.ts
|
|
30
|
+
/**
|
|
31
|
+
* Cast a raw number to {@link MinorUnits}. Pass the currency as the second
|
|
32
|
+
* argument to fix the phantom type. With no second arg, `MinorUnits<string>`
|
|
33
|
+
* — useful for generic SDK boundaries that don't know the currency yet.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* MinorUnits(489900, 'BDT') // typed MinorUnits<'BDT'>
|
|
37
|
+
* MinorUnits(199, 'USD') // typed MinorUnits<'USD'>
|
|
38
|
+
*/
|
|
39
|
+
function MinorUnits(amount, _currency) {
|
|
40
|
+
return amount;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Construct {@link Money} from an integer minor-unit amount.
|
|
44
|
+
*
|
|
45
|
+
* @throws TypeError if `amount` is not a finite integer.
|
|
46
|
+
* @throws InvalidCurrencyCodeError if `currency` is not ISO 4217 format.
|
|
47
|
+
*/
|
|
48
|
+
function money(amount, currency) {
|
|
49
|
+
if (!Number.isFinite(amount) || !Number.isInteger(amount)) throw new TypeError(`Money.amount must be a finite integer in minor units, got ${amount}`);
|
|
50
|
+
return {
|
|
51
|
+
amount,
|
|
52
|
+
currency: currencyCode(currency)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Construct {@link Money} from a major-unit number (e.g. dollars, taka) by
|
|
57
|
+
* scaling to the currency's minor unit and rounding half-away-from-zero.
|
|
58
|
+
*
|
|
59
|
+
* Uses `toPrecision(15)` to strip the trailing-bit noise that appears in
|
|
60
|
+
* multiply-then-round (e.g. `0.1 + 0.2 = 0.30000000000000004`). This is
|
|
61
|
+
* enough for the vast majority of decimals encountered in real-world pricing.
|
|
62
|
+
*
|
|
63
|
+
* **Precision caveat.** A handful of decimals cannot be represented exactly
|
|
64
|
+
* in IEEE 754 and will round to the IEEE-closest integer rather than the
|
|
65
|
+
* mathematically-expected one — e.g. `1.005` is stored as `1.00499999…` and
|
|
66
|
+
* rounds to `100` minor units, not `101`. If you need decimal-exact rounding,
|
|
67
|
+
* construct money from a string or use integer minor units directly via
|
|
68
|
+
* {@link money}.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* fromMajor(19.99, 'USD') // { amount: 1999, currency: 'USD' }
|
|
72
|
+
* fromMajor(0.1 + 0.2, 'USD') // { amount: 30, currency: 'USD' }
|
|
73
|
+
* fromMajor(1500, 'JPY') // { amount: 1500, currency: 'JPY' }
|
|
74
|
+
*/
|
|
75
|
+
function fromMajor(major, currency) {
|
|
76
|
+
if (!Number.isFinite(major)) throw new TypeError(`fromMajor received non-finite value: ${major}`);
|
|
77
|
+
const raw = major * minorUnitFactor(currency);
|
|
78
|
+
const cleaned = Number(raw.toPrecision(15));
|
|
79
|
+
return {
|
|
80
|
+
amount: Math.round(Math.abs(cleaned)) * (cleaned < 0 ? -1 : 1),
|
|
81
|
+
currency: currencyCode(currency)
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Convert {@link Money} back to its major-unit floating-point representation. */
|
|
85
|
+
function toMajor(m) {
|
|
86
|
+
return m.amount / minorUnitFactor(m.currency);
|
|
87
|
+
}
|
|
88
|
+
function assertSameCurrency(a, b) {
|
|
89
|
+
if (a.currency !== b.currency) throw new CurrencyMismatchError(a.currency, b.currency);
|
|
90
|
+
}
|
|
91
|
+
function addMoney(a, b) {
|
|
92
|
+
assertSameCurrency(a, b);
|
|
93
|
+
return {
|
|
94
|
+
amount: a.amount + b.amount,
|
|
95
|
+
currency: a.currency
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function subtractMoney(a, b) {
|
|
99
|
+
assertSameCurrency(a, b);
|
|
100
|
+
return {
|
|
101
|
+
amount: a.amount - b.amount,
|
|
102
|
+
currency: a.currency
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Multiply money by a scalar. The result is rounded to an integer number of
|
|
107
|
+
* minor units using half-away-from-zero rounding.
|
|
108
|
+
*/
|
|
109
|
+
function multiplyMoney(m, scalar) {
|
|
110
|
+
if (!Number.isFinite(scalar)) throw new TypeError(`multiplyMoney received non-finite scalar: ${scalar}`);
|
|
111
|
+
const raw = m.amount * scalar;
|
|
112
|
+
return {
|
|
113
|
+
amount: Math.round(Math.abs(raw)) * (raw < 0 ? -1 : 1),
|
|
114
|
+
currency: m.currency
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** Sum a list of {@link Money}. Returns zero in the given currency for empty input. */
|
|
118
|
+
function sumMoney(values, currency) {
|
|
119
|
+
return values.reduce((acc, v) => addMoney(acc, v), {
|
|
120
|
+
amount: 0,
|
|
121
|
+
currency: currencyCode(currency)
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/** Strict equality (amount and currency both match). */
|
|
125
|
+
function equalsMoney(a, b) {
|
|
126
|
+
return a.amount === b.amount && a.currency === b.currency;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Ordering predicate. Returns -1, 0, or 1. Throws on currency mismatch — use
|
|
130
|
+
* {@link equalsMoney} for a total equality check across currencies.
|
|
131
|
+
*/
|
|
132
|
+
function compareMoney(a, b) {
|
|
133
|
+
assertSameCurrency(a, b);
|
|
134
|
+
if (a.amount < b.amount) return -1;
|
|
135
|
+
if (a.amount > b.amount) return 1;
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
function isZeroMoney(m) {
|
|
139
|
+
return m.amount === 0;
|
|
140
|
+
}
|
|
141
|
+
function isPositiveMoney(m) {
|
|
142
|
+
return m.amount > 0;
|
|
143
|
+
}
|
|
144
|
+
function isNegativeMoney(m) {
|
|
145
|
+
return m.amount < 0;
|
|
146
|
+
}
|
|
147
|
+
function negateMoney(m) {
|
|
148
|
+
return {
|
|
149
|
+
amount: m.amount === 0 ? 0 : -m.amount,
|
|
150
|
+
currency: m.currency
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function absMoney(m) {
|
|
154
|
+
return m.amount < 0 ? negateMoney(m) : m;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Decimal major-unit number → integer minor units by decimal exponent.
|
|
158
|
+
* Rounding: raw IEEE `Math.round` — half-up toward +∞ (so `-10.5 → -10`),
|
|
159
|
+
* documented above. Throws when the result leaves the safe-integer range
|
|
160
|
+
* (also catches `NaN`/`Infinity` inputs).
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* majorToMinorUnits(10.5) // 1050
|
|
164
|
+
* majorToMinorUnits(1000, 0) // 1000 (JPY-style)
|
|
165
|
+
* majorToMinorUnits(1.005) // 100 — IEEE, see note above
|
|
166
|
+
*/
|
|
167
|
+
function majorToMinorUnits(major, minorUnitDecimals = 2) {
|
|
168
|
+
const factor = 10 ** minorUnitDecimals;
|
|
169
|
+
const minor = Math.round(major * factor);
|
|
170
|
+
if (!Number.isSafeInteger(minor)) throw new RangeError(`Amount ${major} exceeds safe integer limit when converted to minor units. Max safe amount: ${Number.MAX_SAFE_INTEGER / factor}`);
|
|
171
|
+
return minor;
|
|
172
|
+
}
|
|
173
|
+
/** Integer minor units → decimal major-unit number by decimal exponent: `1050 → 10.5`. */
|
|
174
|
+
function minorUnitsToMajor(minor, minorUnitDecimals = 2) {
|
|
175
|
+
return minor / 10 ** minorUnitDecimals;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Percentage of an integer minor-unit amount → integer minor units.
|
|
179
|
+
* EXACT multiply-then-round: `Math.round((minor × ratePercent) / 100)` —
|
|
180
|
+
* one rounding point, half-up toward +∞. Handles genuinely fractional rates
|
|
181
|
+
* (QST 9.975 %) without precision loss: `percentOfMinor(20000, 9.975) === 1995`.
|
|
182
|
+
*
|
|
183
|
+
* Hosts whose domain guarantees at-most-2-decimal rates MAY snap the rate to
|
|
184
|
+
* integer basis points BEFORE calling (be-prod's `#shared/money` seam does) —
|
|
185
|
+
* that is host policy layered on top, not part of this primitive.
|
|
186
|
+
*/
|
|
187
|
+
function percentOfMinor(minor, ratePercent) {
|
|
188
|
+
if (!Number.isFinite(minor) || !Number.isFinite(ratePercent)) throw new TypeError(`percentOfMinor received non-finite input: minor=${minor}, ratePercent=${ratePercent}`);
|
|
189
|
+
return Math.round(minor * ratePercent / 100);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Integer minor units → plain decimal string (no currency symbol, no locale):
|
|
193
|
+
* `formatMinorUnits(10550) → "105.50"`, `formatMinorUnits(1000, 0) → "1000"`.
|
|
194
|
+
* For localized currency strings use `Intl.NumberFormat` at the display layer.
|
|
195
|
+
*/
|
|
196
|
+
function formatMinorUnits(minor, minorUnitDecimals = 2) {
|
|
197
|
+
return minorUnitsToMajor(minor, minorUnitDecimals).toFixed(minorUnitDecimals);
|
|
198
|
+
}
|
|
199
|
+
/** Structural type guard. */
|
|
200
|
+
function isMoney(value) {
|
|
201
|
+
return typeof value === "object" && value !== null && typeof value.amount === "number" && Number.isInteger(value.amount) && isCurrencyCode(value.currency);
|
|
202
|
+
}
|
|
203
|
+
//#endregion
|
|
204
|
+
export { isCashDestination as C, cashDestinationLabel as S, percentOfMinor as _, equalsMoney as a, toMajor as b, isMoney as c, isZeroMoney as d, majorToMinorUnits as f, negateMoney as g, multiplyMoney as h, compareMoney as i, isNegativeMoney as l, money as m, absMoney as n, formatMinorUnits as o, minorUnitsToMajor as p, addMoney as r, fromMajor as s, MinorUnits as t, isPositiveMoney as u, subtractMoney as v, CASH_DESTINATIONS as x, sumMoney as y };
|
|
@@ -196,6 +196,26 @@ declare function reverseWithSnapshot(base: Money, fx: FxSnapshot, options?: {
|
|
|
196
196
|
*/
|
|
197
197
|
declare function isFxSnapshot(value: unknown): value is FxSnapshot;
|
|
198
198
|
//#endregion
|
|
199
|
+
//#region src/money/cash-destination.d.ts
|
|
200
|
+
/**
|
|
201
|
+
* WHERE received cash landed, as an INTENT — never a chart-of-accounts code.
|
|
202
|
+
*
|
|
203
|
+
* The one vocabulary a request body, a domain event, a client SDK and a
|
|
204
|
+
* settlement dialog all speak. The accounting tier maps an intent to whatever
|
|
205
|
+
* account the active chart assigns, so a chart renumber cannot reach any of
|
|
206
|
+
* them. A literal code in a client once named a bank account the chart had
|
|
207
|
+
* already renumbered, and every settlement it sent was refused.
|
|
208
|
+
*
|
|
209
|
+
* `cash` — the business's bank / current account, the default for a courier
|
|
210
|
+
* remittance, a card payout or a transfer.
|
|
211
|
+
* `petty_cash` — cash in hand, for an in-person handover at the till.
|
|
212
|
+
*/
|
|
213
|
+
declare const CASH_DESTINATIONS: readonly ["cash", "petty_cash"];
|
|
214
|
+
type CashDestination = (typeof CASH_DESTINATIONS)[number];
|
|
215
|
+
declare function isCashDestination(value: unknown): value is CashDestination;
|
|
216
|
+
/** Human label for a destination — the ONE place the wording lives. */
|
|
217
|
+
declare function cashDestinationLabel(destination: CashDestination): string;
|
|
218
|
+
//#endregion
|
|
199
219
|
//#region src/money/money.d.ts
|
|
200
220
|
/**
|
|
201
221
|
* Wire-format monetary amount: integer minor units, branded with a phantom
|
|
@@ -343,4 +363,4 @@ declare function formatMinorUnits(minor: number, minorUnitDecimals?: number): st
|
|
|
343
363
|
/** Structural type guard. */
|
|
344
364
|
declare function isMoney(value: unknown): value is Money;
|
|
345
365
|
//#endregion
|
|
346
|
-
export {
|
|
366
|
+
export { FxRounding as A, reverseWithSnapshot as B, CashDestination as C, CURRENCY_PATTERN as D, CURRENCIES as E, currencyCode as F, isCurrencyCode as I, isFxSnapshot as L, InvalidCurrencyCodeError as M, MINOR_UNIT_FACTOR as N, CurrencyCode as O, convertWithSnapshot as P, isKnownCurrency as R, CASH_DESTINATIONS as S, isCashDestination as T, toCurrencyCode as V, negateMoney as _, compareMoney as a, sumMoney as b, fromMajor as c, isPositiveMoney as d, isZeroMoney as f, multiplyMoney as g, money as h, addMoney as i, FxSnapshot as j, CurrencyMismatchError as k, isMoney as l, minorUnitsToMajor as m, Money as n, equalsMoney as o, majorToMinorUnits as p, absMoney as r, formatMinorUnits as s, MinorUnits as t, isNegativeMoney as u, percentOfMinor as v, cashDestinationLabel as w, toMajor as x, subtractMoney as y, minorUnitFactor as z };
|
package/dist/money.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { T as
|
|
2
|
-
export { CurrencyMismatchError, MinorUnits, Money, absMoney, addMoney, compareMoney, equalsMoney, formatMinorUnits, fromMajor, isMoney, isNegativeMoney, isPositiveMoney, isZeroMoney, majorToMinorUnits, minorUnitsToMajor, money, multiplyMoney, negateMoney, percentOfMinor, subtractMoney, sumMoney, toMajor };
|
|
1
|
+
import { C as CashDestination, S as CASH_DESTINATIONS, T as isCashDestination, _ as negateMoney, a as compareMoney, b as sumMoney, c as fromMajor, d as isPositiveMoney, f as isZeroMoney, g as multiplyMoney, h as money, i as addMoney, k as CurrencyMismatchError, l as isMoney, m as minorUnitsToMajor, n as Money, o as equalsMoney, p as majorToMinorUnits, r as absMoney, s as formatMinorUnits, t as MinorUnits, u as isNegativeMoney, v as percentOfMinor, w as cashDestinationLabel, x as toMajor, y as subtractMoney } from "./money-C9iLWiWR.mjs";
|
|
2
|
+
export { CASH_DESTINATIONS, type CashDestination, CurrencyMismatchError, MinorUnits, Money, absMoney, addMoney, cashDestinationLabel, compareMoney, equalsMoney, formatMinorUnits, fromMajor, isCashDestination, isMoney, isNegativeMoney, isPositiveMoney, isZeroMoney, majorToMinorUnits, minorUnitsToMajor, money, multiplyMoney, negateMoney, percentOfMinor, subtractMoney, sumMoney, toMajor };
|