@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.
Files changed (165) hide show
  1. package/package.json +3 -2
  2. package/src/carts/destroy.ts +54 -0
  3. package/src/carts/fetch.ts +20 -0
  4. package/src/carts/index.ts +13 -0
  5. package/src/carts/store.ts +75 -0
  6. package/src/carts/update.ts +78 -0
  7. package/src/coupons/destroy.ts +49 -0
  8. package/src/coupons/fetch.ts +510 -0
  9. package/src/coupons/index.ts +30 -0
  10. package/src/coupons/store.ts +46 -0
  11. package/src/coupons/update.ts +147 -0
  12. package/src/customers/destroy.ts +62 -0
  13. package/src/customers/fetch.ts +20 -0
  14. package/src/customers/index.ts +17 -0
  15. package/src/customers/store.ts +45 -0
  16. package/src/customers/update.ts +44 -0
  17. package/src/devices/destroy.ts +54 -0
  18. package/src/devices/export.ts +98 -0
  19. package/src/devices/fetch.ts +125 -0
  20. package/src/devices/index.ts +31 -0
  21. package/src/devices/store.ts +82 -0
  22. package/src/devices/update.ts +125 -0
  23. package/src/errors/destroy.ts +54 -0
  24. package/src/errors/fetch.ts +197 -0
  25. package/src/errors/index.ts +28 -0
  26. package/src/errors/update.ts +116 -0
  27. package/src/gift-cards/destroy.ts +87 -0
  28. package/src/gift-cards/fetch.ts +431 -0
  29. package/src/gift-cards/index.ts +26 -0
  30. package/src/gift-cards/store.ts +56 -0
  31. package/src/gift-cards/update.ts +102 -0
  32. package/src/index.ts +78 -0
  33. package/src/orders/destroy.ts +111 -0
  34. package/src/orders/events.ts +121 -0
  35. package/src/orders/export.ts +158 -0
  36. package/src/orders/fetch.ts +379 -0
  37. package/src/orders/guards.ts +261 -0
  38. package/src/orders/index.ts +91 -0
  39. package/src/orders/place-order.ts +274 -0
  40. package/src/orders/store.ts +51 -0
  41. package/src/orders/totals.ts +190 -0
  42. package/src/orders/update.ts +164 -0
  43. package/src/orders/webhook.ts +312 -0
  44. package/src/payments/destroy.ts +56 -0
  45. package/src/payments/fetch.ts +288 -0
  46. package/src/payments/index.ts +16 -0
  47. package/src/payments/store.ts +63 -0
  48. package/src/payments/update.ts +52 -0
  49. package/src/product.ts +0 -0
  50. package/src/products/categories/destroy.ts +103 -0
  51. package/src/products/categories/fetch.ts +285 -0
  52. package/src/products/categories/index.ts +36 -0
  53. package/src/products/categories/store.ts +87 -0
  54. package/src/products/categories/update.ts +251 -0
  55. package/src/products/index.ts +35 -0
  56. package/src/products/items/destroy.ts +54 -0
  57. package/src/products/items/fetch.ts +282 -0
  58. package/src/products/items/index.ts +26 -0
  59. package/src/products/items/store.ts +84 -0
  60. package/src/products/items/update.ts +324 -0
  61. package/src/products/manufacturers/destroy.ts +56 -0
  62. package/src/products/manufacturers/fetch.ts +117 -0
  63. package/src/products/manufacturers/index.ts +24 -0
  64. package/src/products/manufacturers/store.ts +82 -0
  65. package/src/products/manufacturers/update.ts +113 -0
  66. package/src/products/reviews/destroy.ts +56 -0
  67. package/src/products/reviews/fetch.ts +63 -0
  68. package/src/products/reviews/index.ts +25 -0
  69. package/src/products/reviews/store.ts +46 -0
  70. package/src/products/reviews/update.ts +82 -0
  71. package/src/products/units/destroy.ts +56 -0
  72. package/src/products/units/fetch.ts +20 -0
  73. package/src/products/units/index.ts +13 -0
  74. package/src/products/units/store.ts +162 -0
  75. package/src/products/units/update.ts +159 -0
  76. package/src/products/variants/destroy.ts +56 -0
  77. package/src/products/variants/fetch.ts +20 -0
  78. package/src/products/variants/index.ts +21 -0
  79. package/src/products/variants/store.ts +136 -0
  80. package/src/products/variants/update.ts +104 -0
  81. package/src/receipts/destroy.ts +54 -0
  82. package/src/receipts/export.ts +0 -0
  83. package/src/receipts/fetch.ts +222 -0
  84. package/src/receipts/index.ts +24 -0
  85. package/src/receipts/printer.ts +69 -0
  86. package/src/receipts/store.ts +69 -0
  87. package/src/receipts/update.ts +124 -0
  88. package/src/shippings/delivery-routes/destroy.ts +54 -0
  89. package/src/shippings/delivery-routes/fetch.ts +42 -0
  90. package/src/shippings/delivery-routes/index.ts +26 -0
  91. package/src/shippings/delivery-routes/store.ts +69 -0
  92. package/src/shippings/delivery-routes/update.ts +120 -0
  93. package/src/shippings/digital-deliveries/destroy.ts +111 -0
  94. package/src/shippings/digital-deliveries/fetch.ts +20 -0
  95. package/src/shippings/digital-deliveries/index.ts +26 -0
  96. package/src/shippings/digital-deliveries/store.ts +76 -0
  97. package/src/shippings/digital-deliveries/update.ts +129 -0
  98. package/src/shippings/drivers/destroy.ts +78 -0
  99. package/src/shippings/drivers/fetch.ts +20 -0
  100. package/src/shippings/drivers/index.ts +24 -0
  101. package/src/shippings/drivers/store.ts +69 -0
  102. package/src/shippings/drivers/update.ts +117 -0
  103. package/src/shippings/index.ts +39 -0
  104. package/src/shippings/license-keys/destroy.ts +111 -0
  105. package/src/shippings/license-keys/fetch.ts +20 -0
  106. package/src/shippings/license-keys/index.ts +26 -0
  107. package/src/shippings/license-keys/store.ts +77 -0
  108. package/src/shippings/license-keys/update.ts +117 -0
  109. package/src/shippings/shipping-methods/destroy.ts +111 -0
  110. package/src/shippings/shipping-methods/fetch.ts +62 -0
  111. package/src/shippings/shipping-methods/index.ts +28 -0
  112. package/src/shippings/shipping-methods/store.ts +123 -0
  113. package/src/shippings/shipping-methods/update.ts +120 -0
  114. package/src/shippings/shipping-rates/destroy.ts +102 -0
  115. package/src/shippings/shipping-rates/fetch.ts +320 -0
  116. package/src/shippings/shipping-rates/index.ts +36 -0
  117. package/src/shippings/shipping-rates/store.ts +102 -0
  118. package/src/shippings/shipping-rates/update.ts +139 -0
  119. package/src/shippings/shipping-zones/destroy.ts +111 -0
  120. package/src/shippings/shipping-zones/fetch.ts +96 -0
  121. package/src/shippings/shipping-zones/index.ts +26 -0
  122. package/src/shippings/shipping-zones/store.ts +74 -0
  123. package/src/shippings/shipping-zones/update.ts +156 -0
  124. package/src/tax/destroy.ts +54 -0
  125. package/src/tax/fetch.ts +20 -0
  126. package/src/tax/index.ts +24 -0
  127. package/src/tax/store.ts +69 -0
  128. package/src/tax/update.ts +119 -0
  129. package/src/tests/coupons.test.ts +90 -0
  130. package/src/tests/customers.test.ts +39 -0
  131. package/src/tests/device.test.ts +111 -0
  132. package/src/tests/digitals.test.ts +31 -0
  133. package/src/tests/drivers.test.ts +25 -0
  134. package/src/tests/gift-cards.test.ts +36 -0
  135. package/src/tests/license.test.ts +31 -0
  136. package/src/tests/manufacturers.test.ts +80 -0
  137. package/src/tests/orders.test.ts +25 -0
  138. package/src/tests/payments.test.ts +75 -0
  139. package/src/tests/rates.test.ts +367 -0
  140. package/src/tests/receipts.test.ts +287 -0
  141. package/src/tests/restaurant.test.ts +522 -0
  142. package/src/tests/reviews.test.ts +165 -0
  143. package/src/tests/routes.test.ts +51 -0
  144. package/src/tests/setup.ts +149 -0
  145. package/src/tests/shipping.test.ts +88 -0
  146. package/src/tests/tax.test.ts +25 -0
  147. package/src/tests/units.test.ts +110 -0
  148. package/src/tests/variants.test.ts +133 -0
  149. package/src/tests/waitlist.test.ts +303 -0
  150. package/src/tests/zones.test.ts +86 -0
  151. package/src/types.ts +312 -0
  152. package/src/utils/typed-stats.ts +67 -0
  153. package/src/waitlists/index.ts +19 -0
  154. package/src/waitlists/products/destroy.ts +65 -0
  155. package/src/waitlists/products/export.ts +113 -0
  156. package/src/waitlists/products/fetch.ts +362 -0
  157. package/src/waitlists/products/index.ts +33 -0
  158. package/src/waitlists/products/store.ts +112 -0
  159. package/src/waitlists/products/update.ts +125 -0
  160. package/src/waitlists/restaurant/destroy.ts +65 -0
  161. package/src/waitlists/restaurant/export.ts +107 -0
  162. package/src/waitlists/restaurant/fetch.ts +538 -0
  163. package/src/waitlists/restaurant/index.ts +43 -0
  164. package/src/waitlists/restaurant/store.ts +109 -0
  165. package/src/waitlists/restaurant/update.ts +198 -0
