@stacksjs/defaults 0.75.37 → 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 (110) 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/Ci/failure-notifier.ts +11 -7
  16. package/app/Actions/Dashboard/Ci/runner-pressure-monitor.ts +13 -7
  17. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +3 -2
  18. package/app/Actions/Dashboard/Commerce/commerce-dashboard.test.ts +29 -9
  19. package/app/Actions/Dashboard/Commerce/commerce-dashboard.ts +5 -12
  20. package/app/Actions/Dashboard/Commerce/commerce-delivery.test.ts +15 -0
  21. package/app/Actions/Dashboard/Commerce/commerce-delivery.ts +8 -10
  22. package/app/Actions/Dashboard/Commerce/commerce-pos.test.ts +26 -6
  23. package/app/Actions/Dashboard/Commerce/commerce-pos.ts +8 -3
  24. package/app/Actions/Dashboard/Commerce/commerce-product-records.ts +69 -0
  25. package/app/Actions/Dashboard/Kanban/BoardDestroyAction.ts +7 -4
  26. package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +10 -7
  27. package/app/Actions/Dashboard/Kanban/CardAssigneesSyncAction.ts +7 -4
  28. package/app/Actions/Dashboard/Kanban/CardLabelsSyncAction.ts +9 -5
  29. package/app/Actions/Dashboard/Kanban/CardShowAction.ts +8 -5
  30. package/app/Actions/Dashboard/Kanban/CardStoreAction.ts +3 -2
  31. package/app/Actions/Dashboard/Kanban/CardsReorderAction.ts +7 -4
  32. package/app/Actions/Dashboard/Kanban/ColumnDestroyAction.ts +7 -4
  33. package/app/Actions/Dashboard/Kanban/ColumnStoreAction.ts +3 -2
  34. package/app/Actions/Dashboard/Kanban/ColumnsReorderAction.ts +7 -3
  35. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.test.ts +39 -0
  36. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.ts +9 -3
  37. package/app/Actions/Dashboard/Marketing/campaign-records.ts +17 -3
  38. package/app/Actions/Storefront/AddToCartAction.ts +4 -1
  39. package/app/Actions/Storefront/GetCartAction.ts +25 -10
  40. package/app/Actions/Storefront/PlaceOrderAction.ts +6 -1
  41. package/app/Actions/Storefront/_shipping.ts +63 -37
  42. package/app/Broadcasts/OrderShipped.ts +1 -1
  43. package/app/Mail/OrderConfirmation.ts +14 -7
  44. package/app/Models/Campaign.ts +9 -4
  45. package/app/Models/commerce/Cart.ts +5 -4
  46. package/app/Models/commerce/CartItem.ts +6 -4
  47. package/app/Models/commerce/Coupon.ts +9 -3
  48. package/app/Models/commerce/Customer.ts +2 -1
  49. package/app/Models/commerce/GiftCard.ts +5 -2
  50. package/app/Models/commerce/Order.ts +6 -5
  51. package/app/Models/commerce/OrderItem.ts +2 -1
  52. package/app/Models/commerce/ProductVariant.ts +71 -4
  53. package/app/Models/commerce/Transaction.ts +3 -2
  54. package/app/Storefront/StorefrontMoney.ts +54 -0
  55. package/ide/vscode/package.json +1 -1
  56. package/package.json +2 -2
  57. package/resources/components/Dashboard/Analytics/CampaignAnalytics.stx +8 -8
  58. package/resources/components/Dashboard/Analytics/SalesAnalytics.stx +4 -3
  59. package/resources/components/Dashboard/Billing/BillingPlanSummary.stx +4 -1
  60. package/resources/components/Dashboard/Commerce/CommerceCouponDetailsDialog.stx +4 -1
  61. package/resources/components/Dashboard/Commerce/CommerceCouponDialog.stx +34 -10
  62. package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +3 -1
  63. package/resources/components/Dashboard/Commerce/CommerceCouponsTable.stx +4 -1
  64. package/resources/components/Dashboard/Commerce/CommerceCustomerDetailsDialog.stx +2 -6
  65. package/resources/components/Dashboard/Commerce/CommerceCustomerDialog.stx +6 -6
  66. package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +3 -11
  67. package/resources/components/Dashboard/Commerce/CommerceCustomersTable.stx +2 -6
  68. package/resources/components/Dashboard/Commerce/CommerceGiftCardBreakdown.stx +2 -6
  69. package/resources/components/Dashboard/Commerce/CommerceGiftCardDetailsDialog.stx +2 -6
  70. package/resources/components/Dashboard/Commerce/CommerceGiftCardDialog.stx +20 -17
  71. package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +3 -2
  72. package/resources/components/Dashboard/Commerce/CommerceGiftCardsTable.stx +2 -6
  73. package/resources/components/Dashboard/Commerce/CommerceOrderDetailsDialog.stx +2 -6
  74. package/resources/components/Dashboard/Commerce/CommerceOrderDialog.stx +28 -20
  75. package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +7 -15
  76. package/resources/components/Dashboard/Commerce/CommerceOrdersTable.stx +2 -6
  77. package/resources/components/Dashboard/Commerce/CommercePaymentsBreakdown.stx +2 -6
  78. package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +2 -6
  79. package/resources/components/Dashboard/Commerce/CommercePosCart.stx +2 -6
  80. package/resources/components/Dashboard/Commerce/CommercePosCatalog.stx +2 -6
  81. package/resources/components/Dashboard/Commerce/CommercePosCheckoutDialog.stx +2 -6
  82. package/resources/components/Dashboard/Commerce/CommercePosDashboard.stx +5 -7
  83. package/resources/components/Dashboard/Commerce/CommercePosReceiptDialog.stx +2 -8
  84. package/resources/components/Dashboard/Commerce/CommerceProductDetailDashboard.stx +3 -20
  85. package/resources/components/Dashboard/Commerce/CommerceProductDialog.stx +27 -19
  86. package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +4 -15
  87. package/resources/components/Dashboard/Commerce/CommerceProductsTable.stx +2 -6
  88. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodDialog.stx +12 -10
  89. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsTable.stx +3 -4
  90. package/resources/components/Dashboard/Commerce/Delivery/ShippingRateDialog.stx +7 -5
  91. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +3 -2
  92. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesTable.stx +3 -4
  93. package/resources/components/Dashboard/Commerce/PaymentDetailsDialog.stx +2 -6
  94. package/resources/components/Dashboard/Commerce/PaymentRefundDialog.stx +12 -10
  95. package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +3 -10
  96. package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +3 -8
  97. package/resources/components/Dashboard/Marketing/CampaignDialog.stx +26 -8
  98. package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +4 -2
  99. package/resources/components/Dashboard/Marketing/RecoveryCampaignDialog.stx +25 -13
  100. package/resources/components/Dashboard/Transaction/index.stx +6 -3
  101. package/resources/components/Storefront/CartDrawer.stx +11 -7
  102. package/resources/views/cart.stx +19 -23
  103. package/resources/views/checkout/contact.stx +12 -14
  104. package/resources/views/checkout/payment.stx +13 -13
  105. package/resources/views/checkout/shipping.stx +13 -13
  106. package/resources/views/cms/blocks/form.stx +50 -1
  107. package/resources/views/orders/[id].stx +13 -4
  108. package/vcs/github/workflows/buddy-bot.yml +5 -5
  109. package/vcs/github/workflows/ci.yml +3 -3
  110. 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
 
