@stacksjs/commerce 0.70.53 → 0.70.55
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/package.json +3 -2
- package/src/carts/destroy.ts +54 -0
- package/src/carts/fetch.ts +20 -0
- package/src/carts/index.ts +13 -0
- package/src/carts/store.ts +75 -0
- package/src/carts/update.ts +78 -0
- package/src/coupons/destroy.ts +49 -0
- package/src/coupons/fetch.ts +510 -0
- package/src/coupons/index.ts +30 -0
- package/src/coupons/store.ts +46 -0
- package/src/coupons/update.ts +147 -0
- package/src/customers/destroy.ts +62 -0
- package/src/customers/fetch.ts +20 -0
- package/src/customers/index.ts +17 -0
- package/src/customers/store.ts +45 -0
- package/src/customers/update.ts +44 -0
- package/src/devices/destroy.ts +54 -0
- package/src/devices/export.ts +98 -0
- package/src/devices/fetch.ts +125 -0
- package/src/devices/index.ts +31 -0
- package/src/devices/store.ts +82 -0
- package/src/devices/update.ts +125 -0
- package/src/errors/destroy.ts +54 -0
- package/src/errors/fetch.ts +197 -0
- package/src/errors/index.ts +28 -0
- package/src/errors/update.ts +116 -0
- package/src/gift-cards/destroy.ts +87 -0
- package/src/gift-cards/fetch.ts +431 -0
- package/src/gift-cards/index.ts +26 -0
- package/src/gift-cards/store.ts +56 -0
- package/src/gift-cards/update.ts +102 -0
- package/src/index.ts +78 -0
- package/src/orders/destroy.ts +111 -0
- package/src/orders/events.ts +121 -0
- package/src/orders/export.ts +158 -0
- package/src/orders/fetch.ts +379 -0
- package/src/orders/guards.ts +261 -0
- package/src/orders/index.ts +91 -0
- package/src/orders/place-order.ts +274 -0
- package/src/orders/store.ts +51 -0
- package/src/orders/totals.ts +190 -0
- package/src/orders/update.ts +164 -0
- package/src/orders/webhook.ts +312 -0
- package/src/payments/destroy.ts +56 -0
- package/src/payments/fetch.ts +288 -0
- package/src/payments/index.ts +16 -0
- package/src/payments/store.ts +63 -0
- package/src/payments/update.ts +52 -0
- package/src/product.ts +0 -0
- package/src/products/categories/destroy.ts +103 -0
- package/src/products/categories/fetch.ts +285 -0
- package/src/products/categories/index.ts +36 -0
- package/src/products/categories/store.ts +87 -0
- package/src/products/categories/update.ts +251 -0
- package/src/products/index.ts +35 -0
- package/src/products/items/destroy.ts +54 -0
- package/src/products/items/fetch.ts +282 -0
- package/src/products/items/index.ts +26 -0
- package/src/products/items/store.ts +84 -0
- package/src/products/items/update.ts +324 -0
- package/src/products/manufacturers/destroy.ts +56 -0
- package/src/products/manufacturers/fetch.ts +117 -0
- package/src/products/manufacturers/index.ts +24 -0
- package/src/products/manufacturers/store.ts +82 -0
- package/src/products/manufacturers/update.ts +113 -0
- package/src/products/reviews/destroy.ts +56 -0
- package/src/products/reviews/fetch.ts +63 -0
- package/src/products/reviews/index.ts +25 -0
- package/src/products/reviews/store.ts +46 -0
- package/src/products/reviews/update.ts +82 -0
- package/src/products/units/destroy.ts +56 -0
- package/src/products/units/fetch.ts +20 -0
- package/src/products/units/index.ts +13 -0
- package/src/products/units/store.ts +162 -0
- package/src/products/units/update.ts +159 -0
- package/src/products/variants/destroy.ts +56 -0
- package/src/products/variants/fetch.ts +20 -0
- package/src/products/variants/index.ts +21 -0
- package/src/products/variants/store.ts +136 -0
- package/src/products/variants/update.ts +104 -0
- package/src/receipts/destroy.ts +54 -0
- package/src/receipts/export.ts +0 -0
- package/src/receipts/fetch.ts +222 -0
- package/src/receipts/index.ts +24 -0
- package/src/receipts/printer.ts +69 -0
- package/src/receipts/store.ts +69 -0
- package/src/receipts/update.ts +124 -0
- package/src/shippings/delivery-routes/destroy.ts +54 -0
- package/src/shippings/delivery-routes/fetch.ts +42 -0
- package/src/shippings/delivery-routes/index.ts +26 -0
- package/src/shippings/delivery-routes/store.ts +69 -0
- package/src/shippings/delivery-routes/update.ts +120 -0
- package/src/shippings/digital-deliveries/destroy.ts +111 -0
- package/src/shippings/digital-deliveries/fetch.ts +20 -0
- package/src/shippings/digital-deliveries/index.ts +26 -0
- package/src/shippings/digital-deliveries/store.ts +76 -0
- package/src/shippings/digital-deliveries/update.ts +129 -0
- package/src/shippings/drivers/destroy.ts +78 -0
- package/src/shippings/drivers/fetch.ts +20 -0
- package/src/shippings/drivers/index.ts +24 -0
- package/src/shippings/drivers/store.ts +69 -0
- package/src/shippings/drivers/update.ts +117 -0
- package/src/shippings/index.ts +39 -0
- package/src/shippings/license-keys/destroy.ts +111 -0
- package/src/shippings/license-keys/fetch.ts +20 -0
- package/src/shippings/license-keys/index.ts +26 -0
- package/src/shippings/license-keys/store.ts +77 -0
- package/src/shippings/license-keys/update.ts +117 -0
- package/src/shippings/shipping-methods/destroy.ts +111 -0
- package/src/shippings/shipping-methods/fetch.ts +62 -0
- package/src/shippings/shipping-methods/index.ts +28 -0
- package/src/shippings/shipping-methods/store.ts +123 -0
- package/src/shippings/shipping-methods/update.ts +120 -0
- package/src/shippings/shipping-rates/destroy.ts +102 -0
- package/src/shippings/shipping-rates/fetch.ts +320 -0
- package/src/shippings/shipping-rates/index.ts +36 -0
- package/src/shippings/shipping-rates/store.ts +102 -0
- package/src/shippings/shipping-rates/update.ts +139 -0
- package/src/shippings/shipping-zones/destroy.ts +111 -0
- package/src/shippings/shipping-zones/fetch.ts +96 -0
- package/src/shippings/shipping-zones/index.ts +26 -0
- package/src/shippings/shipping-zones/store.ts +74 -0
- package/src/shippings/shipping-zones/update.ts +156 -0
- package/src/tax/destroy.ts +54 -0
- package/src/tax/fetch.ts +20 -0
- package/src/tax/index.ts +24 -0
- package/src/tax/store.ts +69 -0
- package/src/tax/update.ts +119 -0
- package/src/tests/coupons.test.ts +90 -0
- package/src/tests/customers.test.ts +39 -0
- package/src/tests/device.test.ts +111 -0
- package/src/tests/digitals.test.ts +31 -0
- package/src/tests/drivers.test.ts +25 -0
- package/src/tests/gift-cards.test.ts +36 -0
- package/src/tests/license.test.ts +31 -0
- package/src/tests/manufacturers.test.ts +80 -0
- package/src/tests/orders.test.ts +25 -0
- package/src/tests/payments.test.ts +75 -0
- package/src/tests/rates.test.ts +367 -0
- package/src/tests/receipts.test.ts +287 -0
- package/src/tests/restaurant.test.ts +522 -0
- package/src/tests/reviews.test.ts +165 -0
- package/src/tests/routes.test.ts +51 -0
- package/src/tests/setup.ts +149 -0
- package/src/tests/shipping.test.ts +88 -0
- package/src/tests/tax.test.ts +25 -0
- package/src/tests/units.test.ts +110 -0
- package/src/tests/variants.test.ts +133 -0
- package/src/tests/waitlist.test.ts +303 -0
- package/src/tests/zones.test.ts +86 -0
- package/src/types.ts +312 -0
- package/src/utils/typed-stats.ts +67 -0
- package/src/waitlists/index.ts +19 -0
- package/src/waitlists/products/destroy.ts +65 -0
- package/src/waitlists/products/export.ts +113 -0
- package/src/waitlists/products/fetch.ts +362 -0
- package/src/waitlists/products/index.ts +33 -0
- package/src/waitlists/products/store.ts +112 -0
- package/src/waitlists/products/update.ts +125 -0
- package/src/waitlists/restaurant/destroy.ts +65 -0
- package/src/waitlists/restaurant/export.ts +107 -0
- package/src/waitlists/restaurant/fetch.ts +538 -0
- package/src/waitlists/restaurant/index.ts +43 -0
- package/src/waitlists/restaurant/store.ts +109 -0
- package/src/waitlists/restaurant/update.ts +198 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import type { GiftCardStats } from '../types'
|
|
2
|
+
import type { StacksExpressionBuilder } from '@stacksjs/database'
|
|
3
|
+
import { db, sql } from '@stacksjs/database'
|
|
4
|
+
import { formatDate, toTimestamp } from '@stacksjs/orm'
|
|
5
|
+
import { aggregateStats } from '../utils/typed-stats'
|
|
6
|
+
type GiftCardJsonResponse = ModelRow<typeof GiftCard>
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Fetch all gift cards from the database
|
|
10
|
+
*/
|
|
11
|
+
export async function fetchAll(): Promise<GiftCardJsonResponse[]> {
|
|
12
|
+
const giftCards = await db
|
|
13
|
+
.selectFrom('gift_cards')
|
|
14
|
+
.selectAll()
|
|
15
|
+
.execute()
|
|
16
|
+
|
|
17
|
+
return giftCards as GiftCardJsonResponse[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Fetch a gift card by ID
|
|
22
|
+
*/
|
|
23
|
+
export async function fetchById(id: number): Promise<GiftCardJsonResponse | undefined> {
|
|
24
|
+
return await db
|
|
25
|
+
.selectFrom('gift_cards')
|
|
26
|
+
.where('id', '=', id)
|
|
27
|
+
.selectAll()
|
|
28
|
+
.executeTakeFirst() as GiftCardJsonResponse | undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Fetch a gift card by code
|
|
33
|
+
*/
|
|
34
|
+
export async function fetchByCode(code: string): Promise<GiftCardJsonResponse | undefined> {
|
|
35
|
+
return await db
|
|
36
|
+
.selectFrom('gift_cards')
|
|
37
|
+
.where('code', '=', code)
|
|
38
|
+
.selectAll()
|
|
39
|
+
.executeTakeFirst() as GiftCardJsonResponse | undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Fetch active gift cards (is_active = true and not expired)
|
|
44
|
+
*/
|
|
45
|
+
export async function fetchActive(): Promise<GiftCardJsonResponse[]> {
|
|
46
|
+
const giftCards = await db
|
|
47
|
+
.selectFrom('gift_cards')
|
|
48
|
+
.where('is_active', '=', true)
|
|
49
|
+
.selectAll()
|
|
50
|
+
.execute()
|
|
51
|
+
|
|
52
|
+
return giftCards as GiftCardJsonResponse[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get gift card statistics
|
|
57
|
+
*/
|
|
58
|
+
export async function fetchStats(): Promise<GiftCardStats> {
|
|
59
|
+
// Total gift cards
|
|
60
|
+
const { count: totalCount } = await aggregateStats('gift_cards', { count: { kind: 'count' } })
|
|
61
|
+
|
|
62
|
+
// Active gift cards
|
|
63
|
+
const currentDate = toTimestamp(new Date())
|
|
64
|
+
|
|
65
|
+
// bun-query-builder doesn't expose a typed `eb.or([...])` helper, so the
|
|
66
|
+
// OR-with-NULL filter still uses the untyped expression-builder form.
|
|
67
|
+
// The boundary `as any` is localized; the result row uses the typed helper.
|
|
68
|
+
const activeGiftCards = await db
|
|
69
|
+
.selectFrom('gift_cards')
|
|
70
|
+
.where('is_active', '=', true)
|
|
71
|
+
.where('status', '=', 'ACTIVE')
|
|
72
|
+
.where(((eb: StacksExpressionBuilder) => eb.or([
|
|
73
|
+
eb('expiry_date', '>=', currentDate),
|
|
74
|
+
eb('expiry_date', 'is', null),
|
|
75
|
+
])) as any)
|
|
76
|
+
.select(((eb: StacksExpressionBuilder) => eb.fn.count('id').as('count')) as any)
|
|
77
|
+
.executeTakeFirst() as { count: number } | undefined
|
|
78
|
+
|
|
79
|
+
// Gift cards by status
|
|
80
|
+
const giftCardsByStatus = await db
|
|
81
|
+
.selectFrom('gift_cards')
|
|
82
|
+
.select(['status', (eb: StacksExpressionBuilder) => eb.fn.count('id').as('count')] as any)
|
|
83
|
+
.groupBy('status')
|
|
84
|
+
.execute() as { status: string, count: number }[]
|
|
85
|
+
|
|
86
|
+
// Balance distribution: each bucket needs a custom raw fraction filter, so
|
|
87
|
+
// these stay as direct queries — but we use the typed helper for the
|
|
88
|
+
// `count(id)` expression to avoid the `as any` chain on the result.
|
|
89
|
+
const { count: lowBalanceCount } = await aggregateStats('gift_cards', { count: { kind: 'count' } }, qb =>
|
|
90
|
+
qb.where(((eb: StacksExpressionBuilder) => sql`${eb.ref('current_balance')} / ${eb.ref('initial_balance')} < 0.25`) as any))
|
|
91
|
+
|
|
92
|
+
const { count: mediumBalanceCount } = await aggregateStats('gift_cards', { count: { kind: 'count' } }, qb =>
|
|
93
|
+
qb.where(((eb: StacksExpressionBuilder) => sql`${eb.ref('current_balance')} / ${eb.ref('initial_balance')} >= 0.25 AND ${eb.ref('current_balance')} / ${eb.ref('initial_balance')} <= 0.75`) as any))
|
|
94
|
+
|
|
95
|
+
const { count: highBalanceCount } = await aggregateStats('gift_cards', { count: { kind: 'count' } }, qb =>
|
|
96
|
+
qb.where(((eb: StacksExpressionBuilder) => sql`${eb.ref('current_balance')} / ${eb.ref('initial_balance')} > 0.75`) as any))
|
|
97
|
+
|
|
98
|
+
// Expiring soon gift cards (next 30 days)
|
|
99
|
+
const thirtyDaysFromNow = new Date()
|
|
100
|
+
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30)
|
|
101
|
+
|
|
102
|
+
const expiringGiftCards = await db
|
|
103
|
+
.selectFrom('gift_cards')
|
|
104
|
+
.where('is_active', '=', true)
|
|
105
|
+
.where('status', '=', 'ACTIVE')
|
|
106
|
+
.where('expiry_date', '<=', toTimestamp(thirtyDaysFromNow))
|
|
107
|
+
.where('expiry_date', '>=', currentDate)
|
|
108
|
+
.selectAll()
|
|
109
|
+
.limit(5)
|
|
110
|
+
.execute()
|
|
111
|
+
|
|
112
|
+
// Recently used gift cards
|
|
113
|
+
const recentlyUsedGiftCards = await db
|
|
114
|
+
.selectFrom('gift_cards')
|
|
115
|
+
.where('last_used_date', 'is not', null)
|
|
116
|
+
.selectAll()
|
|
117
|
+
.limit(5)
|
|
118
|
+
.execute()
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
total: totalCount,
|
|
122
|
+
active: Number(activeGiftCards?.count || 0),
|
|
123
|
+
by_status: giftCardsByStatus.map((item: { status: string, count: number }) => ({
|
|
124
|
+
status: item.status,
|
|
125
|
+
count: Number(item.count),
|
|
126
|
+
})),
|
|
127
|
+
by_balance: {
|
|
128
|
+
low: lowBalanceCount,
|
|
129
|
+
medium: mediumBalanceCount,
|
|
130
|
+
high: highBalanceCount,
|
|
131
|
+
},
|
|
132
|
+
expiring_soon: expiringGiftCards as GiftCardJsonResponse[],
|
|
133
|
+
recently_used: recentlyUsedGiftCards as GiftCardJsonResponse[],
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Check gift card balance by code
|
|
139
|
+
*/
|
|
140
|
+
export async function checkBalance(code: string): Promise<{ valid: boolean, balance?: number, currency?: string, message?: string }> {
|
|
141
|
+
const giftCard = await fetchByCode(code)
|
|
142
|
+
|
|
143
|
+
if (!giftCard) {
|
|
144
|
+
return { valid: false, message: 'Gift card not found' }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!giftCard.isActive || giftCard.status !== 'ACTIVE') {
|
|
148
|
+
return {
|
|
149
|
+
valid: false,
|
|
150
|
+
balance: giftCard.currentBalance,
|
|
151
|
+
currency: giftCard.currency,
|
|
152
|
+
message: `Gift card is ${giftCard.status.toLowerCase()}`,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Check if expired
|
|
157
|
+
if (giftCard.expiryDate) {
|
|
158
|
+
const expiryDate = new Date(giftCard.expiryDate)
|
|
159
|
+
const currentDate = new Date()
|
|
160
|
+
|
|
161
|
+
if (expiryDate < currentDate) {
|
|
162
|
+
return {
|
|
163
|
+
valid: false,
|
|
164
|
+
balance: giftCard.currentBalance,
|
|
165
|
+
currency: giftCard.currency,
|
|
166
|
+
message: 'Gift card has expired',
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
valid: true,
|
|
173
|
+
balance: giftCard.currentBalance,
|
|
174
|
+
currency: giftCard.currency,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Compare active gift cards between different time periods
|
|
180
|
+
* @param daysRange Number of days to look back (7, 30, 60, etc.)
|
|
181
|
+
*/
|
|
182
|
+
export async function compareActiveGiftCards(_daysRange: number = 30): Promise<{
|
|
183
|
+
current_period: number
|
|
184
|
+
previous_period: number
|
|
185
|
+
difference: number
|
|
186
|
+
percentage_change: number
|
|
187
|
+
days_range: number
|
|
188
|
+
}> {
|
|
189
|
+
const today = new Date()
|
|
190
|
+
|
|
191
|
+
// Current period (last N days)
|
|
192
|
+
const currentPeriodStart = new Date(today)
|
|
193
|
+
currentPeriodStart.setDate(today.getDate() - _daysRange)
|
|
194
|
+
|
|
195
|
+
// Previous period (N days before the current period)
|
|
196
|
+
const previousPeriodEnd = new Date(currentPeriodStart)
|
|
197
|
+
previousPeriodEnd.setDate(previousPeriodEnd.getDate() - 1)
|
|
198
|
+
|
|
199
|
+
const previousPeriodStart = new Date(previousPeriodEnd)
|
|
200
|
+
previousPeriodStart.setDate(previousPeriodEnd.getDate() - _daysRange)
|
|
201
|
+
|
|
202
|
+
// Get active gift cards for current period
|
|
203
|
+
const currentPeriodActive = await db
|
|
204
|
+
.selectFrom('gift_cards')
|
|
205
|
+
.select(((eb: StacksExpressionBuilder) => eb.fn.count('id').as('count')) as any)
|
|
206
|
+
.where('is_active', '=', true)
|
|
207
|
+
.where('status', '=', 'ACTIVE')
|
|
208
|
+
.where(((eb: StacksExpressionBuilder) => eb.or([
|
|
209
|
+
eb('expiry_date', '>=', toTimestamp(today)),
|
|
210
|
+
eb('expiry_date', 'is', null),
|
|
211
|
+
])) as any)
|
|
212
|
+
.where('created_at', '>=', formatDate(currentPeriodStart))
|
|
213
|
+
.where('created_at', '<=', formatDate(today))
|
|
214
|
+
.executeTakeFirst() as { count: number } | undefined
|
|
215
|
+
|
|
216
|
+
// Get active gift cards for previous period
|
|
217
|
+
const previousPeriodActive = await db
|
|
218
|
+
.selectFrom('gift_cards')
|
|
219
|
+
.select(((eb: StacksExpressionBuilder) => eb.fn.count('id').as('count')) as any)
|
|
220
|
+
.where('is_active', '=', true)
|
|
221
|
+
.where('status', '=', 'ACTIVE')
|
|
222
|
+
.where(((eb: StacksExpressionBuilder) => eb.or([
|
|
223
|
+
eb('expiry_date', '>=', toTimestamp(previousPeriodStart)),
|
|
224
|
+
eb('expiry_date', 'is', null),
|
|
225
|
+
])) as any)
|
|
226
|
+
.where('created_at', '>=', formatDate(previousPeriodStart))
|
|
227
|
+
.where('created_at', '<=', formatDate(previousPeriodEnd))
|
|
228
|
+
.executeTakeFirst() as { count: number } | undefined
|
|
229
|
+
|
|
230
|
+
const currentCount = Number(currentPeriodActive?.count || 0)
|
|
231
|
+
const previousCount = Number(previousPeriodActive?.count || 0)
|
|
232
|
+
const difference = currentCount - previousCount
|
|
233
|
+
|
|
234
|
+
// Calculate percentage change, handling division by zero
|
|
235
|
+
const percentageChange = previousCount !== 0
|
|
236
|
+
? (difference / previousCount) * 100
|
|
237
|
+
: (currentCount > 0 ? 100 : 0)
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
current_period: currentCount,
|
|
241
|
+
previous_period: previousCount,
|
|
242
|
+
difference,
|
|
243
|
+
percentage_change: percentageChange,
|
|
244
|
+
days_range: _daysRange,
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Calculate gift card balances and initial values for different time periods
|
|
250
|
+
* with clear increase/decrease indicators
|
|
251
|
+
* @param daysRange Number of days to look back (7, 30, 60, etc.)
|
|
252
|
+
*/
|
|
253
|
+
export async function calculateGiftCardValues(_daysRange: number = 30): Promise<{
|
|
254
|
+
current_period: {
|
|
255
|
+
total_initial_value: number
|
|
256
|
+
total_remaining_balance: number
|
|
257
|
+
utilization_rate: number
|
|
258
|
+
card_count: number
|
|
259
|
+
average_initial_value: number
|
|
260
|
+
average_remaining_balance: number
|
|
261
|
+
}
|
|
262
|
+
previous_period: {
|
|
263
|
+
total_initial_value: number
|
|
264
|
+
total_remaining_balance: number
|
|
265
|
+
utilization_rate: number
|
|
266
|
+
card_count: number
|
|
267
|
+
}
|
|
268
|
+
comparison: {
|
|
269
|
+
initial_value: {
|
|
270
|
+
difference: number
|
|
271
|
+
percentage: number
|
|
272
|
+
is_increase: boolean
|
|
273
|
+
}
|
|
274
|
+
balance: {
|
|
275
|
+
difference: number
|
|
276
|
+
percentage: number
|
|
277
|
+
is_increase: boolean
|
|
278
|
+
}
|
|
279
|
+
card_count: {
|
|
280
|
+
difference: number
|
|
281
|
+
percentage: number
|
|
282
|
+
is_increase: boolean
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
days_range: number
|
|
286
|
+
}> {
|
|
287
|
+
const today = new Date()
|
|
288
|
+
|
|
289
|
+
// Current period (last N days)
|
|
290
|
+
const currentPeriodStart = new Date(today)
|
|
291
|
+
currentPeriodStart.setDate(today.getDate() - _daysRange)
|
|
292
|
+
|
|
293
|
+
// Previous period (N days before the current period)
|
|
294
|
+
const previousPeriodEnd = new Date(currentPeriodStart)
|
|
295
|
+
previousPeriodEnd.setDate(previousPeriodEnd.getDate() - 1)
|
|
296
|
+
|
|
297
|
+
const previousPeriodStart = new Date(previousPeriodEnd)
|
|
298
|
+
previousPeriodStart.setDate(previousPeriodEnd.getDate() - _daysRange)
|
|
299
|
+
|
|
300
|
+
// Get values for current period
|
|
301
|
+
const currentPeriodValues = await db
|
|
302
|
+
.selectFrom('gift_cards')
|
|
303
|
+
.select(((eb: StacksExpressionBuilder) => [
|
|
304
|
+
eb.fn.sum('initial_balance').as('total_initial'),
|
|
305
|
+
eb.fn.sum('current_balance').as('total_balance'),
|
|
306
|
+
eb.fn.count('id').as('card_count'),
|
|
307
|
+
]) as any)
|
|
308
|
+
.where('is_active', '=', true)
|
|
309
|
+
.where('status', '=', 'ACTIVE')
|
|
310
|
+
.where(((eb: StacksExpressionBuilder) => eb.or([
|
|
311
|
+
eb('expiry_date', '>=', toTimestamp(today)),
|
|
312
|
+
eb('expiry_date', 'is', null),
|
|
313
|
+
])) as any)
|
|
314
|
+
.where('created_at', '>=', formatDate(currentPeriodStart))
|
|
315
|
+
.where('created_at', '<=', formatDate(today))
|
|
316
|
+
.executeTakeFirst() as { total_initial: number, total_balance: number, card_count: number } | undefined
|
|
317
|
+
|
|
318
|
+
// Get values for previous period
|
|
319
|
+
const previousPeriodValues = await db
|
|
320
|
+
.selectFrom('gift_cards')
|
|
321
|
+
.select(((eb: StacksExpressionBuilder) => [
|
|
322
|
+
eb.fn.sum('initial_balance').as('total_initial'),
|
|
323
|
+
eb.fn.sum('current_balance').as('total_balance'),
|
|
324
|
+
eb.fn.count('id').as('card_count'),
|
|
325
|
+
]) as any)
|
|
326
|
+
.where('is_active', '=', true)
|
|
327
|
+
.where('status', '=', 'ACTIVE')
|
|
328
|
+
.where(((eb: StacksExpressionBuilder) => eb.or([
|
|
329
|
+
eb('expiry_date', '>=', toTimestamp(previousPeriodStart)),
|
|
330
|
+
eb('expiry_date', 'is', null),
|
|
331
|
+
])) as any)
|
|
332
|
+
.where('created_at', '>=', formatDate(previousPeriodStart))
|
|
333
|
+
.where('created_at', '<=', formatDate(previousPeriodEnd))
|
|
334
|
+
.executeTakeFirst() as { total_initial: number, total_balance: number, card_count: number } | undefined
|
|
335
|
+
|
|
336
|
+
// Calculate values for current period
|
|
337
|
+
const currentInitialValue = Number(currentPeriodValues?.total_initial || 0)
|
|
338
|
+
const currentBalance = Number(currentPeriodValues?.total_balance || 0)
|
|
339
|
+
const currentCardCount = Number(currentPeriodValues?.card_count || 0)
|
|
340
|
+
|
|
341
|
+
// Calculate values for previous period
|
|
342
|
+
const previousInitialValue = Number(previousPeriodValues?.total_initial || 0)
|
|
343
|
+
const previousBalance = Number(previousPeriodValues?.total_balance || 0)
|
|
344
|
+
const previousCardCount = Number(previousPeriodValues?.card_count || 0)
|
|
345
|
+
|
|
346
|
+
// Calculate utilization rates (percentage of gift card value that has been spent)
|
|
347
|
+
const currentUtilizationRate = currentInitialValue > 0
|
|
348
|
+
? ((currentInitialValue - currentBalance) / currentInitialValue) * 100
|
|
349
|
+
: 0
|
|
350
|
+
|
|
351
|
+
const previousUtilizationRate = previousInitialValue > 0
|
|
352
|
+
? ((previousInitialValue - previousBalance) / previousInitialValue) * 100
|
|
353
|
+
: 0
|
|
354
|
+
|
|
355
|
+
// Calculate averages
|
|
356
|
+
const currentAverageInitial = currentCardCount > 0 ? currentInitialValue / currentCardCount : 0
|
|
357
|
+
const currentAverageBalance = currentCardCount > 0 ? currentBalance / currentCardCount : 0
|
|
358
|
+
|
|
359
|
+
// Calculate differences
|
|
360
|
+
const initialValueDifference = currentInitialValue - previousInitialValue
|
|
361
|
+
const balanceDifference = currentBalance - previousBalance
|
|
362
|
+
const cardCountDifference = currentCardCount - previousCardCount
|
|
363
|
+
|
|
364
|
+
// Calculate percentage changes
|
|
365
|
+
const initialValuePercentageChange = previousInitialValue !== 0
|
|
366
|
+
? (initialValueDifference / previousInitialValue) * 100
|
|
367
|
+
: (currentInitialValue > 0 ? 100 : 0)
|
|
368
|
+
|
|
369
|
+
const balancePercentageChange = previousBalance !== 0
|
|
370
|
+
? (balanceDifference / previousBalance) * 100
|
|
371
|
+
: (currentBalance > 0 ? 100 : 0)
|
|
372
|
+
|
|
373
|
+
const cardCountPercentageChange = previousCardCount !== 0
|
|
374
|
+
? (cardCountDifference / previousCardCount) * 100
|
|
375
|
+
: (currentCardCount > 0 ? 100 : 0)
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
current_period: {
|
|
379
|
+
total_initial_value: currentInitialValue,
|
|
380
|
+
total_remaining_balance: currentBalance,
|
|
381
|
+
utilization_rate: currentUtilizationRate,
|
|
382
|
+
card_count: currentCardCount,
|
|
383
|
+
average_initial_value: currentAverageInitial,
|
|
384
|
+
average_remaining_balance: currentAverageBalance,
|
|
385
|
+
},
|
|
386
|
+
previous_period: {
|
|
387
|
+
total_initial_value: previousInitialValue,
|
|
388
|
+
total_remaining_balance: previousBalance,
|
|
389
|
+
utilization_rate: previousUtilizationRate,
|
|
390
|
+
card_count: previousCardCount,
|
|
391
|
+
},
|
|
392
|
+
comparison: {
|
|
393
|
+
initial_value: {
|
|
394
|
+
difference: initialValueDifference,
|
|
395
|
+
percentage: Math.abs(initialValuePercentageChange),
|
|
396
|
+
is_increase: initialValueDifference >= 0,
|
|
397
|
+
},
|
|
398
|
+
balance: {
|
|
399
|
+
difference: balanceDifference,
|
|
400
|
+
percentage: Math.abs(balancePercentageChange),
|
|
401
|
+
is_increase: balanceDifference >= 0,
|
|
402
|
+
},
|
|
403
|
+
card_count: {
|
|
404
|
+
difference: cardCountDifference,
|
|
405
|
+
percentage: Math.abs(cardCountPercentageChange),
|
|
406
|
+
is_increase: cardCountDifference >= 0,
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
days_range: _daysRange,
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Get total value of all gift cards
|
|
415
|
+
*/
|
|
416
|
+
export async function fetchTotalValue(): Promise<{
|
|
417
|
+
total_value: number
|
|
418
|
+
}> {
|
|
419
|
+
const { total_value } = await aggregateStats('gift_cards', { total_value: { kind: 'sum', column: 'initial_balance' } })
|
|
420
|
+
return { total_value }
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Get total current balance of all gift cards
|
|
425
|
+
*/
|
|
426
|
+
export async function fetchTotalCurrentBalance(): Promise<{
|
|
427
|
+
total_balance: number
|
|
428
|
+
}> {
|
|
429
|
+
const { total_balance } = await aggregateStats('gift_cards', { total_balance: { kind: 'sum', column: 'current_balance' } })
|
|
430
|
+
return { total_balance }
|
|
431
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export {
|
|
2
|
+
bulkDestroy,
|
|
3
|
+
destroy,
|
|
4
|
+
} from './destroy'
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
calculateGiftCardValues,
|
|
8
|
+
checkBalance,
|
|
9
|
+
compareActiveGiftCards,
|
|
10
|
+
fetchActive,
|
|
11
|
+
fetchAll,
|
|
12
|
+
fetchByCode,
|
|
13
|
+
fetchById,
|
|
14
|
+
fetchStats,
|
|
15
|
+
fetchTotalCurrentBalance,
|
|
16
|
+
fetchTotalValue,
|
|
17
|
+
} from './fetch'
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
store,
|
|
21
|
+
} from './store'
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
update,
|
|
25
|
+
updateBalance,
|
|
26
|
+
} from './update'
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
type GiftCardJsonResponse = ModelRow<typeof GiftCard>
|
|
2
|
+
type NewGiftCard = NewModelData<typeof GiftCard>
|
|
3
|
+
import { randomUUIDv7 } from 'bun'
|
|
4
|
+
import { db } from '@stacksjs/database'
|
|
5
|
+
import { HttpError } from '@stacksjs/error-handling'
|
|
6
|
+
import { isUniqueViolation } from '@stacksjs/orm'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create a new gift card
|
|
10
|
+
*
|
|
11
|
+
* @param data The gift card data to store
|
|
12
|
+
* @returns The newly created gift card record
|
|
13
|
+
*/
|
|
14
|
+
export async function store(data: NewGiftCard): Promise<GiftCardJsonResponse | undefined> {
|
|
15
|
+
const d = data as Record<string, unknown>
|
|
16
|
+
const giftCardData = {
|
|
17
|
+
...data,
|
|
18
|
+
current_balance: d.current_balance ?? d.initial_balance,
|
|
19
|
+
status: data.status || 'ACTIVE',
|
|
20
|
+
is_active: d.is_active ?? true,
|
|
21
|
+
uuid: randomUUIDv7(),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
// Insert the gift card record
|
|
26
|
+
const createdGiftCard = await db
|
|
27
|
+
.insertInto('gift_cards')
|
|
28
|
+
.values(giftCardData)
|
|
29
|
+
.executeTakeFirst()
|
|
30
|
+
|
|
31
|
+
const insertId = Number(createdGiftCard.insertId) || Number(createdGiftCard.numInsertedOrUpdatedRows)
|
|
32
|
+
|
|
33
|
+
// If insert was successful, retrieve the newly created gift card
|
|
34
|
+
if (insertId) {
|
|
35
|
+
const giftCard = await db
|
|
36
|
+
.selectFrom('gift_cards')
|
|
37
|
+
.where('id', '=', insertId)
|
|
38
|
+
.selectAll()
|
|
39
|
+
.executeTakeFirst()
|
|
40
|
+
|
|
41
|
+
return giftCard as GiftCardJsonResponse
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (error instanceof HttpError)
|
|
48
|
+
throw error
|
|
49
|
+
// Cross-dialect duplicate detection (#1957).
|
|
50
|
+
if (isUniqueViolation(error))
|
|
51
|
+
throw new HttpError(409, 'A gift card with this code already exists')
|
|
52
|
+
if (error instanceof Error)
|
|
53
|
+
throw new Error(`Failed to create gift card: ${error.message}`)
|
|
54
|
+
throw error
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { StacksExpressionBuilder } from '@stacksjs/database'
|
|
2
|
+
import { db } from '@stacksjs/database'
|
|
3
|
+
import { HttpError } from '@stacksjs/error-handling'
|
|
4
|
+
import { formatDate, isUniqueViolation } from '@stacksjs/orm'
|
|
5
|
+
type GiftCardJsonResponse = ModelRow<typeof GiftCard>
|
|
6
|
+
type GiftCardUpdate = UpdateModelData<typeof GiftCard>
|
|
7
|
+
import { fetchById } from './fetch'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Update a gift card by ID
|
|
11
|
+
*
|
|
12
|
+
* @param id The ID of the gift card to update
|
|
13
|
+
* @param data The updated gift card data
|
|
14
|
+
* @returns The updated gift card record
|
|
15
|
+
*/
|
|
16
|
+
export async function update(id: number, data: Omit<GiftCardUpdate, 'id'>): Promise<GiftCardJsonResponse | undefined> {
|
|
17
|
+
// Check if gift card exists
|
|
18
|
+
const existingGiftCard = await fetchById(id)
|
|
19
|
+
if (!existingGiftCard) {
|
|
20
|
+
throw new Error(`Gift card with ID ${id} not found`)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
// Update the gift card
|
|
25
|
+
await db
|
|
26
|
+
.updateTable('gift_cards')
|
|
27
|
+
.set({
|
|
28
|
+
...data,
|
|
29
|
+
updated_at: formatDate(new Date()),
|
|
30
|
+
})
|
|
31
|
+
.where('id', '=', id)
|
|
32
|
+
.execute()
|
|
33
|
+
|
|
34
|
+
// Fetch and return the updated gift card
|
|
35
|
+
return await fetchById(id)
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error instanceof HttpError)
|
|
39
|
+
throw error
|
|
40
|
+
// Cross-dialect duplicate detection (#1957).
|
|
41
|
+
if (isUniqueViolation(error))
|
|
42
|
+
throw new HttpError(409, 'A gift card with this code already exists')
|
|
43
|
+
if (error instanceof Error)
|
|
44
|
+
throw new Error(`Failed to update gift card: ${error.message}`)
|
|
45
|
+
throw error
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Update a gift card's balance atomically (stacksjs/stacks#1879 Co-8).
|
|
51
|
+
*
|
|
52
|
+
* Pre-fix: read balance → compute new → write. Two parallel $50
|
|
53
|
+
* redemptions of a $100 card both saw `current_balance = 100`,
|
|
54
|
+
* both wrote `50`, leaving `50` instead of `0`. The post-read
|
|
55
|
+
* negative-balance guard only caught single-threaded misuse.
|
|
56
|
+
*
|
|
57
|
+
* Post-fix: single conditional UPDATE that does the arithmetic and
|
|
58
|
+
* enforces every precondition in the WHERE clause. The database
|
|
59
|
+
* guarantees only one writer wins the race. Throws when the row
|
|
60
|
+
* was found but the precondition failed (insufficient balance,
|
|
61
|
+
* inactive, missing) so the caller can distinguish from a
|
|
62
|
+
* not-found row.
|
|
63
|
+
*
|
|
64
|
+
* @param id The ID of the gift card
|
|
65
|
+
* @param amount The amount to adjust (positive to add, negative to deduct)
|
|
66
|
+
* @returns The updated gift card with new balance
|
|
67
|
+
*/
|
|
68
|
+
export async function updateBalance(id: number, amount: number): Promise<GiftCardJsonResponse | undefined> {
|
|
69
|
+
const now = formatDate(new Date())
|
|
70
|
+
|
|
71
|
+
// Single atomic UPDATE — the arithmetic happens server-side via
|
|
72
|
+
// `current_balance + ?`, and the `>= 0` predicate stops over-spend.
|
|
73
|
+
// The new status flips to USED when the resulting balance is 0,
|
|
74
|
+
// computed inline via CASE so the status update is part of the
|
|
75
|
+
// same statement.
|
|
76
|
+
const result = await db
|
|
77
|
+
.updateTable('gift_cards')
|
|
78
|
+
.set({
|
|
79
|
+
current_balance: (db as any).raw(`current_balance + ${amount}`),
|
|
80
|
+
last_used_date: now,
|
|
81
|
+
status: (db as any).raw(`CASE WHEN current_balance + ${amount} = 0 THEN 'USED' ELSE 'ACTIVE' END`),
|
|
82
|
+
updated_at: now,
|
|
83
|
+
})
|
|
84
|
+
.where('id', '=', id)
|
|
85
|
+
.where('is_active', '=', 1)
|
|
86
|
+
.where('status', '=', 'ACTIVE')
|
|
87
|
+
.where((eb: StacksExpressionBuilder) => eb.cmpr(eb.raw(`current_balance + ${amount}`), '>=', 0))
|
|
88
|
+
.executeTakeFirst()
|
|
89
|
+
|
|
90
|
+
const affected = Number((result as any)?.numUpdatedRows ?? (result as any)?.affectedRows ?? 0)
|
|
91
|
+
if (affected > 0) {
|
|
92
|
+
return await fetchById(id)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// UPDATE matched zero rows — diagnose for a useful error.
|
|
96
|
+
const existing = await fetchById(id)
|
|
97
|
+
if (!existing) throw new Error(`Gift card with ID ${id} not found`)
|
|
98
|
+
if (!(existing as Record<string, unknown>).is_active || existing.status !== 'ACTIVE')
|
|
99
|
+
throw new Error(`Gift card is not active`)
|
|
100
|
+
// Fell through every other check — must be insufficient balance.
|
|
101
|
+
throw new Error(`Insufficient gift card balance`)
|
|
102
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Main commerce module index file
|
|
2
|
+
import * as coupons from './coupons'
|
|
3
|
+
import * as customers from './customers'
|
|
4
|
+
import * as devices from './devices'
|
|
5
|
+
import * as errors from './errors'
|
|
6
|
+
import * as giftCards from './gift-cards'
|
|
7
|
+
import * as orders from './orders'
|
|
8
|
+
import * as payments from './payments'
|
|
9
|
+
import * as products from './products'
|
|
10
|
+
import * as receipts from './receipts'
|
|
11
|
+
import * as shippings from './shippings'
|
|
12
|
+
import * as tax from './tax'
|
|
13
|
+
import * as waitlists from './waitlists'
|
|
14
|
+
import * as restaurant from './waitlists/restaurant'
|
|
15
|
+
|
|
16
|
+
type CouponsModule = typeof coupons
|
|
17
|
+
type CustomersModule = typeof customers
|
|
18
|
+
type ErrorsModule = typeof errors
|
|
19
|
+
type ShippingsModule = typeof shippings
|
|
20
|
+
type GiftCardsModule = typeof giftCards
|
|
21
|
+
type OrdersModule = typeof orders
|
|
22
|
+
type PaymentsModule = typeof payments
|
|
23
|
+
type ProductsModule = typeof products
|
|
24
|
+
type RestaurantModule = typeof restaurant
|
|
25
|
+
type TaxModule = typeof tax
|
|
26
|
+
type WaitlistsModule = typeof waitlists
|
|
27
|
+
type DevicesModule = typeof devices
|
|
28
|
+
type ReceiptsModule = typeof receipts
|
|
29
|
+
|
|
30
|
+
export interface CommerceNamespace {
|
|
31
|
+
coupons: CouponsModule
|
|
32
|
+
customers: CustomersModule
|
|
33
|
+
errors: ErrorsModule
|
|
34
|
+
giftCards: GiftCardsModule
|
|
35
|
+
orders: OrdersModule
|
|
36
|
+
payments: PaymentsModule
|
|
37
|
+
products: ProductsModule
|
|
38
|
+
restaurant: RestaurantModule
|
|
39
|
+
shippings: ShippingsModule
|
|
40
|
+
tax: TaxModule
|
|
41
|
+
waitlists: WaitlistsModule
|
|
42
|
+
devices: DevicesModule
|
|
43
|
+
receipts: ReceiptsModule
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const commerce: CommerceNamespace = {
|
|
47
|
+
coupons,
|
|
48
|
+
customers,
|
|
49
|
+
devices,
|
|
50
|
+
errors,
|
|
51
|
+
giftCards,
|
|
52
|
+
orders,
|
|
53
|
+
payments,
|
|
54
|
+
products,
|
|
55
|
+
receipts,
|
|
56
|
+
restaurant,
|
|
57
|
+
shippings,
|
|
58
|
+
tax,
|
|
59
|
+
waitlists,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default commerce
|
|
63
|
+
|
|
64
|
+
export {
|
|
65
|
+
coupons,
|
|
66
|
+
customers,
|
|
67
|
+
devices,
|
|
68
|
+
errors,
|
|
69
|
+
giftCards,
|
|
70
|
+
orders,
|
|
71
|
+
payments,
|
|
72
|
+
products,
|
|
73
|
+
receipts,
|
|
74
|
+
restaurant,
|
|
75
|
+
shippings,
|
|
76
|
+
tax,
|
|
77
|
+
waitlists,
|
|
78
|
+
}
|