@stacksjs/defaults 0.75.38 → 0.75.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ai/skills/stacks-ai/SKILL.md +24 -1
- package/ai/skills/stacks-commerce/SKILL.md +1 -1
- package/ai/skills/stacks-config/SKILL.md +1 -1
- package/ai/skills/stacks-database/SKILL.md +1 -1
- package/ai/skills/stacks-dependencies/SKILL.md +35 -3
- package/ai/skills/stacks-desktop/SKILL.md +12 -0
- package/ai/skills/stacks-env/SKILL.md +4 -1
- package/ai/skills/stacks-migrations/SKILL.md +2 -2
- package/ai/skills/stacks-models/SKILL.md +2 -2
- package/app/Actions/Commerce/Product/ProductStoreAction.ts +9 -1
- package/app/Actions/Commerce/Product/ProductUpdateAction.ts +4 -1
- package/app/Actions/Commerce/commerce-action.test.ts +30 -1
- package/app/Actions/Commerce/commerce-action.ts +44 -0
- package/app/Actions/Dashboard/Analytics/sales-analytics.test.ts +19 -0
- package/app/Actions/Dashboard/Analytics/sales-analytics.ts +2 -1
- package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +3 -2
- package/app/Actions/Dashboard/Commerce/commerce-dashboard.test.ts +29 -9
- package/app/Actions/Dashboard/Commerce/commerce-dashboard.ts +5 -12
- package/app/Actions/Dashboard/Commerce/commerce-delivery.test.ts +15 -0
- package/app/Actions/Dashboard/Commerce/commerce-delivery.ts +8 -10
- package/app/Actions/Dashboard/Commerce/commerce-pos.test.ts +26 -6
- package/app/Actions/Dashboard/Commerce/commerce-pos.ts +8 -3
- package/app/Actions/Dashboard/Commerce/commerce-product-records.ts +69 -0
- package/app/Actions/Dashboard/Marketing/abandoned-cart-records.test.ts +39 -0
- package/app/Actions/Dashboard/Marketing/abandoned-cart-records.ts +9 -3
- package/app/Actions/Dashboard/Marketing/campaign-records.ts +17 -3
- package/app/Actions/Payment/CreatePaymentIntentAction.ts +1 -1
- package/app/Actions/Payment/CreateSetupIntentAction.ts +1 -1
- package/app/Actions/Storefront/AddToCartAction.ts +4 -1
- package/app/Actions/Storefront/GetCartAction.ts +25 -10
- package/app/Actions/Storefront/PlaceOrderAction.ts +6 -1
- package/app/Actions/Storefront/_shipping.ts +63 -37
- package/app/Broadcasts/OrderShipped.ts +1 -1
- package/app/Mail/OrderConfirmation.ts +14 -7
- package/app/Models/Campaign.ts +9 -4
- package/app/Models/commerce/Cart.ts +5 -4
- package/app/Models/commerce/CartItem.ts +6 -4
- package/app/Models/commerce/Coupon.ts +9 -3
- package/app/Models/commerce/Customer.ts +2 -1
- package/app/Models/commerce/GiftCard.ts +5 -2
- package/app/Models/commerce/Order.ts +6 -5
- package/app/Models/commerce/OrderItem.ts +2 -1
- package/app/Models/commerce/ProductVariant.ts +71 -4
- package/app/Models/commerce/Transaction.ts +3 -2
- package/app/Storefront/StorefrontMoney.ts +54 -0
- package/ide/vscode/.vscode/settings.json +1 -1
- package/ide/vscode/README.md +65 -20
- package/ide/vscode/package.json +102 -12
- package/ide/vscode/src/extension.ts +59 -0
- package/ide/vscode/src/project.ts +440 -0
- package/ide/vscode/src/stacks.ts +475 -0
- package/ide/vscode/src/vscode.d.ts +181 -0
- package/ide/vscode/tests/extension.test.ts +420 -0
- package/ide/vscode/tests/project.test.ts +268 -0
- package/ide/vscode/tsconfig.json +19 -0
- package/package.json +4 -4
- package/resources/components/Dashboard/Analytics/CampaignAnalytics.stx +8 -8
- package/resources/components/Dashboard/Analytics/SalesAnalytics.stx +4 -3
- package/resources/components/Dashboard/Billing/BillingPlanSummary.stx +4 -1
- package/resources/components/Dashboard/Commerce/CommerceCouponDetailsDialog.stx +4 -1
- package/resources/components/Dashboard/Commerce/CommerceCouponDialog.stx +34 -10
- package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +3 -1
- package/resources/components/Dashboard/Commerce/CommerceCouponsTable.stx +4 -1
- package/resources/components/Dashboard/Commerce/CommerceCustomerDetailsDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceCustomerDialog.stx +6 -6
- package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +3 -11
- package/resources/components/Dashboard/Commerce/CommerceCustomersTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceGiftCardBreakdown.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceGiftCardDetailsDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceGiftCardDialog.stx +20 -17
- package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +3 -2
- package/resources/components/Dashboard/Commerce/CommerceGiftCardsTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceOrderDetailsDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceOrderDialog.stx +28 -20
- package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +7 -15
- package/resources/components/Dashboard/Commerce/CommerceOrdersTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePaymentsBreakdown.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePosCart.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePosCatalog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePosCheckoutDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePosDashboard.stx +5 -7
- package/resources/components/Dashboard/Commerce/CommercePosReceiptDialog.stx +2 -8
- package/resources/components/Dashboard/Commerce/CommerceProductDetailDashboard.stx +3 -20
- package/resources/components/Dashboard/Commerce/CommerceProductDialog.stx +27 -19
- package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +4 -15
- package/resources/components/Dashboard/Commerce/CommerceProductsTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodDialog.stx +12 -10
- package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsTable.stx +3 -4
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRateDialog.stx +7 -5
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +3 -2
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesTable.stx +3 -4
- package/resources/components/Dashboard/Commerce/PaymentDetailsDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/PaymentRefundDialog.stx +12 -10
- package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +3 -10
- package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +3 -8
- package/resources/components/Dashboard/Marketing/CampaignDialog.stx +26 -8
- package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +4 -2
- package/resources/components/Dashboard/Marketing/RecoveryCampaignDialog.stx +25 -13
- package/resources/components/Dashboard/Transaction/index.stx +6 -3
- package/resources/components/Storefront/CartDrawer.stx +11 -7
- package/resources/views/cart.stx +19 -23
- package/resources/views/checkout/contact.stx +12 -14
- package/resources/views/checkout/payment.stx +13 -13
- package/resources/views/checkout/shipping.stx +13 -13
- package/resources/views/cms/blocks/form.stx +50 -1
- package/resources/views/orders/[id].stx +13 -4
- package/vcs/github/workflows/buddy-bot.yml +5 -5
- package/vcs/github/workflows/ci.yml +3 -3
- 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 |
|
|
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` |
|
|
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/` (
|
|
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
|
|
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
|
|
@@ -214,8 +214,20 @@ buddy desktop:apple:init # generate the reusable GitHub Actions caller
|
|
|
214
214
|
buddy desktop:apple:doctor # validate Apple tooling, identities, profile, and API key
|
|
215
215
|
buddy desktop:apple:package # build, sandbox, sign, and create a Store .pkg
|
|
216
216
|
buddy desktop:apple:publish # validate or upload the package to App Store Connect
|
|
217
|
+
buddy desktop:probe # measure what a Craft window offers interactive/game content
|
|
217
218
|
```
|
|
218
219
|
|
|
220
|
+
`desktop:probe` opens a visible Craft window (not `--headless`, which stops
|
|
221
|
+
requestAnimationFrame) and measures WebGL2/WebGPU, frame pacing under load,
|
|
222
|
+
input and audio latency, the Gamepad API, fullscreen and pointer lock.
|
|
223
|
+
`--browser[=Safari]` runs the same page in a browser for comparison, and
|
|
224
|
+
`--record` writes the result into `storage/framework/core/desktop/src/probe/results/`,
|
|
225
|
+
which `buddy docs:capabilities` publishes in `docs/features/capabilities.md`.
|
|
226
|
+
Probes that need a real click or key press are recorded as
|
|
227
|
+
`requires-interaction`, never as a number. Stacks ships no game engine,
|
|
228
|
+
physics or asset pipeline; this only says what an engine someone else wrote
|
|
229
|
+
would get.
|
|
230
|
+
|
|
219
231
|
## Mac App Store
|
|
220
232
|
|
|
221
233
|
Run `buddy desktop:apple:init`, then configure the repository variables and
|
|
@@ -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
|
|
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
|
-
-
|
|
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** —
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
68
|
-
{ id: '2', status: 'CANCELED', totalAmount:
|
|
69
|
-
{ id: '3', status: 'DELIVERED', totalAmount:
|
|
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:
|
|
73
|
-
{ orderId: '2', productId: '4', quantity: 1, price:
|
|
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:
|
|
102
|
-
{ id: '2', status: 'DELIVERED', totalAmount:
|
|
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:
|
|
106
|
-
{ orderId: '2', productId: '4', quantity: 1, price:
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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:
|
|
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
|
-
|
|
44
|
-
expect(sale.
|
|
45
|
-
expect(sale.
|
|
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:
|
|
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(
|
|
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.
|
|
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
|