@stacksjs/commerce 0.70.54 → 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,510 @@
1
+ import type { CouponCountStats, CouponRedemptionStats, CouponStats, CouponTimeStats } from '../types'
2
+ import type { StacksExpressionBuilder } from '@stacksjs/database'
3
+ import { db } from '@stacksjs/database'
4
+ import { extractDate, formatDate } from '@stacksjs/orm'
5
+ type CouponJsonResponse = ModelRow<typeof Coupon>
6
+
7
+ /**
8
+ * Process coupon data from the database
9
+ * Parses JSON strings for applicable_products and applicable_categories
10
+ */
11
+ function processCouponData(coupon: CouponJsonResponse): CouponJsonResponse {
12
+ // Create a copy to avoid modifying the original object
13
+ const processed = { ...coupon }
14
+
15
+ if (processed.endDate) {
16
+ processed.endDate = extractDate(new Date(processed.endDate))
17
+ }
18
+ if (processed.startDate) {
19
+ processed.startDate = extractDate(new Date(processed.startDate))
20
+ }
21
+
22
+ // Convert isActive to boolean if needed
23
+ if (processed.isActive !== undefined) {
24
+ processed.isActive = Boolean(processed.isActive)
25
+ }
26
+
27
+ return processed
28
+ }
29
+
30
+ /**
31
+ * Fetch all coupons from the database
32
+ */
33
+ export async function fetchAll(): Promise<CouponJsonResponse[]> {
34
+ const coupons = await db
35
+ .selectFrom('coupons')
36
+ .selectAll()
37
+ .execute()
38
+
39
+ return (coupons as CouponJsonResponse[]).map(processCouponData)
40
+ }
41
+
42
+ /**
43
+ * Fetch a coupon by ID
44
+ */
45
+ export async function fetchById(id: number): Promise<CouponJsonResponse | undefined> {
46
+ const coupon = await db
47
+ .selectFrom('coupons')
48
+ .where('id', '=', id)
49
+ .selectAll()
50
+ .executeTakeFirst()
51
+
52
+ if (coupon) {
53
+ return processCouponData(coupon as CouponJsonResponse)
54
+ }
55
+
56
+ return undefined
57
+ }
58
+
59
+ /**
60
+ * Fetch a coupon by code
61
+ */
62
+ export async function fetchByCode(code: string): Promise<CouponJsonResponse | undefined> {
63
+ const coupon = await db
64
+ .selectFrom('coupons')
65
+ .where('code', '=', code)
66
+ .selectAll()
67
+ .executeTakeFirst()
68
+
69
+ if (coupon) {
70
+ return processCouponData(coupon as CouponJsonResponse)
71
+ }
72
+
73
+ return undefined
74
+ }
75
+
76
+ /**
77
+ * Fetch active coupons (is_active = true and within date range)
78
+ */
79
+ export async function fetchActive(): Promise<CouponJsonResponse[]> {
80
+ const currentDate = formatDate(new Date())
81
+
82
+ const coupons = await db
83
+ .selectFrom('coupons')
84
+ .where('is_active', '=', true)
85
+ .where('start_date', '<=', currentDate)
86
+ .where('end_date', '>=', currentDate)
87
+ .selectAll()
88
+ .execute()
89
+
90
+ return (coupons as CouponJsonResponse[]).map(processCouponData)
91
+ }
92
+
93
+ /**
94
+ * Get coupon statistics
95
+ */
96
+ export async function fetchStats(): Promise<CouponStats> {
97
+ // Total coupons
98
+ const totalCoupons = await db
99
+ .selectFrom('coupons')
100
+ .select(((eb: StacksExpressionBuilder) => eb.fn.count('id').as('count')) as any)
101
+ .executeTakeFirst() as { count: number } | undefined
102
+
103
+ // Active coupons
104
+ const currentDate = formatDate(new Date())
105
+ const activeCoupons = await db
106
+ .selectFrom('coupons')
107
+ .where('is_active', '=', true)
108
+ .where('start_date', '<=', currentDate)
109
+ .where('end_date', '>=', currentDate)
110
+ .select(((eb: StacksExpressionBuilder) => eb.fn.count('id').as('count')) as any)
111
+ .executeTakeFirst() as { count: number } | undefined
112
+
113
+ // Coupons by discount type
114
+ const couponsByType = await db
115
+ .selectFrom('coupons')
116
+ .select(['discount_type', (eb: StacksExpressionBuilder) => eb.fn.count('id').as('count')] as any)
117
+ .groupBy('discount_type')
118
+ .execute() as { discount_type: string, count: number }[]
119
+
120
+ // Most used coupons
121
+ const mostUsedCoupons = await db
122
+ .selectFrom('coupons')
123
+ .select(['id', 'code', 'discount_type', 'discount_value', 'usage_count'])
124
+ .orderBy('usage_count', 'desc')
125
+ .limit(5)
126
+ .execute() as { id: number, code: string, discount_type: string, discount_value: number, usage_count: number }[]
127
+
128
+ // Upcoming coupons (active but not yet started)
129
+ const upcomingCoupons = await db
130
+ .selectFrom('coupons')
131
+ .where('is_active', '=', true)
132
+ .where('start_date', '>', currentDate)
133
+ .select(['id', 'code', 'discount_type', 'discount_value', 'start_date', 'end_date'])
134
+ .orderBy('start_date', 'asc')
135
+ .limit(5)
136
+ .execute() as { id: number, code: string, discount_type: string, discount_value: number, start_date: string | Date, end_date: string | Date }[]
137
+
138
+ return {
139
+ total: Number(totalCoupons?.count || 0),
140
+ active: Number(activeCoupons?.count || 0),
141
+ by_type: couponsByType,
142
+ most_used: mostUsedCoupons,
143
+ upcoming: upcomingCoupons,
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Get count of active and inactive coupons for different time periods
149
+ */
150
+ export async function fetchCouponCounts(): Promise<CouponTimeStats> {
151
+ const currentDate = new Date()
152
+
153
+ // Calculate start dates for different periods
154
+ const weekStart = new Date(currentDate)
155
+ weekStart.setDate(currentDate.getDate() - 7)
156
+
157
+ const monthStart = new Date(currentDate)
158
+ monthStart.setMonth(currentDate.getMonth() - 1)
159
+
160
+ const yearStart = new Date(currentDate)
161
+ yearStart.setFullYear(currentDate.getFullYear() - 1)
162
+
163
+ // All-time counts
164
+ const allTimeCounts = await fetchCountsForPeriod(null, null, currentDate)
165
+
166
+ // Week counts
167
+ const weekCounts = await fetchCountsForPeriod(weekStart, null, currentDate)
168
+
169
+ // Month counts
170
+ const monthCounts = await fetchCountsForPeriod(monthStart, null, currentDate)
171
+
172
+ // Year counts
173
+ const yearCounts = await fetchCountsForPeriod(yearStart, null, currentDate)
174
+
175
+ return {
176
+ week: weekCounts,
177
+ month: monthCounts,
178
+ year: yearCounts,
179
+ all_time: allTimeCounts,
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Helper function to fetch coupon counts for a specific time period
185
+ *
186
+ * @param startDate - Start of the period (or null for all time)
187
+ * @param endDate - End of the period (or null for current date)
188
+ * @param currentDate - Current date for active/inactive calculation
189
+ */
190
+ async function fetchCountsForPeriod(
191
+ startDate: Date | null,
192
+ endDate: Date | null,
193
+ currentDate: Date,
194
+ ): Promise<CouponCountStats> {
195
+ let query = db.selectFrom('coupons') as any
196
+
197
+ // Apply date filter if startDate is provided
198
+ if (startDate) {
199
+ query = query.where('created_at', '>=', startDate.toISOString())
200
+ }
201
+
202
+ // Apply end date filter if endDate is provided
203
+ if (endDate) {
204
+ query = query.where('created_at', '<=', endDate.toISOString())
205
+ }
206
+
207
+ // Get total count
208
+ const totalResult = await query
209
+ .select(((eb: StacksExpressionBuilder) => eb.fn.count('id').as('count')) as any)
210
+ .executeTakeFirst() as { count: number } | undefined
211
+
212
+ // Get active count (is_active = true, start_date <= current date, end_date >= current date)
213
+ // Create a new query instead of cloning
214
+ let activeQuery = db.selectFrom('coupons') as any
215
+
216
+ // Apply the same date filters as the total query if needed
217
+ if (startDate) {
218
+ activeQuery = activeQuery.where('created_at', '>=', startDate.toISOString())
219
+ }
220
+
221
+ if (endDate) {
222
+ activeQuery = activeQuery.where('created_at', '<=', endDate.toISOString())
223
+ }
224
+
225
+ // Add active coupon conditions
226
+ activeQuery = activeQuery
227
+ .where('is_active', '=', true)
228
+ .where('start_date', '<=', currentDate)
229
+ .where('end_date', '>=', currentDate)
230
+
231
+ const activeResult = await activeQuery
232
+ .select(((eb: StacksExpressionBuilder) => eb.fn.count('id').as('count')) as any)
233
+ .executeTakeFirst() as { count: number } | undefined
234
+
235
+ // Calculate counts
236
+ const total = Number(totalResult?.count || 0)
237
+ const active = Number(activeResult?.count || 0)
238
+ const inactive = total - active
239
+
240
+ return {
241
+ total,
242
+ active,
243
+ inactive,
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Get detailed breakdown of active/inactive coupons by discount type
249
+ */
250
+ export async function fetchCouponCountsByType(): Promise<Record<string, CouponCountStats>> {
251
+ const currentDate = new Date()
252
+
253
+ // Get all discount types
254
+ const discountTypes = await db
255
+ .selectFrom('coupons')
256
+ .select('discount_type' as any)
257
+ .distinct()
258
+ .execute() as { discount_type: string }[]
259
+
260
+ const result: Record<string, CouponCountStats> = {}
261
+
262
+ // For each discount type, get the counts
263
+ for (const { discount_type } of discountTypes) {
264
+ // Total count for this type
265
+ const totalResult = await db
266
+ .selectFrom('coupons')
267
+ .where('discount_type', '=', discount_type)
268
+ .select(((eb: StacksExpressionBuilder) => eb.fn.count('id').as('count')) as any)
269
+ .executeTakeFirst() as { count: number } | undefined
270
+
271
+ // Active count for this type - create a new query instead of cloning
272
+ const activeResult = await db
273
+ .selectFrom('coupons')
274
+ .where('discount_type', '=', discount_type)
275
+ .where('is_active', '=', true)
276
+ .where('start_date', '<=', currentDate)
277
+ .where('end_date', '>=', currentDate)
278
+ .select(((eb: StacksExpressionBuilder) => eb.fn.count('id').as('count')) as any)
279
+ .executeTakeFirst() as { count: number } | undefined
280
+
281
+ const total = Number(totalResult?.count || 0)
282
+ const active = Number(activeResult?.count || 0)
283
+
284
+ result[discount_type] = {
285
+ total,
286
+ active,
287
+ inactive: total - active,
288
+ }
289
+ }
290
+
291
+ return result
292
+ }
293
+
294
+ /**
295
+ * Fetch coupon redemption statistics based on usage_count
296
+ */
297
+ export async function fetchRedemptionStats(): Promise<CouponRedemptionStats> {
298
+ // Total redemptions (sum of all usage_count)
299
+ const totalResult = await db
300
+ .selectFrom('coupons')
301
+ .select(((eb: StacksExpressionBuilder) => eb.fn.sum('usage_count').as('total')) as any)
302
+ .executeTakeFirst() as { total: number } | undefined
303
+
304
+ const currentDate = new Date()
305
+
306
+ // Calculate start dates for different periods
307
+ const weekStart = new Date(currentDate)
308
+ weekStart.setDate(currentDate.getDate() - 7)
309
+
310
+ const monthStart = new Date(currentDate)
311
+ monthStart.setMonth(currentDate.getMonth() - 1)
312
+
313
+ const yearStart = new Date(currentDate)
314
+ yearStart.setFullYear(currentDate.getFullYear() - 1)
315
+
316
+ // Weekly redemptions (for coupons updated in the last week)
317
+ const weekResult = await db
318
+ .selectFrom('coupons')
319
+ .where('updated_at', '>=', weekStart.toISOString())
320
+ .select(((eb: StacksExpressionBuilder) => eb.fn.sum('usage_count').as('total')) as any)
321
+ .executeTakeFirst() as { total: number } | undefined
322
+
323
+ // Monthly redemptions
324
+ const monthResult = await db
325
+ .selectFrom('coupons')
326
+ .where('updated_at', '>=', monthStart.toISOString())
327
+ .select(((eb: StacksExpressionBuilder) => eb.fn.sum('usage_count').as('total')) as any)
328
+ .executeTakeFirst() as { total: number } | undefined
329
+
330
+ // Yearly redemptions
331
+ const yearResult = await db
332
+ .selectFrom('coupons')
333
+ .where('updated_at', '>=', yearStart.toISOString())
334
+ .select(((eb: StacksExpressionBuilder) => eb.fn.sum('usage_count').as('total')) as any)
335
+ .executeTakeFirst() as { total: number } | undefined
336
+
337
+ // Redemptions by discount type
338
+ const byTypeResults = await db
339
+ .selectFrom('coupons')
340
+ .select(['discount_type', (eb: StacksExpressionBuilder) => eb.fn.sum('usage_count').as('total')] as any)
341
+ .groupBy('discount_type')
342
+ .execute() as { discount_type: string, total: number }[]
343
+
344
+ const byType: Record<string, number> = {}
345
+ byTypeResults.forEach((item: any) => {
346
+ byType[item.discount_type] = Number(item.total || 0)
347
+ })
348
+
349
+ return {
350
+ total: Number(totalResult?.total || 0),
351
+ week: Number(weekResult?.total || 0),
352
+ month: Number(monthResult?.total || 0),
353
+ year: Number(yearResult?.total || 0),
354
+ by_type: byType,
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Get top redeemed coupons
360
+ */
361
+ export async function fetchTopRedeemedCoupons(limit: number = 5): Promise<any[]> {
362
+ return await db
363
+ .selectFrom('coupons')
364
+ .select([
365
+ 'id',
366
+ 'code',
367
+ 'discount_type',
368
+ 'discount_value',
369
+ 'usage_count',
370
+ 'start_date',
371
+ 'end_date',
372
+ ])
373
+ .where('usage_count', '>', 0)
374
+ .orderBy('usage_count', 'desc')
375
+ .limit(limit)
376
+ .execute()
377
+ }
378
+
379
+ /**
380
+ * Get redemption trend data in a simplified manner
381
+ */
382
+ export async function fetchRedemptionTrend(days: number = 30): Promise<any[]> {
383
+ const endDate = new Date().toISOString()
384
+ const startDate = new Date()
385
+ startDate.setDate(startDate.getDate() - days)
386
+
387
+ // Get coupons updated in the specified timeframe
388
+ const coupons = await db
389
+ .selectFrom('coupons')
390
+ .select(['id', 'code', 'updated_at', 'usage_count'])
391
+ .where('updated_at', '>=', startDate.toISOString())
392
+ .where('updated_at', '<=', endDate)
393
+ .where('usage_count', '>', 0)
394
+ .orderBy('updated_at', 'desc')
395
+ .execute()
396
+
397
+ // Group by date (this is done in JS rather than SQL for simplicity)
398
+ const trendData: Record<string, number> = {}
399
+
400
+ coupons.forEach((coupon: any) => {
401
+ // Format date as YYYY-MM-DD
402
+ const dateString = coupon.updated_at
403
+ ? new Date(coupon.updated_at).toISOString().split('T')[0]
404
+ : 'unknown-date' // Fallback for undefined date
405
+
406
+ // Add or increment the count for this date
407
+ if (!trendData[dateString]) {
408
+ trendData[dateString] = 0
409
+ }
410
+ trendData[dateString] += coupon?.usage_count || 0
411
+ })
412
+
413
+ // Convert to array format
414
+ return Object.entries(trendData).map(([date, count]) => ({
415
+ date,
416
+ count,
417
+ }))
418
+ }
419
+
420
+ /**
421
+ * Get conversion rate (number of redemptions / number of active coupons)
422
+ */
423
+ export async function fetchConversionRate(): Promise<{
424
+ rate: number
425
+ total_active: number
426
+ total_redeemed: number
427
+ }> {
428
+ const currentDate = new Date()
429
+
430
+ // Get count of active coupons
431
+ const activeResult = await db
432
+ .selectFrom('coupons')
433
+ .where('is_active', '=', true)
434
+ .where('start_date', '<=', currentDate)
435
+ .where('end_date', '>=', currentDate)
436
+ .select(((eb: StacksExpressionBuilder) => eb.fn.count('id').as('count')) as any)
437
+ .executeTakeFirst() as { count: number } | undefined
438
+
439
+ // Get count of coupons with usage > 0
440
+ const redeemedResult = await db
441
+ .selectFrom('coupons')
442
+ .where('usage_count', '>', 0)
443
+ .select(((eb: StacksExpressionBuilder) => eb.fn.count('id').as('count')) as any)
444
+ .executeTakeFirst() as { count: number } | undefined
445
+
446
+ const totalActive = Number(activeResult?.count || 0)
447
+ const totalRedeemed = Number(redeemedResult?.count || 0)
448
+
449
+ // Calculate rate (avoid division by zero)
450
+ const rate = totalActive > 0 ? (totalRedeemed / totalActive) * 100 : 0
451
+
452
+ return {
453
+ rate,
454
+ total_active: totalActive,
455
+ total_redeemed: totalRedeemed,
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Calculate the month-over-month change in active coupons
461
+ */
462
+ export async function getActiveCouponsMoMChange(): Promise<{
463
+ current_month: number
464
+ previous_month: number
465
+ difference: number
466
+ percentage_change: number
467
+ }> {
468
+ const today = new Date()
469
+
470
+ // Current month (start of current month to today)
471
+ const currentMonthStart = new Date(today.getFullYear(), today.getMonth(), 1)
472
+
473
+ // Previous month
474
+ const previousMonthStart = new Date(today.getFullYear(), today.getMonth() - 1, 1)
475
+ const previousMonthEnd = new Date(today.getFullYear(), today.getMonth(), 0)
476
+
477
+ // Get active coupons for current month
478
+ const currentMonthActive = await db
479
+ .selectFrom('coupons')
480
+ .select(((eb: StacksExpressionBuilder) => eb.fn.count('id').as('count')) as any)
481
+ .where('is_active', '=', true)
482
+ .where('start_date', '<=', today)
483
+ .where('end_date', '>=', currentMonthStart)
484
+ .executeTakeFirst() as { count: number } | undefined
485
+
486
+ // Get active coupons for previous month
487
+ const previousMonthActive = await db
488
+ .selectFrom('coupons')
489
+ .select(((eb: StacksExpressionBuilder) => eb.fn.count('id').as('count')) as any)
490
+ .where('is_active', '=', true)
491
+ .where('start_date', '<=', previousMonthEnd)
492
+ .where('end_date', '>=', previousMonthStart)
493
+ .executeTakeFirst() as { count: number } | undefined
494
+
495
+ const currentCount = Number(currentMonthActive?.count || 0)
496
+ const previousCount = Number(previousMonthActive?.count || 0)
497
+ const difference = currentCount - previousCount
498
+
499
+ // Calculate percentage change, handling division by zero
500
+ const percentageChange = previousCount !== 0
501
+ ? (difference / previousCount) * 100
502
+ : (currentCount > 0 ? 100 : 0) // If previous month was 0, and current is > 0, that's a 100% increase
503
+
504
+ return {
505
+ current_month: currentCount,
506
+ previous_month: previousCount,
507
+ difference,
508
+ percentage_change: percentageChange,
509
+ }
510
+ }
@@ -0,0 +1,30 @@
1
+ export {
2
+ deleteCoupon,
3
+ deleteCoupons,
4
+ deleteExpiredCoupons,
5
+ } from './destroy'
6
+
7
+ export {
8
+ fetchActive,
9
+ fetchAll,
10
+ fetchByCode,
11
+ fetchById,
12
+ fetchConversionRate,
13
+ fetchCouponCounts,
14
+ fetchCouponCountsByType,
15
+ fetchRedemptionStats,
16
+ fetchRedemptionTrend,
17
+ fetchStats,
18
+ fetchTopRedeemedCoupons,
19
+ getActiveCouponsMoMChange,
20
+ } from './fetch'
21
+
22
+ export {
23
+ store,
24
+ } from './store'
25
+
26
+ export {
27
+ redeem,
28
+ update,
29
+ } from './update'
30
+ export type { CouponRedemptionResult } from './update'
@@ -0,0 +1,46 @@
1
+ type CouponJsonResponse = ModelRow<typeof Coupon>
2
+ type NewCoupon = NewModelData<typeof Coupon>
3
+ import { db } from '@stacksjs/database'
4
+ import { HttpError } from '@stacksjs/error-handling'
5
+ import { isUniqueViolation } from '@stacksjs/orm'
6
+
7
+ /**
8
+ * Create a new coupon
9
+ *
10
+ * @param data The coupon data to store
11
+ * @returns The newly created coupon record
12
+ */
13
+ export async function store(data: NewCoupon): Promise<CouponJsonResponse | undefined> {
14
+ try {
15
+ // Insert the coupon record
16
+ const createdCoupon = await db
17
+ .insertInto('coupons')
18
+ .values(data)
19
+ .executeTakeFirst()
20
+
21
+ const couponId = Number(createdCoupon.insertId) || Number(createdCoupon.numInsertedOrUpdatedRows)
22
+
23
+ if (couponId) {
24
+ const coupon = await db
25
+ .selectFrom('coupons')
26
+ .where('id', '=', couponId)
27
+ .selectAll()
28
+ .executeTakeFirst()
29
+
30
+ return coupon as CouponJsonResponse
31
+ }
32
+
33
+ return undefined
34
+ }
35
+ catch (error) {
36
+ if (error instanceof HttpError)
37
+ throw error
38
+ // Cross-dialect duplicate detection (#1957) — replaces the MySQL-only
39
+ // 'Duplicate entry' sniff so SQLite/Postgres duplicates also map to 409.
40
+ if (isUniqueViolation(error))
41
+ throw new HttpError(409, 'A coupon with this code already exists')
42
+ if (error instanceof Error)
43
+ throw new Error(`Failed to create coupon: ${error.message}`)
44
+ throw error
45
+ }
46
+ }