@stacksjs/defaults 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.
Files changed (98) hide show
  1. package/ai/skills/stacks-ai/SKILL.md +24 -1
  2. package/ai/skills/stacks-commerce/SKILL.md +1 -1
  3. package/ai/skills/stacks-config/SKILL.md +1 -1
  4. package/ai/skills/stacks-database/SKILL.md +1 -1
  5. package/ai/skills/stacks-dependencies/SKILL.md +35 -3
  6. package/ai/skills/stacks-env/SKILL.md +4 -1
  7. package/ai/skills/stacks-migrations/SKILL.md +2 -2
  8. package/ai/skills/stacks-models/SKILL.md +2 -2
  9. package/app/Actions/Commerce/Product/ProductStoreAction.ts +9 -1
  10. package/app/Actions/Commerce/Product/ProductUpdateAction.ts +4 -1
  11. package/app/Actions/Commerce/commerce-action.test.ts +30 -1
  12. package/app/Actions/Commerce/commerce-action.ts +44 -0
  13. package/app/Actions/Dashboard/Analytics/sales-analytics.test.ts +19 -0
  14. package/app/Actions/Dashboard/Analytics/sales-analytics.ts +2 -1
  15. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +3 -2
  16. package/app/Actions/Dashboard/Commerce/commerce-dashboard.test.ts +29 -9
  17. package/app/Actions/Dashboard/Commerce/commerce-dashboard.ts +5 -12
  18. package/app/Actions/Dashboard/Commerce/commerce-delivery.test.ts +15 -0
  19. package/app/Actions/Dashboard/Commerce/commerce-delivery.ts +8 -10
  20. package/app/Actions/Dashboard/Commerce/commerce-pos.test.ts +26 -6
  21. package/app/Actions/Dashboard/Commerce/commerce-pos.ts +8 -3
  22. package/app/Actions/Dashboard/Commerce/commerce-product-records.ts +69 -0
  23. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.test.ts +39 -0
  24. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.ts +9 -3
  25. package/app/Actions/Dashboard/Marketing/campaign-records.ts +17 -3
  26. package/app/Actions/Storefront/AddToCartAction.ts +4 -1
  27. package/app/Actions/Storefront/GetCartAction.ts +25 -10
  28. package/app/Actions/Storefront/PlaceOrderAction.ts +6 -1
  29. package/app/Actions/Storefront/_shipping.ts +63 -37
  30. package/app/Broadcasts/OrderShipped.ts +1 -1
  31. package/app/Mail/OrderConfirmation.ts +14 -7
  32. package/app/Models/Campaign.ts +9 -4
  33. package/app/Models/commerce/Cart.ts +5 -4
  34. package/app/Models/commerce/CartItem.ts +6 -4
  35. package/app/Models/commerce/Coupon.ts +9 -3
  36. package/app/Models/commerce/Customer.ts +2 -1
  37. package/app/Models/commerce/GiftCard.ts +5 -2
  38. package/app/Models/commerce/Order.ts +6 -5
  39. package/app/Models/commerce/OrderItem.ts +2 -1
  40. package/app/Models/commerce/ProductVariant.ts +71 -4
  41. package/app/Models/commerce/Transaction.ts +3 -2
  42. package/app/Storefront/StorefrontMoney.ts +54 -0
  43. package/ide/vscode/package.json +1 -1
  44. package/package.json +2 -2
  45. package/resources/components/Dashboard/Analytics/CampaignAnalytics.stx +8 -8
  46. package/resources/components/Dashboard/Analytics/SalesAnalytics.stx +4 -3
  47. package/resources/components/Dashboard/Billing/BillingPlanSummary.stx +4 -1
  48. package/resources/components/Dashboard/Commerce/CommerceCouponDetailsDialog.stx +4 -1
  49. package/resources/components/Dashboard/Commerce/CommerceCouponDialog.stx +34 -10
  50. package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +3 -1
  51. package/resources/components/Dashboard/Commerce/CommerceCouponsTable.stx +4 -1
  52. package/resources/components/Dashboard/Commerce/CommerceCustomerDetailsDialog.stx +2 -6
  53. package/resources/components/Dashboard/Commerce/CommerceCustomerDialog.stx +6 -6
  54. package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +3 -11
  55. package/resources/components/Dashboard/Commerce/CommerceCustomersTable.stx +2 -6
  56. package/resources/components/Dashboard/Commerce/CommerceGiftCardBreakdown.stx +2 -6
  57. package/resources/components/Dashboard/Commerce/CommerceGiftCardDetailsDialog.stx +2 -6
  58. package/resources/components/Dashboard/Commerce/CommerceGiftCardDialog.stx +20 -17
  59. package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +3 -2
  60. package/resources/components/Dashboard/Commerce/CommerceGiftCardsTable.stx +2 -6
  61. package/resources/components/Dashboard/Commerce/CommerceOrderDetailsDialog.stx +2 -6
  62. package/resources/components/Dashboard/Commerce/CommerceOrderDialog.stx +28 -20
  63. package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +7 -15
  64. package/resources/components/Dashboard/Commerce/CommerceOrdersTable.stx +2 -6
  65. package/resources/components/Dashboard/Commerce/CommercePaymentsBreakdown.stx +2 -6
  66. package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +2 -6
  67. package/resources/components/Dashboard/Commerce/CommercePosCart.stx +2 -6
  68. package/resources/components/Dashboard/Commerce/CommercePosCatalog.stx +2 -6
  69. package/resources/components/Dashboard/Commerce/CommercePosCheckoutDialog.stx +2 -6
  70. package/resources/components/Dashboard/Commerce/CommercePosDashboard.stx +5 -7
  71. package/resources/components/Dashboard/Commerce/CommercePosReceiptDialog.stx +2 -8
  72. package/resources/components/Dashboard/Commerce/CommerceProductDetailDashboard.stx +3 -20
  73. package/resources/components/Dashboard/Commerce/CommerceProductDialog.stx +27 -19
  74. package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +4 -15
  75. package/resources/components/Dashboard/Commerce/CommerceProductsTable.stx +2 -6
  76. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodDialog.stx +12 -10
  77. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsTable.stx +3 -4
  78. package/resources/components/Dashboard/Commerce/Delivery/ShippingRateDialog.stx +7 -5
  79. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +3 -2
  80. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesTable.stx +3 -4
  81. package/resources/components/Dashboard/Commerce/PaymentDetailsDialog.stx +2 -6
  82. package/resources/components/Dashboard/Commerce/PaymentRefundDialog.stx +12 -10
  83. package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +3 -10
  84. package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +3 -8
  85. package/resources/components/Dashboard/Marketing/CampaignDialog.stx +26 -8
  86. package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +4 -2
  87. package/resources/components/Dashboard/Marketing/RecoveryCampaignDialog.stx +25 -13
  88. package/resources/components/Dashboard/Transaction/index.stx +6 -3
  89. package/resources/components/Storefront/CartDrawer.stx +11 -7
  90. package/resources/views/cart.stx +19 -23
  91. package/resources/views/checkout/contact.stx +12 -14
  92. package/resources/views/checkout/payment.stx +13 -13
  93. package/resources/views/checkout/shipping.stx +13 -13
  94. package/resources/views/cms/blocks/form.stx +50 -1
  95. package/resources/views/orders/[id].stx +13 -4
  96. package/vcs/github/workflows/buddy-bot.yml +5 -5
  97. package/vcs/github/workflows/ci.yml +3 -3
  98. package/vcs/github/workflows/release.yml +1 -1
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-ai
3
- description: Use when integrating AI capabilities into a Stacks application - using Anthropic/OpenAI/Ollama/AWS Bedrock drivers, image generation (DALL-E), vision analysis, RAG/vector search, embeddings, MCP (Model Context Protocol) clients, text summarization, sentiment analysis, content classification, personalization, or the buddy AI assistant. Covers @stacksjs/ai and config/ai.ts.
3
+ description: Use when integrating AI capabilities into a Stacks application - using Anthropic/OpenAI/Ollama/AWS Bedrock drivers, image generation (DALL-E), vision analysis, RAG/vector search, embeddings, text-to-speech and speech-to-text, MCP (Model Context Protocol) clients, text summarization, sentiment analysis, content classification, personalization, or the buddy AI assistant. Covers @stacksjs/ai and config/ai.ts.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -23,6 +23,7 @@ ai/src/
23
23
  │ ├── ollama.ts # Local LLM driver
