@stacksjs/defaults 0.75.37 → 0.75.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/ai/skills/stacks-ai/SKILL.md +24 -1
  2. package/ai/skills/stacks-commerce/SKILL.md +1 -1
  3. package/ai/skills/stacks-config/SKILL.md +1 -1
  4. package/ai/skills/stacks-database/SKILL.md +1 -1
  5. package/ai/skills/stacks-dependencies/SKILL.md +35 -3
  6. package/ai/skills/stacks-env/SKILL.md +4 -1
  7. package/ai/skills/stacks-migrations/SKILL.md +2 -2
  8. package/ai/skills/stacks-models/SKILL.md +2 -2
  9. package/app/Actions/Commerce/Product/ProductStoreAction.ts +9 -1
  10. package/app/Actions/Commerce/Product/ProductUpdateAction.ts +4 -1
  11. package/app/Actions/Commerce/commerce-action.test.ts +30 -1
  12. package/app/Actions/Commerce/commerce-action.ts +44 -0
  13. package/app/Actions/Dashboard/Analytics/sales-analytics.test.ts +19 -0
  14. package/app/Actions/Dashboard/Analytics/sales-analytics.ts +2 -1
  15. package/app/Actions/Dashboard/Ci/failure-notifier.ts +11 -7
  16. package/app/Actions/Dashboard/Ci/runner-pressure-monitor.ts +13 -7
  17. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +3 -2
  18. package/app/Actions/Dashboard/Commerce/commerce-dashboard.test.ts +29 -9
  19. package/app/Actions/Dashboard/Commerce/commerce-dashboard.ts +5 -12
  20. package/app/Actions/Dashboard/Commerce/commerce-delivery.test.ts +15 -0
  21. package/app/Actions/Dashboard/Commerce/commerce-delivery.ts +8 -10
  22. package/app/Actions/Dashboard/Commerce/commerce-pos.test.ts +26 -6
  23. package/app/Actions/Dashboard/Commerce/commerce-pos.ts +8 -3
  24. package/app/Actions/Dashboard/Commerce/commerce-product-records.ts +69 -0
  25. package/app/Actions/Dashboard/Kanban/BoardDestroyAction.ts +7 -4
  26. package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +10 -7
  27. package/app/Actions/Dashboard/Kanban/CardAssigneesSyncAction.ts +7 -4
  28. package/app/Actions/Dashboard/Kanban/CardLabelsSyncAction.ts +9 -5
  29. package/app/Actions/Dashboard/Kanban/CardShowAction.ts +8 -5
  30. package/app/Actions/Dashboard/Kanban/CardStoreAction.ts +3 -2
  31. package/app/Actions/Dashboard/Kanban/CardsReorderAction.ts +7 -4
  32. package/app/Actions/Dashboard/Kanban/ColumnDestroyAction.ts +7 -4
  33. package/app/Actions/Dashboard/Kanban/ColumnStoreAction.ts +3 -2
  34. package/app/Actions/Dashboard/Kanban/ColumnsReorderAction.ts +7 -3
  35. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.test.ts +39 -0
  36. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.ts +9 -3
  37. package/app/Actions/Dashboard/Marketing/campaign-records.ts +17 -3
  38. package/app/Actions/Storefront/AddToCartAction.ts +4 -1
  39. package/app/Actions/Storefront/GetCartAction.ts +25 -10
  40. package/app/Actions/Storefront/PlaceOrderAction.ts +6 -1
  41. package/app/Actions/Storefront/_shipping.ts +63 -37
  42. package/app/Broadcasts/OrderShipped.ts +1 -1
  43. package/app/Mail/OrderConfirmation.ts +14 -7
  44. package/app/Models/Campaign.ts +9 -4
  45. package/app/Models/commerce/Cart.ts +5 -4
  46. package/app/Models/commerce/CartItem.ts +6 -4
  47. package/app/Models/commerce/Coupon.ts +9 -3
  48. package/app/Models/commerce/Customer.ts +2 -1
  49. package/app/Models/commerce/GiftCard.ts +5 -2
  50. package/app/Models/commerce/Order.ts +6 -5
  51. package/app/Models/commerce/OrderItem.ts +2 -1
  52. package/app/Models/commerce/ProductVariant.ts +71 -4
  53. package/app/Models/commerce/Transaction.ts +3 -2
  54. package/app/Storefront/StorefrontMoney.ts +54 -0
  55. package/ide/vscode/package.json +1 -1
  56. package/package.json +2 -2
  57. package/resources/components/Dashboard/Analytics/CampaignAnalytics.stx +8 -8
  58. package/resources/components/Dashboard/Analytics/SalesAnalytics.stx +4 -3
  59. package/resources/components/Dashboard/Billing/BillingPlanSummary.stx +4 -1
  60. package/resources/components/Dashboard/Commerce/CommerceCouponDetailsDialog.stx +4 -1
  61. package/resources/components/Dashboard/Commerce/CommerceCouponDialog.stx +34 -10
  62. package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +3 -1
  63. package/resources/components/Dashboard/Commerce/CommerceCouponsTable.stx +4 -1
  64. package/resources/components/Dashboard/Commerce/CommerceCustomerDetailsDialog.stx +2 -6
  65. package/resources/components/Dashboard/Commerce/CommerceCustomerDialog.stx +6 -6
  66. package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +3 -11
  67. package/resources/components/Dashboard/Commerce/CommerceCustomersTable.stx +2 -6
  68. package/resources/components/Dashboard/Commerce/CommerceGiftCardBreakdown.stx +2 -6
  69. package/resources/components/Dashboard/Commerce/CommerceGiftCardDetailsDialog.stx +2 -6
  70. package/resources/components/Dashboard/Commerce/CommerceGiftCardDialog.stx +20 -17
  71. package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +3 -2
  72. package/resources/components/Dashboard/Commerce/CommerceGiftCardsTable.stx +2 -6
  73. package/resources/components/Dashboard/Commerce/CommerceOrderDetailsDialog.stx +2 -6
  74. package/resources/components/Dashboard/Commerce/CommerceOrderDialog.stx +28 -20
  75. package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +7 -15
  76. package/resources/components/Dashboard/Commerce/CommerceOrdersTable.stx +2 -6
  77. package/resources/components/Dashboard/Commerce/CommercePaymentsBreakdown.stx +2 -6
  78. package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +2 -6
  79. package/resources/components/Dashboard/Commerce/CommercePosCart.stx +2 -6
  80. package/resources/components/Dashboard/Commerce/CommercePosCatalog.stx +2 -6
  81. package/resources/components/Dashboard/Commerce/CommercePosCheckoutDialog.stx +2 -6
  82. package/resources/components/Dashboard/Commerce/CommercePosDashboard.stx +5 -7
  83. package/resources/components/Dashboard/Commerce/CommercePosReceiptDialog.stx +2 -8
  84. package/resources/components/Dashboard/Commerce/CommerceProductDetailDashboard.stx +3 -20
  85. package/resources/components/Dashboard/Commerce/CommerceProductDialog.stx +27 -19
  86. package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +4 -15
  87. package/resources/components/Dashboard/Commerce/CommerceProductsTable.stx +2 -6
  88. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodDialog.stx +12 -10
  89. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsTable.stx +3 -4
  90. package/resources/components/Dashboard/Commerce/Delivery/ShippingRateDialog.stx +7 -5
  91. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +3 -2
  92. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesTable.stx +3 -4
  93. package/resources/components/Dashboard/Commerce/PaymentDetailsDialog.stx +2 -6
  94. package/resources/components/Dashboard/Commerce/PaymentRefundDialog.stx +12 -10
  95. package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +3 -10
  96. package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +3 -8
  97. package/resources/components/Dashboard/Marketing/CampaignDialog.stx +26 -8
  98. package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +4 -2
  99. package/resources/components/Dashboard/Marketing/RecoveryCampaignDialog.stx +25 -13
  100. package/resources/components/Dashboard/Transaction/index.stx +6 -3
  101. package/resources/components/Storefront/CartDrawer.stx +11 -7
  102. package/resources/views/cart.stx +19 -23
  103. package/resources/views/checkout/contact.stx +12 -14
  104. package/resources/views/checkout/payment.stx +13 -13
  105. package/resources/views/checkout/shipping.stx +13 -13
  106. package/resources/views/cms/blocks/form.stx +50 -1
  107. package/resources/views/orders/[id].stx +13 -4
  108. package/vcs/github/workflows/buddy-bot.yml +5 -5
  109. package/vcs/github/workflows/ci.yml +3 -3
  110. package/vcs/github/workflows/release.yml +1 -1
