@stacksjs/commerce 0.75.38 → 0.75.40

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.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Decimal places of a currency's minor unit (USD 2, JPY 0, KWD 3).
3
+ *
4
+ * Read from `Intl`, which carries the ISO 4217 table, so there is no list here
5
+ * to fall out of date. Unknown or missing codes are treated as 2 decimals, the
6
+ * overwhelmingly common case.
7
+ */
8
+ export declare function currencyExponent(currency: string | null | undefined): number;
9
+ /**
10
+ * A decimal price string as integer minor units.
11
+ *
12
+ * Accepts plain decimals only: `"19.99"`, `"0.10"`, `"1999"`, `"19.9"`, `".5"`.
13
+ * Rejects thousands separators (`"1,000.00"`), signs, exponents and anything
14
+ * else, because each is ambiguous in some locale (`"1,000"` is one in German).
15
+ * Digits past the currency's precision round half up, which only arises when a
16
+ * store is configured with more decimals than its currency has.
17
+ *
18
+ * @returns The amount in minor units, or null for an empty value (unpriced).
19
+ */
20
+ export declare function decimalToMinor(value: string | number | null | undefined, exponent?: number): number | null;
21
+ /** Integer minor units back to a decimal string, e.g. `1999, 2 -> "19.99"`. */
22
+ export declare function minorToDecimal(minor: number | bigint | string, exponent?: number): string;
23
+ /**
24
+ * Minor units at one precision as minor units at another.
25
+ *
26
+ * WooCommerce reports `prices.price` at the store's configured decimals
27
+ * (`currency_minor_unit`). A USD store set to 0 decimals sends `"20"` for
28
+ * $20.00, which must become 2000 cents, not 20.
29
+ */
30
+ export declare function rescaleMinor(amount: string | number | null | undefined, fromExponent: number, toExponent: number): number | null;
31
+ /** `1999, 'USD' -> "19.99 USD"`, for reports and exports. Never used for storage. */
32
+ export declare function formatMinor(minor: number, currency: string | null): string;
33
+ /**
34
+ * A stored minor-unit amount as major units, for `Intl.NumberFormat` and
35
+ * charts. `1999, 'USD' -> 19.99`, `1999, 'JPY' -> 1999`, `1999, 'KWD' -> 1.999`.
36
+ *
37
+ * Display only: the result is a float, so never store it or add it up.
38
+ * A non-integer input is rounded to the nearest minor unit first, which is
39
+ * what a legacy row holding a fraction of a cent would have meant.
40
+ */
41
+ export declare function minorToMajor(minor: number | string | null | undefined, currency: string | null | undefined): number;
42
+ /**
43
+ * A stored minor-unit amount, formatted for people.
44
+ *
45
+ * `formatCurrency(1999, 'USD', 'en-US')` is `"$19.99"`; `(1999, 'JPY', 'en-US')`
46
+ * is `"¥1,999"`. The locale defaults to the runtime's (the viewer's browser in
47
+ * the dashboard). A currency code `Intl` does not know falls back to the
48
+ * report form, `"19.99 XYZ"`, rather than throwing out of a table render.
49
+ */
50
+ export declare function formatCurrency(minor: number | string | null | undefined, currency: string | null | undefined, locale?: string): string;
51
+ /**
52
+ * A stored minor-unit amount as the decimal string an input field shows,
53
+ * e.g. `1999, 'USD' -> "19.99"`. The inverse of `parseMoneyInput`.
54
+ */
55
+ export declare function minorToInput(minor: number | string | null | undefined, currency: string | null | undefined): string;
56
+ /** The `step` attribute for a money input: `"0.01"` for USD, `"1"` for JPY. */
57
+ export declare function moneyInputStep(currency: string | null | undefined): string;
58
+ /**
59
+ * Why `input` is not an acceptable amount of `currency`, or `''` when it is.
60
+ *
61
+ * For forms: the dashboard disables Save and shows this next to the field.
62
+ * Stricter than `decimalToMinor` on purpose: a person typing `19.999` for a
63
+ * USD price has made a mistake, and rounding it silently would hide that.
64
+ */
65
+ export declare function moneyInputError(input: string | number | null | undefined, currency: string | null | undefined, options?: MoneyInputOptions): string;
66
+ /**
67
+ * A money field's value as integer minor units: `"19.99"` in USD is `1999`.
68
+ *
69
+ * Throws `PriceFormatError` with the same message `moneyInputError` gives, so
70
+ * a caller that skipped validation still cannot store a float. Returns null
71
+ * only for an empty, optional field.
72
+ */
73
+ export declare function parseMoneyInput(input: string | number | null | undefined, currency: string | null | undefined, options?: MoneyInputOptions & { required?: true }): number;
74
+ export declare function parseMoneyInput(input: string | number | null | undefined, currency: string | null | undefined, options: MoneyInputOptions & { required: false }): number | null;
75
+ export declare interface MoneyInputOptions {
76
+ min?: number
77
+ max?: number
78
+ required?: boolean
79
+ }
80
+ /**
81
+ * Money, at every boundary it crosses.
82
+ *
83
+ * Every amount Stacks commerce stores is an integer in the currency's MINOR
84
+ * unit: cents for USD, whole yen for JPY, fils for KWD. `products.price`,
85
+ * `order_items.price`, `orders.total_amount`, `payments.amount` and the rest
86
+ * hold that integer, and arithmetic (order totals, refunds) happens on it.
87
+ *
88
+ * Conversion happens only where a human or a foreign system is on the other
89
+ * side, and only through this module:
90
+ *
91
+ * - display: `formatCurrency(1999, 'USD')` is `"$19.99"`
92
+ * - input: `parseMoneyInput('19.99', 'USD')` is `1999`
93
+ * - reports: `formatMinor(1999, 'USD')` is `"19.99 USD"`
94
+ * - import: `decimalToMinor('19.99')` and `rescaleMinor(...)`
95
+ *
96
+ * Decimal strings are converted with string and BigInt arithmetic, never
97
+ * through a float: `Math.round(19.99 * 100)` happens to be right,
98
+ * `Math.round(1.005 * 100)` is 100 rather than 101.
99
+ *
100
+ * The module has no imports, so it is safe to bundle into the browser. The
101
+ * dashboard reaches it as `@stacksjs/commerce/money`; server code can use the
102
+ * `@stacksjs/commerce` barrel.
103
+ */
104
+ export declare class PriceFormatError extends Error {
105
+ public readonly value: unknown;
106
+ constructor(value: unknown, reason: string);
107
+ }
package/dist/money.js ADDED
@@ -0,0 +1,2 @@
1
+ // @bun
2
+ var p=Object.defineProperty;var b=(n)=>n;function h(n,e){this[n]=b.bind(null,e)}var x=(n,e)=>{for(var t in e)p(n,t,{get:e[t],enumerable:!0,configurable:!0,set:h.bind(e,t)})};var O=(n,e,t)=>()=>{if(n)try{e=n(n=0)}catch(r){t=[r]}if(t)throw t[0];return e};var S={};x(S,{PriceFormatError:()=>l,currencyExponent:()=>s,decimalToMinor:()=>m,formatCurrency:()=>y,formatMinor:()=>c,minorToDecimal:()=>a,minorToInput:()=>M,minorToMajor:()=>f,moneyInputError:()=>g,moneyInputStep:()=>I,parseMoneyInput:()=>N,rescaleMinor:()=>w});class l extends Error{value;constructor(n,e){super(`Unreadable price ${JSON.stringify(n)}: ${e}`);this.value=n;this.name="PriceFormatError"}}function s(n){if(!n)return 2;try{let e=new Intl.NumberFormat("en",{style:"currency",currency:n}).resolvedOptions().maximumFractionDigits;return typeof e==="number"?e:2}catch{return 2}}function m(n,e=2){if(n===null||n===void 0)return null;let t=String(n).trim();if(t==="")return null;let r=/^(\d*)(?:\.(\d*))?$/.exec(t);if(!r||!/\d/.test(t))throw new l(n,"expected a plain decimal such as 19.99");let u=r[1]||"0",i=r[2]??"",o;if(i.length<=e)o=BigInt(u+i.padEnd(e,"0"));else if(o=BigInt(u+i.slice(0,e)),i.charCodeAt(e)>=53)o+=1n;if(o>BigInt(Number.MAX_SAFE_INTEGER))throw new l(n,"too large to store exactly");return Number(o)}function a(n,e=2){let t=String(n).trim();if(!/^\d+$/.test(t))throw new l(n,"expected a non-negative integer amount in minor units");if(e===0)return t.replace(/^0+(?=\d)/,"");let r=t.padStart(e+1,"0");return`${r.slice(0,-e).replace(/^0+(?=\d)/,"")}.${r.slice(-e)}`}function w(n,e,t){if(n===null||n===void 0||String(n).trim()==="")return null;if(!Number.isInteger(e)||e<0)throw new l(n,`invalid source precision ${e}`);return m(a(String(n),e),t)}function c(n,e){let t=s(e),r=Math.round(Number(n)),u=Number.isSafeInteger(r)?`${r<0?"-":""}${a(Math.abs(r),t)}`:String(n);return e?`${u} ${e}`:u}function f(n,e){let t=Math.round(Number(n??0));if(!Number.isFinite(t))return 0;let r=a(Math.abs(t),s(e));return t<0?-Number(r):Number(r)}function y(n,e,t){let r=(e||"USD").toUpperCase(),u=f(n,r);try{return new Intl.NumberFormat(t,{style:"currency",currency:r}).format(u)}catch{return c(Math.round(Number(n??0))||0,r)}}function M(n,e){if(n===null||n===void 0||n==="")return"";let t=Math.round(Number(n));if(!Number.isFinite(t))return"";let r=a(Math.abs(t),s(e));return t<0?`-${r}`:r}function I(n){let e=s(n);return e===0?"1":`0.${"1".padStart(e,"0")}`}function g(n,e,t={}){let r=n===null||n===void 0?"":String(n).trim(),u=s(e),i=(e||"USD").toUpperCase();if(r==="")return t.required===!1?"":"Enter an amount.";let o=/^(\d*)(?:\.(\d*))?$/.exec(r);if(!o||!/\d/.test(r))return u===0?"Enter a whole number, for example 1999.":`Enter a plain amount, for example ${a(1999,u)}.`;if((o[2]??"").length>u)return u===0?`${i} amounts have no decimal places.`:`${i} amounts have at most ${u} decimal ${u===1?"place":"places"}.`;let d;try{d=m(r,u)}catch{return"That amount is too large."}if(d===null)return"Enter an amount.";if(t.min!==void 0&&d<t.min)return`Enter at least ${c(t.min,i)}.`;if(t.max!==void 0&&d>t.max)return`Enter at most ${c(t.max,i)}.`;return""}function N(n,e,t={}){let r=g(n,e,t);if(r)throw new l(n,r);return m(n,s(e))}export{l as PriceFormatError,s as currencyExponent,m as decimalToMinor,y as formatCurrency,c as formatMinor,a as minorToDecimal,M as minorToInput,f as minorToMajor,g as moneyInputError,I as moneyInputStep,N as parseMoneyInput,w as rescaleMinor};
@@ -1,3 +1,4 @@
1
+ import type { OrderWithTotals } from '../types';
1
2
  import type { SpreadsheetWrapper } from 'ts-spreadsheets';
