@classytic/primitives 0.26.3 → 0.27.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 CHANGED
@@ -3,6 +3,44 @@
3
3
  Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
4
  adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## 0.27.0
7
+
8
+ ### Added — `./csv` subpath: one CSV cell escaper for the whole fleet
9
+
10
+ `escapeCsvCell(value)` and `neutralizeCsvFormula(value)`. Six CSV producers
11
+ across ledger, the spine and the hosts each hand-rolled RFC 4180 quoting, and
12
+ not one neutralized a leading `= + - @ \t \r` — so a customer-supplied payment
13
+ reference or party name reached the accountant's spreadsheet as a live formula.
14
+ Quoting does not help: a spreadsheet strips the quotes and evaluates what is
15
+ left, which is why the two defences ship as one module.
16
+
17
+ The two functions are separable on purpose. A consumer holding a real CSV
18
+ writer (`csv-stringify`) takes `neutralizeCsvFormula` only, so its quoting is
19
+ not done twice; a hand-rolled `rows.map(...).join(',')` takes `escapeCsvCell`.
20
+
21
+ **Well-formed numbers are exempt.** Prefixing `-1500` would make every negative
22
+ amount text and break `SUM` in every finance export — a fix that quietly
23
+ corrupts the product is worse than the hole. The exemption requires the WHOLE
24
+ cell to be a number, so `-2+3+cmd|'/c calc'!A0` is still neutralized.
25
+
26
+ Not for a machine-to-machine upload contract with a fixed column spec (a
27
+ courier's bulk-import CSV): there the receiver is a parser, not a spreadsheet,
28
+ and an added `'` corrupts the payload.
29
+
30
+ ## 0.26.4
31
+
32
+ ### Added — `./price-basis` subpath: measured pricing across physical dimensions
33
+
34
+ New `PriceBasis` type (`FixedPriceBasis | MeasuredPriceBasis`) models how a quoted price spans a sellable quantity — flat fixed or per-unit-of-measure (mass, duration, volume, length, area). Factory functions `fixedPriceBasis()` and `measuredPriceBasis(dimension, qtyUnit, priceUnit, qtyPerPriceUnit)` construct the two shapes; `isPriceBasis()` is a structural guard for DB/wire boundaries. `resolvePriceBasisTotal(unitPriceMinor, quantity, basis?)` converts a minor-unit price × integer quantity to a final minor-unit total using half-even rounding (`roundedDiv` internally) and throws `PriceBasisError` on unsafe inputs or overflow.
35
+
36
+ ### Added — `./unit-cost-rate` re-exports `RoundingMode`
37
+
38
+ `RoundingMode` (`'half-even' | 'half-up'`) is now defined in the new internal `rounded-div.ts` and re-exported from `unit-cost-rate`. No breaking change — existing consumers who import `RoundingMode` from `@classytic/primitives/unit-cost-rate` continue to work.
39
+
40
+ ### Changed — `validateAddress` / `isAddressValid` accept `Address` directly
41
+
42
+ Both functions now accept `Address | PartialAddress | null | undefined`, removing the need for a cast when validating a fully-typed value object.
43
+
6
44
  ## 0.26.3
7
45
 
8
46
  ### Added — `PaymentProviderPort.wantsRawBody`
@@ -298,9 +298,9 @@ interface AddressFieldError {
298
298
  * `' '` satisfies a naive truthiness check and then prints as a blank line on
299
299
  * a courier label. Required means a value, not a character.
300
300
  */
301
- declare function validateAddress(address: PartialAddress | null | undefined, format: AddressFormat): AddressFieldError[];
301
+ declare function validateAddress(address: Address | PartialAddress | null | undefined, format: AddressFormat): AddressFieldError[];
302
302
  /** Convenience for a submit gate. */
303
- declare function isAddressValid(address: PartialAddress | null | undefined, format: AddressFormat): boolean;
303
+ declare function isAddressValid(address: Address | PartialAddress | null | undefined, format: AddressFormat): boolean;
304
304
  /** A readable default message, in the country's own vocabulary. */
305
305
  declare function describeAddressError(error: AddressFieldError): string;
306
306
  //#endregion
package/dist/address.mjs CHANGED
@@ -162,7 +162,8 @@ function supportedCountries(registry) {
162
162
  */
163
163
  function validateAddress(address, format) {
164
164
  const errors = [];
165
- const value = (k) => String(address?.[k] ?? "").trim();
165
+ const values = address;
166
+ const value = (k) => String(values?.[k] ?? "").trim();
166
167
  /**
167
168
  * Canonical and extension fields are checked IDENTICALLY, keyed by
168
169
  * `fieldName`. A required Delivery Area is enforced exactly as a required
package/dist/csv.d.mts ADDED
@@ -0,0 +1,36 @@
1
+ //#region src/serialization/csv.d.ts
2
+ /**
3
+ * `@classytic/primitives/csv` — make one value safe to put in a CSV cell.
4
+ *
5
+ * Two defences, deliberately separable because a consumer that already has a
6
+ * real CSV writer must NOT get its quoting done twice:
7
+ *
8
+ * - `neutralizeCsvFormula` — stops the cell being executed as a formula when
9
+ * the file is opened in Excel / Sheets / LibreOffice. Use this when a
10
+ * library (`csv-stringify`, `papaparse`, …) is doing the quoting.
11
+ * - `escapeCsvCell` — neutralize + RFC 4180 quoting. Use this in a
12
+ * hand-rolled `rows.map(...).join(',')` serializer.
13
+ *
14
+ * WHY quoting is not enough: a spreadsheet strips the quotes first and
15
+ * evaluates what is left, so `"=cmd|'/c calc'!A1"` still executes. Only
16
+ * changing the LEADING character stops it, which is why these are one module
17
+ * and not a quoting helper someone can use alone.
18
+ *
19
+ * Well-formed numbers are exempt. Prefixing `-1500` would make every negative
20
+ * amount text and break the reader's `SUM` — a fix that quietly corrupts every
21
+ * finance export is worse than the hole. The exemption is the WHOLE cell
22
+ * matching a number, so `-2+3+cmd|'/c calc'!A0` is still neutralized.
23
+ *
24
+ * NOT for a machine-to-machine upload contract with a fixed column spec (a
25
+ * courier's bulk-import CSV, say) — there the receiver is a parser, not a
26
+ * spreadsheet, and an added `'` corrupts the payload.
27
+ */
28
+ /**
29
+ * Prefix `'` when the cell would otherwise be evaluated as a formula.
30
+ * `null`/`undefined` become `''`. Numbers are returned unchanged.
31
+ */
32
+ declare function neutralizeCsvFormula(value: unknown): string;
33
+ /** `neutralizeCsvFormula` + RFC 4180 quoting. The one-call form. */
34
+ declare function escapeCsvCell(value: unknown): string;
35
+ //#endregion
36
+ export { escapeCsvCell, neutralizeCsvFormula };
package/dist/csv.mjs ADDED
@@ -0,0 +1,50 @@
1
+ //#region src/serialization/csv.ts
2
+ /**
3
+ * `@classytic/primitives/csv` — make one value safe to put in a CSV cell.
4
+ *
5
+ * Two defences, deliberately separable because a consumer that already has a
6
+ * real CSV writer must NOT get its quoting done twice:
7
+ *
8
+ * - `neutralizeCsvFormula` — stops the cell being executed as a formula when
9
+ * the file is opened in Excel / Sheets / LibreOffice. Use this when a
10
+ * library (`csv-stringify`, `papaparse`, …) is doing the quoting.
11
+ * - `escapeCsvCell` — neutralize + RFC 4180 quoting. Use this in a
12
+ * hand-rolled `rows.map(...).join(',')` serializer.
13
+ *
14
+ * WHY quoting is not enough: a spreadsheet strips the quotes first and
15
+ * evaluates what is left, so `"=cmd|'/c calc'!A1"` still executes. Only
16
+ * changing the LEADING character stops it, which is why these are one module
17
+ * and not a quoting helper someone can use alone.
18
+ *
19
+ * Well-formed numbers are exempt. Prefixing `-1500` would make every negative
20
+ * amount text and break the reader's `SUM` — a fix that quietly corrupts every
21
+ * finance export is worse than the hole. The exemption is the WHOLE cell
22
+ * matching a number, so `-2+3+cmd|'/c calc'!A0` is still neutralized.
23
+ *
24
+ * NOT for a machine-to-machine upload contract with a fixed column spec (a
25
+ * courier's bulk-import CSV, say) — there the receiver is a parser, not a
26
+ * spreadsheet, and an added `'` corrupts the payload.
27
+ */
28
+ /** Leading characters a spreadsheet may treat as the start of a formula or DDE call. */
29
+ const FORMULA_LEAD = /^[=+\-@\t\r]/;
30
+ /** The WHOLE cell is a plain number — no separators, no currency symbol. */
31
+ const WHOLE_NUMBER = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?$/;
32
+ /** Characters that force RFC 4180 quoting. */
33
+ const NEEDS_QUOTING = /[",\r\n]/;
34
+ /**
35
+ * Prefix `'` when the cell would otherwise be evaluated as a formula.
36
+ * `null`/`undefined` become `''`. Numbers are returned unchanged.
37
+ */
38
+ function neutralizeCsvFormula(value) {
39
+ const text = value == null ? "" : String(value);
40
+ if (!FORMULA_LEAD.test(text)) return text;
41
+ if (WHOLE_NUMBER.test(text)) return text;
42
+ return `'${text}`;
43
+ }
44
+ /** `neutralizeCsvFormula` + RFC 4180 quoting. The one-call form. */
45
+ function escapeCsvCell(value) {
46
+ const text = neutralizeCsvFormula(value);
47
+ return NEEDS_QUOTING.test(text) ? `"${text.replace(/"/g, "\"\"")}"` : text;
48
+ }
49
+ //#endregion
50
+ export { escapeCsvCell, neutralizeCsvFormula };
@@ -0,0 +1,31 @@
1
+ //#region src/money/price-basis.d.ts
2
+ /** How a quoted price extends across a sellable quantity. */
3
+ type PriceBasis = FixedPriceBasis | MeasuredPriceBasis;
4
+ interface FixedPriceBasis {
5
+ readonly kind: 'fixed';
6
+ }
7
+ type MeasureDimension = 'mass' | 'duration' | 'volume' | 'length' | 'area';
8
+ interface MeasuredPriceBasis {
9
+ readonly kind: 'measured';
10
+ readonly dimension: MeasureDimension;
11
+ /** Integer unit carried by the line quantity, such as `gram` or `minute`. */
12
+ readonly quantityUnit: string;
13
+ /** Unit named by the quoted price, such as `kilogram` or `hour`. */
14
+ readonly priceUnit: string;
15
+ /** Number of quantity units represented by one quoted price unit. */
16
+ readonly quantityPerPriceUnit: number;
17
+ }
18
+ type PriceBasisErrorCode = 'INVALID_PRICE' | 'INVALID_QUANTITY' | 'INVALID_DENOMINATOR' | 'INVALID_UNIT' | 'UNSAFE_TOTAL';
19
+ declare class PriceBasisError extends Error {
20
+ readonly name = "PriceBasisError";
21
+ readonly code: PriceBasisErrorCode;
22
+ constructor(code: PriceBasisErrorCode, message: string);
23
+ }
24
+ declare function fixedPriceBasis(): FixedPriceBasis;
25
+ declare function measuredPriceBasis(dimension: MeasureDimension, quantityUnit: string, priceUnit: string, quantityPerPriceUnit: number): MeasuredPriceBasis;
26
+ /** Structural guard for database and wire boundaries. */
27
+ declare function isPriceBasis(value: unknown): value is PriceBasis;
28
+ /** Extend a minor-unit price using integer quantity and exact half-even rounding. */
29
+ declare function resolvePriceBasisTotal(unitPriceMinor: number, quantity: number, basis?: PriceBasis): number;
30
+ //#endregion
31
+ export { FixedPriceBasis, MeasureDimension, MeasuredPriceBasis, PriceBasis, PriceBasisError, PriceBasisErrorCode, fixedPriceBasis, isPriceBasis, measuredPriceBasis, resolvePriceBasisTotal };
@@ -0,0 +1,51 @@
1
+ import { t as roundedDiv } from "./rounded-div-BWJb98Ws.mjs";
2
+ //#region src/money/price-basis.ts
3
+ var PriceBasisError = class extends Error {
4
+ name = "PriceBasisError";
5
+ code;
6
+ constructor(code, message) {
7
+ super(message);
8
+ this.code = code;
9
+ }
10
+ };
11
+ function fixedPriceBasis() {
12
+ return { kind: "fixed" };
13
+ }
14
+ function measuredPriceBasis(dimension, quantityUnit, priceUnit, quantityPerPriceUnit) {
15
+ if (quantityUnit.trim() === "" || priceUnit.trim() === "") throw new PriceBasisError("INVALID_UNIT", "quantityUnit and priceUnit must not be empty");
16
+ if (!Number.isSafeInteger(quantityPerPriceUnit) || quantityPerPriceUnit <= 0) throw new PriceBasisError("INVALID_DENOMINATOR", "quantityPerPriceUnit must be a positive safe integer");
17
+ return {
18
+ kind: "measured",
19
+ dimension,
20
+ quantityUnit,
21
+ priceUnit,
22
+ quantityPerPriceUnit
23
+ };
24
+ }
25
+ const MEASURE_DIMENSIONS = /* @__PURE__ */ new Set([
26
+ "mass",
27
+ "duration",
28
+ "volume",
29
+ "length",
30
+ "area"
31
+ ]);
32
+ /** Structural guard for database and wire boundaries. */
33
+ function isPriceBasis(value) {
34
+ if (typeof value !== "object" || value === null) return false;
35
+ const candidate = value;
36
+ if (candidate.kind === "fixed") return true;
37
+ return candidate.kind === "measured" && MEASURE_DIMENSIONS.has(candidate.dimension) && typeof candidate.quantityUnit === "string" && candidate.quantityUnit.trim() !== "" && typeof candidate.priceUnit === "string" && candidate.priceUnit.trim() !== "" && typeof candidate.quantityPerPriceUnit === "number" && Number.isSafeInteger(candidate.quantityPerPriceUnit) && candidate.quantityPerPriceUnit > 0;
38
+ }
39
+ /** Extend a minor-unit price using integer quantity and exact half-even rounding. */
40
+ function resolvePriceBasisTotal(unitPriceMinor, quantity, basis = fixedPriceBasis()) {
41
+ if (!Number.isSafeInteger(unitPriceMinor) || unitPriceMinor < 0) throw new PriceBasisError("INVALID_PRICE", "unitPriceMinor must be a non-negative safe integer");
42
+ if (!Number.isSafeInteger(quantity) || quantity < 0) throw new PriceBasisError("INVALID_QUANTITY", "quantity must be a non-negative safe integer");
43
+ const numerator = BigInt(unitPriceMinor) * BigInt(quantity);
44
+ const denominator = basis.kind === "measured" ? BigInt(basis.quantityPerPriceUnit) : 1n;
45
+ const rounded = roundedDiv(numerator, denominator, "half-even");
46
+ const total = Number(rounded);
47
+ if (!Number.isSafeInteger(total)) throw new PriceBasisError("UNSAFE_TOTAL", "resolved total exceeds Number.MAX_SAFE_INTEGER");
48
+ return total;
49
+ }
50
+ //#endregion
51
+ export { PriceBasisError, fixedPriceBasis, isPriceBasis, measuredPriceBasis, resolvePriceBasisTotal };
@@ -0,0 +1,13 @@
1
+ //#region src/money/rounded-div.ts
2
+ /** Exact signed integer division rounded at the rational boundary. */
3
+ function roundedDiv(numerator, denominator, mode) {
4
+ if (denominator <= 0n) throw new RangeError("denominator must be positive");
5
+ const negative = numerator < 0n;
6
+ const magnitude = negative ? -numerator : numerator;
7
+ const quotient = magnitude / denominator;
8
+ const twiceRemainder = magnitude % denominator * 2n;
9
+ const rounded = twiceRemainder > denominator || twiceRemainder === denominator && (mode === "half-up" || quotient % 2n !== 0n) ? quotient + 1n : quotient;
10
+ return negative ? -rounded : rounded;
11
+ }
12
+ //#endregion
13
+ export { roundedDiv as t };
@@ -1,9 +1,10 @@
1
1
  import { t as MinorUnits, w as CurrencyCode } from "./money-CfAvfNiM.mjs";
2
+ //#region src/money/rounded-div.d.ts
3
+ type RoundingMode = 'half-even' | 'half-up';
4
+ //#endregion
2
5
  //#region src/money/unit-cost-rate.d.ts
3
6
  /** Fixed, ecosystem-wide rate scale: 6 decimal places below the minor unit. */
4
7
  declare const RATE_SCALE = 1000000;
5
- /** Half-to-even (banker's) is the default; half-up available for legacy parity. */
6
- type RoundingMode = 'half-even' | 'half-up';
7
8
  /**
8
9
  * Minor units per unit, stored as an integer scaled by {@link RATE_SCALE}.
9
10
  * `scale` is carried so a value is self-describing on the wire even if
@@ -67,5 +68,10 @@ declare function weightedAverageScaledRate(onHandQuantity: number, onHandScaledR
67
68
  declare function rateMinorPerUnit(rate: UnitCostRate): number;
68
69
  /** Structural guard. */
69
70
  declare function isUnitCostRate(value: unknown): value is UnitCostRate;
71
+ /**
72
+ * Rounded integer division of two bigints, `numerator / denominator`
73
+ * (`denominator > 0`), honouring the rounding mode at the exact half.
74
+ * Sign-safe for negative numerators.
75
+ */
70
76
  //#endregion
71
- export { RATE_SCALE, RoundingMode, UnitCostRate, UnitCostRateError, UnitCostRateErrorCode, extendedAmount, isUnitCostRate, rateMinorPerUnit, unitCostRate, unitCostRateFromTotal, weightedAverageScaledRate };
77
+ export { RATE_SCALE, type RoundingMode, UnitCostRate, UnitCostRateError, UnitCostRateErrorCode, extendedAmount, isUnitCostRate, rateMinorPerUnit, unitCostRate, unitCostRateFromTotal, weightedAverageScaledRate };
@@ -1,4 +1,5 @@
1
1
  import { MinorUnits } from "./money.mjs";
2
+ import { t as roundedDiv } from "./rounded-div-BWJb98Ws.mjs";
2
3
  //#region src/money/unit-cost-rate.ts
3
4
  /** Fixed, ecosystem-wide rate scale: 6 decimal places below the minor unit. */
4
5
  const RATE_SCALE = 1e6;
@@ -127,19 +128,5 @@ function isUnitCostRate(value) {
127
128
  * (`denominator > 0`), honouring the rounding mode at the exact half.
128
129
  * Sign-safe for negative numerators.
129
130
  */
130
- function roundedDiv(numerator, denominator, mode) {
131
- const neg = numerator < 0n;
132
- const n = neg ? -numerator : numerator;
133
- const q = n / denominator;
134
- const remainder = n - q * denominator;
135
- if (remainder === 0n) return neg ? -q : q;
136
- const twiceRem = remainder * 2n;
137
- let up;
138
- if (twiceRem > denominator) up = true;
139
- else if (twiceRem < denominator) up = false;
140
- else up = mode === "half-up" ? true : q % 2n !== 0n;
141
- const result = up ? q + 1n : q;
142
- return neg ? -result : result;
143
- }
144
131
  //#endregion
145
132
  export { RATE_SCALE, UnitCostRateError, extendedAmount, isUnitCostRate, rateMinorPerUnit, unitCostRate, unitCostRateFromTotal, weightedAverageScaledRate };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/primitives",
3
- "version": "0.26.3",
3
+ "version": "0.27.0",
4
4
  "description": "Framework-agnostic TypeScript primitives shared across Classytic packages — Money, events, pagination, tenant config, and other building blocks.",
5
5
  "author": "Classytic",
6
6
  "license": "MIT",
@@ -33,6 +33,10 @@
33
33
  "types": "./dist/unit-cost-rate.d.mts",
34
34
  "default": "./dist/unit-cost-rate.mjs"
35
35
  },
36
+ "./price-basis": {
37
+ "types": "./dist/price-basis.d.mts",
38
+ "default": "./dist/price-basis.mjs"
39
+ },
36
40
  "./address": {
37
41
  "types": "./dist/address.d.mts",
38
42
  "default": "./dist/address.mjs"
@@ -201,6 +205,10 @@
201
205
  "types": "./dist/canonical.d.mts",
202
206
  "default": "./dist/canonical.mjs"
203
207
  },
208
+ "./csv": {
209
+ "types": "./dist/csv.d.mts",
210
+ "default": "./dist/csv.mjs"
211
+ },
204
212
  "./package.json": "./package.json",
205
213
  "./state-diagram": {
206
214
  "types": "./dist/state-diagram.d.mts",