@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.
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-desktop/SKILL.md +12 -0
  7. package/ai/skills/stacks-env/SKILL.md +4 -1
  8. package/ai/skills/stacks-migrations/SKILL.md +2 -2
  9. package/ai/skills/stacks-models/SKILL.md +2 -2
  10. package/app/Actions/Commerce/Product/ProductStoreAction.ts +9 -1
  11. package/app/Actions/Commerce/Product/ProductUpdateAction.ts +4 -1
  12. package/app/Actions/Commerce/commerce-action.test.ts +30 -1
  13. package/app/Actions/Commerce/commerce-action.ts +44 -0
  14. package/app/Actions/Dashboard/Analytics/sales-analytics.test.ts +19 -0
  15. package/app/Actions/Dashboard/Analytics/sales-analytics.ts +2 -1
  16. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +3 -2
  17. package/app/Actions/Dashboard/Commerce/commerce-dashboard.test.ts +29 -9
  18. package/app/Actions/Dashboard/Commerce/commerce-dashboard.ts +5 -12
  19. package/app/Actions/Dashboard/Commerce/commerce-delivery.test.ts +15 -0
  20. package/app/Actions/Dashboard/Commerce/commerce-delivery.ts +8 -10
  21. package/app/Actions/Dashboard/Commerce/commerce-pos.test.ts +26 -6
  22. package/app/Actions/Dashboard/Commerce/commerce-pos.ts +8 -3
  23. package/app/Actions/Dashboard/Commerce/commerce-product-records.ts +69 -0
  24. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.test.ts +39 -0
  25. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.ts +9 -3
  26. package/app/Actions/Dashboard/Marketing/campaign-records.ts +17 -3
  27. package/app/Actions/Payment/CreatePaymentIntentAction.ts +1 -1
  28. package/app/Actions/Payment/CreateSetupIntentAction.ts +1 -1
  29. package/app/Actions/Storefront/AddToCartAction.ts +4 -1
  30. package/app/Actions/Storefront/GetCartAction.ts +25 -10
  31. package/app/Actions/Storefront/PlaceOrderAction.ts +6 -1
  32. package/app/Actions/Storefront/_shipping.ts +63 -37
  33. package/app/Broadcasts/OrderShipped.ts +1 -1
  34. package/app/Mail/OrderConfirmation.ts +14 -7
  35. package/app/Models/Campaign.ts +9 -4
  36. package/app/Models/commerce/Cart.ts +5 -4
  37. package/app/Models/commerce/CartItem.ts +6 -4
  38. package/app/Models/commerce/Coupon.ts +9 -3
  39. package/app/Models/commerce/Customer.ts +2 -1
  40. package/app/Models/commerce/GiftCard.ts +5 -2
  41. package/app/Models/commerce/Order.ts +6 -5
  42. package/app/Models/commerce/OrderItem.ts +2 -1
  43. package/app/Models/commerce/ProductVariant.ts +71 -4
  44. package/app/Models/commerce/Transaction.ts +3 -2
  45. package/app/Storefront/StorefrontMoney.ts +54 -0
  46. package/ide/vscode/.vscode/settings.json +1 -1
  47. package/ide/vscode/README.md +65 -20
  48. package/ide/vscode/package.json +102 -12
  49. package/ide/vscode/src/extension.ts +59 -0
  50. package/ide/vscode/src/project.ts +440 -0
  51. package/ide/vscode/src/stacks.ts +475 -0
  52. package/ide/vscode/src/vscode.d.ts +181 -0
  53. package/ide/vscode/tests/extension.test.ts +420 -0
  54. package/ide/vscode/tests/project.test.ts +268 -0
  55. package/ide/vscode/tsconfig.json +19 -0
  56. package/package.json +4 -4
  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,9 +1,11 @@
1
+ import { moneyInputError, parseMoneyInput } from '@stacksjs/commerce/money'
1
2
  import { commerceTimestamp } from './commerce-record'
2
3
 
