@softure-ai/core 0.1.8 → 0.1.9

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 CHANGED
@@ -4,6 +4,15 @@ Newest first. Each version lists what changed for an app that uses `@softure-ai/
4
4
  production, the version gets a line `verified in: <app>@<commit>` ([docs/05](../../docs/05-adoption-playbook.md),
5
5
  "Definition of done"). Versions before the first one below are described in their GitHub Releases (`core@x.y.z`).
6
6
 
7
+ ## 0.1.9
8
+
9
+ - Day arithmetic on `YYYY-MM-DD` days with documented semantics: `isCalendarDay`, `addCalendarDays`,
10
+ `addCalendarMonths(day, months, { endOfMonth: "clamp" | "overflow" })` (clamp by default: Jan 31 + 1 month =
11
+ Feb 28/29), `calendarDaysBetween` and `wholeMonthsBetween` (#312).
12
+ - Display: `formatCalendarDay(day, locale, "long" | "medium" | "numeric")`, `formatMoney(minor, currency, locale,
13
+ { rounded, signed, compact })` (thousands always grouped, so pl writes `1 234,56`) and `formatPercent(basisPoints,
14
+ locale)`. The pinned ISO 4217 table `CURRENCY_MINOR_UNIT_DIGITS` moves here from billing (#312).
15
+
7
16
  ## 0.1.8
8
17
 
9
18
  - Request origins (#311): `readRequestHost`, `readRequestOrigin`, `readForwardedProto`, `parseOrigin`,
package/README.md CHANGED
@@ -296,6 +296,32 @@ app's `timezone` from the config. Both throw a `RangeError` for an invalid date
296
296
  getCalendarDay(context.clock, config.timezone); // "2026-10-08"
297
297
  ```
298
298
 