@@ -20,7 +20,7 @@
20
20
 
21
21
  import type { DashboardData, FailedTransition, PreviousRunState } from '@stacksjs/github'
22
22
  import { dashboard as dashboardConfig } from '@stacksjs/config'
23
- import { db } from '@stacksjs/database/runtime'
23
+ import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
24
24
  import { detectNewlyFailedRuns } from '@stacksjs/github'
25
25
  import { notify } from '@stacksjs/notifications'
26
26
 
@@ -60,13 +60,17 @@ async function upsertSnapshotStates(snapshot: DashboardData): Promise<void> {
60
60
  const nowIso = new Date().toISOString()
61
61
  for (const r of snapshot.repos) {
62
62
  const runId = parseRunIdFromUrl(r.runUrl)
63
- // Two-step upsert — works across SQLite/MySQL/Postgres without
64
- // dialect-specific ON CONFLICT shapes. Each repo is its own
65
- // round-trip, but the table is small (one row per repo, so
66
- // typically O(50) for a busy fleet) and this runs in the
67
- // background after the response goes out.
63
+ // Two-step upsert, which avoids the dialect-specific ON CONFLICT
64
+ // shapes. Each repo is its own round-trip, but the table is small
65
+ // (one row per repo, so typically O(50) for a busy fleet) and this
66
+ // runs in the background after the response goes out.
67
+ //
68
+ // The upsert shape is portable; the placeholder is not. Postgres
69
+ // numbers its parameters, so the `?` this used to carry bound
70
+ // nothing there (stacksjs/stacks#2846).
71
+ const { param } = sqlHelpers(getDatabaseDialect())
68
72
  const existing = await db.unsafe(
69
- 'SELECT repo_full_name FROM ci_run_states WHERE repo_full_name = ? LIMIT 1',
73
+ `SELECT repo_full_name FROM ci_run_states WHERE repo_full_name = ${param(1)} LIMIT 1`,
70
74
  [r.fullName],
71
75
  ).execute() as Array<{ repo_full_name: string }>
72
76
  if (existing?.length) {
@@ -26,7 +26,7 @@
26
26
 
27
27
  import type { DashboardData, PressureAction, RunnerAlertState, RunnerSample } from '@stacksjs/github'
28
28
  import { dashboard as dashboardConfig } from '@stacksjs/config'
29
- import { db } from '@stacksjs/database/runtime'
29
+ import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
30
30
  import { detectRunnerPressure } from '@stacksjs/github'
31
31
  import { notify } from '@stacksjs/notifications'
32
32
 
@@ -69,8 +69,9 @@ async function pruneOldSamples(retentionHours: number, nowMs: number): Promise<v
69
69
 
70
70
  async function loadWindowSamples(windowMinutes: number, nowMs: number): Promise<RunnerSample[]> {
71
71
  const cutoffIso = new Date(nowMs - windowMinutes * 60_000).toISOString()
72
+ const { param } = sqlHelpers(getDatabaseDialect())
72
73
  const rows = await db.unsafe(
73
- 'SELECT org, running, queued, cap, sampled_at FROM ci_runner_samples WHERE sampled_at >= ? ORDER BY sampled_at ASC',
74
+ `SELECT org, running, queued, cap, sampled_at FROM ci_runner_samples WHERE sampled_at >= ${param(1)} ORDER BY sampled_at ASC`,
74
75
  [cutoffIso],
75
76
  ).execute() as unknown as SampleRow[]
76
77
 
@@ -102,11 +103,15 @@ async function loadAlertStates(): Promise<Map<string, RunnerAlertState>> {
102
103
 
103
104
  async function upsertAlertState(org: string, alerting: boolean, nowIso: string): Promise<void> {
104
105
  // Cross-dialect upsert: SELECT-then-INSERT/UPDATE. Same shape as
105
- // the failure-notifier's state writes — keep the patterns
106
- // consistent so future maintainers don't second-guess the
106
+ // the failure-notifier's state writes, so keep the patterns
107
+ // consistent and future maintainers don't second-guess the
107
108
  // dialect choice in one place vs another.
109
+ //
110
+ // That portability covers the upsert, not the placeholder, which has
111
+ // to be rendered per dialect (stacksjs/stacks#2846).
112
+ const { param } = sqlHelpers(getDatabaseDialect())
108
113
  const existing = await db.unsafe(
109
- 'SELECT org FROM ci_runner_alert_states WHERE org = ? LIMIT 1',
114
+ `SELECT org FROM ci_runner_alert_states WHERE org = ${param(1)} LIMIT 1`,
110
115
  [org],
111
116
  ).execute() as Array<{ org: string }>
112
117
 
@@ -241,12 +246,13 @@ export async function fetchRunnerHistory(
241
246
 
242
247
  // ASC-by-newest then re-reverse so we get the N most recent samples
243
248
  // in ASC order (oldest → newest) — what the sparkline path needs.
249
+ const { param } = sqlHelpers(getDatabaseDialect())
244
250
  const rows = await db.unsafe(
245
251
  `SELECT org, running, queued, cap, sampled_at
246
252
  FROM ci_runner_samples
247
- WHERE org = ? AND sampled_at >= ?
253
+ WHERE org = ${param(1)} AND sampled_at >= ${param(2)}
248
254
  ORDER BY sampled_at DESC
249
- LIMIT ?`,
255
+ LIMIT ${param(3)}`,
250
256
  [org, cutoffIso, limit],
251
257
  ).execute() as unknown as SampleRow[]
252
258
 
@@ -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 {