@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
|
@@ -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 =
|
|
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 ?
|
|
242
|
-
spent:
|
|
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:
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
10
|
-
* `STOREFRONT_FLAT_SHIPPING
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
*
|
|
31
|
-
* otherwise free at/above the threshold, otherwise the
|
|
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 >=
|
|
46
|
+
if (subtotal >= freeShippingThreshold(currency))
|
|
37
47
|
return 0
|
|
38
|
-
return
|
|
48
|
+
return flatShipping(currency)
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
/**
|
|
42
|
-
*
|
|
43
|
-
* shape both 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 {
|
|
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
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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)} × ${Number(item.qty) || 0}</td>
|
|
71
|
-
<td style="padding: 12px 0; color: #ececec; font-size: 15px; text-align: right;">${
|
|
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
|
}
|
package/app/Models/Campaign.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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: {
|