@@ -0,0 +1,379 @@
1
+ type OrderJsonResponse = ModelRow<typeof Order>
2
+ import type {
3
+ OrderStats,
4
+ OrderTypeCount,
5
+ StatusCount,
6
+ } from '../types'
7
+ import type { StacksExpressionBuilder } from '@stacksjs/database'
8
+ import { db } from '@stacksjs/database'
9
+
10
+ /**
11
+ * Fetch all orders from the database with their items
12
+ * @param limit Optional limit on number of orders to fetch
13
+ */
14
+ export async function fetchAll(limit?: number): Promise<OrderJsonResponse[]> {
15
+ let query = db
16
+ .selectFrom('orders')
17
+ .selectAll()
18
+ .orderBy('created_at', 'desc')
19
+
20
+ if (limit) {
21
+ query = query.limit(limit)
22
+ }
23
+
24
+ const orders = await query.execute()
25
+
26
+ // Fetch items for each order
27
+ return await Promise.all(orders.map(async (order: OrderJsonResponse) => {
28
+ const items = await db
29
+ .selectFrom('order_items')
30
+ .where('order_id', '=', order.id)
31
+ .selectAll()
32
+ .execute()
33
+
34
+ return {
35
+ ...order,
36
+ items,
37
+ }
38
+ }))
39
+ }
40
+
41
+ /**
42
+ * Fetch the most recent orders
43
+ * @param limit Number of recent orders to fetch (default: 10)
44
+ */
45
+ export async function fetchRecent(limit: number = 10): Promise<OrderJsonResponse[]> {
46
+ return fetchAll(limit)
47
+ }
48
+
49
+ /**
50
+ * Fetch an order by ID
51
+ */
52
+ export async function fetchById(id: number): Promise<OrderJsonResponse | undefined> {
53
+ const order = await db
54
+ .selectFrom('orders')
55
+ .where('id', '=', id)
56
+ .selectAll()
57
+ .executeTakeFirst()
58
+
59
+ return order as OrderJsonResponse | undefined
60
+ }
61
+
62
+ /**
63
+ * Get order statistics
64
+ */
65
+ export async function fetchStats(): Promise<OrderStats> {
66
+ // Total orders
67
+ const totalOrders = await db
68
+ .selectFrom('orders')
69
+ .select(((eb: StacksExpressionBuilder) => eb.fn.count('id').as('count')) as any)
70
+ .executeTakeFirst() as { count: number } | undefined
71
+
72
+ // Orders by status
73
+ const ordersByStatus = await db
74
+ .selectFrom('orders')
75
+ .select(['status', (eb: StacksExpressionBuilder) => eb.fn.count('id').as('count')] as any)
76
+ .groupBy('status')
77
+ .execute() as unknown as StatusCount[]
78
+
79
+ // Orders by type
80
+ const ordersByType = await db
81
+ .selectFrom('orders')
82
+ .select(['order_type', (eb: StacksExpressionBuilder) => eb.fn.count('id').as('count')] as any)
83
+ .groupBy('order_type')
84
+ .execute() as unknown as OrderTypeCount[]
85
+
86
+ // Recent orders with their items
87
+ const recentOrdersRaw = await db
88
+ .selectFrom('orders')
89
+ .selectAll()
90
+ .orderBy('created_at', 'desc')
91
+ .limit(5)
92
+ .execute()
93
+
94
+ // Fetch items for each recent order
95
+ const recentOrders = await Promise.all(recentOrdersRaw.map(async (order: OrderJsonResponse) => {
96
+ const items = await db
97
+ .selectFrom('order_items')
98
+ .where('order_id', '=', order.id)
99
+ .selectAll()
100
+ .execute()
101
+
102
+ return {
103
+ ...order,
104
+ items,
105
+ }
106
+ }))
107
+
108
+ // Total revenue
109
+ const revenue = await db
110
+ .selectFrom('orders')
111
+ .select(((eb: StacksExpressionBuilder) => eb.fn.sum('total_amount').as('total')) as any)
112
+ .executeTakeFirst() as { total: number } | undefined
113
+
114
+ return {
115
+ total: Number(totalOrders?.count || 0),
116
+ by_status: ordersByStatus,
117
+ by_type: ordersByType,
118
+ recent: recentOrders,
119
+ revenue: Number(revenue?.total || 0),
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Compare orders between different time periods
125
+ * @param daysRange Number of days to look back (7, 30, 60, etc.)
126
+ */
127
+ export async function compareOrdersByPeriod(_daysRange: number = 30): Promise<{
128
+ current_period: number
129
+ previous_period: number
130
+ difference: number
131
+ percentage_change: number
132
+ days_range: number
133
+ }> {
134
+ const today = new Date()
135
+
136
+ // Current period (last N days)
137
+ const currentPeriodStart = new Date(today)
138
+ currentPeriodStart.setDate(today.getDate() - _daysRange)
139
+
140
+ // Previous period (N days before the current period)
141
+ const previousPeriodEnd = new Date(currentPeriodStart)
142
+ previousPeriodEnd.setDate(previousPeriodEnd.getDate() - 1)
143
+
144
+ const previousPeriodStart = new Date(previousPeriodEnd)
145
+ previousPeriodStart.setDate(previousPeriodEnd.getDate() - _daysRange)
146
+
147
+ // Get orders for current period
148
+ const currentPeriodOrders = await db
149
+ .selectFrom('orders')
150
+ .select(((eb: StacksExpressionBuilder) => eb.fn.count('id').as('count')) as any)
151
+ .where('created_at', '>=', currentPeriodStart.toISOString())
152
+ .where('created_at', '<=', today.toISOString())
153
+ .executeTakeFirst() as { count: number } | undefined
154
+
155
+ // Get orders for previous period
156
+ const previousPeriodOrders = await db
157
+ .selectFrom('orders')
158
+ .select(((eb: StacksExpressionBuilder) => eb.fn.count('id').as('count')) as any)
159
+ .where('created_at', '>=', previousPeriodStart.toISOString())
160
+ .where('created_at', '<=', previousPeriodEnd.toISOString())
161
+ .executeTakeFirst() as { count: number } | undefined
162
+
163
+ const currentCount = Number(currentPeriodOrders?.count || 0)
164
+ const previousCount = Number(previousPeriodOrders?.count || 0)
165
+ const difference = currentCount - previousCount
166
+
167
+ // Calculate percentage change, handling division by zero
168
+ const percentageChange = previousCount !== 0
169
+ ? (difference / previousCount) * 100
170
+ : (currentCount > 0 ? 100 : 0)
171
+
172
+ return {
173
+ current_period: currentCount,
174
+ previous_period: previousCount,
175
+ difference,
176
+ percentage_change: percentageChange,
177
+ days_range: _daysRange,
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Calculate order values and metrics for different time periods
183
+ * @param daysRange Number of days to look back (7, 30, 60, etc.)
184
+ */
185
+ export async function calculateOrderMetrics(_daysRange: number = 30): Promise<{
186
+ current_period: {
187
+ total_orders: number
188
+ total_revenue: number
189
+ average_order_value: number
190
+ orders_by_status: { status: string, count: number }[]
191
+ orders_by_type: { order_type: string, count: number }[]
192
+ }
193
+ previous_period: {
194
+ total_orders: number
195
+ total_revenue: number
196
+ average_order_value: number
197
+ }
198
+ comparison: {
199
+ orders: {
200
+ difference: number
201
+ percentage: number
202
+ is_increase: boolean
203
+ }
204
+ revenue: {
205
+ difference: number
206
+ percentage: number
207
+ is_increase: boolean
208
+ }
209
+ average_order_value: {
210
+ difference: number
211
+ percentage: number
212
+ is_increase: boolean
213
+ }
214
+ }
215
+ days_range: number
216
+ }> {
217
+ const today = new Date()
218
+
219
+ // Current period (last N days)
220
+ const currentPeriodStart = new Date(today)
221
+ currentPeriodStart.setDate(today.getDate() - _daysRange)
222
+
223
+ // Previous period (N days before the current period)
224
+ const previousPeriodEnd = new Date(currentPeriodStart)
225
+ previousPeriodEnd.setDate(previousPeriodEnd.getDate() - 1)
226
+
227
+ const previousPeriodStart = new Date(previousPeriodEnd)
228
+ previousPeriodStart.setDate(previousPeriodEnd.getDate() - _daysRange)
229
+
230
+ // Get values for current period
231
+ const currentPeriodValues = await db
232
+ .selectFrom('orders')
233
+ .select(((eb: StacksExpressionBuilder) => [
234
+ eb.fn.count('id').as('total_orders'),
235
+ eb.fn.sum('total_amount').as('total_revenue'),
236
+ ]) as any)
237
+ .where('created_at', '>=', currentPeriodStart.toISOString())
238
+ .where('created_at', '<=', today.toISOString())
239
+ .executeTakeFirst() as { total_orders: number, total_revenue: number } | undefined
240
+
241
+ // Get values for previous period
242
+ const previousPeriodValues = await db
243
+ .selectFrom('orders')
244
+ .select(((eb: StacksExpressionBuilder) => [
245
+ eb.fn.count('id').as('total_orders'),
246
+ eb.fn.sum('total_amount').as('total_revenue'),
247
+ ]) as any)
248
+ .where('created_at', '>=', previousPeriodStart.toISOString())
249
+ .where('created_at', '<=', previousPeriodEnd.toISOString())
250
+ .executeTakeFirst() as { total_orders: number, total_revenue: number } | undefined
251
+
252
+ // Get orders by status for current period
253
+ const ordersByStatus = await db
254
+ .selectFrom('orders')
255
+ .select(['status', (eb: StacksExpressionBuilder) => eb.fn.count('id').as('count')] as any)
256
+ .where('created_at', '>=', currentPeriodStart.toISOString())
257
+ .where('created_at', '<=', today.toISOString())
258
+ .groupBy('status')
259
+ .execute() as { status: string, count: number }[]
260
+
261
+ // Get orders by type for current period
262
+ const ordersByType = await db
263
+ .selectFrom('orders')
264
+ .select(['order_type', (eb: StacksExpressionBuilder) => eb.fn.count('id').as('count')] as any)
265
+ .where('created_at', '>=', currentPeriodStart.toISOString())
266
+ .where('created_at', '<=', today.toISOString())
267
+ .groupBy('order_type')
268
+ .execute() as { order_type: string, count: number }[]
269
+
270
+ // Calculate values for current period
271
+ const currentTotalOrders = Number(currentPeriodValues?.total_orders || 0)
272
+ const currentTotalRevenue = Number(currentPeriodValues?.total_revenue || 0)
273
+ const currentAverageOrderValue = currentTotalOrders > 0
274
+ ? currentTotalRevenue / currentTotalOrders
275
+ : 0
276
+
277
+ // Calculate values for previous period
278
+ const previousTotalOrders = Number(previousPeriodValues?.total_orders || 0)
279
+ const previousTotalRevenue = Number(previousPeriodValues?.total_revenue || 0)
280
+ const previousAverageOrderValue = previousTotalOrders > 0
281
+ ? previousTotalRevenue / previousTotalOrders
282
+ : 0
283
+
284
+ // Calculate differences
285
+ const ordersDifference = currentTotalOrders - previousTotalOrders
286
+ const revenueDifference = currentTotalRevenue - previousTotalRevenue
287
+ const aovDifference = currentAverageOrderValue - previousAverageOrderValue
288
+
289
+ // Calculate percentage changes
290
+ const ordersPercentageChange = previousTotalOrders !== 0
291
+ ? (ordersDifference / previousTotalOrders) * 100
292
+ : (currentTotalOrders > 0 ? 100 : 0)
293
+
294
+ const revenuePercentageChange = previousTotalRevenue !== 0
295
+ ? (revenueDifference / previousTotalRevenue) * 100
296
+ : (currentTotalRevenue > 0 ? 100 : 0)
297
+
298
+ const aovPercentageChange = previousAverageOrderValue !== 0
299
+ ? (aovDifference / previousAverageOrderValue) * 100
300
+ : (currentAverageOrderValue > 0 ? 100 : 0)
301
+
302
+ return {
303
+ current_period: {
304
+ total_orders: currentTotalOrders,
305
+ total_revenue: currentTotalRevenue,
306
+ average_order_value: currentAverageOrderValue,
307
+ orders_by_status: ordersByStatus.map((item: any) => ({
308
+ status: item.status,
309
+ count: Number(item.count),
310
+ })),
311
+ orders_by_type: ordersByType.map((item: any) => ({
312
+ order_type: item.order_type,
313
+ count: Number(item.count),
314
+ })),
315
+ },
316
+ previous_period: {
317
+ total_orders: previousTotalOrders,
318
+ total_revenue: previousTotalRevenue,
319
+ average_order_value: previousAverageOrderValue,
320
+ },
321
+ comparison: {
322
+ orders: {
323
+ difference: ordersDifference,
324
+ percentage: Math.abs(ordersPercentageChange),
325
+ is_increase: ordersDifference >= 0,
326
+ },
327
+ revenue: {
328
+ difference: revenueDifference,
329
+ percentage: Math.abs(revenuePercentageChange),
330
+ is_increase: revenueDifference >= 0,
331
+ },
332
+ average_order_value: {
333
+ difference: aovDifference,
334
+ percentage: Math.abs(aovPercentageChange),
335
+ is_increase: aovDifference >= 0,
336
+ },
337
+ },
338
+ days_range: _daysRange,
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Get daily order counts for a time period
344
+ * @param daysRange Number of days to look back
345
+ */
346
+ export async function fetchDailyOrderTrends(_daysRange: number = 30): Promise<{
347
+ date: string
348
+ order_count: number
349
+ revenue: number
350
+ }[]> {
351
+ const today = new Date()
352
+ const startDate = new Date(today)
353
+ startDate.setDate(today.getDate() - _daysRange)
354
+
355
+ // Query all orders in the range, then aggregate in JS by date
356
+ const orders = await db
357
+ .selectFrom('orders')
358
+ .select(['created_at', 'total_amount'] as any)
359
+ .where('created_at', '>=', startDate.toISOString())
360
+ .where('created_at', '<=', today.toISOString())
361
+ .orderBy('created_at', 'asc')
362
+ .execute() as { created_at: string, total_amount: number }[]
363
+
364
+ // Group by date (YYYY-MM-DD)
365
+ const dailyMap = new Map<string, { order_count: number, revenue: number }>()
366
+ for (const order of orders) {
367
+ const date = (order.created_at || '').split('T')[0] || 'unknown'
368
+ const entry = dailyMap.get(date) || { order_count: 0, revenue: 0 }
369
+ entry.order_count++
370
+ entry.revenue += Number(order.total_amount || 0)
371
+ dailyMap.set(date, entry)
372
+ }
373
+
374
+ return Array.from(dailyMap.entries()).map(([date, stats]) => ({
375
+ date,
376
+ order_count: stats.order_count,
377
+ revenue: stats.revenue,
378
+ }))
379
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Order / cart policy guards (stacksjs/stacks#1879 Co-7, Co-14, Co-15).
3
+ *
4
+ * Three small helpers that callers pre-flight against the cart
5
+ * before handing off to `placeOrder`. Kept together because they
6
+ * all answer the same question: "should this checkout be
7
+ * allowed to proceed?"
8
+ */
9
+
10
+ import type { StacksExpressionBuilder } from '@stacksjs/database'
11
+ import { db } from '@stacksjs/database'
12
+
13
+ // ============================================================================
14
+ // Co-7: coupon stacking guard
15
+ // ============================================================================
16
+
17
+ export interface CouponStackingPolicy {
18
+ /**
19
+ * Allow multiple coupons on the same order. Default is `false`
20
+ * (single-coupon-only). Apps that intentionally stack coupons
21
+ * (e.g. one fixed-amount + one percentage) opt in explicitly.
22
+ */
23
+ allowMultiple?: boolean
24
+ /**
25
+ * When `allowMultiple: true`, cap the number of stacked coupons.
26
+ * Default unlimited (no cap). Useful for "up to 2 coupons" or
27
+ * "BOGO + free-shipping" style rules.
28
+ */
29
+ maxStacked?: number
30
+ }
31
+
32
+ export interface CouponStackingResult {
33
+ ok: boolean
34
+ /** Number of coupon ids the caller submitted. */
35
+ count: number
36
+ /** Set when the policy rejected the combination. */
37
+ reason?: 'multiple-not-allowed' | 'exceeds-max-stacked'
38
+ }
39
+
40
+ /**
41
+ * Validate that a set of coupon ids satisfies the stacking policy
42
+ * (stacksjs/stacks#1879 Co-7). Pre-fix there was no enforcement
43
+ * anywhere — frontend could submit two coupons and double-discount
44
+ * the order with no signal. Default policy is single-coupon-only;
45
+ * apps that intentionally support stacking opt in.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * const stack = validateCouponStacking(cart.couponIds)
50
+ * if (!stack.ok)
51
+ * throw new HttpError(400, `Coupon stacking violation: ${stack.reason}`)
52
+ * ```
53
+ */
54
+ export function validateCouponStacking(
55
+ couponIds: ReadonlyArray<number>,
56
+ policy: CouponStackingPolicy = {},
57
+ ): CouponStackingResult {
58
+ const count = couponIds.length
59
+ if (count <= 1) return { ok: true, count }
60
+ if (!policy.allowMultiple) {
61
+ return { ok: false, count, reason: 'multiple-not-allowed' }
62
+ }
63
+ const cap = policy.maxStacked ?? Number.POSITIVE_INFINITY
64
+ if (count > cap) {
65
+ return { ok: false, count, reason: 'exceeds-max-stacked' }
66
+ }
67
+ return { ok: true, count }
68
+ }
69
+
70
+ // ============================================================================
71
+ // Co-14: min/max order quantity bounds
72
+ // ============================================================================
73
+
74
+ export interface QuantityBoundsResult {
75
+ ok: boolean
76
+ productId: number
77
+ quantity: number
78
+ /** Set when the quantity violated a bound. */
79
+ reason?: 'below-min' | 'above-max' | 'non-positive' | 'product-missing'
80
+ /** The bound that was violated (when applicable). */
81
+ bound?: number
82
+ }
83
+
84
+ /**
85
+ * Validate that a cart line-item's quantity respects the product's
86
+ * `min_order_qty` / `max_order_qty` fields
87
+ * (stacksjs/stacks#1879 Co-14). Looks up the product server-side
88
+ * so the client can't bypass by lying about the bounds.
89
+ *
90
+ * Products without explicit bounds default to "any positive int."
91
+ * A quantity of 0 or negative always fails — the cart's UI is
92
+ * responsible for letting users remove items, not for passing
93
+ * sentinel zeros through the API.
94
+ */
95
+ export async function validateQuantityBounds(
96
+ productId: number,
97
+ quantity: number,
98
+ ): Promise<QuantityBoundsResult> {
99
+ if (!Number.isFinite(quantity) || quantity <= 0)
100
+ return { ok: false, productId, quantity, reason: 'non-positive' }
101
+
102
+ let product: { min_order_qty?: number | null, max_order_qty?: number | null } | undefined
103
+ try {
104
+ product = await (db as any)
105
+ .selectFrom('products')
106
+ .where('id', '=', productId)
107
+ .select(['min_order_qty', 'max_order_qty'])
108
+ .executeTakeFirst()
109
+ }
110
+ catch {
111
+ // Schema doesn't have the columns yet — degrade to "any positive
112
+ // quantity is fine," matching the pre-fix behavior. Apps that
113
+ // need bounds enforcement add the columns first.
114
+ return { ok: true, productId, quantity }
115
+ }
116
+
117
+ if (!product)
118
+ return { ok: false, productId, quantity, reason: 'product-missing' }
119
+
120
+ const min = product.min_order_qty ?? null
121
+ const max = product.max_order_qty ?? null
122
+
123
+ if (min != null && quantity < min)
124
+ return { ok: false, productId, quantity, reason: 'below-min', bound: min }
125
+ if (max != null && quantity > max)
126
+ return { ok: false, productId, quantity, reason: 'above-max', bound: max }
127
+
128
+ return { ok: true, productId, quantity }
129
+ }
130
+
131
+ /**
132
+ * Batch variant — short-circuits on the first violation so the
133
+ * caller can surface a single useful "item X has min qty Y"
134
+ * error rather than a list of every failed line.
135
+ */
136
+ export async function validateCartQuantities(
137
+ items: ReadonlyArray<{ productId: number, quantity: number }>,
138
+ ): Promise<QuantityBoundsResult> {
139
+ for (const item of items) {
140
+ const r = await validateQuantityBounds(item.productId, item.quantity)
141
+ if (!r.ok) return r
142
+ }
143
+ // ok-path uses the last item's id as a placeholder; callers
144
+ // only branch on `.ok`.
145
+ const last = items[items.length - 1] ?? { productId: 0, quantity: 0 }
146
+ return { ok: true, productId: last.productId, quantity: last.quantity }
147
+ }
148
+
149
+ // ============================================================================
150
+ // Co-15: abandoned cart cleanup
151
+ // ============================================================================
152
+
153
+ export interface CleanupAbandonedCartsOptions {
154
+ /**
155
+ * Delete carts whose `updated_at` is older than this many days.
156
+ * Default 30. Apps with rolling-window cart-recovery emails set
157
+ * higher (e.g. 90).
158
+ */
159
+ olderThanDays?: number
160
+ /**
161
+ * Maximum rows to delete in a single call. Default 1000 — keeps
162
+ * the delete from holding a long write lock on busy databases.
163
+ * Apps call repeatedly until `deleted < limit`.
164
+ */
165
+ limit?: number
166
+ }
167
+
168
+ export interface CleanupAbandonedCartsResult {
169
+ /** Number of cart rows deleted in this call. */
170
+ deleted: number
171
+ /** Cutoff timestamp used for the delete (ISO 8601). */
172
+ cutoffAt: string
173
+ }
174
+
175
+ /**
176
+ * Delete carts that have been untouched for more than
177
+ * `olderThanDays` days (stacksjs/stacks#1879 Co-15). Default 30.
178
+ * Capped at `limit` rows per call (default 1000) so the delete
179
+ * doesn't hold a long write lock on a busy database. Returns the
180
+ * count + cutoff so callers can log or loop.
181
+ *
182
+ * Schedule via `@stacksjs/scheduler` (now fully wired since
183
+ * #1877's scheduler audit closed) to run nightly:
184
+ *
185
+ * @example
186
+ * ```ts
187
+ * // app/Scheduler.ts
188
+ * import { cleanupAbandonedCarts } from '@stacksjs/commerce'
189
+ *
190
+ * schedule.job(async () => {
191
+ * let total = 0
192
+ * while (true) {
193
+ * const r = await cleanupAbandonedCarts({ olderThanDays: 30, limit: 1000 })
194
+ * total += r.deleted
195
+ * if (r.deleted < 1000) break
196
+ * }
197
+ * log.info(`Cleaned up ${total} abandoned carts`)
198
+ * }).daily().setTimeZone('UTC').withName('CleanupAbandonedCarts')
199
+ * ```
200
+ */
201
+ export async function cleanupAbandonedCarts(
202
+ options: CleanupAbandonedCartsOptions = {},
203
+ ): Promise<CleanupAbandonedCartsResult> {
204
+ const olderThanDays = options.olderThanDays ?? 30
205
+ const limit = options.limit ?? 1000
206
+
207
+ const cutoff = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000)
208
+ const cutoffAt = cutoff.toISOString().slice(0, 19).replace('T', ' ')
209
+
210
+ // The pattern below uses a sub-select to apply the LIMIT because
211
+ // most SQL drivers don't accept LIMIT on DELETE directly. SQLite
212
+ // and MySQL both accept the DELETE-from-subselect form; PG needs
213
+ // a CTE-style alternative. Fall back via try/catch so the helper
214
+ // works across drivers without driver-detect plumbing.
215
+ try {
216
+ const result: any = await (db as any)
217
+ .deleteFrom('carts')
218
+ .where('updated_at', '<', cutoffAt)
219
+ .where('id', 'in', (eb: StacksExpressionBuilder) =>
220
+ eb.selectFrom('carts')
221
+ .select('id')
222
+ .where('updated_at', '<', cutoffAt)
223
+ .limit(limit),
224
+ )
225
+ .execute()
226
+ const deleted = Number(
227
+ result?.numDeletedRows
228
+ ?? result?.[0]?.numDeletedRows
229
+ ?? result?.affectedRows
230
+ ?? 0,
231
+ )
232
+ return { deleted, cutoffAt }
233
+ }
234
+ catch {
235
+ // Fallback: select-then-delete by id. Slower but driver-portable.
236
+ try {
237
+ const rows = await (db as any)
238
+ .selectFrom('carts')
239
+ .where('updated_at', '<', cutoffAt)
240
+ .select(['id'])
241
+ .limit(limit)
242
+ .execute() as Array<{ id: number }>
243
+ if (rows.length === 0) return { deleted: 0, cutoffAt }
244
+ const ids = rows.map(r => r.id)
245
+ const result: any = await (db as any)
246
+ .deleteFrom('carts')
247
+ .where('id', 'in', ids)
248
+ .execute()
249
+ const deleted = Number(
250
+ result?.numDeletedRows
251
+ ?? result?.[0]?.numDeletedRows
252
+ ?? result?.affectedRows
253
+ ?? rows.length,
254
+ )
255
+ return { deleted, cutoffAt }
256
+ }
257
+ catch {
258
+ return { deleted: 0, cutoffAt }
259
+ }
260
+ }
261
+ }