@@ -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
+ }
@@ -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: {
@@ -52,7 +52,8 @@ export default defineModel({
52
52
  validation: {
53
53
  rule: schema.number().required().min(0),
54
54
  },
55
- factory: faker => faker.number.int({ min: 1, max: 1000 }),
55
+ // Integer minor units, in the same range as Product.price.
56
+ factory: faker => faker.number.int({ min: 100, max: 10000 }),
56
57
  },
57
58
 
58
59
  totalPrice: {
@@ -61,7 +62,7 @@ export default defineModel({
61
62
  validation: {
62
63
  rule: schema.number().required().min(0),
63
64
  },
64
- factory: faker => faker.number.int({ min: 1, max: 1000 }),
65
+ factory: faker => faker.number.int({ min: 100, max: 50000 }),
65
66
  },
66
67
 
67
68
  taxRate: {
@@ -79,7 +80,7 @@ export default defineModel({
79
80
  validation: {
80
81
  rule: schema.number().min(0),
81
82
  },
82
- factory: faker => faker.number.int({ min: 0, max: 50 }),
83
+ factory: faker => faker.number.int({ min: 0, max: 1000 }),
83
84
  },
84
85
 
85
86
  discountPercentage: {
@@ -97,7 +98,8 @@ export default defineModel({
97
98
  validation: {
98
99
  rule: schema.number().min(0),
99
100
  },
100
- factory: faker => faker.number.int({ min: 0, max: 25 }),
101
+ // At most $1.00, so it never exceeds the smallest unit price above.
102
+ factory: faker => faker.number.int({ min: 0, max: 100 }),
101
103
  },
102
104
 
103
105
  productName: {
@@ -106,7 +106,11 @@ export default defineModel({
106
106
  min: 'Discount value must be greater than 0.01',
107
107
  },
108
108
  },
109
- factory: faker => faker.number.int({ min: 5, max: 50 }),
109
+ // A percentage stays a plain number; a fixed amount is integer minor units
110
+ // ($5 to $50). discountType is declared above, so the seeder has drawn it.
111
+ factory: (faker, { discountType }) => discountType === 'fixed_amount'
112
+ ? faker.helpers.arrayElement([500, 1000, 1500, 2000, 2500, 5000])
113
+ : faker.number.int({ min: 5, max: 50 }),
110
114
  },
111
115
 
112
116
  minOrderAmount: {
@@ -118,7 +122,8 @@ export default defineModel({
118
122
  min: 'Min order amount must be greater than 0',
119
123
  },
120
124
  },
121
- factory: faker => faker.number.int({ min: 0, max: 50 }),
125
+ // Integer minor units: no minimum, or $25 to $100.
126
+ factory: faker => faker.helpers.arrayElement([0, 2500, 5000, 7500, 10000]),
122
127
  },
123
128
 
124
129
  maxDiscountAmount: {
@@ -130,7 +135,8 @@ export default defineModel({
130
135
  min: 'Max discount amount must be greater than 0',
131
136
  },
132
137
  },
133
- factory: faker => faker.number.int({ min: 5, max: 100 }),
138
+ // Integer minor units: $10 to $100.
139
+ factory: faker => faker.number.int({ min: 1000, max: 10000 }),
134
140
  },
135
141
 
136
142
  freeProductId: {
@@ -82,7 +82,8 @@ export default defineModel({
82
82
  min: 'Total spent cannot be negative',
83
83
  },
84
84
  },
85
- factory: faker => faker.number.int({ min: 0, max: 2000 }),
85
+ // Integer minor units: $0 to $5,000.
86
+ factory: faker => faker.number.int({ min: 0, max: 500000 }),
86
87
  },
87
88
 
88
89
  lastOrder: {
@@ -57,7 +57,8 @@ export default defineModel({
57
57
  validation: {
58
58
  rule: schema.number().required().min(1),
59
59
  },
60
- factory: faker => faker.number.int({ min: 100, max: 2000 }),
60
+ // Integer minor units, in the denominations cards are actually sold in: $25 to $250.
61
+ factory: faker => faker.helpers.arrayElement([2500, 5000, 10000, 15000, 20000, 25000]),
61
62
  },
62
63
 
63
64
  currentBalance: {
@@ -66,7 +67,9 @@ export default defineModel({
66
67
  validation: {
67
68
  rule: schema.number().required().min(0),
68
69
  },
69
- factory: () => 1,
70
+ // Drawn below the smallest initial balance above, so a seeded card never
71
+ // holds more than it was issued with.
72
+ factory: faker => faker.number.int({ min: 0, max: 2500 }),
70
73
  },
71
74
 
72
75
  currency: {
@@ -64,7 +64,8 @@ export default defineModel({
64
64
  validation: {
65
65
  rule: schema.number().required().min(0),
66
66
  },
67
- factory: faker => faker.number.int({ min: 100, max: 2000 }),
67
+ // Integer minor units, like every commerce amount: 1999 is $19.99.
68
+ factory: faker => faker.number.int({ min: 1000, max: 50000 }),
68
69
  },
69
70
 
70
71
  currency: {
@@ -84,7 +85,7 @@ export default defineModel({
84
85
  validation: {
85
86
  rule: schema.number().min(0),
86
87
  },
87
- factory: faker => faker.number.int({ min: 10, max: 200 }),
88
+ factory: faker => faker.number.int({ min: 0, max: 4000 }),
88
89
  },
89
90
 
90
91
  discountAmount: {
@@ -94,7 +95,7 @@ export default defineModel({
94
95
  validation: {
95
96
  rule: schema.number().min(0),
96
97
  },
97
- factory: faker => faker.number.int({ min: 0, max: 150 }),
98
+ factory: faker => faker.number.int({ min: 0, max: 2000 }),
98
99
  },
99
100
 
100
101
  deliveryFee: {
@@ -104,7 +105,7 @@ export default defineModel({
104
105
  validation: {
105
106
  rule: schema.number().min(0),
106
107
  },
107
- factory: faker => faker.number.int({ min: 0, max: 100 }),
108
+ factory: faker => faker.helpers.arrayElement([0, 299, 499, 799]),
108
109
  },
109
110
 
110
111
  tipAmount: {
@@ -114,7 +115,7 @@ export default defineModel({
114
115
  validation: {
115
116
  rule: schema.number().min(0),
116
117
  },
117
- factory: faker => faker.number.int({ min: 0, max: 200 }),
118
+ factory: faker => faker.number.int({ min: 0, max: 1500 }),
118
119
  },
119
120
 
120
121
  orderType: {
@@ -36,7 +36,8 @@ export default defineModel({
36
36
  min: 'Price cannot be negative',
37
37
  },
38
38
  },
39
- factory: faker => faker.number.int({ min: 5, max: 50 }),
39
+ // Integer minor units, in the same range as Product.price.
40
+ factory: faker => faker.number.int({ min: 100, max: 10000 }),
40
41
  },
41
42
 
42
43
  special_instructions: {