2
3
  /**
3
4
  * Export orders to a spreadsheet
@@ -5,6 +6,29 @@ import type { SpreadsheetWrapper } from 'ts-spreadsheets';
5
6
  * @returns Spreadsheet object ready for download or storage
6
7
  */
7
8
  export declare function exportOrders(format?: 'csv' | 'excel'): Promise<SpreadsheetWrapper>;
9
+ /**
10
+ * Every order with its customer and its line items (each with its product),
11
+ * ready for `prepareOrdersForExport`.
12
+ *
13
+ * Four flat queries, whatever the number of orders: the orders, then the
14
+ * customers, line items and products they reference, each read with `IN` and
15
+ * stitched together in memory. Nothing is fetched per order.
16
+ *
17
+ * The orders are read on their own rather than joined to customers: a
18
+ * `SELECT *` across that join returns two `id` columns, and the customer's
19
+ * silently replaces the order's in the row object.
20
+ */
21
+ export declare function fetchOrdersForExport(): Promise<OrderWithTotals[]>;
22
+ /**
23
+ * Prepare orders data for spreadsheet export
24
+ *
25
+ * Amounts are stored as integer minor units, so `total_amount: 1999` in USD is
26
+ * written as `19.99 USD`, never as `1999` or `$1999` (stacksjs/stacks#2851).
27
+ *
28
+ * @param orders Array of order objects
29
+ * @returns Spreadsheet data structure
30
+ */
31
+ export declare function prepareOrdersForExport(orders: OrderWithTotals[]): { headings: (keyof ExportedOrder)[], data: (string | number)[][] };
8
32
  /**
9
33
  * Export orders and automatically download
10
34
  * @param format The format of the spreadsheet (default is CSV)
@@ -26,7 +50,7 @@ export declare interface ExportedOrder {
26
50
  'Order ID': number
27
51
  'Customer': string
28
52
  'Date': string
29
- 'Total': number
53
+ 'Total': string
30
54
  'Status': string
31
55
  'Items': string
32
56
  }
package/package.json CHANGED
@@ -1,8 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/commerce",
3
3
  "type": "module",
4
- "sideEffects": false,
5
- "version": "0.75.38",
4
+ "version": "0.75.40",
6
5
  "description": "Stacks commerce utilities.",
7
6
  "author": "Chris Breuer",
8
7
  "contributors": [
@@ -58,17 +57,17 @@
58
57
  "prepublishOnly": "bun run build"
59
58
  },
60
59
  "dependencies": {
61
- "@stacksjs/cache": "0.75.38",
62
- "@stacksjs/config": "0.75.38",
63
- "@stacksjs/database": "0.75.38",
64
- "@stacksjs/env": "0.75.38",
65
- "@stacksjs/error-handling": "0.75.38",
66
- "@stacksjs/events": "0.75.38",
67
- "@stacksjs/logging": "0.75.38",
68
- "@stacksjs/orm": "0.75.38",
69
- "@stacksjs/payments": "0.75.38",
70
- "@stacksjs/realtime": "0.75.38",
71
- "@stacksjs/strings": "0.75.38",
60
+ "@stacksjs/cache": "0.75.40",
61
+ "@stacksjs/config": "0.75.40",
62
+ "@stacksjs/database": "0.75.40",
63
+ "@stacksjs/env": "0.75.40",
64
+ "@stacksjs/error-handling": "0.75.40",
65
+ "@stacksjs/events": "0.75.40",
66
+ "@stacksjs/logging": "0.75.40",
67
+ "@stacksjs/orm": "0.75.40",
68
+ "@stacksjs/payments": "0.75.40",
69
+ "@stacksjs/realtime": "0.75.40",
70
+ "@stacksjs/strings": "0.75.40",
72
71
  "ts-spreadsheets": "^0.2.0"
73
72
  },
74
73
  "devDependencies": {