299
+ **Day arithmetic.** Work on the `YYYY-MM-DD` strings themselves, never on `Date`s at local midnight: a day
300
+ is a calendar day, whatever DST makes of its hours. Every function throws a `RangeError` for a malformed day
301
+ or a fractional count.
302
+
303
+ | Function | Semantics |
304
+ | --- | --- |
305
+ | `isCalendarDay(value)` | A real day written `YYYY-MM-DD` (`2026-02-29` and `2026-1-1` are not). |
306
+ | `addCalendarDays(day, days)` | `day` moved by whole days, negative goes back. |
307
+ | `addCalendarMonths(day, months, { endOfMonth })` | `"clamp"` (default): a day the month lacks becomes its last day, Jan 31 + 1 = Feb 28/29. `"overflow"`: it runs on, Jan 31 + 1 = Mar 3 (2026). |
308
+ | `calendarDaysBetween(from, to)` | `to - from` in days; negative when `to` comes first. |
309
+ | `wholeMonthsBetween(from, to)` | The most months `n` with `addCalendarMonths(from, n)` (clamped) not after `to`: Jan 31 to Feb 28 is 1. Swapping the days turns the sign. |
310
+
311
+ **Display.** One way to show days, money and percentages in every module and app, in `config.locale`:
312
+
313
+ ```ts
314
+ formatCalendarDay("2026-10-04", "en"); // "October 4, 2026"; "medium": "Oct 4, 2026"; "numeric": "10/04/2026"
315
+ formatCalendarDay(toCalendarDay(instant, config.timezone), config.locale); // an instant's day in the app's zone
316
+ formatMoney(123456, "PLN", "en"); // "PLN 1,234.56"; pl groups four digits too
317
+ formatMoney(-123456, "PLN", "en", { rounded: true, signed: true }); // "-PLN 1,235"; { compact: true }: "PLN 1.2K"
318
+ formatPercent(1250, "en"); // "12.5%" (basis points, at most two fraction digits)
319
+ ```
320
+
321
+ A day is formatted at UTC midnight in UTC, so no zone moves it; the Polish long style names the month in
322
+ the genitive. `formatMoney` takes the amount in the currency's minor unit, read from the pinned ISO 4217
323
+ table `CURRENCY_MINOR_UNIT_DIGITS` (a code outside it gets `Intl`'s digits), and always groups thousands.
324
+
299
325
  ## 10. Hooks
300
326
 
301
327
  `ModuleContext` is what every server function of a module receives: `{ db, clock, config }`
@@ -320,4 +346,5 @@ manifest's `privacy.exports` / `privacy.deletes` flag is true. The `privacy` mod
320
346
  are rejected.
321
347
  - Route maps mix mounted paths and redirect targets, so two modules may share a path; mount
322
348
  collisions are left to `softure doctor`.
323
- - No date or number formatting helpers beyond the calendar day; apps use `Intl` with `config.locale` and `config.timezone`.
349
+ - Formatting covers days, money and percentages; times of day and other numbers use `Intl` with `config.locale` and
350
+ `config.timezone`.
@@ -3,4 +3,27 @@ import type { Clock } from "./clock.js";
3
3
  export declare function toCalendarDay(instant: Date, timeZone: string): string;
4
4
  /** Today's calendar day (`YYYY-MM-DD`) in `timeZone`, read from `clock`. */
5
5
  export declare function getCalendarDay(clock: Clock, timeZone: string): string;
6
+ /** What `addCalendarMonths` does with a day the target month lacks (31 January + 1 month). */
7
+ export type EndOfMonth = "clamp" | "overflow";
8
+ /** Whether `value` is a real calendar day written `YYYY-MM-DD` (`2026-02-29` and `2026-1-1` are not). */
9
+ export declare function isCalendarDay(value: unknown): value is string;
10
+ /** `day` moved by `days` calendar days (negative goes back). Throws a `RangeError` for a malformed day or count. */
11
+ export declare function addCalendarDays(day: string, days: number): string;
12
+ /**
13
+ * `day` moved by `months` calendar months (negative goes back). A day the target month lacks is the
14
+ * month's last day with `endOfMonth: "clamp"` (the default: 31 January + 1 month = 28 or 29 February,
15
+ * as billing periods count), or runs on into the next month with `"overflow"` (3 March in 2026, as
16
+ * `Date` does). Throws a `RangeError` for a malformed day or count.
17
+ */
18
+ export declare function addCalendarMonths(day: string, months: number, options?: {
19
+ readonly endOfMonth?: EndOfMonth;
20
+ }): string;
21
+ /** Calendar days from `from` to `to`: 1 from one day to the next, negative when `to` comes first. */
22
+ export declare function calendarDaysBetween(from: string, to: string): number;
23
+ /**
24
+ * Whole months from `from` to `to`: the most months `n` for which `addCalendarMonths(from, n)` (clamped)
25
+ * is not after `to`. So 31 January to 28 February is one month, as a monthly period counts it.
26
+ * Negative when `to` comes first: `wholeMonthsBetween(to, from)` with the sign turned.
27
+ */
28
+ export declare function wholeMonthsBetween(from: string, to: string): number;
6
29
  //# sourceMappingURL=calendar-day.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"calendar-day.d.ts","sourceRoot":"","sources":["../src/calendar-day.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAIxC,qHAAqH;AACrH,wBAAgB,aAAa,CAAC,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAQrE;AAED,4EAA4E;AAC5E,wBAAgB,cAAc,CAAC,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAErE"}
1
+ {"version":3,"file":"calendar-day.d.ts","sourceRoot":"","sources":["../src/calendar-day.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAIxC,qHAAqH;AACrH,wBAAgB,aAAa,CAAC,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAQrE;AAED,4EAA4E;AAC5E,wBAAgB,cAAc,CAAC,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAErE;AAiCD,8FAA8F;AAC9F,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,UAAU,CAAC;AAE9C,yGAAyG;AACzG,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAE7D;AAED,oHAAoH;AACpH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAIjE;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE;IAAE,QAAQ,CAAC,UAAU,CAAC,EAAE,UAAU,CAAA;CAAO,GAAG,MAAM,CAQzH;AAED,qGAAqG;AACrG,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAGpE;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAOnE"}
@@ -29,4 +29,91 @@ function createFormatter(timeZone) {
29
29
  throw new RangeError(`toCalendarDay: "${timeZone}" is not an IANA time zone`, { cause: error });
30
30
  }
31
31
  }
32
+ // Arithmetic on calendar days (`YYYY-MM-DD`): civil days with no time zone, so a day is a day even
33
+ // when DST makes it 23 or 25 hours long. A day number counts days since 1970-01-01.
34
+ const DAY_MS = 24 * 60 * 60 * 1000;
35
+ const MONTHS_PER_YEAR = 12;
36
+ const DAY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
37
+ /** Whether `value` is a real calendar day written `YYYY-MM-DD` (`2026-02-29` and `2026-1-1` are not). */
38
+ export function isCalendarDay(value) {
39
+ return typeof value === "string" && parseCivilDate(value) !== null;
40
+ }
41
+ /** `day` moved by `days` calendar days (negative goes back). Throws a `RangeError` for a malformed day or count. */
42
+ export function addCalendarDays(day, days) {
43
+ const operation = "addCalendarDays";
44
+ assertWhole(operation, days, "days");
45
+ return fromDayNumber(operation, toDayNumber(operation, day) + days);
46
+ }
47
+ /**
48
+ * `day` moved by `months` calendar months (negative goes back). A day the target month lacks is the
49
+ * month's last day with `endOfMonth: "clamp"` (the default: 31 January + 1 month = 28 or 29 February,
50
+ * as billing periods count), or runs on into the next month with `"overflow"` (3 March in 2026, as
51
+ * `Date` does). Throws a `RangeError` for a malformed day or count.
52
+ */
53
+ export function addCalendarMonths(day, months, options = {}) {
54
+ const operation = "addCalendarMonths";
55
+ assertWhole(operation, months, "months");
56
+ const date = readCivilDate(operation, day);
57
+ const month = date.month + months;
58
+ const lastDay = new Date(utcMilliseconds({ year: date.year, month: month + 1, day: 0 })).getUTCDate();
59
+ const target = options.endOfMonth === "overflow" ? date.day : Math.min(date.day, lastDay);
60
+ return fromDayNumber(operation, utcMilliseconds({ year: date.year, month, day: target }) / DAY_MS);
61
+ }
62
+ /** Calendar days from `from` to `to`: 1 from one day to the next, negative when `to` comes first. */
63
+ export function calendarDaysBetween(from, to) {
64
+ const operation = "calendarDaysBetween";
65
+ return toDayNumber(operation, to) - toDayNumber(operation, from);
66
+ }
67
+ /**
68
+ * Whole months from `from` to `to`: the most months `n` for which `addCalendarMonths(from, n)` (clamped)
69
+ * is not after `to`. So 31 January to 28 February is one month, as a monthly period counts it.
70
+ * Negative when `to` comes first: `wholeMonthsBetween(to, from)` with the sign turned.
71
+ */
72
+ export function wholeMonthsBetween(from, to) {
73
+ const operation = "wholeMonthsBetween";
74
+ const start = readCivilDate(operation, from);
75
+ const end = readCivilDate(operation, to);
76
+ if (to < from)
77
+ return -wholeMonthsBetween(to, from);
78
+ const months = (end.year - start.year) * MONTHS_PER_YEAR + (end.month - start.month);
79
+ return addCalendarMonths(from, months) > to ? months - 1 : months;
80
+ }
81
+ function assertWhole(operation, count, unit) {
82
+ if (!Number.isSafeInteger(count))
83
+ throw new RangeError(`${operation}: ${String(count)} is not a whole number of ${unit}`);
84
+ }
85
+ /** Milliseconds of UTC midnight of a civil date; month and day may overflow, as in `Date.UTC`, for any year. */
86
+ function utcMilliseconds({ year, month, day }) {
87
+ // Not `Date.UTC`: it reads years 0-99 as 1900-1999.
88
+ const date = new Date(0);
89
+ date.setUTCFullYear(year, month, day);
90
+ return date.getTime();
91
+ }
92
+ function parseCivilDate(text) {
93
+ const match = DAY_PATTERN.exec(text);
94
+ if (match === null)
95
+ return null;
96
+ const date = { year: Number(match[1]), month: Number(match[2]) - 1, day: Number(match[3]) };
97
+ const parsed = new Date(utcMilliseconds(date));
98
+ if (parsed.getUTCMonth() !== date.month || parsed.getUTCDate() !== date.day)
99
+ return null;
100
+ return date;
101
+ }
102
+ function readCivilDate(operation, day) {
103
+ const date = parseCivilDate(day);
104
+ if (date === null)
105
+ throw new RangeError(`${operation}: "${day}" is not a YYYY-MM-DD calendar day`);
106
+ return date;
107
+ }
108
+ function toDayNumber(operation, day) {
109
+ return utcMilliseconds(readCivilDate(operation, day)) / DAY_MS;
110
+ }
111
+ function fromDayNumber(operation, dayNumber) {
112
+ const date = new Date(dayNumber * DAY_MS);
113
+ const year = date.getUTCFullYear();
114
+ if (year < 0 || year > 9999)
115
+ throw new RangeError(`${operation}: the result falls outside the years 0000-9999`);
116
+ const pad = (value) => String(value).padStart(2, "0");
117
+ return `${String(year).padStart(4, "0")}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
118
+ }
32
119
  //# sourceMappingURL=calendar-day.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"calendar-day.js","sourceRoot":"","sources":["../src/calendar-day.ts"],"names":[],"mappings":"AAIA,MAAM,UAAU,GAAG,IAAI,GAAG,EAA+B,CAAC;AAE1D,qHAAqH;AACrH,MAAM,UAAU,aAAa,CAAC,OAAa,EAAE,QAAgB;IAC3D,IAAI,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC;QACpC,MAAM,IAAI,UAAU,CAAC,6BAA6B,CAAC,CAAC;IACtD,CAAC;IACD,uGAAuG;IACvG,MAAM,KAAK,GAAG,YAAY,CAAC,QAAQ,CAAC,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;IAC5D,MAAM,QAAQ,GAAG,CAAC,IAAkC,EAAU,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,KAAK,IAAI,CAAC,EAAE,KAAK,IAAI,EAAE,CAAC;IACvH,OAAO,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,IAAI,QAAQ,CAAC,OAAO,CAAC,IAAI,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;AACxF,CAAC;AAED,4EAA4E;AAC5E,MAAM,UAAU,cAAc,CAAC,KAAY,EAAE,QAAgB;IAC3D,OAAO,aAAa,CAAC,KAAK,CAAC,GAAG,EAAE,EAAE,QAAQ,CAAC,CAAC;AAC9C,CAAC;AAED,SAAS,YAAY,CAAC,QAAgB;IACpC,IAAI,MAAM,GAAG,UAAU,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IACtC,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,MAAM,GAAG,eAAe,CAAC,QAAQ,CAAC,CAAC;QACnC,UAAU,CAAC,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IACnC,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,SAAS,eAAe,CAAC,QAAgB;IACvC,IAAI,CAAC;QACH,OAAO,IAAI,IAAI,CAAC,cAAc,CAAC,OAAO,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,SAAS,EAAE,GAAG,EAAE,SAAS,EAAE,CAAC,CAAC;IAC3G,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,IAAI,UAAU,CAAC,mBAAmB,QAAQ,4BAA4B,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;IAClG,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"calendar-day.js","sourceRoot":"","sources":["../src/calendar-day.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ /** Digits of the minor unit per accepted currency code, e.g. `PLN: 2`, `JPY: 0`, `KWD: 3`. */
2
+ export declare const CURRENCY_MINOR_UNIT_DIGITS: Readonly<Record<string, number>>;
3
+ //# sourceMappingURL=currency-digits.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"currency-digits.d.ts","sourceRoot":"","sources":["../src/currency-digits.ts"],"names":[],"mappings":"AAgCA,8FAA8F;AAC9F,eAAO,MAAM,0BAA0B,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAIvE,CAAC"}
@@ -0,0 +1,33 @@
1
+ // The minor unit pinned for each currency SOFTURE accepts: an amount in minor units (billing's
2
+ // `price.amount`, `formatMoney`) counts these units on every runtime, whatever the runtime's `Intl`
3
+ // (CLDR) says. Source: ISO 4217 List One, published 2024-06-25, without fund codes, `N.A.` units
4
+ // (metals, XDR, XTS, XXX ...) and UYW (a wage index unit), plus the two choices below. CLDR differs
5
+ // from ISO on AFN, ALL, IRR, KPW, LAK, LBP, MMK, RSD, SOS, SYP and YER (0 instead of 2) and IQD
6
+ // (0 instead of 3), and its HUF and TWD digits have changed between Node builds; the table follows
7
+ // ISO, as Stripe does for all but MGA.
8
+ /** ISO 4217 codes by the digits of their minor unit. */
9
+ const CODES_BY_DIGITS = {
10
+ 0: [
11
+ "BIF", "CLP", "DJF", "GNF", "ISK", "JPY", "KMF", "KRW", "PYG", "RWF", "UGX", "VND", "VUV", "XAF", "XOF", "XPF",
12
+ // Override: ISO writes 2, but one ariary is five iraimbilanja (not a decimal subunit), and CLDR
13
+ // and Stripe both count it without a minor unit.
14
+ "MGA",
15
+ ],
16
+ 2: [
17
+ "AED", "AFN", "ALL", "AMD", "ANG", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM", "BBD", "BDT", "BGN", "BMD", "BND",
18
+ "BOB", "BRL", "BSD", "BTN", "BWP", "BYN", "BZD", "CAD", "CDF", "CHF", "CNY", "COP", "CRC", "CUC", "CUP", "CVE",
19
+ "CZK", "DKK", "DOP", "DZD", "EGP", "ERN", "ETB", "EUR", "FJD", "FKP", "GBP", "GEL", "GHS", "GIP", "GMD", "GTQ",
20
+ "GYD", "HKD", "HNL", "HTG", "HUF", "IDR", "ILS", "INR", "IRR", "JMD", "KES", "KGS", "KHR", "KPW", "KYD", "KZT",
21
+ "LAK", "LBP", "LKR", "LRD", "LSL", "MAD", "MDL", "MKD", "MMK", "MNT", "MOP", "MRU", "MUR", "MVR", "MWK", "MXN",
22
+ "MYR", "MZN", "NAD", "NGN", "NIO", "NOK", "NPR", "NZD", "PAB", "PEN", "PGK", "PHP", "PKR", "PLN", "QAR", "RON",
23
+ "RSD", "RUB", "SAR", "SBD", "SCR", "SDG", "SEK", "SGD", "SHP", "SLE", "SOS", "SRD", "SSP", "STN", "SVC", "SYP",
24
+ "SZL", "THB", "TJS", "TMT", "TOP", "TRY", "TTD", "TWD", "TZS", "UAH", "USD", "UYU", "UZS", "VED", "VES", "WST",
25
+ "XCD", "YER", "ZAR", "ZMW", "ZWG",
26
+ // Addition: the Caribbean guilder, which replaced ANG on 2025-03-31, after the pinned list.
27
+ "XCG",
28
+ ],
29
+ 3: ["BHD", "IQD", "JOD", "KWD", "LYD", "OMR", "TND"],
30
+ };
31
+ /** Digits of the minor unit per accepted currency code, e.g. `PLN: 2`, `JPY: 0`, `KWD: 3`. */
32
+ export const CURRENCY_MINOR_UNIT_DIGITS = Object.freeze(Object.fromEntries(Object.entries(CODES_BY_DIGITS).flatMap(([digits, codes]) => codes.map((code) => [code, Number(digits)]))));
33
+ //# sourceMappingURL=currency-digits.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"currency-digits.js","sourceRoot":"","sources":["../src/currency-digits.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,23 @@
1
+ import type { Locale } from "./i18n.js";
2
+ /** `long`: October 4, 2026 (pl names the month in the genitive); `medium`: Oct 4, 2026; `numeric`: 10/04/2026 (pl: 04.10.2026). */
3
+ export type CalendarDayStyle = "long" | "medium" | "numeric";
4
+ export interface FormatMoneyOptions {
5
+ /** Whole units only, rounded half away from zero: PLN 1,235 for 1234.56. */
6
+ readonly rounded?: boolean;
7
+ /** A `+` before a positive amount (a `-` before a negative one is always there); zero has neither. */
8
+ readonly signed?: boolean;
9
+ /** The locale's short notation for large amounts: PLN 1.2M. */
10
+ readonly compact?: boolean;
11
+ }
12
+ /** A `YYYY-MM-DD` day as `locale` writes it. Throws a `RangeError` for a malformed day. */
13
+ export declare function formatCalendarDay(day: string, locale: Locale, style?: CalendarDayStyle): string;
14
+ /**
15
+ * An amount in the currency's minor unit (2900 PLN = PLN 29.00) in `locale`'s notation, thousands always
16
+ * grouped (pl writes 1 234,56 where its default would leave four digits together). The minor unit is the pinned ISO
17
+ * 4217 one (`CURRENCY_MINOR_UNIT_DIGITS`); a code outside the table gets `Intl`'s digits. Throws a
18
+ * `RangeError` for an amount that is not a whole number of minor units, or a code `Intl` refuses.
19
+ */
20
+ export declare function formatMoney(minor: number, currency: string, locale: Locale, options?: FormatMoneyOptions): string;
21
+ /** Basis points as a percentage in `locale`: 1250 is 12.5%, at most two fraction digits. Throws a `RangeError` for a non-finite value. */
22
+ export declare function formatPercent(basisPoints: number, locale: Locale): string;
23
+ //# sourceMappingURL=format.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../src/format.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAExC,mIAAmI;AACnI,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,CAAC;AAE7D,MAAM,WAAW,kBAAkB;IACjC,4EAA4E;IAC5E,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,sGAAsG;IACtG,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAC1B,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;CAC5B;AAcD,2FAA2F;AAC3F,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,GAAE,gBAAyB,GAAG,MAAM,CASvG;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE,kBAAuB,GAAG,MAAM,CAarH;AAED,0IAA0I;AAC1I,wBAAgB,aAAa,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAIzE"}
package/dist/format.js ADDED
@@ -0,0 +1,63 @@
1
+ // Days, money and percentages as people read them, one way for every module and app. A calendar day
2
+ // is formatted at UTC midnight in UTC, so neither the server's nor the reader's zone moves it.
3
+ import { isCalendarDay } from "./calendar-day.js";
4
+ import { CURRENCY_MINOR_UNIT_DIGITS } from "./currency-digits.js";
5
+ const BASIS_POINTS_PER_UNIT = 10_000;
6
+ const MAX_PERCENT_FRACTION_DIGITS = 2;
7
+ const dayFormatters = new Map();
8
+ const numberFormatters = new Map();
9
+ const DAY_STYLE_OPTIONS = {
10
+ long: { dateStyle: "long" },
11
+ medium: { dateStyle: "medium" },
12
+ numeric: { day: "2-digit", month: "2-digit", year: "numeric" },
13
+ };
14
+ /** A `YYYY-MM-DD` day as `locale` writes it. Throws a `RangeError` for a malformed day. */
15
+ export function formatCalendarDay(day, locale, style = "long") {
16
+ if (!isCalendarDay(day))
17
+ throw new RangeError(`formatCalendarDay: "${String(day)}" is not a YYYY-MM-DD calendar day`);
18
+ const key = `${locale}:${style}`;
19
+ let format = dayFormatters.get(key);
20
+ if (format === undefined) {
21
+ format = new Intl.DateTimeFormat(locale, { ...DAY_STYLE_OPTIONS[style], timeZone: "UTC" });
22
+ dayFormatters.set(key, format);
23
+ }
24
+ return format.format(new Date(`${day}T00:00:00Z`));
25
+ }
26
+ /**
27
+ * An amount in the currency's minor unit (2900 PLN = PLN 29.00) in `locale`'s notation, thousands always
28
+ * grouped (pl writes 1 234,56 where its default would leave four digits together). The minor unit is the pinned ISO
29
+ * 4217 one (`CURRENCY_MINOR_UNIT_DIGITS`); a code outside the table gets `Intl`'s digits. Throws a
30
+ * `RangeError` for an amount that is not a whole number of minor units, or a code `Intl` refuses.
31
+ */
32
+ export function formatMoney(minor, currency, locale, options = {}) {
33
+ if (!Number.isSafeInteger(minor))
34
+ throw new RangeError(`formatMoney: ${String(minor)} is not a whole number of minor units`);
35
+ const pinned = Object.hasOwn(CURRENCY_MINOR_UNIT_DIGITS, currency) ? CURRENCY_MINOR_UNIT_DIGITS[currency] : undefined;
36
+ const digits = pinned ?? getNumberFormatter(locale, { style: "currency", currency }).resolvedOptions().maximumFractionDigits ?? 2;
37
+ const shownDigits = options.rounded === true ? 0 : digits;
38
+ const format = getNumberFormatter(locale, {
39
+ style: "currency",
40
+ currency,
41
+ useGrouping: "always",
42
+ signDisplay: options.signed === true ? "exceptZero" : "auto",
43
+ ...(options.compact === true ? { notation: "compact" } : { minimumFractionDigits: shownDigits, maximumFractionDigits: shownDigits }),
44
+ });
45
+ return format.format(minor / 10 ** digits);
46
+ }
47
+ /** Basis points as a percentage in `locale`: 1250 is 12.5%, at most two fraction digits. Throws a `RangeError` for a non-finite value. */
48
+ export function formatPercent(basisPoints, locale) {
49
+ if (!Number.isFinite(basisPoints))
50
+ throw new RangeError(`formatPercent: ${String(basisPoints)} is not a finite number of basis points`);
51
+ const format = getNumberFormatter(locale, { style: "percent", maximumFractionDigits: MAX_PERCENT_FRACTION_DIGITS });
52
+ return format.format(basisPoints / BASIS_POINTS_PER_UNIT);
53
+ }
54
+ function getNumberFormatter(locale, options) {
55
+ const key = `${locale}:${JSON.stringify(options)}`;
56
+ let format = numberFormatters.get(key);
57
+ if (format === undefined) {
58
+ format = new Intl.NumberFormat(locale, options);
59
+ numberFormatters.set(key, format);
60
+ }
61
+ return format;
62
+ }
63
+ //# sourceMappingURL=format.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format.js","sourceRoot":"","sources":["../src/format.ts"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
- export { getCalendarDay, toCalendarDay } from "./calendar-day.js";
1
+ export { addCalendarDays, addCalendarMonths, calendarDaysBetween, getCalendarDay, isCalendarDay, toCalendarDay, wholeMonthsBetween, type EndOfMonth, } from "./calendar-day.js";
2
+ export { CURRENCY_MINOR_UNIT_DIGITS } from "./currency-digits.js";
3
+ export { formatCalendarDay, formatMoney, formatPercent, type CalendarDayStyle, type FormatMoneyOptions } from "./format.js";
2
4
  export { createTestClock, systemClock, type Clock, type TestClock } from "./clock.js";
3
5
  export { formatMessage, getMessage, isLocale, LOCALES, mergeMessages, selectPlural, type DeepPartial, type Dictionaries, type Locale, type MessageOverrides, type MessageTree, type PluralForms, } from "./i18n.js";
4
6
  export { coreMessages, type CoreMessages } from "./messages/index.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,EAAE,eAAe,EAAE,WAAW,EAAE,KAAK,KAAK,EAAE,KAAK,SAAS,EAAE,MAAM,YAAY,CAAC;AACtF,OAAO,EACL,aAAa,EACb,UAAU,EACV,QAAQ,EACR,OAAO,EACP,aAAa,EACb,YAAY,EACZ,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,MAAM,EACX,KAAK,gBAAgB,EACrB,KAAK,WAAW,EAChB,KAAK,WAAW,GACjB,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,YAAY,EAAE,KAAK,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACtE,OAAO,EAAE,GAAG,EAAE,EAAE,EAAE,KAAK,GAAG,EAAE,KAAK,SAAS,EAAE,KAAK,EAAE,EAAE,KAAK,MAAM,EAAE,MAAM,aAAa,CAAC;AACtF,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,aAAa,EAAE,WAAW,EAAE,SAAS,EAAE,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAC7H,OAAO,EACL,mBAAmB,EACnB,SAAS,EACT,yBAAyB,EACzB,KAAK,aAAa,EAClB,KAAK,kBAAkB,EACvB,KAAK,qBAAqB,EAC1B,KAAK,qBAAqB,EAC1B,KAAK,0BAA0B,EAC/B,KAAK,cAAc,EACnB,KAAK,mBAAmB,GACzB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AACjE,OAAO,EAAE,oBAAoB,EAAE,KAAK,cAAc,EAAE,MAAM,eAAe,CAAC;AAC1E,OAAO,EACL,YAAY,EACZ,oBAAoB,EACpB,YAAY,EACZ,KAAK,gBAAgB,EACrB,KAAK,WAAW,EAChB,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,UAAU,EACf,KAAK,kBAAkB,EACvB,KAAK,aAAa,GACnB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,iBAAiB,EACjB,WAAW,EACX,kBAAkB,EAClB,eAAe,EACf,iBAAiB,EACjB,gBAAgB,EAChB,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACtB,KAAK,uBAAuB,GAC7B,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,mBAAmB,EAAE,WAAW,EAAE,KAAK,eAAe,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AACvG,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAE,KAAK,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,eAAe,CAAC;AACpG,OAAO,EAAE,SAAS,EAAE,iBAAiB,EAAE,cAAc,EAAE,KAAK,OAAO,EAAE,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,mBAAmB,EACnB,cAAc,EACd,aAAa,EACb,aAAa,EACb,kBAAkB,EAClB,KAAK,UAAU,GAChB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,0BAA0B,EAAE,MAAM,sBAAsB,CAAC;AAClE,OAAO,EAAE,iBAAiB,EAAE,WAAW,EAAE,aAAa,EAAE,KAAK,gBAAgB,EAAE,KAAK,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAC5H,OAAO,EAAE,eAAe,EAAE,WAAW,EAAE,KAAK,KAAK,EAAE,KAAK,SAAS,EAAE,MAAM,YAAY,CAAC;AACtF,OAAO,EACL,aAAa,EACb,UAAU,EACV,QAAQ,EACR,OAAO,EACP,aAAa,EACb,YAAY,EACZ,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,MAAM,EACX,KAAK,gBAAgB,EACrB,KAAK,WAAW,EAChB,KAAK,WAAW,GACjB,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,YAAY,EAAE,KAAK,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACtE,OAAO,EAAE,GAAG,EAAE,EAAE,EAAE,KAAK,GAAG,EAAE,KAAK,SAAS,EAAE,KAAK,EAAE,EAAE,KAAK,MAAM,EAAE,MAAM,aAAa,CAAC;AACtF,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,aAAa,EAAE,WAAW,EAAE,SAAS,EAAE,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAC7H,OAAO,EACL,mBAAmB,EACnB,SAAS,EACT,yBAAyB,EACzB,KAAK,aAAa,EAClB,KAAK,kBAAkB,EACvB,KAAK,qBAAqB,EAC1B,KAAK,qBAAqB,EAC1B,KAAK,0BAA0B,EAC/B,KAAK,cAAc,EACnB,KAAK,mBAAmB,GACzB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AACjE,OAAO,EAAE,oBAAoB,EAAE,KAAK,cAAc,EAAE,MAAM,eAAe,CAAC;AAC1E,OAAO,EACL,YAAY,EACZ,oBAAoB,EACpB,YAAY,EACZ,KAAK,gBAAgB,EACrB,KAAK,WAAW,EAChB,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,UAAU,EACf,KAAK,kBAAkB,EACvB,KAAK,aAAa,GACnB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,iBAAiB,EACjB,WAAW,EACX,kBAAkB,EAClB,eAAe,EACf,iBAAiB,EACjB,gBAAgB,EAChB,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACtB,KAAK,uBAAuB,GAC7B,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,mBAAmB,EAAE,WAAW,EAAE,KAAK,eAAe,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AACvG,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAE,KAAK,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,eAAe,CAAC;AACpG,OAAO,EAAE,SAAS,EAAE,iBAAiB,EAAE,cAAc,EAAE,KAAK,OAAO,EAAE,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC"}
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // Public API of @softure-ai/core: the contract every SOFTURE module stands on
2
2
  // (docs/02-module-standard.md). The Next.js registry is a separate entry: `@softure-ai/core/next`.
3
- export { getCalendarDay, toCalendarDay } from "./calendar-day.js";
3
+ export { addCalendarDays, addCalendarMonths, calendarDaysBetween, getCalendarDay, isCalendarDay, toCalendarDay, wholeMonthsBetween, } from "./calendar-day.js";
4
+ export { CURRENCY_MINOR_UNIT_DIGITS } from "./currency-digits.js";
5
+ export { formatCalendarDay, formatMoney, formatPercent } from "./format.js";
4
6
  export { createTestClock, systemClock } from "./clock.js";
5
7
  export { formatMessage, getMessage, isLocale, LOCALES, mergeMessages, selectPlural, } from "./i18n.js";
6
8
  export { coreMessages } from "./messages/index.js";
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,mGAAmG;AACnG,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,EAAE,eAAe,EAAE,WAAW,EAA8B,MAAM,YAAY,CAAC;AACtF,OAAO,EACL,aAAa,EACb,UAAU,EACV,QAAQ,EACR,OAAO,EACP,aAAa,EACb,YAAY,GAOb,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,YAAY,EAAqB,MAAM,qBAAqB,CAAC;AACtE,OAAO,EAAE,GAAG,EAAE,EAAE,EAAkD,MAAM,aAAa,CAAC;AACtF,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,aAAa,EAAE,WAAW,EAAE,SAAS,EAAsB,MAAM,iBAAiB,CAAC;AAC7H,OAAO,EACL,mBAAmB,EACnB,SAAS,EACT,yBAAyB,GAQ1B,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AACjE,OAAO,EAAE,oBAAoB,EAAuB,MAAM,eAAe,CAAC;AAC1E,OAAO,EACL,YAAY,EACZ,oBAAoB,EACpB,YAAY,GAUb,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,iBAAiB,EACjB,WAAW,EACX,kBAAkB,EAClB,eAAe,EACf,iBAAiB,EACjB,gBAAgB,GAIjB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,mBAAmB,EAAE,WAAW,EAAuC,MAAM,gBAAgB,CAAC;AACvG,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAyC,MAAM,eAAe,CAAC;AACpG,OAAO,EAAE,SAAS,EAAE,iBAAiB,EAAE,cAAc,EAAmC,MAAM,oBAAoB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,mGAAmG;AACnG,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,mBAAmB,EACnB,cAAc,EACd,aAAa,EACb,aAAa,EACb,kBAAkB,GAEnB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,0BAA0B,EAAE,MAAM,sBAAsB,CAAC;AAClE,OAAO,EAAE,iBAAiB,EAAE,WAAW,EAAE,aAAa,EAAkD,MAAM,aAAa,CAAC;AAC5H,OAAO,EAAE,eAAe,EAAE,WAAW,EAA8B,MAAM,YAAY,CAAC;AACtF,OAAO,EACL,aAAa,EACb,UAAU,EACV,QAAQ,EACR,OAAO,EACP,aAAa,EACb,YAAY,GAOb,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,YAAY,EAAqB,MAAM,qBAAqB,CAAC;AACtE,OAAO,EAAE,GAAG,EAAE,EAAE,EAAkD,MAAM,aAAa,CAAC;AACtF,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,aAAa,EAAE,WAAW,EAAE,SAAS,EAAsB,MAAM,iBAAiB,CAAC;AAC7H,OAAO,EACL,mBAAmB,EACnB,SAAS,EACT,yBAAyB,GAQ1B,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AACjE,OAAO,EAAE,oBAAoB,EAAuB,MAAM,eAAe,CAAC;AAC1E,OAAO,EACL,YAAY,EACZ,oBAAoB,EACpB,YAAY,GAUb,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,iBAAiB,EACjB,WAAW,EACX,kBAAkB,EAClB,eAAe,EACf,iBAAiB,EACjB,gBAAgB,GAIjB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,mBAAmB,EAAE,WAAW,EAAuC,MAAM,gBAAgB,CAAC;AACvG,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAyC,MAAM,eAAe,CAAC;AACpG,OAAO,EAAE,SAAS,EAAE,iBAAiB,EAAE,cAAc,EAAmC,MAAM,oBAAoB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softure-ai/core",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "The SOFTURE AI module contract: app config, module definitions, Result, Clock and messages.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,3 +36,107 @@ function createFormatter(timeZone: string): Intl.DateTimeFormat {
36
36
  throw new RangeError(`toCalendarDay: "${timeZone}" is not an IANA time zone`, { cause: error });
37
37
  }
38
38
  }
39
+
40
+ // Arithmetic on calendar days (`YYYY-MM-DD`): civil days with no time zone, so a day is a day even
41
+ // when DST makes it 23 or 25 hours long. A day number counts days since 1970-01-01.
42
+
43
+ const DAY_MS = 24 * 60 * 60 * 1000;
44
+ const MONTHS_PER_YEAR = 12;
45
+ const DAY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
46
+
47
+ interface CivilDate {
48
+ readonly year: number;
49
+ /** 0-based, as `Date` counts months. */
50
+ readonly month: number;
51
+ readonly day: number;
52
+ }
53
+
54
+ /** What `addCalendarMonths` does with a day the target month lacks (31 January + 1 month). */
55
+ export type EndOfMonth = "clamp" | "overflow";
56
+
57
+ /** Whether `value` is a real calendar day written `YYYY-MM-DD` (`2026-02-29` and `2026-1-1` are not). */
58
+ export function isCalendarDay(value: unknown): value is string {
59
+ return typeof value === "string" && parseCivilDate(value) !== null;
60
+ }
61
+
62
+ /** `day` moved by `days` calendar days (negative goes back). Throws a `RangeError` for a malformed day or count. */
63
+ export function addCalendarDays(day: string, days: number): string {
64
+ const operation = "addCalendarDays";
65
+ assertWhole(operation, days, "days");
66
+ return fromDayNumber(operation, toDayNumber(operation, day) + days);
67
+ }
68
+
69
+ /**
70
+ * `day` moved by `months` calendar months (negative goes back). A day the target month lacks is the
71
+ * month's last day with `endOfMonth: "clamp"` (the default: 31 January + 1 month = 28 or 29 February,
72
+ * as billing periods count), or runs on into the next month with `"overflow"` (3 March in 2026, as
73
+ * `Date` does). Throws a `RangeError` for a malformed day or count.
74
+ */
75
+ export function addCalendarMonths(day: string, months: number, options: { readonly endOfMonth?: EndOfMonth } = {}): string {
76
+ const operation = "addCalendarMonths";
77
+ assertWhole(operation, months, "months");
78
+ const date = readCivilDate(operation, day);
79
+ const month = date.month + months;
80
+ const lastDay = new Date(utcMilliseconds({ year: date.year, month: month + 1, day: 0 })).getUTCDate();
81
+ const target = options.endOfMonth === "overflow" ? date.day : Math.min(date.day, lastDay);
82
+ return fromDayNumber(operation, utcMilliseconds({ year: date.year, month, day: target }) / DAY_MS);
83
+ }
84
+
85
+ /** Calendar days from `from` to `to`: 1 from one day to the next, negative when `to` comes first. */
86
+ export function calendarDaysBetween(from: string, to: string): number {
87
+ const operation = "calendarDaysBetween";
88
+ return toDayNumber(operation, to) - toDayNumber(operation, from);
89
+ }
90
+
91
+ /**
92
+ * Whole months from `from` to `to`: the most months `n` for which `addCalendarMonths(from, n)` (clamped)
93
+ * is not after `to`. So 31 January to 28 February is one month, as a monthly period counts it.
94
+ * Negative when `to` comes first: `wholeMonthsBetween(to, from)` with the sign turned.
95
+ */
96
+ export function wholeMonthsBetween(from: string, to: string): number {
97
+ const operation = "wholeMonthsBetween";
98
+ const start = readCivilDate(operation, from);
99
+ const end = readCivilDate(operation, to);
100
+ if (to < from) return -wholeMonthsBetween(to, from);
101
+ const months = (end.year - start.year) * MONTHS_PER_YEAR + (end.month - start.month);
102
+ return addCalendarMonths(from, months) > to ? months - 1 : months;
103
+ }
104
+
105
+ function assertWhole(operation: string, count: number, unit: string): void {
106
+ if (!Number.isSafeInteger(count)) throw new RangeError(`${operation}: ${String(count)} is not a whole number of ${unit}`);
107
+ }
108
+
109
+ /** Milliseconds of UTC midnight of a civil date; month and day may overflow, as in `Date.UTC`, for any year. */
110
+ function utcMilliseconds({ year, month, day }: CivilDate): number {
111
+ // Not `Date.UTC`: it reads years 0-99 as 1900-1999.
112
+ const date = new Date(0);
113
+ date.setUTCFullYear(year, month, day);
114
+ return date.getTime();
115
+ }
116
+
117
+ function parseCivilDate(text: string): CivilDate | null {
118
+ const match = DAY_PATTERN.exec(text);
119
+ if (match === null) return null;
120
+ const date = { year: Number(match[1]), month: Number(match[2]) - 1, day: Number(match[3]) };
121
+ const parsed = new Date(utcMilliseconds(date));
122
+ if (parsed.getUTCMonth() !== date.month || parsed.getUTCDate() !== date.day) return null;
123
+ return date;
124
+ }
125
+
126
+ function readCivilDate(operation: string, day: string): CivilDate {
127
+ const date = parseCivilDate(day);
128
+ if (date === null) throw new RangeError(`${operation}: "${day}" is not a YYYY-MM-DD calendar day`);
129
+ return date;
130
+ }
131
+
132
+ function toDayNumber(operation: string, day: string): number {
133
+ return utcMilliseconds(readCivilDate(operation, day)) / DAY_MS;
134
+ }
135
+
136
+ function fromDayNumber(operation: string, dayNumber: number): string {
137
+ const date = new Date(dayNumber * DAY_MS);
138
+ const year = date.getUTCFullYear();
139
+ if (year < 0 || year > 9999) throw new RangeError(`${operation}: the result falls outside the years 0000-9999`);
140
+ const pad = (value: number): string => String(value).padStart(2, "0");
141
+ return `${String(year).padStart(4, "0")}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
142
+ }
@@ -0,0 +1,38 @@
1
+ // The minor unit pinned for each currency SOFTURE accepts: an amount in minor units (billing's
2
+ // `price.amount`, `formatMoney`) counts these units on every runtime, whatever the runtime's `Intl`
3
+ // (CLDR) says. Source: ISO 4217 List One, published 2024-06-25, without fund codes, `N.A.` units
4
+ // (metals, XDR, XTS, XXX ...) and UYW (a wage index unit), plus the two choices below. CLDR differs
5
+ // from ISO on AFN, ALL, IRR, KPW, LAK, LBP, MMK, RSD, SOS, SYP and YER (0 instead of 2) and IQD
6
+ // (0 instead of 3), and its HUF and TWD digits have changed between Node builds; the table follows
7
+ // ISO, as Stripe does for all but MGA.
8
+
9
+ /** ISO 4217 codes by the digits of their minor unit. */
10
+ const CODES_BY_DIGITS: Readonly<Record<0 | 2 | 3, readonly string[]>> = {
11
+ 0: [
12
+ "BIF", "CLP", "DJF", "GNF", "ISK", "JPY", "KMF", "KRW", "PYG", "RWF", "UGX", "VND", "VUV", "XAF", "XOF", "XPF",
13
+ // Override: ISO writes 2, but one ariary is five iraimbilanja (not a decimal subunit), and CLDR
14
+ // and Stripe both count it without a minor unit.
15
+ "MGA",
16
+ ],
17
+ 2: [
18
+ "AED", "AFN", "ALL", "AMD", "ANG", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM", "BBD", "BDT", "BGN", "BMD", "BND",
19
+ "BOB", "BRL", "BSD", "BTN", "BWP", "BYN", "BZD", "CAD", "CDF", "CHF", "CNY", "COP", "CRC", "CUC", "CUP", "CVE",
20
+ "CZK", "DKK", "DOP", "DZD", "EGP", "ERN", "ETB", "EUR", "FJD", "FKP", "GBP", "GEL", "GHS", "GIP", "GMD", "GTQ",
21
+ "GYD", "HKD", "HNL", "HTG", "HUF", "IDR", "ILS", "INR", "IRR", "JMD", "KES", "KGS", "KHR", "KPW", "KYD", "KZT",
22
+ "LAK", "LBP", "LKR", "LRD", "LSL", "MAD", "MDL", "MKD", "MMK", "MNT", "MOP", "MRU", "MUR", "MVR", "MWK", "MXN",
23
+ "MYR", "MZN", "NAD", "NGN", "NIO", "NOK", "NPR", "NZD", "PAB", "PEN", "PGK", "PHP", "PKR", "PLN", "QAR", "RON",
24
+ "RSD", "RUB", "SAR", "SBD", "SCR", "SDG", "SEK", "SGD", "SHP", "SLE", "SOS", "SRD", "SSP", "STN", "SVC", "SYP",
25
+ "SZL", "THB", "TJS", "TMT", "TOP", "TRY", "TTD", "TWD", "TZS", "UAH", "USD", "UYU", "UZS", "VED", "VES", "WST",
26
+ "XCD", "YER", "ZAR", "ZMW", "ZWG",
27
+ // Addition: the Caribbean guilder, which replaced ANG on 2025-03-31, after the pinned list.
28
+ "XCG",
29
+ ],
30
+ 3: ["BHD", "IQD", "JOD", "KWD", "LYD", "OMR", "TND"],
31
+ };
32
+
33
+ /** Digits of the minor unit per accepted currency code, e.g. `PLN: 2`, `JPY: 0`, `KWD: 3`. */
34
+ export const CURRENCY_MINOR_UNIT_DIGITS: Readonly<Record<string, number>> = Object.freeze(
35
+ Object.fromEntries(
36
+ Object.entries(CODES_BY_DIGITS).flatMap(([digits, codes]) => codes.map((code) => [code, Number(digits)] as const)),
37
+ ),
38
+ );
package/src/format.ts ADDED
@@ -0,0 +1,79 @@
1
+ // Days, money and percentages as people read them, one way for every module and app. A calendar day
2
+ // is formatted at UTC midnight in UTC, so neither the server's nor the reader's zone moves it.
3
+ import { isCalendarDay } from "./calendar-day.js";
4
+ import { CURRENCY_MINOR_UNIT_DIGITS } from "./currency-digits.js";
5
+ import type { Locale } from "./i18n.js";
6
+
7
+ /** `long`: October 4, 2026 (pl names the month in the genitive); `medium`: Oct 4, 2026; `numeric`: 10/04/2026 (pl: 04.10.2026). */
8
+ export type CalendarDayStyle = "long" | "medium" | "numeric";
9
+
10
+ export interface FormatMoneyOptions {
11
+ /** Whole units only, rounded half away from zero: PLN 1,235 for 1234.56. */
12
+ readonly rounded?: boolean;
13
+ /** A `+` before a positive amount (a `-` before a negative one is always there); zero has neither. */
14
+ readonly signed?: boolean;
15
+ /** The locale's short notation for large amounts: PLN 1.2M. */
16
+ readonly compact?: boolean;
17
+ }
18
+
19
+ const BASIS_POINTS_PER_UNIT = 10_000;
20
+ const MAX_PERCENT_FRACTION_DIGITS = 2;
21
+
22
+ const dayFormatters = new Map<string, Intl.DateTimeFormat>();
23
+ const numberFormatters = new Map<string, Intl.NumberFormat>();
24
+
25
+ const DAY_STYLE_OPTIONS: Readonly<Record<CalendarDayStyle, Intl.DateTimeFormatOptions>> = {
26
+ long: { dateStyle: "long" },
27
+ medium: { dateStyle: "medium" },
28
+ numeric: { day: "2-digit", month: "2-digit", year: "numeric" },
29
+ };
30
+
31
+ /** A `YYYY-MM-DD` day as `locale` writes it. Throws a `RangeError` for a malformed day. */
32
+ export function formatCalendarDay(day: string, locale: Locale, style: CalendarDayStyle = "long"): string {
33
+ if (!isCalendarDay(day)) throw new RangeError(`formatCalendarDay: "${String(day)}" is not a YYYY-MM-DD calendar day`);
34
+ const key = `${locale}:${style}`;
35
+ let format = dayFormatters.get(key);
36
+ if (format === undefined) {
37
+ format = new Intl.DateTimeFormat(locale, { ...DAY_STYLE_OPTIONS[style], timeZone: "UTC" });
38
+ dayFormatters.set(key, format);
39
+ }
40
+ return format.format(new Date(`${day}T00:00:00Z`));
41
+ }
42
+
43
+ /**
44
+ * An amount in the currency's minor unit (2900 PLN = PLN 29.00) in `locale`'s notation, thousands always
45
+ * grouped (pl writes 1 234,56 where its default would leave four digits together). The minor unit is the pinned ISO
46
+ * 4217 one (`CURRENCY_MINOR_UNIT_DIGITS`); a code outside the table gets `Intl`'s digits. Throws a
47
+ * `RangeError` for an amount that is not a whole number of minor units, or a code `Intl` refuses.
48
+ */
49
+ export function formatMoney(minor: number, currency: string, locale: Locale, options: FormatMoneyOptions = {}): string {
50
+ if (!Number.isSafeInteger(minor)) throw new RangeError(`formatMoney: ${String(minor)} is not a whole number of minor units`);
51
+ const pinned = Object.hasOwn(CURRENCY_MINOR_UNIT_DIGITS, currency) ? CURRENCY_MINOR_UNIT_DIGITS[currency] : undefined;
52
+ const digits = pinned ?? getNumberFormatter(locale, { style: "currency", currency }).resolvedOptions().maximumFractionDigits ?? 2;
53
+ const shownDigits = options.rounded === true ? 0 : digits;
54
+ const format = getNumberFormatter(locale, {
55
+ style: "currency",
56
+ currency,
57
+ useGrouping: "always",
58
+ signDisplay: options.signed === true ? "exceptZero" : "auto",
59
+ ...(options.compact === true ? { notation: "compact" } : { minimumFractionDigits: shownDigits, maximumFractionDigits: shownDigits }),
60
+ });
61
+ return format.format(minor / 10 ** digits);
62
+ }
63
+
64
+ /** Basis points as a percentage in `locale`: 1250 is 12.5%, at most two fraction digits. Throws a `RangeError` for a non-finite value. */
65
+ export function formatPercent(basisPoints: number, locale: Locale): string {
66
+ if (!Number.isFinite(basisPoints)) throw new RangeError(`formatPercent: ${String(basisPoints)} is not a finite number of basis points`);
67
+ const format = getNumberFormatter(locale, { style: "percent", maximumFractionDigits: MAX_PERCENT_FRACTION_DIGITS });
68
+ return format.format(basisPoints / BASIS_POINTS_PER_UNIT);
69
+ }
70
+
71
+ function getNumberFormatter(locale: Locale, options: Intl.NumberFormatOptions): Intl.NumberFormat {
72
+ const key = `${locale}:${JSON.stringify(options)}`;
73
+ let format = numberFormatters.get(key);
74
+ if (format === undefined) {
75
+ format = new Intl.NumberFormat(locale, options);
76
+ numberFormatters.set(key, format);
77
+ }
78
+ return format;
79
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,17 @@
1
1
  // Public API of @softure-ai/core: the contract every SOFTURE module stands on
2
2
  // (docs/02-module-standard.md). The Next.js registry is a separate entry: `@softure-ai/core/next`.
3
- export { getCalendarDay, toCalendarDay } from "./calendar-day.js";
3
+ export {
4
+ addCalendarDays,
5
+ addCalendarMonths,
6
+ calendarDaysBetween,
7
+ getCalendarDay,
8
+ isCalendarDay,
9
+ toCalendarDay,
10
+ wholeMonthsBetween,
11
+ type EndOfMonth,
12
+ } from "./calendar-day.js";
13
+ export { CURRENCY_MINOR_UNIT_DIGITS } from "./currency-digits.js";
14
+ export { formatCalendarDay, formatMoney, formatPercent, type CalendarDayStyle, type FormatMoneyOptions } from "./format.js";
4
15
  export { createTestClock, systemClock, type Clock, type TestClock } from "./clock.js";
5
16
  export {
6
17
  formatMessage,