3
4
  export interface CommerceProductRecord {
4
5
  id: string
5
6
  name: string
6
7
  description: string
8
+ /** Integer minor units of the store currency (1999 is $19.99 in USD). */
7
9
  price: number
8
10
  imageUrl: string
9
11
  isAvailable: boolean
@@ -60,6 +62,73 @@ export interface ProductReviewSummary {
60
62
  averageRating: number
61
63
  }
62
64
 
65
+ /**
66
+ * The product form as the dashboard dialog holds it: every field is the text
67
+ * the person typed, including the price in MAJOR units (`"19.99"`).
68
+ */
69
+ export interface ProductFormFields {
70
+ name: string
71
+ description: string
72
+ price: string
73
+ imageUrl: string
74
+ isAvailable: boolean
75
+ inventoryCount: string
76
+ preparationTime: string
77
+ allergens: string
78
+ nutritionalInfo: string
79
+ categoryId: string
80
+ manufacturerId: string
81
+ }
82
+
83
+ /** What the dialog sends to `POST`/`PATCH /api/dashboard/commerce/products`. */
84
+ export interface ProductWritePayload {
85
+ name: string
86
+ description: string
87
+ /** Integer minor units, never a decimal (stacksjs/stacks#2851). */
88
+ price: number
89
+ imageUrl: string
90
+ isAvailable: boolean
91
+ inventoryCount: number
92
+ preparationTime: number
93
+ allergens: string
94
+ nutritionalInfo: string
95
+ categoryId: number | null
96
+ manufacturerId: number | null
97
+ }
98
+
99
+ /** One minor unit (a cent in USD): the Product model's `price` minimum. */
100
+ export const MIN_PRODUCT_PRICE = 1
101
+
102
+ /** Why a typed price cannot be saved in `currency`, or `''` when it can. */
103
+ export function productPriceInputError(price: string, currency: string): string {
104
+ return moneyInputError(price, currency, { min: MIN_PRODUCT_PRICE })
105
+ }
106
+
107
+ /**
108
+ * The dialog's fields as the payload the products API stores.
109
+ *
110
+ * The one place a typed price becomes the stored integer: `"19.99"` in USD is
111
+ * `1999`, by string arithmetic, so the dashboard writes the same unit the
112
+ * catalog importer and order totals read. Throws `PriceFormatError` for a
113
+ * price `productPriceInputError` would reject.
114
+ */
115
+ export function productWritePayload(form: ProductFormFields, currency: string): ProductWritePayload {
116
+ const allergens = form.allergens.split(',').map(item => item.trim()).filter(Boolean)
117
+ return {
118
+ name: form.name.trim(),
119
+ description: form.description.trim(),
120
+ price: parseMoneyInput(form.price, currency, { min: MIN_PRODUCT_PRICE }),
121
+ imageUrl: form.imageUrl.trim(),
122
+ isAvailable: form.isAvailable,
123
+ inventoryCount: Number(form.inventoryCount),
124
+ preparationTime: Number(form.preparationTime),
125
+ allergens: JSON.stringify(allergens),
126
+ nutritionalInfo: form.nutritionalInfo.trim() || '{}',
127
+ categoryId: form.categoryId ? Number(form.categoryId) : null,
128
+ manufacturerId: form.manufacturerId ? Number(form.manufacturerId) : null,
129
+ }
130
+ }
131
+
63
132
  function value(record: any, ...keys: string[]): unknown {
64
133
  for (const key of keys) {
65
134
  const result = typeof record?.get === 'function' ? record.get(key) : record?.[key]
@@ -314,3 +314,42 @@ describe('composing a recovery campaign', () => {
314
314
  expect(JSON.parse(data.segment_definition).rules[1].value).toBe(4)
315
315
  })
316
316
  })
317
+
318
+ describe('cart money is integer minor units (stacksjs/stacks#2851)', () => {
319
+ test('a 1999-cent cart is worth $19.99, and the average rounds to a whole cent', () => {
320
+ const result = normalizeAbandonedCarts(
321
+ [cart({ id: 1, total: 1999 }), cart({ id: 2, customer_id: 12, total: 1000 })],
322
+ [],
323
+ CUSTOMERS,
324
+ [],
325
+ [],
326
+ { now: NOW },
327
+ )
328
+
329
+ expect(result.records[0].value).toBe(1999)
330
+ expect(result.summary.openValue).toBe(2999)
331
+ // 1499.5 cents is not an amount anything can hold.
332
+ expect(result.summary.averageValue).toBe(1500)
333
+ })
334
+
335
+ test('the dialog\'s threshold arrives in minor units and is compared against cart totals as such', () => {
336
+ // RecoveryCampaignDialog sends parseMoneyInput('25.00', 'USD'), which is 2500.
337
+ const data = recoveryCampaignWriteData({ name: 'Recovery', subject: 'Still here', minimumValue: 2500 })
338
+ expect(JSON.parse(data.segment_definition).rules[2]).toEqual({ field: 'cart.value', operator: 'gte', value: 2500 })
339
+
340
+ const records = normalizeAbandonedCarts(
341
+ [cart({ id: 1, total: 2499 }), cart({ id: 2, customer_id: 12, total: 2500 })],
342
+ [],
343
+ CUSTOMERS,
344
+ [],
345
+ [],
346
+ { now: NOW },
347
+ ).records
348
+ expect(reachOf(records, 1, 2500)).toEqual({ carts: 1, value: 2500 })
349
+ })
350
+
351
+ test('a fractional threshold is rounded to a whole minor unit', () => {
352
+ const data = recoveryCampaignWriteData({ name: 'Recovery', subject: 'Still here', minimumValue: 2500.4 })
353
+ expect(JSON.parse(data.segment_definition).rules[2].value).toBe(2500)
354
+ })
355
+ })
@@ -34,6 +34,7 @@ export interface AbandonedCartRecord {
34
34
  itemCount: number
35
35
  /** The first few product names, for a row that says what was left behind. */
36
36
  items: string[]
37
+ /** The cart's total, in integer minor units of `currency` (1999 is $19.99). */
37
38
  value: number
38
39
  currency: string
39
40
  abandonedAt: string
@@ -48,7 +49,7 @@ export interface AbandonedCartRecord {
48
49
  export interface AbandonedCartSummary {
49
50
  /** Carts sitting abandoned or expired right now. */
50
51
  open: number
51
- /** What those carts are worth. */
52
+ /** What those carts are worth, in integer minor units. */
52
53
  openValue: number
53
54
  /** Of those, how many have already been written to. */
54
55
  contacted: number
@@ -57,6 +58,7 @@ export interface AbandonedCartSummary {
57
58
  recoveredValue: number
58
59
  /** Recovered as a share of everything that was ever chased. */
59
60
  recoveryRate: number
61
+ /** Rounded to a whole minor unit. */
60
62
  averageValue: number
61
63
  currency: string
62
64
  }
@@ -66,6 +68,7 @@ export interface RecoveryCampaignRecord {
66
68
  name: string
67
69
  status: string
68
70
  idleHours: number
71
+ /** The smallest cart total the campaign writes about, in integer minor units. */
69
72
  minimumValue: number
70
73
  sentCount: number
71
74
  openedCount: number
@@ -314,7 +317,7 @@ export function normalizeAbandonedCarts(
314
317
  recovered: recovered.length,
315
318
  recoveredValue,
316
319
  recoveryRate: chased > 0 ? recovered.length / chased * 100 : 0,
317
- averageValue: open.length > 0 ? openValue / open.length : 0,
320
+ averageValue: open.length > 0 ? Math.round(openValue / open.length) : 0,
318
321
  currency: records[0]?.currency || defaultCurrency,
319
322
  },
320
323
  campaigns,
@@ -354,6 +357,7 @@ export interface RecoveryCampaignInput {
354
357
  fromAddress: string
355
358
  emailListId: number | null
356
359
  idleHours: number
360
+ /** Integer minor units, compared against a cart's `total`. */
357
361
  minimumValue: number
358
362
  scheduledAt: string | null
359
363
  currency: string
@@ -378,7 +382,9 @@ export function recoveryCampaignWriteData(
378
382
  currency: string
379
383
  } {
380
384
  const idleHours = Math.max(1, Math.round(Number(input.idleHours)) || DEFAULT_IDLE_HOURS)
381
- const minimumValue = Math.max(0, Number(input.minimumValue) || 0)
385
+ // Minor units, like the cart totals it is compared against: a fraction of a
386
+ // cent is not a threshold anybody set.
387
+ const minimumValue = Math.max(0, Math.round(Number(input.minimumValue)) || 0)
382
388
  const emailListId = Number(input.emailListId ?? input.email_list_id)
383
389
  const scheduledAt = text(input.scheduledAt ?? input.scheduled_at).trim()
384
390
 
@@ -44,7 +44,9 @@ export interface CampaignRecord {
44
44
  openRate: number
45
45
  clickRate: number
46
46
  conversionRate: number
47
+ /** Integer minor units of `currency` (1999 is $19.99). */
47
48
  budget: number
49
+ /** Integer minor units of `currency`. */
48
50
  spent: number
49
51
  currency: string
50
52
  startDate: string
@@ -224,8 +226,11 @@ export function campaignWriteData(input: Record<string, unknown>, defaultCurrenc
224
226
  end_date: string | null
225
227
  } {
226
228
  const emailListId = Number(input.emailListId ?? input.email_list_id)
229
+ // Integer minor units of the campaign's currency: the dialog converts what
230
+ // was typed through @stacksjs/commerce/money before sending it, and
231
+ // validateCampaignWriteData refuses anything that is not a whole amount.
227
232
  const budget = text(input.budget).trim()
228
- const spent = Number(input.spent)
233
+ const spent = text(input.spent).trim()
229
234
  return {
230
235
  name: text(input.name).trim(),
231
236
  description: text(input.description).trim() || null,
@@ -238,8 +243,8 @@ export function campaignWriteData(input: Record<string, unknown>, defaultCurrenc
238
243
  from_address: text(input.fromAddress ?? input.from_address).trim() || null,
239
244
  email_list_id: Number.isInteger(emailListId) && emailListId > 0 ? emailListId : null,
240
245
  scheduled_at: text(input.scheduledAt ?? input.scheduled_at).trim() || null,
241
- budget: budget ? Math.max(0, Number(budget) || 0) : null,
242
- spent: Number.isFinite(spent) ? Math.max(0, spent) : 0,
246
+ budget: budget ? Number(budget) : null,
247
+ spent: spent ? Number(spent) : 0,
243
248
  currency: text(input.currency).trim().toUpperCase() || defaultCurrency,
244
249
  start_date: text(input.startDate ?? input.start_date).trim() || null,
245
250
  end_date: text(input.endDate ?? input.end_date).trim() || null,
@@ -267,6 +272,10 @@ export function validateCampaignWriteData(
267
272
  if (scheduledAt <= now.getTime())
268
273
  return 'Campaign schedule time must be in the future.'
269
274
  }
275
+ if (data.budget !== null && !isMinorAmount(data.budget))
276
+ return 'Campaign budgets must be a whole number of minor units, for example 1999 for 19.99.'
277
+ if (!isMinorAmount(data.spent))
278
+ return 'Campaign spend must be a whole number of minor units, for example 1999 for 19.99.'
270
279
  if (data.start_date && data.end_date) {
271
280
  const startsAt = new Date(data.start_date.replace(' ', 'T')).getTime()
272
281
  const endsAt = new Date(data.end_date.replace(' ', 'T')).getTime()
@@ -275,3 +284,8 @@ export function validateCampaignWriteData(
275
284
  }
276
285
  return ''
277
286
  }
287
+
288
+ /** A non-negative whole number of minor units, which is all a money column holds. */
289
+ function isMinorAmount(value: number): boolean {
290
+ return Number.isSafeInteger(value) && value >= 0
291
+ }
@@ -30,7 +30,7 @@ export default new Action({
30
30
  // method is `createPayment`: `user.paymentIntent()` never existed.
31
31
  const paymentIntent = await user.createPayment(Number(product.get('price')), {
32
32
  currency: 'usd',
33
- payment_method_types: ['card'],
33
+ allowed_payment_method_types: ['card'],
34
34
  })
35
35
 
36
36
  return response.json(paymentIntent)
@@ -17,7 +17,7 @@ export default new Action({
17
17
  return response.error(BILLING_NOT_ENABLED, 503)
18
18
 
19
19
  const setupIntent = await user.createSetupIntent({
20
- payment_method_types: ['card', 'link', 'us_bank_account'],
20
+ allowed_payment_method_types: ['card', 'link', 'us_bank_account'],
21
21
  })
22
22
 
23
23
  return response.json(setupIntent)
@@ -1,8 +1,10 @@
1
1
  import { Action } from '@stacksjs/actions'
2
+ import { config } from '@stacksjs/config'
2
3
  import { db } from '@stacksjs/database/runtime'
3
4
  import { response } from '@stacksjs/router'
4
5
  import { randomUUIDv7 } from 'bun'
5
6
  import { readCartCookie, writeCartCookie } from '../../Storefront/CartCookie'
7
+ import { storefrontCurrency } from '../../Storefront/StorefrontMoney'
6
8
 
7
9
  const CART_COOKIE = 'stacks_cart'
8
10
  const COOKIE_OPTS = {
@@ -64,7 +66,8 @@ export default new Action({
64
66
  total_items: 0,
65
67
  subtotal: 0,
66
68
  total: 0,
67
- currency: 'USD',
69
+ // The store's currency: unit_price and the totals are minor units of it.
70
+ currency: storefrontCurrency(config.commerce?.currency),
68
71
  session_token: token,
69
72
  checkout_step: 'cart',
70
73
  })
@@ -2,6 +2,7 @@ import { Action } from '@stacksjs/actions'
2
2
  import { db } from '@stacksjs/database/runtime'
3
3
  import { response } from '@stacksjs/router'
4
4
  import { readCartCookie } from '../../Storefront/CartCookie'
5
+ import { storefrontCurrency, storefrontMoney } from '../../Storefront/StorefrontMoney'
5
6
 
6
7
  const CART_COOKIE = 'stacks_cart'
7
8
 
@@ -9,6 +10,10 @@ const CART_COOKIE = 'stacks_cart'
9
10
  * Read-only cart endpoint used by the slide-in CartDrawer to refresh
10
11
  * its contents after an add/remove. Returns the same shape the drawer
11
12
  * renders directly so the client doesn't have to do any reshaping.
13
+ *
14
+ * Amounts are integer minor units of `currency` (1999 is $19.99), and each
15
+ * comes with a `*Label` already formatted through `@stacksjs/commerce/money`,
16
+ * so the drawer never formats money itself.
12
17
  */
13
18
  export default new Action({
14
19
  name: 'GetCartAction',
@@ -39,20 +44,30 @@ export default new Action({
39
44
  .selectAll()
40
45
  .execute()
41
46
 
42
- const items = rawItems.map((row: any) => ({
43
- id: row.id,
44
- slug: row.product_sku,
45
- name: row.product_name,
46
- image: row.product_image,
47
- qty: Number(row.quantity),
48
- price: Number(row.unit_price),
49
- lineTotal: Number(row.total_price || 0),
50
- }))
47
+ const currency = storefrontCurrency(cart.currency)
48
+ const items = rawItems.map((row: any) => {
49
+ const price = Number(row.unit_price)
50
+ const lineTotal = Number(row.total_price || 0)
51
+ return {
52
+ id: row.id,
53
+ slug: row.product_sku,
54
+ name: row.product_name,
55
+ image: row.product_image,
56
+ qty: Number(row.quantity),
57
+ price,
58
+ priceLabel: storefrontMoney(price, currency),
59
+ lineTotal,
60
+ lineTotalLabel: storefrontMoney(lineTotal, currency),
61
+ }
62
+ })
63
+ const subtotal = Number(cart.subtotal || 0)
51
64
 
52
65
  return response.json({
53
66
  items,
54
67
  total_items: Number(cart.total_items || 0),
55
- subtotal: Number(cart.subtotal || 0),
68
+ subtotal,
69
+ subtotalLabel: storefrontMoney(subtotal, currency),
70
+ currency,
56
71
  })
57
72
  },
58
73
  })
@@ -4,6 +4,7 @@ import { db } from '@stacksjs/database/runtime'
4
4
  import { response } from '@stacksjs/router'
5
5
  import { sendOrderConfirmation } from '../../Mail/OrderConfirmation'
6
6
  import { readCartCookie } from '../../Storefront/CartCookie'
7
+ import { storefrontCurrency } from '../../Storefront/StorefrontMoney'
7
8
  import { totalsFor } from './_shipping'
8
9
 
9
10
  const CART_COOKIE = 'stacks_cart'
@@ -73,8 +74,10 @@ export default new Action({
73
74
  .executeTakeFirst()
74
75
  }
75
76
 
77
+ // Integer minor units of the cart's currency, like every amount here.
78
+ const currency = storefrontCurrency(cart.currency)
76
79
  const itemsSubtotal = items.reduce((s: number, i: any) => s + Number(i.total_price || 0), 0)
77
- const { subtotal, shipping, total } = totalsFor(itemsSubtotal)
80
+ const { subtotal, shipping, total } = totalsFor(itemsSubtotal, currency)
78
81
 
79
82
  const fullAddress = [
80
83
  cart.shipping_address,
@@ -88,6 +91,7 @@ export default new Action({
88
91
  status: 'paid',
89
92
  order_type: 'shipping',
90
93
  total_amount: total,
94
+ currency,
91
95
  delivery_fee: shipping,
92
96
  delivery_address: fullAddress,
93
97
  })
@@ -169,6 +173,7 @@ export default new Action({
169
173
  qty: Number(row.quantity),
170
174
  lineTotal: Number(row.total_price || 0),
171
175
  })),
176
+ currency,
172
177
  subtotal,
173
178
  shipping,
174
179
  total,
@@ -1,64 +1,90 @@
1
+ import { currencyExponent, decimalToMinor } from '@stacksjs/commerce/money'
2
+
1
3
  /**
2
- * Shipping rules used by both the cart view and the place-order action.
4
+ * Shipping rules used by both the cart and checkout views and the
5
+ * place-order action.
6
+ *
7
+ * Centralized so the price the shopper sees and the total they're charged
8
+ * can't drift: the views read their totals through
9
+ * `app/Storefront/StorefrontMoney.ts`, which calls {@link totalsFor} here.
3
10
  *
4
- * Centralized so a copy in cart.stx and another in PlaceOrderAction.ts
5
- * can't drift — when finance changes "free shipping over $40" to
6
- * "free over $50" you don't want one of the two surfaces (the price
7
- * the shopper sees vs. the total they're charged) to lag the other.
11
+ * Every amount in and out is integer minor units of the cart's currency,
12
+ * like `cart_items.total_price` and `orders.total_amount` (1999 is $19.99).
13
+ * The thresholds were plain numbers compared against those cents, so the
14
+ * default "free over 40" meant free over 40 cents and the flat rate was
15
+ * five cents.
8
16
  *
9
- * Override per-app by setting `STOREFRONT_FREE_SHIPPING_THRESHOLD` and
10
- * `STOREFRONT_FLAT_SHIPPING` in the environment, or by editing this
11
- * file in the application's own `app/Actions/Storefront/_shipping.ts`
12
- * if the rule needs more logic than two numbers (per-region, weight-
13
- * based, etc.).
17
+ * Override per-app with `STOREFRONT_FREE_SHIPPING_THRESHOLD` and
18
+ * `STOREFRONT_FLAT_SHIPPING`, written as major-unit decimals the way a
19
+ * person says them (`40`, `4.99`) and converted to the currency's minor
20
+ * unit here, or by editing this file in the application's own
21
+ * `app/Actions/Storefront/_shipping.ts` if the rule needs more logic than
22
+ * two numbers (per-region, weight-based, etc.).
14
23
  */
15
24
 
16
- const DEFAULT_FREE_SHIPPING_THRESHOLD = 40
17
- const DEFAULT_FLAT_SHIPPING = 5
25
+ const DEFAULT_FREE_SHIPPING_THRESHOLD = '40'
26
+ const DEFAULT_FLAT_SHIPPING = '5'
18
27
 
19
- export const FREE_SHIPPING_THRESHOLD = numFromEnv(
20
- 'STOREFRONT_FREE_SHIPPING_THRESHOLD',
21
- DEFAULT_FREE_SHIPPING_THRESHOLD,
22
- )
28
+ /** Subtotal, in minor units of `currency`, at or above which shipping is free. */
29
+ export function freeShippingThreshold(currency: string): number {
30
+ return minorFromEnv('STOREFRONT_FREE_SHIPPING_THRESHOLD', DEFAULT_FREE_SHIPPING_THRESHOLD, currency)
31
+ }
23
32
 
24
- export const FLAT_SHIPPING = numFromEnv(
25
- 'STOREFRONT_FLAT_SHIPPING',
26
- DEFAULT_FLAT_SHIPPING,
27
- )
33
+ /** The flat shipping fee, in minor units of `currency`. */
34
+ export function flatShipping(currency: string): number {
35
+ return minorFromEnv('STOREFRONT_FLAT_SHIPPING', DEFAULT_FLAT_SHIPPING, currency)
36
+ }
28
37
 
29
38
  /**
30
- * Compute the shipping fee for a given subtotal. Empty cart → 0,
31
- * otherwise free at/above the threshold, otherwise the flat rate.
39
+ * The shipping fee for a subtotal, both in minor units of `currency`.
40
+ * Empty cart → 0, otherwise free at/above the threshold, otherwise the
41
+ * flat rate.
32
42
  */
33
- export function shippingFor(subtotal: number): number {
43
+ export function shippingFor(subtotal: number, currency: string): number {
34
44
  if (!Number.isFinite(subtotal) || subtotal <= 0)
35
45
  return 0
36
- if (subtotal >= FREE_SHIPPING_THRESHOLD)
46
+ if (subtotal >= freeShippingThreshold(currency))
37
47
  return 0
38
- return FLAT_SHIPPING
48
+ return flatShipping(currency)
39
49
  }
40
50
 
41
51
  /**
42
- * Return `{ subtotal, shipping, total }` for a given subtotal — the
43
- * shape both the cart view and the place-order action want, with the
44
- * shipping rule applied consistently.
52
+ * `{ subtotal, shipping, total, remainingForFree }` for a subtotal, all in
53
+ * minor units of `currency`: the shape both the views and the place-order
54
+ * action want, with the shipping rule applied consistently.
45
55
  */
46
- export function totalsFor(subtotal: number): {
56
+ export function totalsFor(subtotal: number, currency: string): {
47
57
  subtotal: number
48
58
  shipping: number
49
59
  total: number
60
+ remainingForFree: number
50
61
  } {
51
- const sub = Number.isFinite(subtotal) ? subtotal : 0
52
- const shipping = shippingFor(sub)
53
- return { subtotal: sub, shipping, total: sub + shipping }
62
+ const sub = Number.isFinite(subtotal) ? Math.round(subtotal) : 0
63
+ const shipping = shippingFor(sub, currency)
64
+ return {
65
+ subtotal: sub,
66
+ shipping,
67
+ total: sub + shipping,
68
+ remainingForFree: shipping > 0 ? Math.max(0, freeShippingThreshold(currency) - sub) : 0,
69
+ }
54
70
  }
55
71
 
56
- function numFromEnv(name: string, fallback: number): number {
72
+ function minorFromEnv(name: string, fallback: string, currency: string): number {
73
+ const exponent = currencyExponent(currency)
57
74
  const raw = (typeof process !== 'undefined' && process.env)
58
75
  ? process.env[name]
59
76
  : undefined
60
- if (raw === undefined || raw === '')
61
- return fallback
62
- const parsed = Number(raw)
63
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback
77
+
78
+ if (raw !== undefined && raw.trim() !== '') {
79
+ try {
80
+ const parsed = decimalToMinor(raw, exponent)
81
+ if (parsed !== null)
82
+ return parsed
83
+ }
84
+ catch {
85
+ // An unreadable value falls back to the default, as it always has.
86
+ }
87
+ }
88
+
89
+ return decimalToMinor(fallback, exponent) ?? 0
64
90
  }
@@ -43,7 +43,7 @@ const orderData = {
43
43
  { id: 'prod_1', name: 'Widget', quantity: 2 },
44
44
  { id: 'prod_2', name: 'Gadget', quantity: 1 },
45
45
  ],
46
- totalAmount: 99.99,
46
+ totalAmount: 9999, // integer minor units: $99.99
47
47
  shippingAddress: {
48
48
  street: '123 Main St',
49
49
  city: 'Anytown',
@@ -1,9 +1,11 @@
1
+ import { formatCurrency } from '@stacksjs/commerce/money'
1
2
  import { config } from '@stacksjs/config'
2
3
  import { escapeHtml, mail, safe, template } from '@stacksjs/email'
3
4
 
4
5
  export interface OrderConfirmationItem {
5
6
  name: string
6
7
  qty: number
8
+ /** Integer minor units of the order's currency. */
7
9
  lineTotal: number
8
10
  }
9
11
 
@@ -12,6 +14,8 @@ export interface OrderConfirmationOptions {
12
14
  orderId: number | string
13
15
  customerName?: string
14
16
  items: OrderConfirmationItem[]
17
+ /** The amounts below are integer minor units of this currency (1999 is $19.99). */
18
+ currency?: string
15
19
  subtotal: number
16
20
  shipping: number
17
21
  total: number
@@ -33,6 +37,8 @@ export async function sendOrderConfirmation(options: OrderConfirmationOptions):
33
37
  const appName = config.app.name || 'PetStore'
34
38
  const fromAddress = config.email.from?.address || 'hello@stacksjs.com'
35
39
 
40
+ const currency = options.currency || 'USD'
41
+
36
42
  const { html, text } = await template('order-confirmation', {
37
43
  variables: {
38
44
  orderId: options.orderId,
@@ -41,10 +47,15 @@ export async function sendOrderConfirmation(options: OrderConfirmationOptions):
41
47
  // A template variable is a scalar - an array would be interpolated as
42
48
  // "[object Object],[object Object]". The rows are rendered here and
43
49
  // marked safe, with every value the customer supplied escaped first.
44
- itemRows: safe(renderItemRows(options.items)),
50
+ itemRows: safe(renderItemRows(options.items, currency)),
45
51
  subtotal: options.subtotal,
46
52
  shipping: options.shipping,
47
53
  total: options.total,
54
+ // Formatted here, through @stacksjs/commerce/money, so the template
55
+ // never has to know the amounts are minor units or which currency.
56
+ subtotalLabel: formatCurrency(options.subtotal, currency),
57
+ shippingLabel: formatCurrency(options.shipping, currency),
58
+ totalLabel: formatCurrency(options.total, currency),
48
59
  shippingAddress: options.shippingAddress || '',
49
60
  appName,
50
61
  },
@@ -60,15 +71,11 @@ export async function sendOrderConfirmation(options: OrderConfirmationOptions):
60
71
  })
61
72
  }
62
73
 
63
- function formatMoney(amount: number): string {
64
- return (amount / 100).toFixed(2)
65
- }
66
-
67
- function renderItemRows(items: OrderConfirmationItem[]): string {
74
+ function renderItemRows(items: OrderConfirmationItem[], currency: string): string {
68
75
  return items
69
76
  .map(item => `<tr>
70
77
  <td style="padding: 12px 0; color: #d4d4d4; font-size: 15px;">${escapeHtml(item.name)} &times; ${Number(item.qty) || 0}</td>
71
- <td style="padding: 12px 0; color: #ececec; font-size: 15px; text-align: right;">${formatMoney(Number(item.lineTotal) || 0)}</td>
78
+ <td style="padding: 12px 0; color: #ececec; font-size: 15px; text-align: right;">${escapeHtml(formatCurrency(Number(item.lineTotal) || 0, currency))}</td>
72
79
  </tr>`)
73
80
  .join('\n')
74
81
  }
@@ -267,13 +267,17 @@ export default defineModel({
267
267
  factory: faker => faker.number.float({ min: 0.5, max: 8, fractionDigits: 1 }),
268
268
  },
269
269
 
270
+ // Integer minor units of `currency`, like every money column (1999 is
271
+ // $19.99). The columns are INTEGER; a decimal here was stored as a REAL by
272
+ // SQLite and truncated by MySQL and Postgres.
270
273
  budget: {
271
274
  required: false,
272
275
  fillable: true,
273
276
  validation: {
274
- rule: schema.number().min(0),
277
+ rule: schema.number().integer().min(0),
275
278
  },
276
- factory: faker => faker.number.float({ min: 100, max: 10000, fractionDigits: 2 }),
279
+ // $100 to $10,000.
280
+ factory: faker => faker.number.int({ min: 10000, max: 1000000 }),
277
281
  },
278
282
 
279
283
  spent: {
@@ -281,9 +285,10 @@ export default defineModel({
281
285
  fillable: true,
282
286
  default: 0,
283
287
  validation: {
284
- rule: schema.number().min(0),
288
+ rule: schema.number().integer().min(0),
285
289
  },
286
- factory: faker => faker.number.float({ min: 0, max: 5000, fractionDigits: 2 }),
290
+ // Within the budget drawn above, so a seeded campaign is never over it.
291
+ factory: (faker, { budget }) => faker.number.int({ min: 0, max: Number(budget) || 0 }),
287
292
  },
288
293
 
289
294
  currency: {
@@ -65,7 +65,8 @@ export default defineModel({
65
65
  validation: {
66
66
  rule: schema.number().min(0),
67
67
  },
68
- factory: faker => faker.number.int({ min: 0, max: 1000 }),
68
+ // Integer minor units, like every commerce amount: 1999 is $19.99.
69
+ factory: faker => faker.number.int({ min: 1000, max: 20000 }),
69
70
  },
70
71
 
71
72
  taxAmount: {
@@ -75,7 +76,7 @@ export default defineModel({
75
76
  validation: {
76
77
  rule: schema.number().min(0),
77
78
  },
78
- factory: faker => faker.number.int({ min: 0, max: 200 }),
79
+ factory: faker => faker.number.int({ min: 0, max: 1500 }),
79
80
  },
80
81
 
81
82
  discountAmount: {
@@ -85,7 +86,7 @@ export default defineModel({
85
86
  validation: {
86
87
  rule: schema.number().min(0),
87
88
  },
88
- factory: faker => faker.number.int({ min: 0, max: 100 }),
89
+ factory: faker => faker.number.int({ min: 0, max: 1000 }),
89
90
  },
90
91
 
91
92
  total: {
@@ -95,7 +96,7 @@ export default defineModel({
95
96
  validation: {
96
97
  rule: schema.number().min(0),
97
98
  },
98
- factory: faker => faker.number.int({ min: 0, max: 1200 }),
99
+ factory: faker => faker.number.int({ min: 1000, max: 21500 }),
99
100
  },
100
101
 
101
102
  expiresAt: {