@stacksjs/defaults 0.75.38 → 0.75.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ai/skills/stacks-ai/SKILL.md +24 -1
- package/ai/skills/stacks-commerce/SKILL.md +1 -1
- package/ai/skills/stacks-config/SKILL.md +1 -1
- package/ai/skills/stacks-database/SKILL.md +1 -1
- package/ai/skills/stacks-dependencies/SKILL.md +35 -3
- package/ai/skills/stacks-desktop/SKILL.md +12 -0
- package/ai/skills/stacks-env/SKILL.md +4 -1
- package/ai/skills/stacks-migrations/SKILL.md +2 -2
- package/ai/skills/stacks-models/SKILL.md +2 -2
- package/app/Actions/Commerce/Product/ProductStoreAction.ts +9 -1
- package/app/Actions/Commerce/Product/ProductUpdateAction.ts +4 -1
- package/app/Actions/Commerce/commerce-action.test.ts +30 -1
- package/app/Actions/Commerce/commerce-action.ts +44 -0
- package/app/Actions/Dashboard/Analytics/sales-analytics.test.ts +19 -0
- package/app/Actions/Dashboard/Analytics/sales-analytics.ts +2 -1
- package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +3 -2
- package/app/Actions/Dashboard/Commerce/commerce-dashboard.test.ts +29 -9
- package/app/Actions/Dashboard/Commerce/commerce-dashboard.ts +5 -12
- package/app/Actions/Dashboard/Commerce/commerce-delivery.test.ts +15 -0
- package/app/Actions/Dashboard/Commerce/commerce-delivery.ts +8 -10
- package/app/Actions/Dashboard/Commerce/commerce-pos.test.ts +26 -6
- package/app/Actions/Dashboard/Commerce/commerce-pos.ts +8 -3
- package/app/Actions/Dashboard/Commerce/commerce-product-records.ts +69 -0
- package/app/Actions/Dashboard/Marketing/abandoned-cart-records.test.ts +39 -0
- package/app/Actions/Dashboard/Marketing/abandoned-cart-records.ts +9 -3
- package/app/Actions/Dashboard/Marketing/campaign-records.ts +17 -3
- package/app/Actions/Payment/CreatePaymentIntentAction.ts +1 -1
- package/app/Actions/Payment/CreateSetupIntentAction.ts +1 -1
- package/app/Actions/Storefront/AddToCartAction.ts +4 -1
- package/app/Actions/Storefront/GetCartAction.ts +25 -10
- package/app/Actions/Storefront/PlaceOrderAction.ts +6 -1
- package/app/Actions/Storefront/_shipping.ts +63 -37
- package/app/Broadcasts/OrderShipped.ts +1 -1
- package/app/Mail/OrderConfirmation.ts +14 -7
- package/app/Models/Campaign.ts +9 -4
- package/app/Models/commerce/Cart.ts +5 -4
- package/app/Models/commerce/CartItem.ts +6 -4
- package/app/Models/commerce/Coupon.ts +9 -3
- package/app/Models/commerce/Customer.ts +2 -1
- package/app/Models/commerce/GiftCard.ts +5 -2
- package/app/Models/commerce/Order.ts +6 -5
- package/app/Models/commerce/OrderItem.ts +2 -1
- package/app/Models/commerce/ProductVariant.ts +71 -4
- package/app/Models/commerce/Transaction.ts +3 -2
- package/app/Storefront/StorefrontMoney.ts +54 -0
- package/ide/vscode/.vscode/settings.json +1 -1
- package/ide/vscode/README.md +65 -20
- package/ide/vscode/package.json +102 -12
- package/ide/vscode/src/extension.ts +59 -0
- package/ide/vscode/src/project.ts +440 -0
- package/ide/vscode/src/stacks.ts +475 -0
- package/ide/vscode/src/vscode.d.ts +181 -0
- package/ide/vscode/tests/extension.test.ts +420 -0
- package/ide/vscode/tests/project.test.ts +268 -0
- package/ide/vscode/tsconfig.json +19 -0
- package/package.json +4 -4
- package/resources/components/Dashboard/Analytics/CampaignAnalytics.stx +8 -8
- package/resources/components/Dashboard/Analytics/SalesAnalytics.stx +4 -3
- package/resources/components/Dashboard/Billing/BillingPlanSummary.stx +4 -1
- package/resources/components/Dashboard/Commerce/CommerceCouponDetailsDialog.stx +4 -1
- package/resources/components/Dashboard/Commerce/CommerceCouponDialog.stx +34 -10
- package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +3 -1
- package/resources/components/Dashboard/Commerce/CommerceCouponsTable.stx +4 -1
- package/resources/components/Dashboard/Commerce/CommerceCustomerDetailsDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceCustomerDialog.stx +6 -6
- package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +3 -11
- package/resources/components/Dashboard/Commerce/CommerceCustomersTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceGiftCardBreakdown.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceGiftCardDetailsDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceGiftCardDialog.stx +20 -17
- package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +3 -2
- package/resources/components/Dashboard/Commerce/CommerceGiftCardsTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceOrderDetailsDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceOrderDialog.stx +28 -20
- package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +7 -15
- package/resources/components/Dashboard/Commerce/CommerceOrdersTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePaymentsBreakdown.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePosCart.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePosCatalog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePosCheckoutDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePosDashboard.stx +5 -7
- package/resources/components/Dashboard/Commerce/CommercePosReceiptDialog.stx +2 -8
- package/resources/components/Dashboard/Commerce/CommerceProductDetailDashboard.stx +3 -20
- package/resources/components/Dashboard/Commerce/CommerceProductDialog.stx +27 -19
- package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +4 -15
- package/resources/components/Dashboard/Commerce/CommerceProductsTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodDialog.stx +12 -10
- package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsTable.stx +3 -4
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRateDialog.stx +7 -5
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +3 -2
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesTable.stx +3 -4
- package/resources/components/Dashboard/Commerce/PaymentDetailsDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/PaymentRefundDialog.stx +12 -10
- package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +3 -10
- package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +3 -8
- package/resources/components/Dashboard/Marketing/CampaignDialog.stx +26 -8
- package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +4 -2
- package/resources/components/Dashboard/Marketing/RecoveryCampaignDialog.stx +25 -13
- package/resources/components/Dashboard/Transaction/index.stx +6 -3
- package/resources/components/Storefront/CartDrawer.stx +11 -7
- package/resources/views/cart.stx +19 -23
- package/resources/views/checkout/contact.stx +12 -14
- package/resources/views/checkout/payment.stx +13 -13
- package/resources/views/checkout/shipping.stx +13 -13
- package/resources/views/cms/blocks/form.stx +50 -1
- package/resources/views/orders/[id].stx +13 -4
- package/vcs/github/workflows/buddy-bot.yml +5 -5
- package/vcs/github/workflows/ci.yml +3 -3
- package/vcs/github/workflows/release.yml +1 -1
|
@@ -1,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
|
-
|
|
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 =
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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: {
|