@stll/money 0.0.1-placeholder.0 → 0.1.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/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # @stll/money
2
+
3
+ Branded minor-unit monetary values and exact billing arithmetic for Stella.
4
+
5
+ The package keeps money in integer minor units, rejects fractional cents, and
6
+ separates totals by currency so incompatible amounts cannot be combined.
7
+
8
+ ```ts
9
+ import { MoneyTotals, cents } from "@stll/money";
10
+
11
+ const totals = new MoneyTotals();
12
+ totals.add("EUR", cents(1250));
13
+ ```
14
+
15
+ Public API changes require a changeset.
@@ -0,0 +1,137 @@
1
+ //#region src/index.d.ts
2
+ /**
3
+ * Branded monetary amounts and minor-unit billing arithmetic.
4
+ *
5
+ * Money in this codebase is stored and computed in minor units
6
+ * ("cents" for USD/EUR, halere for CZK, etc.). The `CentsAmount`
7
+ * brand prevents the canonical 100x bug where a major-unit value
8
+ * (12.50 dollars) is silently mixed with minor-unit math (1250 cents).
9
+ *
10
+ * The brand lives here, in a shared package, so it threads end to end:
11
+ * the same `CentsAmount` flows from a Drizzle column declared with
12
+ * `.$type<CentsAmount>()`, across the API boundary (Eden infers the
13
+ * brand from the handler's return type), into browser previews and back.
14
+ * A plain `number` is not assignable to `CentsAmount`; mint one with
15
+ * `cents()` after validating minor-unit input, or `unsafeCents()` at a
16
+ * documented boundary.
17
+ *
18
+ * Currency-agnostic: the currency code lives alongside the amount in the
19
+ * schema (e.g. invoices.currency); pairing them is the call site's
20
+ * responsibility.
21
+ */
22
+ declare const __cents: unique symbol;
23
+ type CentsAmount = number & {
24
+ readonly [__cents]: "CentsAmount";
25
+ };
26
+ /**
27
+ * Construct a CentsAmount from a value already known to be in minor
28
+ * units. Use at boundaries where the input is validated as an integer
29
+ * minor-unit value (e.g. after Elysia `t.Integer({ minimum: 0 })` or
30
+ * after parsing user input that has been multiplied by 100).
31
+ *
32
+ * Throws on non-integer input — money math at the minor-unit level must
33
+ * be exact.
34
+ */
35
+ declare const cents: (value: number) => CentsAmount;
36
+ /**
37
+ * Escape hatch for code paths that genuinely need to attach the brand
38
+ * without a runtime check (test fixtures, generated code). Prefer
39
+ * `cents()` everywhere else; reach for this only with a `// SAFETY:`
40
+ * comment naming why the value is already a valid minor-unit integer.
41
+ */
42
+ declare const unsafeCents: (value: number) => CentsAmount;
43
+ type ProrateHourlyCentsInput = {
44
+ billedMinutes: number;
45
+ hourlyRateCents: CentsAmount;
46
+ };
47
+ declare const prorateHourlyCents: ({ billedMinutes, hourlyRateCents }: ProrateHourlyCentsInput) => CentsAmount;
48
+ type ApplyMarkupCentsInput = {
49
+ amountCents: CentsAmount;
50
+ markupPercent: number;
51
+ };
52
+ declare const applyMarkupCents: ({ amountCents, markupPercent }: ApplyMarkupCentsInput) => CentsAmount;
53
+ declare const __currency: unique symbol;
54
+ /**
55
+ * A `CentsAmount` additionally branded with its ISO 4217-ish currency code
56
+ * `C`. This makes cross-currency addition a compile error instead of a
57
+ * runtime bug: `addCents` only accepts two `CurrencyCents` sharing the same
58
+ * `C`, so `addCents(usdAmount, eurAmount)` fails to typecheck rather than
59
+ * silently producing a meaningless sum.
60
+ *
61
+ * Mint one with `currencyCents()`; there is no unsafe escape hatch because
62
+ * the underlying `CentsAmount` validation (`cents()`) is cheap and the
63
+ * currency code is a plain string carried alongside it, so there is no
64
+ * boundary that needs to skip it.
65
+ */
66
+ type CurrencyCents<C extends string = string> = CentsAmount & {
67
+ readonly [__currency]: C;
68
+ };
69
+ /**
70
+ * Construct a `CurrencyCents<C>` from a currency code and a minor-unit
71
+ * amount. The only producer of `CurrencyCents`; downstream code narrows `C`
72
+ * from the literal `currency` argument (e.g. `currencyCents("USD", 100)`
73
+ * infers `CurrencyCents<"USD">`).
74
+ */
75
+ declare const currencyCents: <C extends string>(currency: C, amount: number) => CurrencyCents<C>;
76
+ type UnionToIntersection<U> = (U extends unknown ? (x: U) => void : never) extends ((x: infer I) => void) ? I : never;
77
+ type IsSingletonCurrency<C extends string> = string extends C ? never : [C] extends [UnionToIntersection<C>] ? C : never;
78
+ /**
79
+ * Add two `CurrencyCents` amounts of the SAME currency. The second
80
+ * parameter's currency `B` is constrained to `extends A`, so passing a
81
+ * different currency literal (e.g. `addCents(usdAmount, eurAmount)`) is a
82
+ * compile error, not a runtime bug — see `packages/money/src/index.test.ts`
83
+ * for the `@ts-expect-error` proof.
84
+ *
85
+ * Both operands are further constrained by `IsSingletonCurrency` to reject
86
+ * any non-singleton currency type: the WIDE `CurrencyCents<string>`, and
87
+ * also a finite union such as `CurrencyCents<"USD" | "EUR">` (e.g. from a
88
+ * validator's `t.UnionEnum`). A currency read back from a DB row or
89
+ * narrowed only to a finite set of allowed codes types as `string` or a
90
+ * union, not a single literal, so without this, `addCents(currencyCents(a,
91
+ * x), currencyCents(b, y))` would still typecheck even when `a`/`b` are
92
+ * both known only up to a union of currencies that could differ at
93
+ * runtime — the compile-time guarantee above only bites for a genuine
94
+ * single-literal currency type on both operands. Code that aggregates rows
95
+ * with a dynamic or union (non-singleton) currency must use `MoneyTotals`
96
+ * instead, which buckets by currency at runtime and is the runtime-correct
97
+ * tool for that case.
98
+ */
99
+ declare const addCents: <A extends string, B extends A = A>(a: IsSingletonCurrency<A> extends never ? never : CurrencyCents<A>, b: IsSingletonCurrency<B> extends never ? never : CurrencyCents<B>) => CurrencyCents<A>;
100
+ /**
101
+ * Per-currency accumulator for aggregating money across rows that may carry
102
+ * different currencies (e.g. time entries across matters, expenses across
103
+ * clients). There is deliberately no method that returns a single combined
104
+ * number: the only way to read totals out is `entries()`, which groups by
105
+ * currency and sorts deterministically by currency code. This keeps
106
+ * cross-currency summation structurally unreachable through this package's
107
+ * API — callers must handle each currency's total explicitly.
108
+ *
109
+ * Division of responsibility with `addCents`: `addCents` gives a
110
+ * compile-time guarantee, but only when both operands carry a single
111
+ * string-literal currency type (`CurrencyCents<"USD">`); it rejects both
112
+ * the wide `CurrencyCents<string>` and a finite union such as
113
+ * `CurrencyCents<"USD" | "EUR">` outright. Any flow whose currency is
114
+ * dynamic or only known up to a union of allowed codes (read from a DB row,
115
+ * request body, etc.) cannot satisfy that singleton-literal constraint and
116
+ * must bucket through `MoneyTotals` instead, which enforces
117
+ * the same "never sum across currencies" invariant at runtime via the
118
+ * per-currency `Map`.
119
+ */
120
+ type MoneyTotalsEntry = {
121
+ currency: string;
122
+ amountCents: CentsAmount;
123
+ };
124
+ declare class MoneyTotals {
125
+ #private;
126
+ /** Add `amountCents` to the running total for `currency`. */
127
+ add(currency: string, amountCents: CentsAmount): void;
128
+ /**
129
+ * Per-currency totals, sorted deterministically by currency code so
130
+ * output (PDF lines, API responses) does not depend on insertion order.
131
+ * Sorts by UTF-16 code unit (default `Array.sort`) rather than
132
+ * `localeCompare`, so ordering does not vary with the runtime's locale.
133
+ */
134
+ entries(): MoneyTotalsEntry[];
135
+ }
136
+ //#endregion
137
+ export { ApplyMarkupCentsInput, CentsAmount, CurrencyCents, MoneyTotals, MoneyTotalsEntry, ProrateHourlyCentsInput, addCents, applyMarkupCents, cents, currencyCents, prorateHourlyCents, unsafeCents };
package/dist/index.js ADDED
@@ -0,0 +1,89 @@
1
+ //#region src/index.ts
2
+ /**
3
+ * Construct a CentsAmount from a value already known to be in minor
4
+ * units. Use at boundaries where the input is validated as an integer
5
+ * minor-unit value (e.g. after Elysia `t.Integer({ minimum: 0 })` or
6
+ * after parsing user input that has been multiplied by 100).
7
+ *
8
+ * Throws on non-integer input — money math at the minor-unit level must
9
+ * be exact.
10
+ */
11
+ const cents = (value) => {
12
+ if (!Number.isInteger(value)) throw new TypeError(`cents(${value}): money values must be integer minor units`);
13
+ return value;
14
+ };
15
+ /**
16
+ * Escape hatch for code paths that genuinely need to attach the brand
17
+ * without a runtime check (test fixtures, generated code). Prefer
18
+ * `cents()` everywhere else; reach for this only with a `// SAFETY:`
19
+ * comment naming why the value is already a valid minor-unit integer.
20
+ */
21
+ const unsafeCents = (value) => value;
22
+ const prorateHourlyCents = ({ billedMinutes, hourlyRateCents }) => {
23
+ assertNonNegativeInteger("billedMinutes", billedMinutes);
24
+ assertNonNegativeInteger("hourlyRateCents", hourlyRateCents);
25
+ return cents(Math.floor((billedMinutes * hourlyRateCents + 30) / 60));
26
+ };
27
+ const applyMarkupCents = ({ amountCents, markupPercent }) => {
28
+ assertNonNegativeInteger("amountCents", amountCents);
29
+ assertNonNegativeInteger("markupPercent", markupPercent);
30
+ return cents(Math.floor((amountCents * (100 + markupPercent) + 50) / 100));
31
+ };
32
+ function assertNonNegativeInteger(name, value) {
33
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) throw new TypeError(`${name} must be a finite non-negative integer`);
34
+ }
35
+ /**
36
+ * Construct a `CurrencyCents<C>` from a currency code and a minor-unit
37
+ * amount. The only producer of `CurrencyCents`; downstream code narrows `C`
38
+ * from the literal `currency` argument (e.g. `currencyCents("USD", 100)`
39
+ * infers `CurrencyCents<"USD">`).
40
+ */
41
+ const currencyCents = (currency, amount) => {
42
+ if (!currency) throw new TypeError("currencyCents(): currency must be a non-empty code");
43
+ return cents(amount);
44
+ };
45
+ /**
46
+ * Add two `CurrencyCents` amounts of the SAME currency. The second
47
+ * parameter's currency `B` is constrained to `extends A`, so passing a
48
+ * different currency literal (e.g. `addCents(usdAmount, eurAmount)`) is a
49
+ * compile error, not a runtime bug — see `packages/money/src/index.test.ts`
50
+ * for the `@ts-expect-error` proof.
51
+ *
52
+ * Both operands are further constrained by `IsSingletonCurrency` to reject
53
+ * any non-singleton currency type: the WIDE `CurrencyCents<string>`, and
54
+ * also a finite union such as `CurrencyCents<"USD" | "EUR">` (e.g. from a
55
+ * validator's `t.UnionEnum`). A currency read back from a DB row or
56
+ * narrowed only to a finite set of allowed codes types as `string` or a
57
+ * union, not a single literal, so without this, `addCents(currencyCents(a,
58
+ * x), currencyCents(b, y))` would still typecheck even when `a`/`b` are
59
+ * both known only up to a union of currencies that could differ at
60
+ * runtime — the compile-time guarantee above only bites for a genuine
61
+ * single-literal currency type on both operands. Code that aggregates rows
62
+ * with a dynamic or union (non-singleton) currency must use `MoneyTotals`
63
+ * instead, which buckets by currency at runtime and is the runtime-correct
64
+ * tool for that case.
65
+ */
66
+ const addCents = (a, b) => cents(a + b);
67
+ var MoneyTotals = class {
68
+ #totals = /* @__PURE__ */ new Map();
69
+ /** Add `amountCents` to the running total for `currency`. */
70
+ add(currency, amountCents) {
71
+ if (!currency) throw new TypeError("MoneyTotals.add(): currency must be a non-empty code");
72
+ const running = this.#totals.get(currency) ?? cents(0);
73
+ this.#totals.set(currency, cents(running + amountCents));
74
+ }
75
+ /**
76
+ * Per-currency totals, sorted deterministically by currency code so
77
+ * output (PDF lines, API responses) does not depend on insertion order.
78
+ * Sorts by UTF-16 code unit (default `Array.sort`) rather than
79
+ * `localeCompare`, so ordering does not vary with the runtime's locale.
80
+ */
81
+ entries() {
82
+ return [...this.#totals.keys()].sort().map((currency) => ({
83
+ currency,
84
+ amountCents: this.#totals.get(currency) ?? cents(0)
85
+ }));
86
+ }
87
+ };
88
+ //#endregion
89
+ export { MoneyTotals, addCents, applyMarkupCents, cents, currencyCents, prorateHourlyCents, unsafeCents };
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "@stll/money",
3
- "version": "0.0.1-placeholder.0",
4
- "description": "Placeholder to bootstrap npm trusted publishing. Real releases are published from stella/stella via GitHub Actions.",
5
- "license": "Apache-2.0"
3
+ "version": "0.1.0",
4
+ "description": "Branded minor-unit monetary amounts and currency-safe billing arithmetic.",
5
+ "keywords": [
6
+ "billing",
7
+ "currency",
8
+ "minor-units",
9
+ "money",
10
+ "typescript"
11
+ ],
12
+ "homepage": "https://github.com/stella/stella/tree/main/packages/money",
13
+ "bugs": {
14
+ "url": "https://github.com/stella/stella/issues"
15
+ },
16
+ "license": "Apache-2.0",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/stella/stella.git",
20
+ "directory": "packages/money"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "README.md"
25
+ ],
26
+ "type": "module",
27
+ "sideEffects": false,
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js"
32
+ }
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "scripts": {
38
+ "clean": "git clean -xdf dist .cache .turbo node_modules",
39
+ "build": "tsdown",
40
+ "pack:dry-run": "bun pm pack --dry-run",
41
+ "test": "bun test src",
42
+ "typecheck": "bun ../../packages/scripts/src/tsc-native.ts --noEmit",
43
+ "lint": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --report-unused-disable-directives-severity=error --deny-warnings --type-aware packages/money",
44
+ "lint:fix": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --type-aware --fix packages/money",
45
+ "format": "oxfmt .",
46
+ "prepack": "bun run build"
47
+ },
48
+ "devDependencies": {
49
+ "@stll/typescript-config": "0.0.0",
50
+ "@types/bun": "1.3.14",
51
+ "tsdown": "0.22.14"
52
+ },
53
+ "main": "./dist/index.js",
54
+ "types": "./dist/index.d.ts"
6
55
  }