24
24
  │ └── bedrock.ts # AWS Bedrock utilities
25
25
  ├── image.ts # Image generation & vision
26
+ ├── speech.ts # Provider-neutral text-to-speech & speech-to-text
26
27
  ├── search.ts # RAG, embeddings, vector index
27
28
  ├── mcp.ts # Model Context Protocol client
28
29
  ├── personalization.ts # Sentiment, classification, recommendations
@@ -77,6 +78,28 @@ if (configuration.configured) {
77
78
  }
78
79
  ```
79
80
 
81
+ ## Speech (Text-to-Speech, Speech-to-Text)
82
+
83
+ Provider-neutral, in `ai/src/speech.ts`. The driver is the `driver` option, else
84
+ `default` from `config/ai.ts`. Only `openai` implements speech (`SPEECH_DRIVERS`);
85
+ any other driver throws naming itself and the supported ones - do not catch that
86
+ and fall back to a different provider.
87
+
88
+ ```typescript
89
+ import { speechToText, storeSpeech, textToSpeech } from '@stacksjs/ai'
90
+
91
+ const speech = await textToSpeech('Hello', { voice: 'nova', format: 'mp3', speed: 1.1 })
92
+ // { audio: Uint8Array, mimeType: 'audio/mpeg', format, driver, model, voice }
93
+ await storeSpeech(speech, 'audio/hello.mp3', { disk: 'public' }) // disk name or adapter
94
+ const { text } = await speechToText(Bun.file('memo.m4a'), { language: 'en' })
95
+ ```
96
+
97
+ Defaults: model `gpt-4o-mini-tts`, voice `alloy`, format `mp3`, speed omitted
98
+ (provider default 1.0); transcription model `whisper-1`. `instructions` is
99
+ rejected for `tts-1` / `tts-1-hd`. OpenAI caps input at 4096 characters per
100
+ request. HTTP failures throw with the status and a body snippet. In tests, mock
101
+ `globalThis.fetch` and pass `config` explicitly (see `ai/tests/speech.test.ts`).
102
+
80
103
  ## Ollama Driver (Local LLMs)
81
104
 
82
105
  ```typescript
