@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.
- 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-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/Ci/failure-notifier.ts +11 -7
- package/app/Actions/Dashboard/Ci/runner-pressure-monitor.ts +13 -7
- 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/Kanban/BoardDestroyAction.ts +7 -4
- package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +10 -7
- package/app/Actions/Dashboard/Kanban/CardAssigneesSyncAction.ts +7 -4
- package/app/Actions/Dashboard/Kanban/CardLabelsSyncAction.ts +9 -5
- package/app/Actions/Dashboard/Kanban/CardShowAction.ts +8 -5
- package/app/Actions/Dashboard/Kanban/CardStoreAction.ts +3 -2
- package/app/Actions/Dashboard/Kanban/CardsReorderAction.ts +7 -4
- package/app/Actions/Dashboard/Kanban/ColumnDestroyAction.ts +7 -4
- package/app/Actions/Dashboard/Kanban/ColumnStoreAction.ts +3 -2
- package/app/Actions/Dashboard/Kanban/ColumnsReorderAction.ts +7 -3
- 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/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/package.json +1 -1
- package/package.json +2 -2
- 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
|
|
@@ -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
|
|
|
@@ -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
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
106
|
-
// consistent
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 {
|