@classytic/primitives 0.27.0 → 0.29.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 +122 -0
- package/dist/approval.d.mts +26 -2
- package/dist/approval.mjs +26 -3
- package/dist/bank-transaction.d.mts +1 -1
- package/dist/canonical.d.mts +6 -1
- package/dist/canonical.mjs +10 -4
- package/dist/currency.d.mts +1 -1
- package/dist/environment.d.mts +30 -1
- package/dist/environment.mjs +72 -1
- package/dist/events.d.mts +39 -1
- package/dist/events.mjs +169 -1
- package/dist/idempotency.d.mts +4 -0
- package/dist/idempotency.mjs +4 -0
- package/dist/item-stock.d.mts +43 -0
- package/dist/item-stock.mjs +67 -0
- package/dist/legal-form.d.mts +12 -0
- package/dist/legal-form.mjs +27 -0
- package/dist/line-settlement.d.mts +70 -0
- package/dist/line-settlement.mjs +79 -0
- package/dist/memory-outbox.mjs +2 -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/outbox.d.mts +20 -3
- package/dist/payment-events.d.mts +7 -1
- package/dist/payment-gateway.d.mts +1 -1
- package/dist/period.d.mts +22 -1
- package/dist/period.mjs +30 -1
- package/dist/phone.d.mts +13 -1
- package/dist/phone.mjs +21 -1
- package/dist/proration.d.mts +12 -2
- package/dist/proration.mjs +24 -1
- package/dist/signed-token.d.mts +54 -0
- package/dist/signed-token.mjs +139 -0
- package/dist/unit-cost-rate.d.mts +1 -1
- package/dist/unit-cost-rate.mjs +1 -1
- package/package.json +75 -1
package/dist/idempotency.mjs
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { canonicalDigest, canonicalJson } from "./canonical.mjs";
|
|
2
2
|
//#region src/workflow/idempotency.ts
|
|
3
3
|
/**
|
|
4
|
+
* @deprecated Moved to `@classytic/repo-core/idempotency` — the claim contract plus its store
|
|
5
|
+
* port, `runIdempotent`, the reference store and the conformance suite, beside `./lock`. This
|
|
6
|
+
* copy has no consumers and is removed in the next minor.
|
|
7
|
+
*
|
|
4
8
|
* Idempotency claim + lease — the CONTRACT, not the store.
|
|
5
9
|
*
|
|
6
10
|
* ## Why this exists
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { SkuFields, StockKey, StockKeySource } from "./item-identity.mjs";
|
|
2
|
+
//#region src/inventory/item-stock.d.ts
|
|
3
|
+
/** One stock row as the summary reads it. */
|
|
4
|
+
interface StockRow {
|
|
5
|
+
readonly quantity?: number;
|
|
6
|
+
/** Explicitly `false` deactivates the row. Absent means active. */
|
|
7
|
+
readonly isActive?: boolean;
|
|
8
|
+
/** Display cost of one unit, as the source reports it. */
|
|
9
|
+
readonly costPrice?: number;
|
|
10
|
+
/** On-hand value at the source's valuation, integer MINOR units; absent = unvalued. */
|
|
11
|
+
readonly valueMinor?: number;
|
|
12
|
+
/** Units at or below which the item reads as low (the item-level row's). */
|
|
13
|
+
readonly reorderPoint?: number;
|
|
14
|
+
}
|
|
15
|
+
/** What a reader shows for an item's stock in one place. */
|
|
16
|
+
interface ItemStock {
|
|
17
|
+
quantity: number;
|
|
18
|
+
inStock: boolean;
|
|
19
|
+
lowStock: boolean;
|
|
20
|
+
valueMinor?: number;
|
|
21
|
+
/** Per variant LABEL — a projection of the same rows, not a second source. */
|
|
22
|
+
variants?: Array<{
|
|
23
|
+
sku: string;
|
|
24
|
+
quantity: number;
|
|
25
|
+
costPrice?: number;
|
|
26
|
+
}>;
|
|
27
|
+
}
|
|
28
|
+
/** The item shape the summary reads: its id and its variants. */
|
|
29
|
+
interface StockableItem extends StockKeySource {
|
|
30
|
+
readonly variants?: ReadonlyArray<SkuFields & {
|
|
31
|
+
readonly sku: string;
|
|
32
|
+
readonly isActive?: boolean;
|
|
33
|
+
}> | null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The item's stock. `rowOf(null)` is the item-level row (filed under the item's own id);
|
|
37
|
+
* `rowOf(key)` the row filed under one variant's stock key.
|
|
38
|
+
*/
|
|
39
|
+
declare function itemStockOf(item: StockableItem, rowOf: (stockKey: StockKey | null) => StockRow | undefined, options: {
|
|
40
|
+
readonly defaultReorderPoint: number;
|
|
41
|
+
}): ItemStock;
|
|
42
|
+
//#endregion
|
|
43
|
+
export { ItemStock, StockRow, StockableItem, itemStockOf };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { stockKeyFor } from "./item-identity.mjs";
|
|
2
|
+
//#region src/inventory/item-stock.ts
|
|
3
|
+
/**
|
|
4
|
+
* An item's stock in one place (a branch), from the rows its stock source files under stock keys —
|
|
5
|
+
* the ONE summary every reader shows: the server's product reads and an offline till alike.
|
|
6
|
+
*
|
|
7
|
+
* itemStockOf(product, (key) => rows.get(key ?? productId), { defaultReorderPoint: 10 })
|
|
8
|
+
*
|
|
9
|
+
* Rows are keyed by STOCK KEY (`stockKeyFor`), and a row reached by two lookups is counted once —
|
|
10
|
+
* a sole variant's key IS the product id, so summing lookups instead of rows doubles it.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* The item's stock. `rowOf(null)` is the item-level row (filed under the item's own id);
|
|
14
|
+
* `rowOf(key)` the row filed under one variant's stock key.
|
|
15
|
+
*/
|
|
16
|
+
function itemStockOf(item, rowOf, options) {
|
|
17
|
+
const itemId = String(item._id);
|
|
18
|
+
const variants = item.variants ?? [];
|
|
19
|
+
const keyOf = (variant) => stockKeyFor({
|
|
20
|
+
_id: itemId,
|
|
21
|
+
variants
|
|
22
|
+
}, {
|
|
23
|
+
kind: "variant",
|
|
24
|
+
variant
|
|
25
|
+
});
|
|
26
|
+
const rows = /* @__PURE__ */ new Map();
|
|
27
|
+
const itemRow = rowOf(null);
|
|
28
|
+
if (itemRow) rows.set(itemId, itemRow);
|
|
29
|
+
for (const variant of variants) {
|
|
30
|
+
if (variant.isActive === false) continue;
|
|
31
|
+
const key = keyOf(variant);
|
|
32
|
+
const row = rowOf(key);
|
|
33
|
+
if (!row && rows.has(key)) continue;
|
|
34
|
+
rows.set(key, row);
|
|
35
|
+
}
|
|
36
|
+
let quantity = 0;
|
|
37
|
+
let valueMinor = 0;
|
|
38
|
+
for (const row of rows.values()) {
|
|
39
|
+
if (row?.isActive === false) continue;
|
|
40
|
+
const rowQuantity = row?.quantity ?? 0;
|
|
41
|
+
quantity += rowQuantity;
|
|
42
|
+
if (rowQuantity > 0) valueMinor = row?.valueMinor === void 0 || valueMinor === void 0 ? void 0 : valueMinor + row.valueMinor;
|
|
43
|
+
}
|
|
44
|
+
const variantStocks = [];
|
|
45
|
+
for (const variant of variants) {
|
|
46
|
+
if (variant.isActive === false) continue;
|
|
47
|
+
const key = keyOf(variant);
|
|
48
|
+
if (!rows.has(key)) continue;
|
|
49
|
+
const row = rows.get(key);
|
|
50
|
+
if (row?.isActive === false) continue;
|
|
51
|
+
variantStocks.push({
|
|
52
|
+
sku: variant.sku,
|
|
53
|
+
quantity: row?.quantity ?? 0,
|
|
54
|
+
...row?.costPrice !== void 0 ? { costPrice: row.costPrice } : {}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const reorderPoint = itemRow?.reorderPoint ?? options.defaultReorderPoint;
|
|
58
|
+
return {
|
|
59
|
+
quantity,
|
|
60
|
+
inStock: quantity > 0,
|
|
61
|
+
lowStock: quantity > 0 && quantity <= reorderPoint,
|
|
62
|
+
...quantity > 0 && valueMinor !== void 0 ? { valueMinor } : {},
|
|
63
|
+
...variantStocks.length ? { variants: variantStocks } : {}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
export { itemStockOf };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/identity/legal-form.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The legal form of a person or entity — what the law treats it as, for the reporting company and its
|
|
4
|
+
* counterparties alike. A sole proprietorship is an `individual`: the business has no legal personality
|
|
5
|
+
* of its own. Country packs decide what follows from a form (equity accounts, the year-end close, tax).
|
|
6
|
+
*/
|
|
7
|
+
declare const LEGAL_FORMS: readonly ["individual", "partnership", "company", "cooperative", "nonprofit", "government"];
|
|
8
|
+
type LegalForm = (typeof LEGAL_FORMS)[number];
|
|
9
|
+
declare const LEGAL_FORM_LABELS: Readonly<Record<LegalForm, string>>;
|
|
10
|
+
declare function isLegalForm(value: unknown): value is LegalForm;
|
|
11
|
+
//#endregion
|
|
12
|
+
export { LEGAL_FORMS, LEGAL_FORM_LABELS, LegalForm, isLegalForm };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
//#region src/identity/legal-form.ts
|
|
2
|
+
/**
|
|
3
|
+
* The legal form of a person or entity — what the law treats it as, for the reporting company and its
|
|
4
|
+
* counterparties alike. A sole proprietorship is an `individual`: the business has no legal personality
|
|
5
|
+
* of its own. Country packs decide what follows from a form (equity accounts, the year-end close, tax).
|
|
6
|
+
*/
|
|
7
|
+
const LEGAL_FORMS = [
|
|
8
|
+
"individual",
|
|
9
|
+
"partnership",
|
|
10
|
+
"company",
|
|
11
|
+
"cooperative",
|
|
12
|
+
"nonprofit",
|
|
13
|
+
"government"
|
|
14
|
+
];
|
|
15
|
+
const LEGAL_FORM_LABELS = {
|
|
16
|
+
individual: "Individual / sole proprietorship",
|
|
17
|
+
partnership: "Partnership firm",
|
|
18
|
+
company: "Company",
|
|
19
|
+
cooperative: "Cooperative society",
|
|
20
|
+
nonprofit: "Nonprofit / NGO / trust",
|
|
21
|
+
government: "Government body"
|
|
22
|
+
};
|
|
23
|
+
function isLegalForm(value) {
|
|
24
|
+
return typeof value === "string" && LEGAL_FORMS.includes(value);
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
export { LEGAL_FORMS, LEGAL_FORM_LABELS, isLegalForm };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Result } from "./result.mjs";
|
|
2
|
+
//#region src/inventory/line-settlement.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* A document line's quantity, split into OUTCOME buckets — the one definition
|
|
5
|
+
* of "how much of this line is still open".
|
|
6
|
+
*
|
|
7
|
+
* Every kernel that moves goods against a document asks this: an order line is
|
|
8
|
+
* fulfilled / returned / canceled, a transfer line is received / returned /
|
|
9
|
+
* written off, a purchase line is received / rejected. The buckets are the
|
|
10
|
+
* kernel's vocabulary; the arithmetic, the over-settlement guard and the state
|
|
11
|
+
* derivation are this file's, so no two kernels can answer differently.
|
|
12
|
+
*
|
|
13
|
+
* Quantities are whole units. A bucket that is absent counts as zero.
|
|
14
|
+
*/
|
|
15
|
+
interface SettlementLine<B extends string> {
|
|
16
|
+
/** What the line promised to move. */
|
|
17
|
+
readonly quantity: number;
|
|
18
|
+
/** What has been settled so far, per outcome. */
|
|
19
|
+
readonly settled: Readonly<Partial<Record<B, number>>>;
|
|
20
|
+
}
|
|
21
|
+
type SettlementState = "open" | "partial" | "settled";
|
|
22
|
+
/** Sum of every bucket. */
|
|
23
|
+
declare const settledTotal: <B extends string>(line: SettlementLine<B>) => number;
|
|
24
|
+
/**
|
|
25
|
+
* `quantity − Σ buckets`. NOT clamped at zero: a negative means the line was
|
|
26
|
+
* over-settled by data written outside {@link planSettlement}, and hiding it
|
|
27
|
+
* would erase the only signal that happened.
|
|
28
|
+
*/
|
|
29
|
+
declare const outstandingOf: <B extends string>(line: SettlementLine<B>) => number;
|
|
30
|
+
/** `open` — nothing settled; `settled` — nothing outstanding; otherwise `partial`. */
|
|
31
|
+
declare const settlementState: <B extends string>(line: SettlementLine<B>) => SettlementState;
|
|
32
|
+
/** Every line settled ⇒ `settled`; every line untouched ⇒ `open`; else `partial`. */
|
|
33
|
+
declare const documentSettlementState: <B extends string>(lines: ReadonlyArray<SettlementLine<B>>) => SettlementState;
|
|
34
|
+
/** `true` when every line is settled entirely through `bucket` (e.g. all received, nothing lost). */
|
|
35
|
+
declare const settledEntirelyIn: <B extends string>(lines: ReadonlyArray<SettlementLine<B>>, bucket: B) => boolean;
|
|
36
|
+
type SettlementRefusal = {
|
|
37
|
+
readonly code: "not_positive_integer";
|
|
38
|
+
readonly requested: number;
|
|
39
|
+
} | {
|
|
40
|
+
readonly code: "exceeds_outstanding";
|
|
41
|
+
readonly requested: number;
|
|
42
|
+
readonly outstanding: number;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Settle `quantity` more units into `bucket`, or refuse.
|
|
46
|
+
*
|
|
47
|
+
* Refuses a non-positive or fractional quantity and anything above what is
|
|
48
|
+
* outstanding — an over-settlement is an input error, never silently trimmed.
|
|
49
|
+
* Use {@link clampSettlement} only where the surface's CONTRACT is "take what fits".
|
|
50
|
+
*/
|
|
51
|
+
declare const planSettlement: <B extends string>(line: SettlementLine<B>, bucket: B, quantity: number) => Result<SettlementLine<B>, SettlementRefusal>;
|
|
52
|
+
/**
|
|
53
|
+
* Settle up to `requested` units into `bucket`, taking only what is outstanding.
|
|
54
|
+
* `applied` is what was actually settled; `0` leaves the line unchanged.
|
|
55
|
+
*/
|
|
56
|
+
declare const clampSettlement: <B extends string>(line: SettlementLine<B>, bucket: B, requested: number) => {
|
|
57
|
+
readonly line: SettlementLine<B>;
|
|
58
|
+
readonly applied: number;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* The share of a line-level amount (a value or a cost, integer MINOR units) owed by one
|
|
62
|
+
* settlement batch, given how many units the whole line had settled before and after it.
|
|
63
|
+
*
|
|
64
|
+
* `floor(total·after/q) − floor(total·before/q)` TELESCOPES: however a line is split into
|
|
65
|
+
* batches, and across every outcome bucket, the shares sum to exactly `total` once the line
|
|
66
|
+
* is fully settled — no remainder to chase, no batch-order dependence.
|
|
67
|
+
*/
|
|
68
|
+
declare const shareOfSettled: (total: number, quantity: number, settledBefore: number, settledAfter: number) => number;
|
|
69
|
+
//#endregion
|
|
70
|
+
export { SettlementLine, SettlementRefusal, SettlementState, clampSettlement, documentSettlementState, outstandingOf, planSettlement, settledEntirelyIn, settledTotal, settlementState, shareOfSettled };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { err, ok } from "./result.mjs";
|
|
2
|
+
//#region src/inventory/line-settlement.ts
|
|
3
|
+
/** Sum of every bucket. */
|
|
4
|
+
const settledTotal = (line) => Object.values(line.settled).reduce((sum, n) => sum + (n ?? 0), 0);
|
|
5
|
+
/**
|
|
6
|
+
* `quantity − Σ buckets`. NOT clamped at zero: a negative means the line was
|
|
7
|
+
* over-settled by data written outside {@link planSettlement}, and hiding it
|
|
8
|
+
* would erase the only signal that happened.
|
|
9
|
+
*/
|
|
10
|
+
const outstandingOf = (line) => line.quantity - settledTotal(line);
|
|
11
|
+
/** `open` — nothing settled; `settled` — nothing outstanding; otherwise `partial`. */
|
|
12
|
+
const settlementState = (line) => {
|
|
13
|
+
if (outstandingOf(line) <= 0) return "settled";
|
|
14
|
+
return settledTotal(line) === 0 ? "open" : "partial";
|
|
15
|
+
};
|
|
16
|
+
/** Every line settled ⇒ `settled`; every line untouched ⇒ `open`; else `partial`. */
|
|
17
|
+
const documentSettlementState = (lines) => {
|
|
18
|
+
const states = lines.map(settlementState);
|
|
19
|
+
if (states.every((s) => s === "settled")) return "settled";
|
|
20
|
+
if (states.every((s) => s === "open")) return "open";
|
|
21
|
+
return "partial";
|
|
22
|
+
};
|
|
23
|
+
/** `true` when every line is settled entirely through `bucket` (e.g. all received, nothing lost). */
|
|
24
|
+
const settledEntirelyIn = (lines, bucket) => lines.every((l) => (l.settled[bucket] ?? 0) >= l.quantity);
|
|
25
|
+
/**
|
|
26
|
+
* Settle `quantity` more units into `bucket`, or refuse.
|
|
27
|
+
*
|
|
28
|
+
* Refuses a non-positive or fractional quantity and anything above what is
|
|
29
|
+
* outstanding — an over-settlement is an input error, never silently trimmed.
|
|
30
|
+
* Use {@link clampSettlement} only where the surface's CONTRACT is "take what fits".
|
|
31
|
+
*/
|
|
32
|
+
const planSettlement = (line, bucket, quantity) => {
|
|
33
|
+
if (!Number.isInteger(quantity) || quantity <= 0) return err({
|
|
34
|
+
code: "not_positive_integer",
|
|
35
|
+
requested: quantity
|
|
36
|
+
});
|
|
37
|
+
const outstanding = outstandingOf(line);
|
|
38
|
+
if (quantity > outstanding) return err({
|
|
39
|
+
code: "exceeds_outstanding",
|
|
40
|
+
requested: quantity,
|
|
41
|
+
outstanding
|
|
42
|
+
});
|
|
43
|
+
return ok(withBucket(line, bucket, quantity));
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Settle up to `requested` units into `bucket`, taking only what is outstanding.
|
|
47
|
+
* `applied` is what was actually settled; `0` leaves the line unchanged.
|
|
48
|
+
*/
|
|
49
|
+
const clampSettlement = (line, bucket, requested) => {
|
|
50
|
+
const applied = Math.max(0, Math.min(Math.trunc(requested), outstandingOf(line)));
|
|
51
|
+
return {
|
|
52
|
+
line: applied > 0 ? withBucket(line, bucket, applied) : line,
|
|
53
|
+
applied
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* The share of a line-level amount (a value or a cost, integer MINOR units) owed by one
|
|
58
|
+
* settlement batch, given how many units the whole line had settled before and after it.
|
|
59
|
+
*
|
|
60
|
+
* `floor(total·after/q) − floor(total·before/q)` TELESCOPES: however a line is split into
|
|
61
|
+
* batches, and across every outcome bucket, the shares sum to exactly `total` once the line
|
|
62
|
+
* is fully settled — no remainder to chase, no batch-order dependence.
|
|
63
|
+
*/
|
|
64
|
+
const shareOfSettled = (total, quantity, settledBefore, settledAfter) => {
|
|
65
|
+
if (!Number.isInteger(total) || total < 0) throw new RangeError(`total must be a non-negative integer, got ${total}`);
|
|
66
|
+
if (!Number.isInteger(quantity) || quantity <= 0) throw new RangeError(`quantity must be a positive integer, got ${quantity}`);
|
|
67
|
+
if (!(0 <= settledBefore && settledBefore <= settledAfter && settledAfter <= quantity)) throw new RangeError(`need 0 <= before (${settledBefore}) <= after (${settledAfter}) <= quantity (${quantity})`);
|
|
68
|
+
const at = (n) => Number(BigInt(total) * BigInt(n) / BigInt(quantity));
|
|
69
|
+
return at(settledAfter) - at(settledBefore);
|
|
70
|
+
};
|
|
71
|
+
const withBucket = (line, bucket, quantity) => ({
|
|
72
|
+
quantity: line.quantity,
|
|
73
|
+
settled: {
|
|
74
|
+
...line.settled,
|
|
75
|
+
[bucket]: (line.settled[bucket] ?? 0) + quantity
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
//#endregion
|
|
79
|
+
export { clampSettlement, documentSettlementState, outstandingOf, planSettlement, settledEntirelyIn, settledTotal, settlementState, shareOfSettled };
|
package/dist/memory-outbox.mjs
CHANGED
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 };
|