@@ -79,7 +79,7 @@ Each sub-module typically provides:
79
79
  | Model | Key Fields | Relationships |
80
80
  |-------|-----------|---------------|
81
81
  | Product | name, price, inventoryCount, allergens(JSON) | belongsTo: Category, Manufacturer; hasMany: Review, ProductUnit, ProductVariant |
82
- | ProductVariant | SKU, options, pricing | belongsTo: Product |
82
+ | ProductVariant | variant, type, options, status, sku (unique per product), price / compareAtPrice (minor units; null price inherits the product's), inventoryCount (null = untracked) | belongsTo: Product |
83
83
  | ProductUnit | unit pricing | belongsTo: Product |
84
84
  | Category | name, slug, isActive, displayOrder | hasMany: Product |
85
85
  | Cart | status, total, currency(USD), expiresAt | hasMany: CartItem; belongsTo: Customer |
@@ -127,7 +127,7 @@ they name: `defineEvents` and `defineListener` from `@stacksjs/events`,
127
127
  ### Other
128
128
  | File | Type | Key Settings |
129
129
  |------|------|-------------|
130
- | `buddy-bot.ts` | BuddyBotConfig | repository, dashboard, workflows |
130
+ | `buddy-bot.ts` | BuddyConfig (`@buddysh/buddy`) | repository, dashboard, workflows |
131
131
  | `cli.ts` | BinaryConfig | name, command, description |
132
132
  | `deps.ts` | PantryConfig | system dependencies (bun, sqlite, redis, etc.) |
133
133
  | `errors.ts` | ErrorConfig | comprehensive validation error messages |
@@ -12,7 +12,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
12
12
  - Database package: `storage/framework/core/database/src/`
13
13
  - Configuration: `config/database.ts`
14
14
  - QB config: `config/query-builder.ts`
15
- - Migrations: `database/migrations/` (229 migration files, `.sql` format)
15
+ - Migrations: `database/migrations/` (231 migration files, `.sql` format)
16
16
  - QB state: `.qb/`
17
17
  - ORM: `storage/framework/orm/`
18
18
 
@@ -95,11 +95,43 @@ bun run upgrade # Upgrade dependencies
95
95
  bun run build:reset # Full reset: rm deps → reinstall → generate → lint → build
96
96
  ```
97
97
 
98
+ ## Lockfiles
99
+
100
+ A Stacks project commits two lockfiles, and they describe one tree:
101
+
102
+ - `bun.lock` resolves `node_modules/`, which the app root reads.
103
+ - `pantry.lock` records the system toolchain (Bun itself, SQLite, curl, git)
104
+ **and** every npm package, which `pantry/` holds and the framework source
105
+ resolves from.
106
+
107
+ Since pantry 0.11.65 an npm range resolves to `bun.lock`'s pin whenever that
108
+ pin satisfies it, so the two agree on every package they both name. Since
109
+ 0.11.60 the file is platform-independent: `pantry install` on macOS writes what
110
+ CI writes on Linux, foreign-platform records included.
111
+
112
+ **Regenerating.** Change the ranges, run `bun install`, then `pantry install`,
113
+ on any platform, and commit `bun.lock` and `pantry.lock` with the manifests.
114
+ Check `head -2 bun.lock` says `lockfileVersion: 2`; an older Bun on PATH
115
+ downgrades it.
116
+
117
+ **Who else writes them.**
118
+
119
+ - `buddy release` regenerates both and refuses to commit a `pantry.lock` that
120
+ violates a range it records or drops the root `system` block.
121
+ - The dependency bot (`@buddysh/buddy` 0.11.5+, via `better-dx`) runs
122
+ `pantry install` after the JS manager on its Linux runner, so dependency pull
123
+ requests arrive with both lockfiles current.
124
+
125
+ **The guards.** CI's `compile` job installs and fails if either lockfile
126
+ changed, and `lockfile-matches-manifests.test.ts` compares the ranges both
127
+ lockfiles record against the manifests. Either failure means a lockfile was
128
+ not regenerated with its manifest, not that the guard is wrong.
129
+
98
130
  ## Dependency Update System
99
131
 
100
- - **buddy-bot** handles dependency updates — NOT renovatebot
101
- - Automated PR creation for dependency updates
102
- - Configured via `config/buddy-bot.ts`
132
+ - **buddy-bot** (`@buddysh/buddy`, shipped by `better-dx`) handles dependency updates, NOT renovatebot
133
+ - Automated PR creation for dependency updates, run as `bunx @buddysh/buddy` in `.github/workflows/buddy-bot.yml`
134
+ - Configured via `config/buddy-bot.ts` (read as the pre-rename fallback for `config/buddy.ts`, which in a Stacks app would read as the Stacks CLI's config)
103
135
 
104
136
  ## Gotchas
105
137
  - **buddy-bot, not renovatebot** — Stacks uses its own dependency update bot
@@ -164,7 +164,10 @@ allowing your own machine must not also allow CI.
164
164
 
165
165
  The answer is the same in a web or API server, the dashboard, a worker and a
166
166
  Buddy command, because all of them read the same variable. Browser integrations
167
- receive only the resolved public label, never server credentials.
167
+ receive only the resolved public label, never server credentials. With the
168
+ AnalyticsHQ driver that label is the tag's `data-environment` attribute, which
169
+ the tracker attaches to every pageview, custom event and Web Vitals beacon, so
170
+ `staging` traffic is distinguishable without a second site id.
168
171
 
169
172
  `APP_ENV` is a configuration label, not proof of deployment identity: a local
170
173
  command launched with `APP_ENV=production` qualifies. It prevents accidents; it
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-migrations
3
- description: Use when working with database migrations in a Stacks application - creating migration files, running migrations, fresh migration (drop + recreate), seeding after migration, migration file naming conventions, or the 229 built-in migration files. For the database API itself (queries, connections, SQL helpers), see stacks-database.
3
+ description: Use when working with database migrations in a Stacks application - creating migration files, running migrations, fresh migration (drop + recreate), seeding after migration, migration file naming conventions, or the 231 built-in migration files. For the database API itself (queries, connections, SQL helpers), see stacks-database.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript, SQLite >= 3.47.2
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -119,6 +119,6 @@ The framework includes migrations for all built-in models:
119
119
  - Do not commit a second snapshot under `.qb/`; that indicates a missing `snapshotDir` configuration
120
120
  - If a generated SQLite migration rebuilds tables, test it against a copy of the current database and run `PRAGMA integrity_check` plus `PRAGMA foreign_key_check`
121
121
  - `--seed` flag after `migrate:fresh` seeds the database with factory data
122
- - 229 migration files exist by default for all framework models
122
+ - 231 migration files exist by default for all framework models
123
123
  - SQLite >= 3.47.2 is required (system requirement)
124
124
  - For the database API (queries, connections), see the `stacks-database` skill
@@ -88,7 +88,7 @@ generated model types stay precise.
88
88
  | `guarded` | Block mass assignment |
89
89
  | `hidden` | Exclude from JSON serialization (passwords, tokens) |
90
90
  | `foreignKey` | Disable, infer, or configure the FK constraint |
91
- | `factory` | `(faker) => value`, used by seeders and tests |
91
+ | `factory` | `(faker, attributes) => value`, used by seeders and tests. `attributes` holds what this record's earlier-declared factories produced, so one value can depend on another: `(faker, { discountType }) => ...` |
92
92
  | `validation` | `{ rule, message? }` - `rule` from `schema`, `message` keyed by rule name |
93
93
 
94
94
  ### Traits
@@ -294,7 +294,7 @@ list, or `buddy list` for what a given project resolves.
294
294
 
295
295
  ### Commerce (20+ models)
296
296
  - **Product** — name(max100), description, price(min1), imageUrl, isAvailable, inventoryCount, preparationTime, allergens(JSON), nutritionalInfo(JSON) | belongsTo: Category, Manufacturer | hasMany: Review, ProductUnit, ProductVariant, LicenseKey, WaitlistProduct, Coupon | seeder: 10, dashboard: highlighted
297
- - **ProductVariant** — SKU, options, pricing
297
+ - **ProductVariant** — variant, type, options, status, sku (unique per product), price / compareAtPrice (minor units, null price inherits the product's), inventoryCount (null = untracked) | belongsTo: Product | seeder: 50
298
298
  - **ProductUnit** — unit-specific pricing
299
299
  - **Cart** — status(active|abandoned|converted|expired), totalItems, subtotal, taxAmount, discountAmount, total, expiresAt, currency(USD), notes | hasMany: CartItem | belongsTo: Customer, Coupon
300
300
  - **CartItem** — quantity(min1), unitPrice, totalPrice, taxRate, taxAmount, discountPercentage, productName, productSku | belongsTo: Cart
@@ -1,7 +1,9 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
 
3
3
  import { products } from '@stacksjs/commerce'
4
+ import { toSnakeCaseKeys } from '@stacksjs/orm'
4
5
  import { response } from '@stacksjs/router'
6
+ import { commerceMinorAmountError } from '../commerce-action'
5
7
 
6
8
  export default new Action({
7
9
  name: 'ProductItem Store',
@@ -10,7 +12,13 @@ export default new Action({
10
12
  model: Product,
11
13
  async handle(request: RequestInstance) {
12
14
  await request.validate()
13
- const data = request.all()
15
+ const priceError = commerceMinorAmountError(request, 'price', 'Price', { required: true, min: 1 })
16
+ if (priceError)
17
+ return priceError
18
+ // The dashboard sends camelCase (`imageUrl`); the table is snake_case, as
19
+ // ProductUpdateAction already accounts for. Without this every product
20
+ // added from the dashboard failed with "no column named imageUrl".
21
+ const data = toSnakeCaseKeys(request.all())
14
22
 
15
23
  const model = await products.items.store(data)
16
24
 
@@ -2,7 +2,7 @@ import { Action } from '@stacksjs/actions'
2
2
  import { products } from '@stacksjs/commerce'
3
3
  import { toSnakeCaseKeys } from '@stacksjs/orm'
4
4
  import { response } from '@stacksjs/router'
5
- import { commerceIdentifier, commerceNotFound } from '../commerce-action'
5
+ import { commerceIdentifier, commerceMinorAmountError, commerceNotFound } from '../commerce-action'
6
6
 
7
7
  export default new Action({
8
8
  name: 'Product Update',
@@ -16,6 +16,9 @@ export default new Action({
16
16
  const { id } = identifier
17
17
 
18
18
  await request.validate()
19
+ const priceError = commerceMinorAmountError(request, 'price', 'Price', { min: 1 })
20
+ if (priceError)
21
+ return priceError
19
22
  const data = toSnakeCaseKeys(request.all())
20
23
 
21
24
  const model = await products.items.update(id, data)
@@ -1,6 +1,6 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { describe, expect, test } from 'bun:test'
3
- import { commerceIdentifier, commerceNotFound } from './commerce-action'
3
+ import { commerceIdentifier, commerceMinorAmountError, commerceNotFound } from './commerce-action'
4
4
 
5
5
  function request(id: string): RequestInstance {
6
6
  return {
@@ -29,3 +29,32 @@ describe('commerce action responses', () => {
29
29
  })
30
30
  })
31
31
  })
32
+
33
+ function body(fields: Record<string, unknown>): RequestInstance {
34
+ return {
35
+ get: (name: string) => fields[name],
36
+ } as unknown as RequestInstance
37
+ }
38
+
39
+ describe('commerce minor-unit amounts (stacksjs/stacks#2851)', () => {
40
+ test('accepts an integer number of minor units', () => {
41
+ expect(commerceMinorAmountError(body({ price: 1999 }), 'price', 'Price', { required: true, min: 1 })).toBeUndefined()
42
+ expect(commerceMinorAmountError(body({ price: '1999' }), 'price', 'Price', { required: true, min: 1 })).toBeUndefined()
43
+ })
44
+
45
+ test('rejects a decimal, which would be stored as a fraction of a cent', async () => {
46
+ const result = commerceMinorAmountError(body({ price: 19.99 }), 'price', 'Price', { required: true, min: 1 })
47
+ expect(result?.status).toBe(422)
48
+ expect(await result?.json()).toEqual({
49
+ message: 'Price must be a whole number of minor units, for example 1999 for 19.99.',
50
+ errors: { price: ['Price must be a whole number of minor units, for example 1999 for 19.99.'] },
51
+ })
52
+ expect(commerceMinorAmountError(body({ price: '19.99' }), 'price', 'Price')?.status).toBe(422)
53
+ })
54
+
55
+ test('enforces the minimum and presence only when asked', () => {
56
+ expect(commerceMinorAmountError(body({ price: 0 }), 'price', 'Price', { min: 1 })?.status).toBe(422)
57
+ expect(commerceMinorAmountError(body({}), 'price', 'Price', { required: true })?.status).toBe(422)
58
+ expect(commerceMinorAmountError(body({}), 'price', 'Price')).toBeUndefined()
59
+ })
60
+ })
@@ -21,3 +21,47 @@ export function commerceIdentifier(
21
21
  export function commerceNotFound(resource: string, id: number): Response {
22
22
  return response.json({ message: `${resource} ${id} was not found.` }, 404)
23
23
  }
24
+
25
+ export interface CommerceMinorAmountOptions {
26
+ /** Whether a missing value is an error (create) or leaves the column alone (update). */
27
+ required?: boolean
28
+ /** Smallest accepted amount in minor units. Defaults to 0. */
29
+ min?: number
30
+ }
31
+
32
+ /**
33
+ * A 422 when `request.<field>` is not an integer number of minor units.
34
+ *
35
+ * Money columns hold integer minor units (`1999` is $19.99), and model
36
+ * validation only says "a number", so `19.99` used to be stored as nineteen
37
+ * cents and 99 hundredths and then totalled as such (stacksjs/stacks#2851).
38
+ * The dashboard converts what people type before sending it; this stops any
39
+ * other caller writing a decimal.
40
+ */
41
+ export function commerceMinorAmountError(
42
+ request: RequestInstance,
43
+ field: string,
44
+ label: string,
45
+ options: CommerceMinorAmountOptions = {},
46
+ ): Response | undefined {
47
+ const raw = request.get(field) as unknown
48
+ if (raw === undefined || raw === null || raw === '') {
49
+ if (!options.required)
50
+ return undefined
51
+ return minorAmountResponse(field, `${label} is required.`)
52
+ }
53
+
54
+ const amount = typeof raw === 'string' && /^\d+$/.test(raw.trim()) ? Number(raw) : raw
55
+ if (typeof amount !== 'number' || !Number.isSafeInteger(amount))
56
+ return minorAmountResponse(field, `${label} must be a whole number of minor units, for example 1999 for 19.99.`)
57
+
58
+ const min = options.min ?? 0
59
+ if (amount < min)
60
+ return minorAmountResponse(field, `${label} must be at least ${min}.`)
61
+
62
+ return undefined
63
+ }
64
+
65
+ function minorAmountResponse(field: string, message: string): Response {
66
+ return response.json({ message, errors: { [field]: [message] } }, 422)
67
+ }
@@ -63,4 +63,23 @@ describe('sales analytics', () => {
63
63
  currency: 'USD',
64
64
  }])
65
65
  })
66
+
67
+ test('averages integer minor units to a whole minor unit (stacksjs/stacks#2851)', () => {
68
+ const result = buildSalesAnalytics(
69
+ [
70
+ { id: '1', status: 'DELIVERED', totalAmount: 1999, currency: 'USD', createdAt: '2026-07-29T10:00:00.000Z' },
71
+ { id: '2', status: 'DELIVERED', totalAmount: 1000, currency: 'USD', createdAt: '2026-07-29T10:30:00.000Z' },
72
+ ],
73
+ [],
74
+ [],
75
+ [],
76
+ [],
77
+ 'day',
78
+ now,
79
+ )
80
+
81
+ // 1499.5 cents would render as $15.00 anyway; the payload should not carry
82
+ // an amount no column can hold.
83
+ expect(result.currencyTotals[0]).toMatchObject({ gross: 2999, net: 2999, average: 1500 })
84
+ })
66
85
  })
@@ -111,7 +111,8 @@ export function buildSalesAnalytics(
111
111
  gross: total.gross,
112
112
  cancelled: total.cancelled,
113
113
  net: total.gross - total.cancelled,
114
- average: total.orders > 0 ? Math.round((total.gross / total.orders) * 100) / 100 : 0,
114
+ // Amounts are integer minor units, so the average rounds to a whole one.
115
+ average: total.orders > 0 ? Math.round(total.gross / total.orders) : 0,
115
116
  }))
116
117
  .sort((left, right) => right.net - left.net)
117
118
 
@@ -112,7 +112,8 @@ async function existingReceipt(order: any) {
112
112
  }))
113
113
  const taxAmount = receiptNumber(field(order, 'tax_amount', 'taxAmount'), source, 'tax_amount', { min: 0 })
114
114
  const totalAmount = receiptNumber(field(order, 'total_amount', 'totalAmount'), source, 'total_amount', { min: 0 })
115
- const subtotal = Math.round((totalAmount - taxAmount + Number.EPSILON) * 100) / 100
115
+ // Integer minor units, as the sale was recorded (stacksjs/stacks#2851).
116
+ const subtotal = totalAmount - taxAmount
116
117
  if (subtotal < 0)
117
118
  throw new TypeError(`${source}.tax_amount cannot exceed total_amount.`)
118
119
  const taxRate = deriveCommercePosTaxRate(subtotal, taxAmount)
@@ -137,7 +138,7 @@ async function existingReceipt(order: any) {
137
138
  name,
138
139
  quantity,
139
140
  unitPrice,
140
- lineTotal: Math.round((unitPrice * quantity + Number.EPSILON) * 100) / 100,
141
+ lineTotal: unitPrice * quantity,
141
142
  specialInstructions: receiptOptionalText(
142
143
  field(item, 'special_instructions', 'specialInstructions'),
143
144
  itemSource,
@@ -64,13 +64,13 @@ describe('commerce dashboard', () => {
64
64
  test('builds aligned metrics and chart buckets without counting cancelled revenue', () => {
65
65
  const result = buildCommerceDashboard(
66
66
  [
67
- { id: '1', status: 'DELIVERED', totalAmount: 100, currency: 'USD', customerId: '9', createdAt: '2026-07-29 10:00:00' },
68
- { id: '2', status: 'CANCELED', totalAmount: 60, currency: 'USD', customerId: '', createdAt: '2026-07-29 11:00:00' },
69
- { id: '3', status: 'DELIVERED', totalAmount: 50, currency: 'USD', customerId: '9', createdAt: '2026-07-28 10:00:00' },
67
+ { id: '1', status: 'DELIVERED', totalAmount: 10000, currency: 'USD', customerId: '9', createdAt: '2026-07-29 10:00:00' },
68
+ { id: '2', status: 'CANCELED', totalAmount: 6000, currency: 'USD', customerId: '', createdAt: '2026-07-29 11:00:00' },
69
+ { id: '3', status: 'DELIVERED', totalAmount: 5000, currency: 'USD', customerId: '9', createdAt: '2026-07-28 10:00:00' },
70
70
  ],
71
71
  [
72
- { orderId: '1', productId: '4', quantity: 2, price: 50 },
73
- { orderId: '2', productId: '4', quantity: 1, price: 60 },
72
+ { orderId: '1', productId: '4', quantity: 2, price: 5000 },
73
+ { orderId: '2', productId: '4', quantity: 1, price: 6000 },
74
74
  ],
75
75
  [{ id: '4', name: 'Native Kit' }],
76
76
  [{ id: '9', name: 'Ada Lovelace' }],
@@ -82,6 +82,7 @@ describe('commerce dashboard', () => {
82
82
  expect(result.stats.map(stat => stat.value)).toEqual(['$100.00', '2', '$100.00', '100.0%'])
83
83
  expect(result.charts.labels).toHaveLength(13)
84
84
  expect(result.charts.orders.reduce((sum, value) => sum + value, 0)).toBe(2)
85
+ // Stored in cents, charted in dollars.
85
86
  expect(result.charts.revenue[0].data.reduce((sum, value) => sum + value, 0)).toBe(100)
86
87
  expect(result.topProducts).toEqual([
87
88
  { id: '4:USD', name: 'Native Kit', sales: 2, revenue: '$100.00' },
@@ -98,12 +99,12 @@ describe('commerce dashboard', () => {
98
99
  test('keeps currencies separate in revenue, averages, charts, and product totals', () => {
99
100
  const result = buildCommerceDashboard(
100
101
  [
101
- { id: '1', status: 'DELIVERED', totalAmount: 100, currency: 'USD', customerId: '', createdAt: '2026-07-29T08:00:00.000Z' },
102
- { id: '2', status: 'DELIVERED', totalAmount: 80, currency: 'EUR', customerId: '', createdAt: '2026-07-29T09:00:00.000Z' },
102
+ { id: '1', status: 'DELIVERED', totalAmount: 10000, currency: 'USD', customerId: '', createdAt: '2026-07-29T08:00:00.000Z' },
103
+ { id: '2', status: 'DELIVERED', totalAmount: 8000, currency: 'EUR', customerId: '', createdAt: '2026-07-29T09:00:00.000Z' },
103
104
  ],
104
105
  [
105
- { orderId: '1', productId: '4', quantity: 2, price: 50 },
106
- { orderId: '2', productId: '4', quantity: 1, price: 80 },
106
+ { orderId: '1', productId: '4', quantity: 2, price: 5000 },
107
+ { orderId: '2', productId: '4', quantity: 1, price: 8000 },
107
108
  ],
108
109
  [{ id: '4', name: 'Native Kit' }],
109
110
  [],
@@ -119,6 +120,25 @@ describe('commerce dashboard', () => {
119
120
  expect(result.topProducts).toHaveLength(2)
120
121
  })
121
122
 
123
+ test('shows integer minor units in each currency precision (stacksjs/stacks#2851)', () => {
124
+ const result = buildCommerceDashboard(
125
+ [
126
+ { id: '1', status: 'DELIVERED', totalAmount: 1999, currency: 'USD', customerId: '', createdAt: '2026-07-29T08:00:00.000Z' },
127
+ { id: '2', status: 'DELIVERED', totalAmount: 1999, currency: 'JPY', customerId: '', createdAt: '2026-07-29T09:00:00.000Z' },
128
+ ],
129
+ [{ orderId: '1', productId: '4', quantity: 1, price: 1999 }],
130
+ [{ id: '4', name: 'Native Kit' }],
131
+ [],
132
+ 'today',
133
+ now,
134
+ )
135
+
136
+ expect(result.stats[0].detail).toContain('$19.99')
137
+ expect(result.stats[0].detail).toContain('¥1,999')
138
+ expect(result.topProducts[0]?.revenue).toBe('$19.99')
139
+ expect(result.charts.revenue.find(series => series.currency === 'USD')?.data.reduce((sum, value) => sum + value, 0)).toBe(19.99)
140
+ })
141
+
122
142
  test('compares the selected range with the immediately preceding range', () => {
123
143
  const result = buildCommerceDashboard(
124
144
  [
@@ -1,3 +1,4 @@
1
+ import { formatCurrency, minorToMajor } from '@stacksjs/commerce/money'
1
2
  import {
2
3
  commerceCurrency,
3
4
  commerceIdentifier,
@@ -45,6 +46,7 @@ export interface CommerceDashboardStat {
45
46
 
46
47
  export interface CommerceDashboardChartSeries {
47
48
  labels: string[]
49
+ /** Major units per bucket (19.99, not 1999): chart axes are read by people. */
48
50
  revenue: Array<{ currency: string, data: number[] }>
49
51
  orders: number[]
50
52
  }
@@ -239,18 +241,9 @@ function percentChange(current: number, previous: number): string {
239
241
  return `${sign}${percentage.toFixed(1)}%`
240
242
  }
241
243
 
244
+ /** Order amounts are integer minor units (stacksjs/stacks#2851). */
242
245
  function formatMoney(amount: number, code: string): string {
243
- try {
244
- return new Intl.NumberFormat('en-US', {
245
- style: 'currency',
246
- currency: code,
247
- minimumFractionDigits: 2,
248
- maximumFractionDigits: 2,
249
- }).format(amount)
250
- }
251
- catch {
252
- return `${code} ${amount.toFixed(2)}`
253
- }
246
+ return formatCurrency(amount, code, 'en-US')
254
247
  }
255
248
 
256
249
  function formatCurrencyTotals(totals: Map<string, number>): { value: string, detail: string } {
@@ -505,7 +498,7 @@ export function buildCommerceDashboard(
505
498
  labels: buckets.map(bucket => bucket.label),
506
499
  revenue: [...revenueSeries.entries()]
507
500
  .sort(([left], [right]) => left.localeCompare(right))
508
- .map(([code, data]) => ({ currency: code, data })),
501
+ .map(([code, data]) => ({ currency: code, data: data.map(amount => minorToMajor(amount, code)) })),
509
502
  orders: orderSeries,
510
503
  },
511
504
  topProducts: [...productTotals.entries()]
@@ -84,6 +84,21 @@ describe('commerce delivery overview', () => {
84
84
  })
85
85
  })
86
86
 
87
+ test('formats shipping rates in the currency\'s own minor unit (stacksjs/stacks#2851)', () => {
88
+ const methods = (currency: string) => buildDeliveryOverview(
89
+ [{ id: 1, name: 'Express', status: 'active', base_rate: 1500, free_shipping: 5000 }],
90
+ [],
91
+ [],
92
+ [],
93
+ currency,
94
+ now,
95
+ ).methods[0]
96
+
97
+ // A yen has no minor unit, so 1500 is 1,500 yen, not 15.
98
+ expect(methods('JPY')).toMatchObject({ baseRate: '¥1,500', freeShipping: '¥5,000' })
99
+ expect(methods('USD')).toMatchObject({ baseRate: '$15.00', freeShipping: '$50.00' })
100
+ })
101
+
87
102
  test('rejects corrupt delivery records and missing courier relationships', () => {
88
103
  expect(() => buildDeliveryOverview(
89
104
  [{ id: 1, name: 'Ground', status: 'active', base_rate: 'free', free_shipping: null }],
@@ -1,3 +1,5 @@
1
+ import { formatCurrency } from '@stacksjs/commerce/money'
2
+
1
3
  export interface DeliveryOverviewStat {
2
4
  label: string
3
5
  value: string
@@ -45,16 +47,12 @@ export interface DeliveryOverviewResult {
45
47
 
46
48
  const DAY_MS = 24 * 60 * 60 * 1000
47
49
 
48
- function formatMoney(cents: number, currency: string): string {
49
- try {
50
- return new Intl.NumberFormat('en-US', {
51
- style: 'currency',
52
- currency,
53
- }).format(cents / 100)
54
- }
55
- catch {
56
- return `${currency} ${(cents / 100).toFixed(2)}`
57
- }
50
+ /**
51
+ * Shipping rates and thresholds are integer minor units. Dividing by 100 was
52
+ * right for USD and EUR and a hundred times off for JPY (stacksjs/stacks#2851).
53
+ */
54
+ function formatMoney(minor: number, currency: string): string {
55
+ return formatCurrency(minor, currency, 'en-US')
58
56
  }
59
57
 
60
58
  export function deliveryTimestamp(input: unknown, source = 'DeliveryRoute', field = 'last_active'): number {
@@ -25,13 +25,13 @@ describe('dashboard commerce POS helpers', () => {
25
25
  expect(result.error).toContain('between 1 and 999')
26
26
  })
27
27
 
28
- it('recomputes server-authoritative totals from persisted prices', () => {
28
+ it('recomputes server-authoritative totals from persisted prices, in minor units', () => {
29
29
  const sale = calculateCommercePosSale([
30
30
  {
31
31
  id: '4',
32
32
  name: 'Widget',
33
33
  description: '',
34
- price: 12.5,
34
+ price: 1250,
35
35
  isAvailable: true,
36
36
  inventoryCount: 20,
37
37
  preparationTime: 4,
@@ -40,9 +40,29 @@ describe('dashboard commerce POS helpers', () => {
40
40
  categoryName: 'Unassigned category',
41
41
  },
42
42
  ], [{ productId: 4, quantity: 3, specialInstructions: '' }], 8.25)
43
- expect(sale.subtotal).toBe(37.5)
44
- expect(sale.taxAmount).toBe(3.09)
45
- expect(sale.totalAmount).toBe(40.59)
43
+ // $12.50 x 3 = $37.50; 8.25% of it is 309.375 cents, which rounds to 309.
44
+ expect(sale.lines[0]?.lineTotal).toBe(3750)
45
+ expect(sale.subtotal).toBe(3750)
46
+ expect(sale.taxAmount).toBe(309)
47
+ expect(sale.totalAmount).toBe(4059)
48
+ })
49
+
50
+ it('refuses a product whose price is not integer minor units (stacksjs/stacks#2851)', () => {
51
+ expect(() => calculateCommercePosSale([
52
+ {
53
+ id: '4',
54
+ name: 'Widget',
55
+ description: '',
56
+ price: 12.5,
57
+ isAvailable: true,
58
+ inventoryCount: 20,
59
+ preparationTime: 4,
60
+ allergens: [],
61
+ categoryId: '',
62
+ categoryName: 'Unassigned category',
63
+ },
64
+ ], [{ productId: 4, quantity: 1, specialInstructions: '' }], 0))
65
+ .toThrow('expected integer minor units')
46
66
  })
47
67
 
48
68
  it('chooses an active default tax rate', () => {
@@ -81,7 +101,7 @@ describe('dashboard commerce POS helpers', () => {
81
101
  id: '4',
82
102
  name: 'Widget',
83
103
  description: '',
84
- price: 12.5,
104
+ price: 1250,
85
105
  isAvailable: true,
86
106
  inventoryCount: 2,
87
107
  preparationTime: 4,
@@ -51,8 +51,13 @@ export class CommercePosAvailabilityError extends Error {
51
51
  }
52
52
  }
53
53
 
54
+ /**
55
+ * Amounts are integer minor units, as `products.price` is (stacksjs/stacks#2851).
56
+ * Line totals and sums of integers are already whole; tax is the one product
57
+ * that can land between two units, and it rounds to the nearest one.
58
+ */
54
59
  function money(value: number): number {
55
- return Math.round((value + Number.EPSILON) * 100) / 100
60
+ return Math.round(value)
56
61
  }
57
62
 
58
63
  function value(record: any, ...keys: string[]): unknown {
@@ -206,8 +211,8 @@ export function calculateCommercePosSale(
206
211
  const id = Number(product.id)
207
212
  if (!Number.isSafeInteger(id) || id <= 0)
208
213
  throw new TypeError(`Product id "${product.id}" is invalid.`)
209
- if (!Number.isFinite(product.price) || product.price < 0)
210
- throw new TypeError(`Product ${id} has an invalid price.`)
214
+ if (!Number.isSafeInteger(product.price) || product.price < 0)
215
+ throw new TypeError(`Product ${id} has an invalid price: expected integer minor units.`)
211
216
  if (!Number.isSafeInteger(product.inventoryCount) || product.inventoryCount < 0)
212
217
  throw new TypeError(`Product ${id} has an invalid inventory count.`)
213
218
  return [id, product] as const