@parsrun/payments 0.1.0

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.
@@ -0,0 +1,2677 @@
1
+ // src/dunning/dunning-sequence.ts
2
+ var DunningStepBuilder = class {
3
+ step = {
4
+ actions: [],
5
+ notificationChannels: []
6
+ };
7
+ constructor(id, name) {
8
+ this.step.id = id;
9
+ this.step.name = name;
10
+ }
11
+ /**
12
+ * Set days after initial failure
13
+ */
14
+ afterDays(days) {
15
+ this.step.daysAfterFailure = days;
16
+ return this;
17
+ }
18
+ /**
19
+ * Set hours offset within the day
20
+ */
21
+ atHour(hour) {
22
+ this.step.hoursOffset = hour;
23
+ return this;
24
+ }
25
+ /**
26
+ * Add actions to take
27
+ */
28
+ withActions(...actions) {
29
+ this.step.actions = actions;
30
+ return this;
31
+ }
32
+ /**
33
+ * Add notification channels
34
+ */
35
+ notify(...channels) {
36
+ this.step.notificationChannels = channels;
37
+ return this;
38
+ }
39
+ /**
40
+ * Set notification template
41
+ */
42
+ withTemplate(templateId) {
43
+ this.step.notificationTemplateId = templateId;
44
+ return this;
45
+ }
46
+ /**
47
+ * Enable payment retry in this step
48
+ */
49
+ retryPayment(retry = true) {
50
+ this.step.retryPayment = retry;
51
+ if (retry && !this.step.actions?.includes("retry_payment")) {
52
+ this.step.actions = [...this.step.actions || [], "retry_payment"];
53
+ }
54
+ return this;
55
+ }
56
+ /**
57
+ * Set access level for this step
58
+ */
59
+ setAccessLevel(level) {
60
+ this.step.accessLevel = level;
61
+ if (!this.step.actions?.includes("limit_features")) {
62
+ this.step.actions = [...this.step.actions || [], "limit_features"];
63
+ }
64
+ return this;
65
+ }
66
+ /**
67
+ * Mark this as the final step
68
+ */
69
+ final(isFinal = true) {
70
+ this.step.isFinal = isFinal;
71
+ return this;
72
+ }
73
+ /**
74
+ * Add custom action handler
75
+ */
76
+ withCustomAction(handler) {
77
+ this.step.customAction = handler;
78
+ if (!this.step.actions?.includes("custom")) {
79
+ this.step.actions = [...this.step.actions || [], "custom"];
80
+ }
81
+ return this;
82
+ }
83
+ /**
84
+ * Add condition for this step
85
+ */
86
+ when(condition) {
87
+ this.step.condition = condition;
88
+ return this;
89
+ }
90
+ /**
91
+ * Add metadata
92
+ */
93
+ withMetadata(metadata) {
94
+ this.step.metadata = metadata;
95
+ return this;
96
+ }
97
+ /**
98
+ * Build the step
99
+ */
100
+ build() {
101
+ if (!this.step.id || !this.step.name || this.step.daysAfterFailure === void 0) {
102
+ throw new Error("DunningStep requires id, name, and daysAfterFailure");
103
+ }
104
+ const result = {
105
+ id: this.step.id,
106
+ name: this.step.name,
107
+ daysAfterFailure: this.step.daysAfterFailure,
108
+ actions: this.step.actions || []
109
+ };
110
+ if (this.step.hoursOffset !== void 0) result.hoursOffset = this.step.hoursOffset;
111
+ if (this.step.notificationChannels !== void 0)
112
+ result.notificationChannels = this.step.notificationChannels;
113
+ if (this.step.notificationTemplateId !== void 0)
114
+ result.notificationTemplateId = this.step.notificationTemplateId;
115
+ if (this.step.retryPayment !== void 0) result.retryPayment = this.step.retryPayment;
116
+ if (this.step.accessLevel !== void 0) result.accessLevel = this.step.accessLevel;
117
+ if (this.step.isFinal !== void 0) result.isFinal = this.step.isFinal;
118
+ if (this.step.customAction !== void 0) result.customAction = this.step.customAction;
119
+ if (this.step.condition !== void 0) result.condition = this.step.condition;
120
+ if (this.step.metadata !== void 0) result.metadata = this.step.metadata;
121
+ return result;
122
+ }
123
+ };
124
+ function step(id, name) {
125
+ return new DunningStepBuilder(id, name);
126
+ }
127
+ var DunningSequenceBuilder = class {
128
+ sequence = {
129
+ steps: [],
130
+ isActive: true
131
+ };
132
+ constructor(id, name) {
133
+ this.sequence.id = id;
134
+ this.sequence.name = name;
135
+ }
136
+ /**
137
+ * Set description
138
+ */
139
+ describe(description) {
140
+ this.sequence.description = description;
141
+ return this;
142
+ }
143
+ /**
144
+ * Add steps
145
+ */
146
+ withSteps(...steps) {
147
+ this.sequence.steps = steps;
148
+ return this;
149
+ }
150
+ /**
151
+ * Set maximum duration before auto-cancel
152
+ */
153
+ maxDays(days) {
154
+ this.sequence.maxDurationDays = days;
155
+ return this;
156
+ }
157
+ /**
158
+ * Set active status
159
+ */
160
+ active(isActive = true) {
161
+ this.sequence.isActive = isActive;
162
+ return this;
163
+ }
164
+ /**
165
+ * Add metadata
166
+ */
167
+ withMetadata(metadata) {
168
+ this.sequence.metadata = metadata;
169
+ return this;
170
+ }
171
+ /**
172
+ * Build the sequence
173
+ */
174
+ build() {
175
+ if (!this.sequence.id || !this.sequence.name || !this.sequence.maxDurationDays) {
176
+ throw new Error("DunningSequence requires id, name, and maxDurationDays");
177
+ }
178
+ const sortedSteps = [...this.sequence.steps || []].sort(
179
+ (a, b) => a.daysAfterFailure - b.daysAfterFailure
180
+ );
181
+ const result = {
182
+ id: this.sequence.id,
183
+ name: this.sequence.name,
184
+ steps: sortedSteps,
185
+ maxDurationDays: this.sequence.maxDurationDays,
186
+ isActive: this.sequence.isActive ?? true
187
+ };
188
+ if (this.sequence.description !== void 0) result.description = this.sequence.description;
189
+ if (this.sequence.metadata !== void 0) result.metadata = this.sequence.metadata;
190
+ return result;
191
+ }
192
+ };
193
+ function sequence(id, name) {
194
+ return new DunningSequenceBuilder(id, name);
195
+ }
196
+ var standardSaasSequence = sequence("standard-saas", "Standard SaaS Dunning").describe("Standard 28-day dunning sequence for SaaS applications").maxDays(28).withSteps(
197
+ step("immediate-retry", "Immediate Retry").afterDays(0).withActions("retry_payment", "notify").notify("email").withTemplate("dunning-payment-failed").retryPayment().build(),
198
+ step("day-1-reminder", "Day 1 Reminder").afterDays(1).atHour(10).withActions("retry_payment", "notify").notify("email").withTemplate("dunning-reminder").retryPayment().build(),
199
+ step("day-3-warning", "Day 3 Warning").afterDays(3).atHour(10).withActions("retry_payment", "notify").notify("email").withTemplate("dunning-warning").retryPayment().build(),
200
+ step("day-7-limit", "Day 7 Feature Limit").afterDays(7).atHour(10).withActions("retry_payment", "notify", "limit_features").notify("email", "in_app").withTemplate("dunning-feature-limit").retryPayment().setAccessLevel("limited").build(),
201
+ step("day-14-suspend", "Day 14 Suspension").afterDays(14).atHour(10).withActions("retry_payment", "notify", "suspend").notify("email", "in_app").withTemplate("dunning-suspension").retryPayment().setAccessLevel("read_only").build(),
202
+ step("day-21-final-warning", "Day 21 Final Warning").afterDays(21).atHour(10).withActions("notify").notify("email").withTemplate("dunning-final-warning").build(),
203
+ step("day-28-cancel", "Day 28 Cancellation").afterDays(28).atHour(10).withActions("notify", "cancel").notify("email").withTemplate("dunning-canceled").final().setAccessLevel("none").build()
204
+ ).build();
205
+ var aggressiveSequence = sequence("aggressive", "Aggressive Dunning").describe("Aggressive 14-day dunning sequence").maxDays(14).withSteps(
206
+ step("immediate", "Immediate Retry").afterDays(0).withActions("retry_payment", "notify").notify("email").withTemplate("dunning-payment-failed").retryPayment().build(),
207
+ step("day-1", "Day 1").afterDays(1).withActions("retry_payment", "notify").notify("email", "sms").withTemplate("dunning-urgent").retryPayment().build(),
208
+ step("day-3-limit", "Day 3 Limit").afterDays(3).withActions("retry_payment", "notify", "limit_features").notify("email", "in_app").withTemplate("dunning-feature-limit").retryPayment().setAccessLevel("limited").build(),
209
+ step("day-7-suspend", "Day 7 Suspend").afterDays(7).withActions("retry_payment", "notify", "suspend").notify("email", "sms", "in_app").withTemplate("dunning-suspension").retryPayment().setAccessLevel("read_only").build(),
210
+ step("day-14-cancel", "Day 14 Cancel").afterDays(14).withActions("notify", "cancel").notify("email").withTemplate("dunning-canceled").final().setAccessLevel("none").build()
211
+ ).build();
212
+ var lenientSequence = sequence("lenient", "Lenient Dunning").describe("Lenient 45-day dunning sequence for enterprise customers").maxDays(45).withSteps(
213
+ step("immediate", "Immediate Retry").afterDays(0).withActions("retry_payment", "notify").notify("email").withTemplate("dunning-payment-failed-enterprise").retryPayment().build(),
214
+ step("day-3", "Day 3 Reminder").afterDays(3).withActions("retry_payment", "notify").notify("email").withTemplate("dunning-reminder-enterprise").retryPayment().build(),
215
+ step("day-7", "Day 7 Reminder").afterDays(7).withActions("retry_payment", "notify").notify("email").withTemplate("dunning-reminder-enterprise").retryPayment().build(),
216
+ step("day-14", "Day 14 Warning").afterDays(14).withActions("retry_payment", "notify").notify("email", "in_app").withTemplate("dunning-warning-enterprise").retryPayment().build(),
217
+ step("day-21-limit", "Day 21 Feature Limit").afterDays(21).withActions("retry_payment", "notify", "limit_features").notify("email", "in_app").withTemplate("dunning-feature-limit-enterprise").retryPayment().setAccessLevel("limited").build(),
218
+ step("day-30-suspend", "Day 30 Suspension").afterDays(30).withActions("retry_payment", "notify", "suspend").notify("email", "in_app").withTemplate("dunning-suspension-enterprise").retryPayment().setAccessLevel("read_only").build(),
219
+ step("day-40-final", "Day 40 Final Warning").afterDays(40).withActions("notify").notify("email").withTemplate("dunning-final-warning-enterprise").build(),
220
+ step("day-45-cancel", "Day 45 Cancel").afterDays(45).withActions("notify", "cancel").notify("email").withTemplate("dunning-canceled-enterprise").final().setAccessLevel("none").build()
221
+ ).build();
222
+ var minimalSequence = sequence("minimal", "Minimal Dunning").describe("Minimal 7-day dunning sequence").maxDays(7).withSteps(
223
+ step("immediate", "Immediate Retry").afterDays(0).withActions("retry_payment", "notify").notify("email").withTemplate("dunning-payment-failed").retryPayment().build(),
224
+ step("day-3", "Day 3").afterDays(3).withActions("retry_payment", "notify").notify("email").withTemplate("dunning-reminder").retryPayment().build(),
225
+ step("day-7-cancel", "Day 7 Cancel").afterDays(7).withActions("notify", "cancel").notify("email").withTemplate("dunning-canceled").final().setAccessLevel("none").build()
226
+ ).build();
227
+ var defaultSequences = {
228
+ standard: standardSaasSequence,
229
+ aggressive: aggressiveSequence,
230
+ lenient: lenientSequence,
231
+ minimal: minimalSequence
232
+ };
233
+ function getSequenceByTier(tier) {
234
+ if (tier >= 3) return lenientSequence;
235
+ if (tier >= 2) return standardSaasSequence;
236
+ if (tier >= 1) return aggressiveSequence;
237
+ return minimalSequence;
238
+ }
239
+
240
+ // src/dunning/payment-retry.ts
241
+ var stripeErrorCodes = {
242
+ provider: "stripe",
243
+ codes: {
244
+ // Card declined
245
+ card_declined: "card_declined",
246
+ generic_decline: "card_declined",
247
+ do_not_honor: "card_declined",
248
+ transaction_not_allowed: "card_declined",
249
+ // Insufficient funds
250
+ insufficient_funds: "insufficient_funds",
251
+ // Card expired/invalid
252
+ expired_card: "card_expired",
253
+ invalid_expiry_month: "card_expired",
254
+ invalid_expiry_year: "card_expired",
255
+ invalid_number: "invalid_card",
256
+ invalid_cvc: "invalid_card",
257
+ incorrect_number: "invalid_card",
258
+ incorrect_cvc: "invalid_card",
259
+ // Processing errors (retry immediately)
260
+ processing_error: "processing_error",
261
+ try_again_later: "processing_error",
262
+ bank_not_supported: "processing_error",
263
+ // Authentication required
264
+ authentication_required: "authentication_required",
265
+ card_not_supported: "authentication_required",
266
+ // Fraud
267
+ fraudulent: "fraud_suspected",
268
+ merchant_blacklist: "fraud_suspected",
269
+ stolen_card: "fraud_suspected",
270
+ lost_card: "fraud_suspected",
271
+ // Rate limits
272
+ rate_limit: "velocity_exceeded"
273
+ }
274
+ };
275
+ var paddleErrorCodes = {
276
+ provider: "paddle",
277
+ codes: {
278
+ declined: "card_declined",
279
+ insufficient_funds: "insufficient_funds",
280
+ card_expired: "card_expired",
281
+ invalid_card: "invalid_card",
282
+ processing_error: "processing_error",
283
+ authentication_required: "authentication_required",
284
+ fraud: "fraud_suspected"
285
+ }
286
+ };
287
+ var iyzicoErrorCodes = {
288
+ provider: "iyzico",
289
+ codes: {
290
+ // Turkish bank error codes
291
+ "10051": "insufficient_funds",
292
+ // Yetersiz bakiye
293
+ "10054": "card_expired",
294
+ // Süresi dolmuş kart
295
+ "10057": "card_declined",
296
+ // İşlem onaylanmadı
297
+ "10005": "invalid_card",
298
+ // Geçersiz kart
299
+ "10012": "invalid_card",
300
+ // Geçersiz işlem
301
+ "10041": "fraud_suspected",
302
+ // Kayıp kart
303
+ "10043": "fraud_suspected",
304
+ // Çalıntı kart
305
+ "10058": "card_declined",
306
+ // Terminal işlem yapma yetkisi yok
307
+ "10034": "fraud_suspected"
308
+ // Dolandırıcılık şüphesi
309
+ }
310
+ };
311
+ var allErrorCodeMappings = [
312
+ stripeErrorCodes,
313
+ paddleErrorCodes,
314
+ iyzicoErrorCodes
315
+ ];
316
+ var defaultRetryStrategies = [
317
+ {
318
+ category: "card_declined",
319
+ shouldRetry: true,
320
+ initialDelayHours: 24,
321
+ // Wait a day
322
+ maxRetries: 4,
323
+ backoffMultiplier: 2,
324
+ maxDelayHours: 168,
325
+ // 1 week max
326
+ optimalRetryHours: [10, 14, 18],
327
+ // Business hours
328
+ optimalRetryDays: [1, 2, 3, 4, 5]
329
+ // Weekdays
330
+ },
331
+ {
332
+ category: "insufficient_funds",
333
+ shouldRetry: true,
334
+ initialDelayHours: 72,
335
+ // Wait until likely payday
336
+ maxRetries: 4,
337
+ backoffMultiplier: 1.5,
338
+ maxDelayHours: 168,
339
+ // Optimal times: end of month, mid-month (paydays)
340
+ optimalRetryDays: [0, 1, 15, 16, 28, 29, 30, 31].map((d) => d % 7)
341
+ // Around paydays
342
+ },
343
+ {
344
+ category: "card_expired",
345
+ shouldRetry: false,
346
+ // Don't retry - needs card update
347
+ initialDelayHours: 0,
348
+ maxRetries: 0,
349
+ backoffMultiplier: 1,
350
+ maxDelayHours: 0
351
+ },
352
+ {
353
+ category: "invalid_card",
354
+ shouldRetry: false,
355
+ // Don't retry - needs card update
356
+ initialDelayHours: 0,
357
+ maxRetries: 0,
358
+ backoffMultiplier: 1,
359
+ maxDelayHours: 0
360
+ },
361
+ {
362
+ category: "processing_error",
363
+ shouldRetry: true,
364
+ initialDelayHours: 1,
365
+ // Retry soon
366
+ maxRetries: 5,
367
+ backoffMultiplier: 2,
368
+ maxDelayHours: 24
369
+ },
370
+ {
371
+ category: "authentication_required",
372
+ shouldRetry: false,
373
+ // Needs customer action (3DS)
374
+ initialDelayHours: 0,
375
+ maxRetries: 0,
376
+ backoffMultiplier: 1,
377
+ maxDelayHours: 0
378
+ },
379
+ {
380
+ category: "fraud_suspected",
381
+ shouldRetry: false,
382
+ // Never retry fraud
383
+ initialDelayHours: 0,
384
+ maxRetries: 0,
385
+ backoffMultiplier: 1,
386
+ maxDelayHours: 0
387
+ },
388
+ {
389
+ category: "velocity_exceeded",
390
+ shouldRetry: true,
391
+ initialDelayHours: 6,
392
+ // Wait for rate limit reset
393
+ maxRetries: 3,
394
+ backoffMultiplier: 2,
395
+ maxDelayHours: 48
396
+ },
397
+ {
398
+ category: "unknown",
399
+ shouldRetry: true,
400
+ // Cautious retry
401
+ initialDelayHours: 24,
402
+ maxRetries: 2,
403
+ backoffMultiplier: 2,
404
+ maxDelayHours: 72
405
+ }
406
+ ];
407
+ var PaymentRetryCalculator = class {
408
+ strategies;
409
+ errorMappings;
410
+ logger;
411
+ constructor(strategies = defaultRetryStrategies, errorMappings = allErrorCodeMappings, logger) {
412
+ this.strategies = new Map(strategies.map((s) => [s.category, s]));
413
+ this.errorMappings = /* @__PURE__ */ new Map();
414
+ if (logger) {
415
+ this.logger = logger;
416
+ }
417
+ for (const mapping of errorMappings) {
418
+ this.errorMappings.set(mapping.provider, new Map(Object.entries(mapping.codes)));
419
+ }
420
+ }
421
+ /**
422
+ * Map error code to failure category
423
+ */
424
+ categorizeError(provider, errorCode) {
425
+ const providerMapping = this.errorMappings.get(provider.toLowerCase());
426
+ if (providerMapping) {
427
+ const category = providerMapping.get(errorCode.toLowerCase());
428
+ if (category) return category;
429
+ }
430
+ return "unknown";
431
+ }
432
+ /**
433
+ * Get retry strategy for failure category
434
+ */
435
+ getStrategy(category) {
436
+ return this.strategies.get(category) ?? {
437
+ category: "unknown",
438
+ shouldRetry: true,
439
+ initialDelayHours: 24,
440
+ maxRetries: 2,
441
+ backoffMultiplier: 2,
442
+ maxDelayHours: 72
443
+ };
444
+ }
445
+ /**
446
+ * Check if a failure should be retried
447
+ */
448
+ shouldRetry(failure) {
449
+ const strategy = this.getStrategy(failure.category);
450
+ if (!strategy.shouldRetry) {
451
+ this.logger?.debug("Retry not allowed for category", {
452
+ category: failure.category,
453
+ failureId: failure.id
454
+ });
455
+ return false;
456
+ }
457
+ if (failure.retryCount >= strategy.maxRetries) {
458
+ this.logger?.debug("Max retries reached", {
459
+ category: failure.category,
460
+ retryCount: failure.retryCount,
461
+ maxRetries: strategy.maxRetries
462
+ });
463
+ return false;
464
+ }
465
+ return true;
466
+ }
467
+ /**
468
+ * Calculate next retry time
469
+ */
470
+ calculateNextRetry(failure) {
471
+ if (!this.shouldRetry(failure)) {
472
+ return null;
473
+ }
474
+ const strategy = this.getStrategy(failure.category);
475
+ const baseDelay = strategy.initialDelayHours;
476
+ const multiplier = Math.pow(strategy.backoffMultiplier, failure.retryCount);
477
+ let delayHours = Math.min(baseDelay * multiplier, strategy.maxDelayHours);
478
+ let retryTime = new Date(failure.failedAt.getTime() + delayHours * 60 * 60 * 1e3);
479
+ retryTime = this.optimizeRetryTime(retryTime, strategy);
480
+ this.logger?.debug("Calculated next retry time", {
481
+ failureId: failure.id,
482
+ category: failure.category,
483
+ retryCount: failure.retryCount,
484
+ delayHours,
485
+ nextRetry: retryTime.toISOString()
486
+ });
487
+ return retryTime;
488
+ }
489
+ /**
490
+ * Optimize retry time based on strategy
491
+ */
492
+ optimizeRetryTime(baseTime, strategy) {
493
+ const optimalHours = strategy.optimalRetryHours;
494
+ const optimalDays = strategy.optimalRetryDays;
495
+ if (!optimalHours?.length && !optimalDays?.length) {
496
+ return baseTime;
497
+ }
498
+ let optimizedTime = new Date(baseTime);
499
+ if (optimalHours?.length) {
500
+ const currentHour = optimizedTime.getHours();
501
+ const nearestOptimalHour = this.findNearestValue(currentHour, optimalHours);
502
+ if (nearestOptimalHour !== currentHour) {
503
+ optimizedTime.setHours(nearestOptimalHour, 0, 0, 0);
504
+ if (nearestOptimalHour < currentHour) {
505
+ optimizedTime.setDate(optimizedTime.getDate() + 1);
506
+ }
507
+ }
508
+ }
509
+ if (optimalDays?.length) {
510
+ const currentDay = optimizedTime.getDay();
511
+ const nearestOptimalDay = this.findNearestValue(currentDay, optimalDays);
512
+ if (nearestOptimalDay !== currentDay) {
513
+ const daysToAdd = (nearestOptimalDay - currentDay + 7) % 7;
514
+ optimizedTime.setDate(optimizedTime.getDate() + (daysToAdd || 7));
515
+ }
516
+ }
517
+ if (optimizedTime < baseTime) {
518
+ return baseTime;
519
+ }
520
+ return optimizedTime;
521
+ }
522
+ /**
523
+ * Find nearest value in array
524
+ */
525
+ findNearestValue(current, values) {
526
+ const firstValue = values[0];
527
+ if (firstValue === void 0) {
528
+ return current;
529
+ }
530
+ let nearest = firstValue;
531
+ let minDiff = Math.abs(current - nearest);
532
+ for (const value of values) {
533
+ const diff = Math.abs(current - value);
534
+ if (diff < minDiff) {
535
+ minDiff = diff;
536
+ nearest = value;
537
+ }
538
+ }
539
+ return nearest;
540
+ }
541
+ /**
542
+ * Check if failure is recoverable (can be retried eventually)
543
+ */
544
+ isRecoverable(category) {
545
+ const strategy = this.getStrategy(category);
546
+ return strategy.shouldRetry;
547
+ }
548
+ /**
549
+ * Get recommendation message for failure category
550
+ */
551
+ getRecommendation(category) {
552
+ switch (category) {
553
+ case "card_declined":
554
+ return "The payment was declined. We'll retry automatically.";
555
+ case "insufficient_funds":
556
+ return "There were insufficient funds. We'll retry around payday.";
557
+ case "card_expired":
558
+ return "Your card has expired. Please update your payment method.";
559
+ case "invalid_card":
560
+ return "The card information is invalid. Please update your payment method.";
561
+ case "processing_error":
562
+ return "A temporary processing error occurred. We'll retry shortly.";
563
+ case "authentication_required":
564
+ return "Additional authentication is required. Please complete the payment manually.";
565
+ case "fraud_suspected":
566
+ return "The payment was flagged. Please contact your bank or use a different card.";
567
+ case "velocity_exceeded":
568
+ return "Too many payment attempts. We'll retry later.";
569
+ default:
570
+ return "An error occurred. We'll retry the payment.";
571
+ }
572
+ }
573
+ };
574
+ var PaymentRetrier = class {
575
+ calculator;
576
+ retryPayment;
577
+ logger;
578
+ maxSessionRetries;
579
+ constructor(config) {
580
+ this.calculator = config.calculator ?? new PaymentRetryCalculator(void 0, void 0, config.logger);
581
+ this.retryPayment = config.retryPayment;
582
+ if (config.logger) {
583
+ this.logger = config.logger;
584
+ }
585
+ this.maxSessionRetries = config.maxSessionRetries ?? 10;
586
+ }
587
+ /**
588
+ * Attempt to retry a payment
589
+ */
590
+ async retry(context) {
591
+ const failure = context.latestFailure;
592
+ if (!this.calculator.shouldRetry(failure)) {
593
+ this.logger?.info("Payment retry skipped - not recoverable", {
594
+ failureId: failure.id,
595
+ category: failure.category,
596
+ reason: this.calculator.getRecommendation(failure.category)
597
+ });
598
+ return {
599
+ success: false,
600
+ failure,
601
+ attemptedAt: /* @__PURE__ */ new Date()
602
+ };
603
+ }
604
+ if (context.state.totalRetryAttempts >= this.maxSessionRetries) {
605
+ this.logger?.warn("Payment retry skipped - session limit reached", {
606
+ customerId: context.customer.id,
607
+ totalAttempts: context.state.totalRetryAttempts,
608
+ maxAttempts: this.maxSessionRetries
609
+ });
610
+ return {
611
+ success: false,
612
+ failure,
613
+ attemptedAt: /* @__PURE__ */ new Date()
614
+ };
615
+ }
616
+ this.logger?.info("Attempting payment retry", {
617
+ failureId: failure.id,
618
+ customerId: context.customer.id,
619
+ amount: failure.amount,
620
+ retryCount: failure.retryCount
621
+ });
622
+ try {
623
+ const result = await this.retryPayment(context);
624
+ if (result.success) {
625
+ this.logger?.info("Payment retry successful", {
626
+ failureId: failure.id,
627
+ transactionId: result.transactionId
628
+ });
629
+ } else {
630
+ this.logger?.info("Payment retry failed", {
631
+ failureId: failure.id,
632
+ newFailure: result.failure
633
+ });
634
+ }
635
+ return result;
636
+ } catch (error) {
637
+ this.logger?.error("Payment retry error", {
638
+ failureId: failure.id,
639
+ error: error instanceof Error ? error.message : String(error)
640
+ });
641
+ return {
642
+ success: false,
643
+ failure,
644
+ attemptedAt: /* @__PURE__ */ new Date()
645
+ };
646
+ }
647
+ }
648
+ /**
649
+ * Get next retry time for a failure
650
+ */
651
+ getNextRetryTime(failure) {
652
+ return this.calculator.calculateNextRetry(failure);
653
+ }
654
+ /**
655
+ * Check if failure is recoverable
656
+ */
657
+ isRecoverable(failure) {
658
+ return this.calculator.isRecoverable(failure.category);
659
+ }
660
+ /**
661
+ * Categorize an error code
662
+ */
663
+ categorizeError(provider, errorCode) {
664
+ return this.calculator.categorizeError(provider, errorCode);
665
+ }
666
+ };
667
+ function createPaymentRetryCalculator(strategies, errorMappings, logger) {
668
+ return new PaymentRetryCalculator(strategies, errorMappings, logger);
669
+ }
670
+ function createPaymentRetrier(config) {
671
+ return new PaymentRetrier(config);
672
+ }
673
+
674
+ // src/dunning/dunning-manager.ts
675
+ var DunningManager = class {
676
+ config;
677
+ storage;
678
+ eventHandlers = [];
679
+ logger;
680
+ constructor(config, storage) {
681
+ this.config = config;
682
+ this.storage = storage;
683
+ if (config.logger) {
684
+ this.logger = config.logger;
685
+ }
686
+ if (config.onEvent) {
687
+ this.eventHandlers.push(config.onEvent);
688
+ }
689
+ }
690
+ // ============================================================================
691
+ // Dunning Lifecycle
692
+ // ============================================================================
693
+ /**
694
+ * Start dunning process for a payment failure
695
+ */
696
+ async startDunning(failure) {
697
+ const existingState = await this.storage.getDunningState(failure.customerId);
698
+ if (existingState && existingState.status === "active") {
699
+ this.logger?.info("Dunning already active, adding failure", {
700
+ customerId: failure.customerId,
701
+ dunningId: existingState.id
702
+ });
703
+ existingState.failures.push(failure);
704
+ await this.storage.updateDunningState(existingState.id, {
705
+ failures: existingState.failures
706
+ });
707
+ return existingState;
708
+ }
709
+ const sequence2 = await this.getSequenceForCustomer(failure.customerId);
710
+ const firstStep = sequence2.steps[0];
711
+ if (!firstStep) {
712
+ throw new Error(`Dunning sequence ${sequence2.id} has no steps`);
713
+ }
714
+ const state = {
715
+ id: this.generateId(),
716
+ customerId: failure.customerId,
717
+ subscriptionId: failure.subscriptionId,
718
+ sequenceId: sequence2.id,
719
+ currentStepIndex: 0,
720
+ currentStepId: firstStep.id,
721
+ status: "active",
722
+ initialFailure: failure,
723
+ failures: [failure],
724
+ executedSteps: [],
725
+ startedAt: /* @__PURE__ */ new Date(),
726
+ nextStepAt: this.calculateStepTime(firstStep, failure.failedAt),
727
+ totalRetryAttempts: 0
728
+ };
729
+ await this.storage.saveDunningState(state);
730
+ await this.storage.recordPaymentFailure(failure);
731
+ await this.emitEvent({
732
+ type: "dunning.started",
733
+ customerId: state.customerId,
734
+ subscriptionId: state.subscriptionId,
735
+ dunningStateId: state.id,
736
+ timestamp: /* @__PURE__ */ new Date(),
737
+ data: {
738
+ sequenceId: sequence2.id,
739
+ initialFailure: failure
740
+ }
741
+ });
742
+ this.logger?.info("Dunning started", {
743
+ customerId: state.customerId,
744
+ dunningId: state.id,
745
+ sequenceId: sequence2.id
746
+ });
747
+ return state;
748
+ }
749
+ /**
750
+ * Execute the next step in dunning sequence
751
+ */
752
+ async executeStep(stateId) {
753
+ const state = await this.getDunningStateById(stateId);
754
+ if (!state || state.status !== "active") {
755
+ this.logger?.warn("Cannot execute step - invalid state", {
756
+ stateId,
757
+ status: state?.status
758
+ });
759
+ return null;
760
+ }
761
+ const sequence2 = this.getSequence(state.sequenceId);
762
+ const step2 = sequence2.steps[state.currentStepIndex];
763
+ if (!step2) {
764
+ this.logger?.warn("No step found at index", {
765
+ stateId,
766
+ stepIndex: state.currentStepIndex
767
+ });
768
+ return null;
769
+ }
770
+ const context = await this.buildContext(state, step2);
771
+ if (step2.condition) {
772
+ const shouldExecute = await step2.condition(context);
773
+ if (!shouldExecute) {
774
+ this.logger?.info("Step condition not met, skipping", {
775
+ stateId,
776
+ stepId: step2.id
777
+ });
778
+ return this.advanceToNextStep(state);
779
+ }
780
+ }
781
+ const executedStep = await this.performStepActions(context, step2);
782
+ state.executedSteps.push(executedStep);
783
+ state.lastStepAt = executedStep.executedAt;
784
+ state.totalRetryAttempts += executedStep.paymentRetried ? 1 : 0;
785
+ if (executedStep.paymentSucceeded) {
786
+ await this.recoverDunning(state, "payment_recovered");
787
+ return executedStep;
788
+ }
789
+ if (step2.isFinal) {
790
+ await this.exhaustDunning(state);
791
+ return executedStep;
792
+ }
793
+ await this.advanceToNextStep(state);
794
+ return executedStep;
795
+ }
796
+ /**
797
+ * Recover from dunning (payment successful)
798
+ */
799
+ async recoverDunning(stateOrId, reason = "payment_recovered") {
800
+ const state = typeof stateOrId === "string" ? await this.getDunningStateById(stateOrId) : stateOrId;
801
+ if (!state) return;
802
+ state.status = "recovered";
803
+ state.endedAt = /* @__PURE__ */ new Date();
804
+ state.endReason = reason;
805
+ await this.storage.updateDunningState(state.id, {
806
+ status: state.status,
807
+ endedAt: state.endedAt,
808
+ endReason: state.endReason
809
+ });
810
+ if (this.config.onAccessUpdate) {
811
+ await this.config.onAccessUpdate(state.customerId, "full");
812
+ }
813
+ await this.emitEvent({
814
+ type: "dunning.payment_recovered",
815
+ customerId: state.customerId,
816
+ subscriptionId: state.subscriptionId,
817
+ dunningStateId: state.id,
818
+ timestamp: /* @__PURE__ */ new Date(),
819
+ data: { reason }
820
+ });
821
+ this.logger?.info("Dunning recovered", {
822
+ dunningId: state.id,
823
+ customerId: state.customerId
824
+ });
825
+ }
826
+ /**
827
+ * Pause dunning process
828
+ */
829
+ async pauseDunning(stateId) {
830
+ await this.storage.updateDunningState(stateId, {
831
+ status: "paused"
832
+ });
833
+ const state = await this.getDunningStateById(stateId);
834
+ if (state) {
835
+ await this.emitEvent({
836
+ type: "dunning.paused",
837
+ customerId: state.customerId,
838
+ subscriptionId: state.subscriptionId,
839
+ dunningStateId: state.id,
840
+ timestamp: /* @__PURE__ */ new Date(),
841
+ data: {}
842
+ });
843
+ }
844
+ }
845
+ /**
846
+ * Resume paused dunning
847
+ */
848
+ async resumeDunning(stateId) {
849
+ const state = await this.getDunningStateById(stateId);
850
+ if (!state || state.status !== "paused") return;
851
+ const sequence2 = this.getSequence(state.sequenceId);
852
+ const step2 = sequence2.steps[state.currentStepIndex];
853
+ const updates = { status: "active" };
854
+ if (step2) {
855
+ updates.nextStepAt = this.calculateStepTime(step2, /* @__PURE__ */ new Date());
856
+ }
857
+ await this.storage.updateDunningState(stateId, updates);
858
+ await this.emitEvent({
859
+ type: "dunning.resumed",
860
+ customerId: state.customerId,
861
+ subscriptionId: state.subscriptionId,
862
+ dunningStateId: state.id,
863
+ timestamp: /* @__PURE__ */ new Date(),
864
+ data: {}
865
+ });
866
+ }
867
+ /**
868
+ * Cancel dunning manually
869
+ */
870
+ async cancelDunning(stateId, reason) {
871
+ const state = await this.getDunningStateById(stateId);
872
+ if (!state) return;
873
+ state.status = "canceled";
874
+ state.endedAt = /* @__PURE__ */ new Date();
875
+ state.endReason = "manually_canceled";
876
+ await this.storage.updateDunningState(stateId, {
877
+ status: state.status,
878
+ endedAt: state.endedAt,
879
+ endReason: state.endReason,
880
+ metadata: { ...state.metadata, cancelReason: reason }
881
+ });
882
+ await this.emitEvent({
883
+ type: "dunning.canceled",
884
+ customerId: state.customerId,
885
+ subscriptionId: state.subscriptionId,
886
+ dunningStateId: state.id,
887
+ timestamp: /* @__PURE__ */ new Date(),
888
+ data: { reason }
889
+ });
890
+ }
891
+ // ============================================================================
892
+ // State Queries
893
+ // ============================================================================
894
+ /**
895
+ * Get dunning state by customer ID
896
+ */
897
+ async getDunningState(customerId) {
898
+ return this.storage.getDunningState(customerId);
899
+ }
900
+ /**
901
+ * Get dunning state by ID
902
+ */
903
+ async getDunningStateById(stateId) {
904
+ const states = await this.storage.getActiveDunningStates();
905
+ return states.find((s) => s.id === stateId) ?? null;
906
+ }
907
+ /**
908
+ * Get all active dunning states
909
+ */
910
+ async getActiveDunningStates() {
911
+ return this.storage.getActiveDunningStates();
912
+ }
913
+ /**
914
+ * Get dunning states by status
915
+ */
916
+ async getDunningStatesByStatus(status) {
917
+ return this.storage.getDunningStatesByStatus(status);
918
+ }
919
+ /**
920
+ * Get scheduled steps due for execution
921
+ */
922
+ async getScheduledSteps(before) {
923
+ return this.storage.getScheduledSteps(before);
924
+ }
925
+ // ============================================================================
926
+ // Events
927
+ // ============================================================================
928
+ /**
929
+ * Register event handler
930
+ */
931
+ onEvent(handler) {
932
+ this.eventHandlers.push(handler);
933
+ return this;
934
+ }
935
+ /**
936
+ * Emit dunning event
937
+ */
938
+ async emitEvent(event) {
939
+ for (const handler of this.eventHandlers) {
940
+ try {
941
+ await handler(event);
942
+ } catch (error) {
943
+ this.logger?.error("Event handler error", {
944
+ eventType: event.type,
945
+ error: error instanceof Error ? error.message : String(error)
946
+ });
947
+ }
948
+ }
949
+ }
950
+ // ============================================================================
951
+ // Internal Methods
952
+ // ============================================================================
953
+ /**
954
+ * Perform step actions
955
+ */
956
+ async performStepActions(context, step2) {
957
+ const executed = {
958
+ stepId: step2.id,
959
+ stepName: step2.name,
960
+ executedAt: /* @__PURE__ */ new Date(),
961
+ actionsTaken: [],
962
+ paymentRetried: false,
963
+ notificationsSent: []
964
+ };
965
+ for (const action of step2.actions) {
966
+ try {
967
+ switch (action) {
968
+ case "notify":
969
+ const notifyResults = await this.sendNotifications(context, step2);
970
+ executed.notificationsSent = notifyResults.filter((r) => r.success).map((r) => r.channel);
971
+ executed.actionsTaken.push("notify");
972
+ break;
973
+ case "retry_payment":
974
+ if (this.config.onRetryPayment) {
975
+ const retryResult = await this.config.onRetryPayment(context);
976
+ executed.paymentRetried = true;
977
+ executed.paymentSucceeded = retryResult.success;
978
+ executed.actionsTaken.push("retry_payment");
979
+ await this.emitEvent({
980
+ type: "dunning.payment_retried",
981
+ customerId: context.customer.id,
982
+ subscriptionId: context.subscription.id,
983
+ dunningStateId: context.state.id,
984
+ timestamp: /* @__PURE__ */ new Date(),
985
+ data: {
986
+ success: retryResult.success,
987
+ transactionId: retryResult.transactionId
988
+ }
989
+ });
990
+ if (retryResult.success) {
991
+ return executed;
992
+ }
993
+ }
994
+ break;
995
+ case "limit_features":
996
+ if (this.config.onAccessUpdate && step2.accessLevel) {
997
+ await this.config.onAccessUpdate(context.customer.id, step2.accessLevel);
998
+ executed.actionsTaken.push("limit_features");
999
+ await this.emitEvent({
1000
+ type: "dunning.access_limited",
1001
+ customerId: context.customer.id,
1002
+ subscriptionId: context.subscription.id,
1003
+ dunningStateId: context.state.id,
1004
+ timestamp: /* @__PURE__ */ new Date(),
1005
+ data: { accessLevel: step2.accessLevel }
1006
+ });
1007
+ }
1008
+ break;
1009
+ case "suspend":
1010
+ if (this.config.onAccessUpdate) {
1011
+ await this.config.onAccessUpdate(context.customer.id, "read_only");
1012
+ executed.actionsTaken.push("suspend");
1013
+ await this.emitEvent({
1014
+ type: "dunning.suspended",
1015
+ customerId: context.customer.id,
1016
+ subscriptionId: context.subscription.id,
1017
+ dunningStateId: context.state.id,
1018
+ timestamp: /* @__PURE__ */ new Date(),
1019
+ data: {}
1020
+ });
1021
+ }
1022
+ break;
1023
+ case "cancel":
1024
+ if (this.config.onCancelSubscription) {
1025
+ await this.config.onCancelSubscription(
1026
+ context.subscription.id,
1027
+ "dunning_exhausted"
1028
+ );
1029
+ executed.actionsTaken.push("cancel");
1030
+ }
1031
+ break;
1032
+ case "custom":
1033
+ if (step2.customAction) {
1034
+ await step2.customAction(context);
1035
+ executed.actionsTaken.push("custom");
1036
+ }
1037
+ break;
1038
+ }
1039
+ } catch (error) {
1040
+ this.logger?.error("Step action failed", {
1041
+ stepId: step2.id,
1042
+ action,
1043
+ error: error instanceof Error ? error.message : String(error)
1044
+ });
1045
+ executed.error = error instanceof Error ? error.message : String(error);
1046
+ }
1047
+ }
1048
+ await this.emitEvent({
1049
+ type: "dunning.step_executed",
1050
+ customerId: context.customer.id,
1051
+ subscriptionId: context.subscription.id,
1052
+ dunningStateId: context.state.id,
1053
+ timestamp: /* @__PURE__ */ new Date(),
1054
+ data: {
1055
+ stepId: step2.id,
1056
+ stepName: step2.name,
1057
+ actionsTaken: executed.actionsTaken
1058
+ }
1059
+ });
1060
+ return executed;
1061
+ }
1062
+ /**
1063
+ * Send notifications for a step
1064
+ */
1065
+ async sendNotifications(context, step2) {
1066
+ if (!step2.notificationChannels?.length || !this.config.onNotification) {
1067
+ return [];
1068
+ }
1069
+ const results = [];
1070
+ for (const channel of step2.notificationChannels) {
1071
+ const recipient = {
1072
+ customerId: context.customer.id
1073
+ };
1074
+ if (context.customer.email) {
1075
+ recipient.email = context.customer.email;
1076
+ }
1077
+ const variables = {
1078
+ amount: context.amountOwed,
1079
+ currency: context.currency,
1080
+ daysSinceFailure: context.daysSinceFailure
1081
+ };
1082
+ if (context.customer.name) {
1083
+ variables.customerName = context.customer.name;
1084
+ }
1085
+ if (this.config.urls?.updatePayment) {
1086
+ variables.updatePaymentUrl = this.config.urls.updatePayment;
1087
+ }
1088
+ if (this.config.urls?.viewInvoice) {
1089
+ variables.invoiceUrl = this.config.urls.viewInvoice;
1090
+ }
1091
+ if (this.config.urls?.support) {
1092
+ variables.supportUrl = this.config.urls.support;
1093
+ }
1094
+ const notification = {
1095
+ channel,
1096
+ templateId: step2.notificationTemplateId ?? `dunning-${step2.id}`,
1097
+ recipient,
1098
+ variables,
1099
+ context
1100
+ };
1101
+ try {
1102
+ const result = await this.config.onNotification(notification);
1103
+ results.push(result);
1104
+ if (result.success) {
1105
+ await this.emitEvent({
1106
+ type: "dunning.notification_sent",
1107
+ customerId: context.customer.id,
1108
+ subscriptionId: context.subscription.id,
1109
+ dunningStateId: context.state.id,
1110
+ timestamp: /* @__PURE__ */ new Date(),
1111
+ data: {
1112
+ channel,
1113
+ templateId: notification.templateId
1114
+ }
1115
+ });
1116
+ }
1117
+ } catch (error) {
1118
+ results.push({
1119
+ success: false,
1120
+ channel,
1121
+ error: error instanceof Error ? error.message : String(error),
1122
+ sentAt: /* @__PURE__ */ new Date()
1123
+ });
1124
+ }
1125
+ }
1126
+ return results;
1127
+ }
1128
+ /**
1129
+ * Advance to next step
1130
+ */
1131
+ async advanceToNextStep(state) {
1132
+ const sequence2 = this.getSequence(state.sequenceId);
1133
+ const nextIndex = state.currentStepIndex + 1;
1134
+ if (nextIndex >= sequence2.steps.length) {
1135
+ await this.exhaustDunning(state);
1136
+ return null;
1137
+ }
1138
+ const nextStep = sequence2.steps[nextIndex];
1139
+ if (!nextStep) {
1140
+ await this.exhaustDunning(state);
1141
+ return null;
1142
+ }
1143
+ const nextStepTime = this.calculateStepTime(nextStep, state.startedAt);
1144
+ await this.storage.updateDunningState(state.id, {
1145
+ currentStepIndex: nextIndex,
1146
+ currentStepId: nextStep.id,
1147
+ nextStepAt: nextStepTime
1148
+ });
1149
+ await this.storage.scheduleStep(state.id, nextStep.id, nextStepTime);
1150
+ return null;
1151
+ }
1152
+ /**
1153
+ * Mark dunning as exhausted (all steps completed without recovery)
1154
+ */
1155
+ async exhaustDunning(state) {
1156
+ state.status = "exhausted";
1157
+ state.endedAt = /* @__PURE__ */ new Date();
1158
+ state.endReason = "max_retries";
1159
+ await this.storage.updateDunningState(state.id, {
1160
+ status: state.status,
1161
+ endedAt: state.endedAt,
1162
+ endReason: state.endReason
1163
+ });
1164
+ await this.emitEvent({
1165
+ type: "dunning.exhausted",
1166
+ customerId: state.customerId,
1167
+ subscriptionId: state.subscriptionId,
1168
+ dunningStateId: state.id,
1169
+ timestamp: /* @__PURE__ */ new Date(),
1170
+ data: {
1171
+ totalRetries: state.totalRetryAttempts,
1172
+ stepsExecuted: state.executedSteps.length
1173
+ }
1174
+ });
1175
+ this.logger?.info("Dunning exhausted", {
1176
+ dunningId: state.id,
1177
+ customerId: state.customerId
1178
+ });
1179
+ }
1180
+ /**
1181
+ * Build dunning context for step execution
1182
+ */
1183
+ async buildContext(state, step2) {
1184
+ const latestFailure = state.failures[state.failures.length - 1] ?? state.initialFailure;
1185
+ const customer = {
1186
+ id: state.customerId
1187
+ };
1188
+ const customerEmail = state.metadata?.["customerEmail"];
1189
+ if (typeof customerEmail === "string") {
1190
+ customer.email = customerEmail;
1191
+ }
1192
+ const customerName = state.metadata?.["customerName"];
1193
+ if (typeof customerName === "string") {
1194
+ customer.name = customerName;
1195
+ }
1196
+ const customerMetadata = state.metadata?.["customer"];
1197
+ if (customerMetadata && typeof customerMetadata === "object") {
1198
+ customer.metadata = customerMetadata;
1199
+ }
1200
+ const subscription = {
1201
+ id: state.subscriptionId,
1202
+ status: "past_due"
1203
+ };
1204
+ const planId = state.metadata?.["planId"];
1205
+ if (typeof planId === "string") {
1206
+ subscription.planId = planId;
1207
+ }
1208
+ return {
1209
+ state,
1210
+ step: step2,
1211
+ latestFailure,
1212
+ customer,
1213
+ subscription,
1214
+ daysSinceFailure: Math.floor(
1215
+ (Date.now() - state.initialFailure.failedAt.getTime()) / (1e3 * 60 * 60 * 24)
1216
+ ),
1217
+ amountOwed: state.failures.reduce((sum, f) => sum + f.amount, 0),
1218
+ currency: latestFailure.currency
1219
+ };
1220
+ }
1221
+ /**
1222
+ * Calculate when a step should execute
1223
+ */
1224
+ calculateStepTime(step2, baseTime) {
1225
+ const time = new Date(baseTime);
1226
+ time.setDate(time.getDate() + step2.daysAfterFailure);
1227
+ if (step2.hoursOffset !== void 0) {
1228
+ time.setHours(step2.hoursOffset, 0, 0, 0);
1229
+ }
1230
+ return time;
1231
+ }
1232
+ /**
1233
+ * Get sequence for a customer (by tier if configured)
1234
+ */
1235
+ async getSequenceForCustomer(_customerId) {
1236
+ return this.config.defaultSequence;
1237
+ }
1238
+ /**
1239
+ * Get sequence by ID
1240
+ */
1241
+ getSequence(sequenceId) {
1242
+ if (sequenceId === this.config.defaultSequence.id) {
1243
+ return this.config.defaultSequence;
1244
+ }
1245
+ if (this.config.sequencesByPlanTier) {
1246
+ for (const seq of Object.values(this.config.sequencesByPlanTier)) {
1247
+ if (seq.id === sequenceId) return seq;
1248
+ }
1249
+ }
1250
+ return this.config.defaultSequence;
1251
+ }
1252
+ /**
1253
+ * Generate unique ID
1254
+ */
1255
+ generateId() {
1256
+ return `dun_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
1257
+ }
1258
+ };
1259
+ function createDunningManager(config, storage) {
1260
+ return new DunningManager(config, storage);
1261
+ }
1262
+ function createDefaultDunningConfig(overrides) {
1263
+ return {
1264
+ defaultSequence: standardSaasSequence,
1265
+ ...overrides
1266
+ };
1267
+ }
1268
+
1269
+ // src/dunning/dunning-scheduler.ts
1270
+ var DunningScheduler = class {
1271
+ manager;
1272
+ pollInterval;
1273
+ batchSize;
1274
+ maxConcurrent;
1275
+ logger;
1276
+ onError;
1277
+ beforeStep;
1278
+ afterStep;
1279
+ isRunning = false;
1280
+ pollTimer;
1281
+ processingStates = /* @__PURE__ */ new Set();
1282
+ /** Timezone for scheduling (reserved for future use) */
1283
+ timezone;
1284
+ constructor(config) {
1285
+ this.manager = config.manager;
1286
+ this.pollInterval = config.pollInterval ?? 6e4;
1287
+ this.batchSize = config.batchSize ?? 50;
1288
+ this.maxConcurrent = config.maxConcurrent ?? 5;
1289
+ this.timezone = config.timezone ?? "UTC";
1290
+ if (config.logger) {
1291
+ this.logger = config.logger;
1292
+ }
1293
+ if (config.onError) {
1294
+ this.onError = config.onError;
1295
+ }
1296
+ if (config.beforeStep) {
1297
+ this.beforeStep = config.beforeStep;
1298
+ }
1299
+ if (config.afterStep) {
1300
+ this.afterStep = config.afterStep;
1301
+ }
1302
+ }
1303
+ // ============================================================================
1304
+ // Lifecycle
1305
+ // ============================================================================
1306
+ /**
1307
+ * Start the scheduler
1308
+ */
1309
+ start() {
1310
+ if (this.isRunning) {
1311
+ this.logger?.warn("Scheduler already running");
1312
+ return;
1313
+ }
1314
+ this.isRunning = true;
1315
+ this.logger?.info("Dunning scheduler started", {
1316
+ pollInterval: this.pollInterval,
1317
+ batchSize: this.batchSize,
1318
+ maxConcurrent: this.maxConcurrent
1319
+ });
1320
+ this.poll();
1321
+ }
1322
+ /**
1323
+ * Stop the scheduler
1324
+ */
1325
+ stop() {
1326
+ this.isRunning = false;
1327
+ if (this.pollTimer) {
1328
+ clearTimeout(this.pollTimer);
1329
+ delete this.pollTimer;
1330
+ }
1331
+ this.logger?.info("Dunning scheduler stopped");
1332
+ }
1333
+ /**
1334
+ * Check if scheduler is running
1335
+ */
1336
+ get running() {
1337
+ return this.isRunning;
1338
+ }
1339
+ /**
1340
+ * Get current processing count
1341
+ */
1342
+ get processingCount() {
1343
+ return this.processingStates.size;
1344
+ }
1345
+ // ============================================================================
1346
+ // Processing
1347
+ // ============================================================================
1348
+ /**
1349
+ * Poll for scheduled steps
1350
+ */
1351
+ async poll() {
1352
+ if (!this.isRunning) return;
1353
+ try {
1354
+ await this.processScheduledSteps();
1355
+ } catch (error) {
1356
+ this.logger?.error("Poll error", {
1357
+ error: error instanceof Error ? error.message : String(error)
1358
+ });
1359
+ }
1360
+ if (this.isRunning) {
1361
+ this.pollTimer = setTimeout(() => this.poll(), this.pollInterval);
1362
+ }
1363
+ }
1364
+ /**
1365
+ * Process all scheduled steps that are due
1366
+ */
1367
+ async processScheduledSteps() {
1368
+ const now = /* @__PURE__ */ new Date();
1369
+ const scheduled = await this.manager.getScheduledSteps(now);
1370
+ if (scheduled.length === 0) {
1371
+ return 0;
1372
+ }
1373
+ this.logger?.debug("Found scheduled steps", {
1374
+ count: scheduled.length,
1375
+ before: now.toISOString()
1376
+ });
1377
+ const toProcess = scheduled.filter((s) => !this.processingStates.has(s.stateId)).slice(0, this.batchSize);
1378
+ if (toProcess.length === 0) {
1379
+ return 0;
1380
+ }
1381
+ let processed = 0;
1382
+ const batches = this.chunk(toProcess, this.maxConcurrent);
1383
+ for (const batch of batches) {
1384
+ const results = await Promise.allSettled(
1385
+ batch.map((item) => this.executeScheduledStep(item.stateId, item.stepId))
1386
+ );
1387
+ processed += results.filter((r) => r.status === "fulfilled").length;
1388
+ }
1389
+ return processed;
1390
+ }
1391
+ /**
1392
+ * Execute a single scheduled step
1393
+ */
1394
+ async executeScheduledStep(stateId, stepId) {
1395
+ this.processingStates.add(stateId);
1396
+ try {
1397
+ if (this.beforeStep) {
1398
+ await this.beforeStep(stateId, stepId);
1399
+ }
1400
+ this.logger?.debug("Executing scheduled step", { stateId, stepId });
1401
+ const result = await this.manager.executeStep(stateId);
1402
+ const success = result !== null;
1403
+ this.logger?.info("Scheduled step executed", {
1404
+ stateId,
1405
+ stepId,
1406
+ success,
1407
+ actionsTaken: result?.actionsTaken
1408
+ });
1409
+ if (this.afterStep) {
1410
+ await this.afterStep(stateId, stepId, success);
1411
+ }
1412
+ } catch (error) {
1413
+ this.logger?.error("Step execution failed", {
1414
+ stateId,
1415
+ stepId,
1416
+ error: error instanceof Error ? error.message : String(error)
1417
+ });
1418
+ if (this.onError) {
1419
+ await this.onError(error instanceof Error ? error : new Error(String(error)), stateId);
1420
+ }
1421
+ } finally {
1422
+ this.processingStates.delete(stateId);
1423
+ }
1424
+ }
1425
+ /**
1426
+ * Manually trigger processing (for testing or cron jobs)
1427
+ */
1428
+ async trigger() {
1429
+ return this.processScheduledSteps();
1430
+ }
1431
+ /**
1432
+ * Process a specific dunning state immediately
1433
+ */
1434
+ async processNow(stateId) {
1435
+ const state = await this.manager.getDunningState(stateId);
1436
+ if (!state) {
1437
+ this.logger?.warn("State not found for immediate processing", { stateId });
1438
+ return false;
1439
+ }
1440
+ try {
1441
+ await this.executeScheduledStep(state.id, state.currentStepId);
1442
+ return true;
1443
+ } catch (error) {
1444
+ this.logger?.error("Immediate processing failed", {
1445
+ stateId,
1446
+ error: error instanceof Error ? error.message : String(error)
1447
+ });
1448
+ return false;
1449
+ }
1450
+ }
1451
+ // ============================================================================
1452
+ // Utilities
1453
+ // ============================================================================
1454
+ /**
1455
+ * Split array into chunks
1456
+ */
1457
+ chunk(array, size) {
1458
+ const chunks = [];
1459
+ for (let i = 0; i < array.length; i += size) {
1460
+ chunks.push(array.slice(i, i + size));
1461
+ }
1462
+ return chunks;
1463
+ }
1464
+ };
1465
+ function createDunningScheduler(config) {
1466
+ return new DunningScheduler(config);
1467
+ }
1468
+ function createDunningCronHandler(manager, options) {
1469
+ return async () => {
1470
+ const config = {
1471
+ manager,
1472
+ batchSize: options?.batchSize ?? 100,
1473
+ maxConcurrent: options?.maxConcurrent ?? 10
1474
+ };
1475
+ if (options?.logger) {
1476
+ config.logger = options.logger;
1477
+ }
1478
+ const scheduler = new DunningScheduler(config);
1479
+ let errors = 0;
1480
+ const originalOnError = scheduler["onError"];
1481
+ scheduler["onError"] = async (error, stateId) => {
1482
+ errors++;
1483
+ if (originalOnError) await originalOnError(error, stateId);
1484
+ };
1485
+ const processed = await scheduler.trigger();
1486
+ return { processed, errors };
1487
+ };
1488
+ }
1489
+ function createDunningEdgeHandler(manager, options) {
1490
+ const maxDuration = options?.maxDurationMs ?? 3e4;
1491
+ const batchSize = options?.batchSize ?? 25;
1492
+ return async () => {
1493
+ const startTime = Date.now();
1494
+ let processed = 0;
1495
+ let errors = 0;
1496
+ let timedOut = false;
1497
+ const now = /* @__PURE__ */ new Date();
1498
+ const scheduled = await manager.getScheduledSteps(now);
1499
+ for (let i = 0; i < scheduled.length; i += batchSize) {
1500
+ if (Date.now() - startTime > maxDuration) {
1501
+ timedOut = true;
1502
+ options?.logger?.warn("Edge handler timed out", {
1503
+ processed,
1504
+ remaining: scheduled.length - i
1505
+ });
1506
+ break;
1507
+ }
1508
+ const batch = scheduled.slice(i, i + batchSize);
1509
+ const results = await Promise.allSettled(
1510
+ batch.map(async (item) => {
1511
+ try {
1512
+ await manager.executeStep(item.stateId);
1513
+ return true;
1514
+ } catch (error) {
1515
+ options?.logger?.error("Step execution error", {
1516
+ stateId: item.stateId,
1517
+ error: error instanceof Error ? error.message : String(error)
1518
+ });
1519
+ throw error;
1520
+ }
1521
+ })
1522
+ );
1523
+ for (const result of results) {
1524
+ if (result.status === "fulfilled") {
1525
+ processed++;
1526
+ } else {
1527
+ errors++;
1528
+ }
1529
+ }
1530
+ }
1531
+ const duration = Date.now() - startTime;
1532
+ return { processed, errors, duration, timedOut };
1533
+ };
1534
+ }
1535
+
1536
+ // src/dunning/memory-storage.ts
1537
+ var MemoryDunningStorage = class {
1538
+ states = /* @__PURE__ */ new Map();
1539
+ failures = /* @__PURE__ */ new Map();
1540
+ scheduledSteps = [];
1541
+ // ============================================================================
1542
+ // Dunning State Methods
1543
+ // ============================================================================
1544
+ async getDunningState(customerId) {
1545
+ for (const state of this.states.values()) {
1546
+ if (state.customerId === customerId) {
1547
+ return state;
1548
+ }
1549
+ }
1550
+ return null;
1551
+ }
1552
+ async getActiveDunningStates() {
1553
+ return Array.from(this.states.values()).filter((s) => s.status === "active");
1554
+ }
1555
+ async getDunningStatesByStatus(status) {
1556
+ return Array.from(this.states.values()).filter((s) => s.status === status);
1557
+ }
1558
+ async saveDunningState(state) {
1559
+ this.states.set(state.id, { ...state });
1560
+ }
1561
+ async updateDunningState(id, updates) {
1562
+ const state = this.states.get(id);
1563
+ if (state) {
1564
+ this.states.set(id, { ...state, ...updates });
1565
+ }
1566
+ }
1567
+ // ============================================================================
1568
+ // Payment Failure Methods
1569
+ // ============================================================================
1570
+ async recordPaymentFailure(failure) {
1571
+ const customerFailures = this.failures.get(failure.customerId) ?? [];
1572
+ customerFailures.push({ ...failure });
1573
+ this.failures.set(failure.customerId, customerFailures);
1574
+ }
1575
+ async getPaymentFailures(customerId, limit = 50) {
1576
+ const customerFailures = this.failures.get(customerId) ?? [];
1577
+ return customerFailures.sort((a, b) => b.failedAt.getTime() - a.failedAt.getTime()).slice(0, limit);
1578
+ }
1579
+ // ============================================================================
1580
+ // Scheduled Steps Methods
1581
+ // ============================================================================
1582
+ async getScheduledSteps(before) {
1583
+ return this.scheduledSteps.filter((s) => s.scheduledAt <= before);
1584
+ }
1585
+ async scheduleStep(stateId, stepId, scheduledAt) {
1586
+ await this.removeScheduledStep(stateId, stepId);
1587
+ this.scheduledSteps.push({ stateId, stepId, scheduledAt });
1588
+ }
1589
+ async removeScheduledStep(stateId, stepId) {
1590
+ this.scheduledSteps = this.scheduledSteps.filter(
1591
+ (s) => !(s.stateId === stateId && s.stepId === stepId)
1592
+ );
1593
+ }
1594
+ // ============================================================================
1595
+ // Utility Methods
1596
+ // ============================================================================
1597
+ /**
1598
+ * Clear all data (for testing)
1599
+ */
1600
+ clear() {
1601
+ this.states.clear();
1602
+ this.failures.clear();
1603
+ this.scheduledSteps = [];
1604
+ }
1605
+ /**
1606
+ * Get state by ID
1607
+ */
1608
+ getStateById(id) {
1609
+ return this.states.get(id);
1610
+ }
1611
+ /**
1612
+ * Get all states (for debugging)
1613
+ */
1614
+ getAllStates() {
1615
+ return Array.from(this.states.values());
1616
+ }
1617
+ /**
1618
+ * Get all scheduled steps (for debugging)
1619
+ */
1620
+ getAllScheduledSteps() {
1621
+ return [...this.scheduledSteps];
1622
+ }
1623
+ };
1624
+ function createMemoryDunningStorage() {
1625
+ return new MemoryDunningStorage();
1626
+ }
1627
+
1628
+ // src/dunning/drizzle-storage.ts
1629
+ import { eq, and, lt, desc } from "drizzle-orm";
1630
+
1631
+ // src/dunning/schema.ts
1632
+ import {
1633
+ pgTable,
1634
+ text,
1635
+ integer,
1636
+ boolean,
1637
+ timestamp,
1638
+ jsonb,
1639
+ uniqueIndex,
1640
+ index
1641
+ } from "drizzle-orm/pg-core";
1642
+ var dunningSequences = pgTable("dunning_sequences", {
1643
+ id: text("id").primaryKey(),
1644
+ name: text("name").notNull().unique(),
1645
+ description: text("description"),
1646
+ maxDurationDays: integer("max_duration_days").notNull().default(28),
1647
+ isActive: boolean("is_active").notNull().default(true),
1648
+ isDefault: boolean("is_default").notNull().default(false),
1649
+ planTier: integer("plan_tier"),
1650
+ // null = default for all, otherwise specific tier
1651
+ metadata: jsonb("metadata").$type(),
1652
+ createdAt: timestamp("created_at").defaultNow().notNull(),
1653
+ updatedAt: timestamp("updated_at").defaultNow().notNull()
1654
+ });
1655
+ var dunningSteps = pgTable(
1656
+ "dunning_steps",
1657
+ {
1658
+ id: text("id").primaryKey(),
1659
+ sequenceId: text("sequence_id").notNull().references(() => dunningSequences.id, { onDelete: "cascade" }),
1660
+ name: text("name").notNull(),
1661
+ stepOrder: integer("step_order").notNull(),
1662
+ // Order within sequence
1663
+ daysAfterFailure: integer("days_after_failure").notNull().default(0),
1664
+ hoursOffset: integer("hours_offset"),
1665
+ // Hour of day to execute (0-23)
1666
+ actions: jsonb("actions").$type().notNull().default([]),
1667
+ // Array of action types
1668
+ notificationChannels: jsonb("notification_channels").$type(),
1669
+ // email, sms, in_app, webhook, push
1670
+ notificationTemplateId: text("notification_template_id"),
1671
+ accessLevel: text("access_level"),
1672
+ // full, limited, read_only, none
1673
+ isFinal: boolean("is_final").notNull().default(false),
1674
+ metadata: jsonb("metadata").$type()
1675
+ },
1676
+ (table) => ({
1677
+ sequenceOrderIdx: uniqueIndex("dunning_steps_sequence_order_idx").on(
1678
+ table.sequenceId,
1679
+ table.stepOrder
1680
+ ),
1681
+ sequenceIdx: index("dunning_steps_sequence_idx").on(table.sequenceId)
1682
+ })
1683
+ );
1684
+ var paymentFailures = pgTable(
1685
+ "dunning_payment_failures",
1686
+ {
1687
+ id: text("id").primaryKey(),
1688
+ customerId: text("customer_id").notNull(),
1689
+ subscriptionId: text("subscription_id").notNull(),
1690
+ invoiceId: text("invoice_id"),
1691
+ amount: integer("amount").notNull(),
1692
+ // cents
1693
+ currency: text("currency").notNull().default("usd"),
1694
+ category: text("category").notNull(),
1695
+ // card_declined, insufficient_funds, etc.
1696
+ errorCode: text("error_code").notNull(),
1697
+ errorMessage: text("error_message").notNull(),
1698
+ provider: text("provider").notNull(),
1699
+ // stripe, paddle, iyzico
1700
+ failedAt: timestamp("failed_at").notNull(),
1701
+ retryCount: integer("retry_count").notNull().default(0),
1702
+ nextRetryAt: timestamp("next_retry_at"),
1703
+ isRecoverable: boolean("is_recoverable").notNull().default(true),
1704
+ metadata: jsonb("metadata").$type()
1705
+ },
1706
+ (table) => ({
1707
+ customerIdx: index("dunning_payment_failures_customer_idx").on(table.customerId),
1708
+ subscriptionIdx: index("dunning_payment_failures_subscription_idx").on(
1709
+ table.subscriptionId
1710
+ ),
1711
+ failedAtIdx: index("dunning_payment_failures_failed_at_idx").on(table.failedAt),
1712
+ categoryIdx: index("dunning_payment_failures_category_idx").on(table.category)
1713
+ })
1714
+ );
1715
+ var dunningStates = pgTable(
1716
+ "dunning_states",
1717
+ {
1718
+ id: text("id").primaryKey(),
1719
+ customerId: text("customer_id").notNull(),
1720
+ subscriptionId: text("subscription_id").notNull(),
1721
+ sequenceId: text("sequence_id").notNull().references(() => dunningSequences.id),
1722
+ currentStepIndex: integer("current_step_index").notNull().default(0),
1723
+ currentStepId: text("current_step_id").notNull(),
1724
+ status: text("status").notNull().default("active"),
1725
+ // active, recovered, exhausted, canceled, paused
1726
+ initialFailureId: text("initial_failure_id").notNull().references(() => paymentFailures.id),
1727
+ failureIds: jsonb("failure_ids").$type().notNull().default([]),
1728
+ // All failure IDs
1729
+ startedAt: timestamp("started_at").notNull(),
1730
+ lastStepAt: timestamp("last_step_at"),
1731
+ nextStepAt: timestamp("next_step_at"),
1732
+ endedAt: timestamp("ended_at"),
1733
+ endReason: text("end_reason"),
1734
+ // payment_recovered, max_retries, manually_canceled, subscription_canceled
1735
+ totalRetryAttempts: integer("total_retry_attempts").notNull().default(0),
1736
+ metadata: jsonb("metadata").$type()
1737
+ },
1738
+ (table) => ({
1739
+ customerIdx: uniqueIndex("dunning_states_customer_idx").on(table.customerId),
1740
+ statusIdx: index("dunning_states_status_idx").on(table.status),
1741
+ nextStepIdx: index("dunning_states_next_step_idx").on(table.nextStepAt),
1742
+ subscriptionIdx: index("dunning_states_subscription_idx").on(table.subscriptionId)
1743
+ })
1744
+ );
1745
+ var executedSteps = pgTable(
1746
+ "dunning_executed_steps",
1747
+ {
1748
+ id: text("id").primaryKey(),
1749
+ dunningStateId: text("dunning_state_id").notNull().references(() => dunningStates.id, { onDelete: "cascade" }),
1750
+ stepId: text("step_id").notNull(),
1751
+ stepName: text("step_name").notNull(),
1752
+ executedAt: timestamp("executed_at").notNull(),
1753
+ actionsTaken: jsonb("actions_taken").$type().notNull().default([]),
1754
+ paymentRetried: boolean("payment_retried").notNull().default(false),
1755
+ paymentSucceeded: boolean("payment_succeeded"),
1756
+ notificationsSent: jsonb("notifications_sent").$type().notNull().default([]),
1757
+ error: text("error"),
1758
+ metadata: jsonb("metadata").$type()
1759
+ },
1760
+ (table) => ({
1761
+ stateIdx: index("dunning_executed_steps_state_idx").on(table.dunningStateId),
1762
+ executedAtIdx: index("dunning_executed_steps_executed_at_idx").on(table.executedAt)
1763
+ })
1764
+ );
1765
+ var scheduledSteps = pgTable(
1766
+ "dunning_scheduled_steps",
1767
+ {
1768
+ id: text("id").primaryKey(),
1769
+ dunningStateId: text("dunning_state_id").notNull().references(() => dunningStates.id, { onDelete: "cascade" }),
1770
+ stepId: text("step_id").notNull(),
1771
+ scheduledAt: timestamp("scheduled_at").notNull(),
1772
+ createdAt: timestamp("created_at").defaultNow().notNull()
1773
+ },
1774
+ (table) => ({
1775
+ stateStepIdx: uniqueIndex("dunning_scheduled_steps_state_step_idx").on(
1776
+ table.dunningStateId,
1777
+ table.stepId
1778
+ ),
1779
+ scheduledAtIdx: index("dunning_scheduled_steps_scheduled_at_idx").on(
1780
+ table.scheduledAt
1781
+ )
1782
+ })
1783
+ );
1784
+ var dunningEvents = pgTable(
1785
+ "dunning_events",
1786
+ {
1787
+ id: text("id").primaryKey(),
1788
+ type: text("type").notNull(),
1789
+ // dunning.started, dunning.step_executed, etc.
1790
+ customerId: text("customer_id").notNull(),
1791
+ subscriptionId: text("subscription_id").notNull(),
1792
+ dunningStateId: text("dunning_state_id").notNull().references(() => dunningStates.id, { onDelete: "cascade" }),
1793
+ timestamp: timestamp("timestamp").notNull(),
1794
+ data: jsonb("data").$type().notNull().default({})
1795
+ },
1796
+ (table) => ({
1797
+ typeIdx: index("dunning_events_type_idx").on(table.type),
1798
+ customerIdx: index("dunning_events_customer_idx").on(table.customerId),
1799
+ timestampIdx: index("dunning_events_timestamp_idx").on(table.timestamp),
1800
+ stateIdx: index("dunning_events_state_idx").on(table.dunningStateId)
1801
+ })
1802
+ );
1803
+ var retryStrategies = pgTable(
1804
+ "dunning_retry_strategies",
1805
+ {
1806
+ id: text("id").primaryKey(),
1807
+ category: text("category").notNull().unique(),
1808
+ // failure category
1809
+ shouldRetry: boolean("should_retry").notNull().default(true),
1810
+ initialDelayHours: integer("initial_delay_hours").notNull().default(24),
1811
+ maxRetries: integer("max_retries").notNull().default(4),
1812
+ backoffMultiplier: integer("backoff_multiplier").notNull().default(2),
1813
+ // stored as x100 for decimals
1814
+ maxDelayHours: integer("max_delay_hours").notNull().default(168),
1815
+ optimalRetryHours: jsonb("optimal_retry_hours").$type(),
1816
+ optimalRetryDays: jsonb("optimal_retry_days").$type(),
1817
+ metadata: jsonb("metadata").$type(),
1818
+ updatedAt: timestamp("updated_at").defaultNow().notNull()
1819
+ },
1820
+ (table) => ({
1821
+ categoryIdx: uniqueIndex("dunning_retry_strategies_category_idx").on(table.category)
1822
+ })
1823
+ );
1824
+ var dunningSchema = {
1825
+ dunningSequences,
1826
+ dunningSteps,
1827
+ paymentFailures,
1828
+ dunningStates,
1829
+ executedSteps,
1830
+ scheduledSteps,
1831
+ dunningEvents,
1832
+ retryStrategies
1833
+ };
1834
+
1835
+ // src/dunning/drizzle-storage.ts
1836
+ var DrizzleDunningStorage = class {
1837
+ db;
1838
+ constructor(config) {
1839
+ this.db = config.db;
1840
+ }
1841
+ // ============================================================================
1842
+ // Dunning State Methods
1843
+ // ============================================================================
1844
+ async getDunningState(customerId) {
1845
+ const rows = await this.db.select().from(dunningStates).where(eq(dunningStates.customerId, customerId));
1846
+ const row = rows[0];
1847
+ if (!row) return null;
1848
+ return this.mapRowToState(row);
1849
+ }
1850
+ async getActiveDunningStates() {
1851
+ const rows = await this.db.select().from(dunningStates).where(eq(dunningStates.status, "active"));
1852
+ return Promise.all(rows.map((row) => this.mapRowToState(row)));
1853
+ }
1854
+ async getDunningStatesByStatus(status) {
1855
+ const rows = await this.db.select().from(dunningStates).where(eq(dunningStates.status, status));
1856
+ return Promise.all(rows.map((row) => this.mapRowToState(row)));
1857
+ }
1858
+ async saveDunningState(state) {
1859
+ await this.db.insert(dunningStates).values({
1860
+ id: state.id,
1861
+ customerId: state.customerId,
1862
+ subscriptionId: state.subscriptionId,
1863
+ sequenceId: state.sequenceId,
1864
+ currentStepIndex: state.currentStepIndex,
1865
+ currentStepId: state.currentStepId,
1866
+ status: state.status,
1867
+ initialFailureId: state.initialFailure.id,
1868
+ failureIds: state.failures.map((f) => f.id),
1869
+ startedAt: state.startedAt,
1870
+ lastStepAt: state.lastStepAt,
1871
+ nextStepAt: state.nextStepAt,
1872
+ endedAt: state.endedAt,
1873
+ endReason: state.endReason,
1874
+ totalRetryAttempts: state.totalRetryAttempts,
1875
+ metadata: state.metadata
1876
+ });
1877
+ for (const step2 of state.executedSteps) {
1878
+ await this.saveExecutedStep(state.id, step2);
1879
+ }
1880
+ }
1881
+ async updateDunningState(id, updates) {
1882
+ const setValues = {};
1883
+ if (updates["currentStepIndex"] !== void 0)
1884
+ setValues["currentStepIndex"] = updates["currentStepIndex"];
1885
+ if (updates["currentStepId"] !== void 0)
1886
+ setValues["currentStepId"] = updates["currentStepId"];
1887
+ if (updates["status"] !== void 0) setValues["status"] = updates["status"];
1888
+ if (updates["lastStepAt"] !== void 0) setValues["lastStepAt"] = updates["lastStepAt"];
1889
+ if (updates["nextStepAt"] !== void 0) setValues["nextStepAt"] = updates["nextStepAt"];
1890
+ if (updates["endedAt"] !== void 0) setValues["endedAt"] = updates["endedAt"];
1891
+ if (updates["endReason"] !== void 0) setValues["endReason"] = updates["endReason"];
1892
+ if (updates["totalRetryAttempts"] !== void 0)
1893
+ setValues["totalRetryAttempts"] = updates["totalRetryAttempts"];
1894
+ if (updates["metadata"] !== void 0) setValues["metadata"] = updates["metadata"];
1895
+ if (updates["failures"] !== void 0)
1896
+ setValues["failureIds"] = updates["failures"].map((f) => f.id);
1897
+ if (Object.keys(setValues).length > 0) {
1898
+ await this.db.update(dunningStates).set(setValues).where(eq(dunningStates.id, id));
1899
+ }
1900
+ }
1901
+ // ============================================================================
1902
+ // Payment Failure Methods
1903
+ // ============================================================================
1904
+ async recordPaymentFailure(failure) {
1905
+ await this.db.insert(paymentFailures).values({
1906
+ id: failure.id,
1907
+ customerId: failure.customerId,
1908
+ subscriptionId: failure.subscriptionId,
1909
+ invoiceId: failure.invoiceId,
1910
+ amount: failure.amount,
1911
+ currency: failure.currency,
1912
+ category: failure.category,
1913
+ errorCode: failure.errorCode,
1914
+ errorMessage: failure.errorMessage,
1915
+ provider: failure.provider,
1916
+ failedAt: failure.failedAt,
1917
+ retryCount: failure.retryCount,
1918
+ nextRetryAt: failure.nextRetryAt,
1919
+ isRecoverable: failure.isRecoverable,
1920
+ metadata: failure.metadata
1921
+ });
1922
+ }
1923
+ async getPaymentFailures(customerId, limit = 50) {
1924
+ const rows = await this.db.select().from(paymentFailures).where(eq(paymentFailures.customerId, customerId)).orderBy(desc(paymentFailures.failedAt)).limit(limit);
1925
+ return rows.map((row) => this.mapRowToFailure(row));
1926
+ }
1927
+ // ============================================================================
1928
+ // Scheduled Steps Methods
1929
+ // ============================================================================
1930
+ async getScheduledSteps(before) {
1931
+ const rows = await this.db.select().from(scheduledSteps).where(lt(scheduledSteps.scheduledAt, before));
1932
+ return rows.map((row) => ({
1933
+ stateId: row.dunningStateId,
1934
+ stepId: row.stepId,
1935
+ scheduledAt: row.scheduledAt
1936
+ }));
1937
+ }
1938
+ async scheduleStep(stateId, stepId, scheduledAt) {
1939
+ const id = `sched_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
1940
+ await this.db.insert(scheduledSteps).values({
1941
+ id,
1942
+ dunningStateId: stateId,
1943
+ stepId,
1944
+ scheduledAt
1945
+ });
1946
+ }
1947
+ async removeScheduledStep(stateId, stepId) {
1948
+ await this.db.delete(scheduledSteps).where(
1949
+ and(eq(scheduledSteps.dunningStateId, stateId), eq(scheduledSteps.stepId, stepId))
1950
+ );
1951
+ }
1952
+ // ============================================================================
1953
+ // Helper Methods
1954
+ // ============================================================================
1955
+ async saveExecutedStep(stateId, step2) {
1956
+ const id = `exec_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
1957
+ await this.db.insert(executedSteps).values({
1958
+ id,
1959
+ dunningStateId: stateId,
1960
+ stepId: step2.stepId,
1961
+ stepName: step2.stepName,
1962
+ executedAt: step2.executedAt,
1963
+ actionsTaken: step2.actionsTaken,
1964
+ paymentRetried: step2.paymentRetried,
1965
+ paymentSucceeded: step2.paymentSucceeded,
1966
+ notificationsSent: step2.notificationsSent,
1967
+ error: step2.error
1968
+ });
1969
+ }
1970
+ async mapRowToState(row) {
1971
+ const failureRows = await this.db.select().from(paymentFailures).where(eq(paymentFailures.id, row.initialFailureId));
1972
+ const initialFailureRow = failureRows[0];
1973
+ if (!initialFailureRow) {
1974
+ throw new Error(`Initial failure not found: ${row.initialFailureId}`);
1975
+ }
1976
+ const initialFailure = this.mapRowToFailure(initialFailureRow);
1977
+ const failures = [initialFailure];
1978
+ const failureIds = row.failureIds || [];
1979
+ for (const failureId of failureIds) {
1980
+ if (failureId !== row.initialFailureId) {
1981
+ const additionalRows = await this.db.select().from(paymentFailures).where(eq(paymentFailures.id, failureId));
1982
+ const additionalRow = additionalRows[0];
1983
+ if (additionalRow) {
1984
+ failures.push(this.mapRowToFailure(additionalRow));
1985
+ }
1986
+ }
1987
+ }
1988
+ const execStepRows = await this.db.select().from(executedSteps).where(eq(executedSteps.dunningStateId, row.id)).orderBy(executedSteps.executedAt);
1989
+ const executedStepsList = execStepRows.map((es) => {
1990
+ const step2 = {
1991
+ stepId: es.stepId,
1992
+ stepName: es.stepName,
1993
+ executedAt: es.executedAt,
1994
+ actionsTaken: es.actionsTaken || [],
1995
+ paymentRetried: es.paymentRetried,
1996
+ notificationsSent: es.notificationsSent || []
1997
+ };
1998
+ if (es.paymentSucceeded !== null) step2.paymentSucceeded = es.paymentSucceeded;
1999
+ if (es.error !== null) step2.error = es.error;
2000
+ return step2;
2001
+ });
2002
+ const result = {
2003
+ id: row.id,
2004
+ customerId: row.customerId,
2005
+ subscriptionId: row.subscriptionId,
2006
+ sequenceId: row.sequenceId,
2007
+ currentStepIndex: row.currentStepIndex,
2008
+ currentStepId: row.currentStepId,
2009
+ status: row.status,
2010
+ initialFailure,
2011
+ failures,
2012
+ executedSteps: executedStepsList,
2013
+ startedAt: row.startedAt,
2014
+ totalRetryAttempts: row.totalRetryAttempts
2015
+ };
2016
+ if (row.lastStepAt) result.lastStepAt = row.lastStepAt;
2017
+ if (row.nextStepAt) result.nextStepAt = row.nextStepAt;
2018
+ if (row.endedAt) result.endedAt = row.endedAt;
2019
+ const endReason = row.endReason;
2020
+ if (endReason === "payment_recovered" || endReason === "max_retries" || endReason === "manually_canceled" || endReason === "subscription_canceled") {
2021
+ result.endReason = endReason;
2022
+ }
2023
+ if (row.metadata) result.metadata = row.metadata;
2024
+ return result;
2025
+ }
2026
+ mapRowToFailure(row) {
2027
+ const result = {
2028
+ id: row.id,
2029
+ customerId: row.customerId,
2030
+ subscriptionId: row.subscriptionId,
2031
+ amount: row.amount,
2032
+ currency: row.currency,
2033
+ category: row.category,
2034
+ errorCode: row.errorCode,
2035
+ errorMessage: row.errorMessage,
2036
+ provider: row.provider,
2037
+ failedAt: row.failedAt,
2038
+ retryCount: row.retryCount,
2039
+ isRecoverable: row.isRecoverable
2040
+ };
2041
+ if (row.invoiceId) result.invoiceId = row.invoiceId;
2042
+ if (row.nextRetryAt) result.nextRetryAt = row.nextRetryAt;
2043
+ if (row.metadata) result.metadata = row.metadata;
2044
+ return result;
2045
+ }
2046
+ };
2047
+ function createDrizzleDunningStorage(config) {
2048
+ return new DrizzleDunningStorage(config);
2049
+ }
2050
+
2051
+ // src/dunning/email-templates.ts
2052
+ function renderTemplate(template, data) {
2053
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
2054
+ const value = data[key];
2055
+ return value !== void 0 && value !== null ? String(value) : "";
2056
+ });
2057
+ }
2058
+ function formatAmount(amountCents, currency) {
2059
+ const amount = amountCents / 100;
2060
+ return new Intl.NumberFormat("en-US", {
2061
+ style: "currency",
2062
+ currency: currency.toUpperCase()
2063
+ }).format(amount);
2064
+ }
2065
+ var paymentFailedTemplate = {
2066
+ id: "dunning-payment-failed",
2067
+ name: "Payment Failed",
2068
+ subject: "Action required: Your payment failed",
2069
+ html: `
2070
+ <h1>Your payment didn't go through</h1>
2071
+ <p>Hi{{customerName}},</p>
2072
+ <p>We weren't able to process your payment of <strong>{{amount}}</strong> for your subscription.</p>
2073
+ {{cardInfo}}
2074
+ <p>Don't worry - we'll automatically retry your payment. In the meantime, please make sure your payment information is up to date.</p>
2075
+ <div style="text-align: center; margin: 24px 0;">
2076
+ <a href="{{updatePaymentUrl}}" style="display: inline-block; background: {{brandColor}}; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 6px; font-weight: 600;">Update Payment Method</a>
2077
+ </div>
2078
+ <p style="color: #666; font-size: 14px;">If you have any questions, please <a href="{{supportUrl}}">contact our support team</a>.</p>
2079
+ `,
2080
+ text: `Your payment didn't go through
2081
+
2082
+ Hi{{customerName}},
2083
+
2084
+ We weren't able to process your payment of {{amount}} for your subscription.
2085
+
2086
+ Don't worry - we'll automatically retry your payment. In the meantime, please make sure your payment information is up to date.
2087
+
2088
+ Update your payment method: {{updatePaymentUrl}}
2089
+
2090
+ If you have any questions, please contact our support team: {{supportUrl}}`
2091
+ };
2092
+ var paymentReminderTemplate = {
2093
+ id: "dunning-reminder",
2094
+ name: "Payment Reminder",
2095
+ subject: "Reminder: Please update your payment method",
2096
+ html: `
2097
+ <h1>Friendly reminder about your payment</h1>
2098
+ <p>Hi{{customerName}},</p>
2099
+ <p>We wanted to remind you that we were unable to process your payment of <strong>{{amount}}</strong>. It's been {{daysSinceFailure}} days since we first tried.</p>
2100
+ <p>To keep your subscription active, please update your payment information.</p>
2101
+ <div style="text-align: center; margin: 24px 0;">
2102
+ <a href="{{updatePaymentUrl}}" style="display: inline-block; background: {{brandColor}}; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 6px; font-weight: 600;">Update Payment Method</a>
2103
+ </div>
2104
+ <p style="color: #666; font-size: 14px;">Need help? <a href="{{supportUrl}}">Contact support</a></p>
2105
+ `,
2106
+ text: `Friendly reminder about your payment
2107
+
2108
+ Hi{{customerName}},
2109
+
2110
+ We wanted to remind you that we were unable to process your payment of {{amount}}. It's been {{daysSinceFailure}} days since we first tried.
2111
+
2112
+ To keep your subscription active, please update your payment information.
2113
+
2114
+ Update your payment method: {{updatePaymentUrl}}
2115
+
2116
+ Need help? Contact support: {{supportUrl}}`
2117
+ };
2118
+ var paymentWarningTemplate = {
2119
+ id: "dunning-warning",
2120
+ name: "Payment Warning",
2121
+ subject: "Urgent: Your account access may be limited",
2122
+ html: `
2123
+ <h1>Your account access may be limited soon</h1>
2124
+ <p>Hi{{customerName}},</p>
2125
+ <p>We've been trying to process your payment of <strong>{{amount}}</strong> for {{daysSinceFailure}} days without success.</p>
2126
+ <p style="background: #fff3cd; border: 1px solid #ffc107; border-radius: 6px; padding: 16px; margin: 16px 0;">
2127
+ <strong>\u26A0\uFE0F Important:</strong> If we don't receive payment within {{daysUntilLimit}} days, some features will be limited.
2128
+ </p>
2129
+ <p>Please update your payment method to avoid any service interruption.</p>
2130
+ <div style="text-align: center; margin: 24px 0;">
2131
+ <a href="{{updatePaymentUrl}}" style="display: inline-block; background: {{brandColor}}; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 6px; font-weight: 600;">Update Payment Now</a>
2132
+ </div>
2133
+ <p style="color: #666; font-size: 14px;">Having trouble? <a href="{{supportUrl}}">We're here to help</a></p>
2134
+ `,
2135
+ text: `Your account access may be limited soon
2136
+
2137
+ Hi{{customerName}},
2138
+
2139
+ We've been trying to process your payment of {{amount}} for {{daysSinceFailure}} days without success.
2140
+
2141
+ \u26A0\uFE0F Important: If we don't receive payment within {{daysUntilLimit}} days, some features will be limited.
2142
+
2143
+ Please update your payment method to avoid any service interruption.
2144
+
2145
+ Update your payment method: {{updatePaymentUrl}}
2146
+
2147
+ Having trouble? We're here to help: {{supportUrl}}`
2148
+ };
2149
+ var featuresLimitedTemplate = {
2150
+ id: "dunning-feature-limit",
2151
+ name: "Features Limited",
2152
+ subject: "Some features have been limited on your account",
2153
+ html: `
2154
+ <h1>Some features have been limited</h1>
2155
+ <p>Hi{{customerName}},</p>
2156
+ <p>Due to the outstanding payment of <strong>{{amount}}</strong>, we've had to limit some features on your account.</p>
2157
+ <p style="background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 6px; padding: 16px; margin: 16px 0;">
2158
+ <strong>Current status:</strong> Limited access<br>
2159
+ <strong>Outstanding amount:</strong> {{amount}}<br>
2160
+ <strong>Days until suspension:</strong> {{daysUntilSuspension}}
2161
+ </p>
2162
+ <p>To restore full access, please update your payment method immediately.</p>
2163
+ <div style="text-align: center; margin: 24px 0;">
2164
+ <a href="{{updatePaymentUrl}}" style="display: inline-block; background: #dc3545; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 6px; font-weight: 600;">Restore Full Access</a>
2165
+ </div>
2166
+ `,
2167
+ text: `Some features have been limited
2168
+
2169
+ Hi{{customerName}},
2170
+
2171
+ Due to the outstanding payment of {{amount}}, we've had to limit some features on your account.
2172
+
2173
+ Current status: Limited access
2174
+ Outstanding amount: {{amount}}
2175
+ Days until suspension: {{daysUntilSuspension}}
2176
+
2177
+ To restore full access, please update your payment method immediately.
2178
+
2179
+ Restore full access: {{updatePaymentUrl}}`
2180
+ };
2181
+ var accountSuspendedTemplate = {
2182
+ id: "dunning-suspension",
2183
+ name: "Account Suspended",
2184
+ subject: "Your account has been suspended",
2185
+ html: `
2186
+ <h1>Your account has been suspended</h1>
2187
+ <p>Hi{{customerName}},</p>
2188
+ <p>We've suspended your account due to an outstanding payment of <strong>{{amount}}</strong>.</p>
2189
+ <p style="background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 6px; padding: 16px; margin: 16px 0;">
2190
+ <strong>\u26D4 Your account is now suspended</strong><br>
2191
+ You have read-only access to your data.<br>
2192
+ <strong>Days until cancellation:</strong> {{daysUntilCancellation}}
2193
+ </p>
2194
+ <p>To reactivate your account and regain full access, please pay the outstanding balance.</p>
2195
+ <div style="text-align: center; margin: 24px 0;">
2196
+ <a href="{{updatePaymentUrl}}" style="display: inline-block; background: #dc3545; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 6px; font-weight: 600;">Reactivate Account</a>
2197
+ </div>
2198
+ <p style="color: #666; font-size: 14px;">If you need to discuss payment options, please <a href="{{supportUrl}}">contact us</a>.</p>
2199
+ `,
2200
+ text: `Your account has been suspended
2201
+
2202
+ Hi{{customerName}},
2203
+
2204
+ We've suspended your account due to an outstanding payment of {{amount}}.
2205
+
2206
+ \u26D4 Your account is now suspended
2207
+ You have read-only access to your data.
2208
+ Days until cancellation: {{daysUntilCancellation}}
2209
+
2210
+ To reactivate your account and regain full access, please pay the outstanding balance.
2211
+
2212
+ Reactivate account: {{updatePaymentUrl}}
2213
+
2214
+ If you need to discuss payment options, please contact us: {{supportUrl}}`
2215
+ };
2216
+ var finalWarningTemplate = {
2217
+ id: "dunning-final-warning",
2218
+ name: "Final Warning",
2219
+ subject: "Final notice: Your subscription will be canceled",
2220
+ html: `
2221
+ <h1>Final notice before cancellation</h1>
2222
+ <p>Hi{{customerName}},</p>
2223
+ <p>This is your final notice. Your subscription will be <strong>automatically canceled</strong> in {{daysUntilCancellation}} days due to an unpaid balance of <strong>{{amount}}</strong>.</p>
2224
+ <p style="background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 6px; padding: 16px; margin: 16px 0;">
2225
+ <strong>\u{1F6A8} Action required immediately</strong><br>
2226
+ After cancellation, your data may be permanently deleted according to our data retention policy.
2227
+ </p>
2228
+ <p>Please pay now to keep your account and data.</p>
2229
+ <div style="text-align: center; margin: 24px 0;">
2230
+ <a href="{{updatePaymentUrl}}" style="display: inline-block; background: #dc3545; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 6px; font-weight: 600;">Pay Now - Prevent Cancellation</a>
2231
+ </div>
2232
+ <p style="color: #666; font-size: 14px;">Questions? <a href="{{supportUrl}}">Contact us immediately</a></p>
2233
+ `,
2234
+ text: `Final notice before cancellation
2235
+
2236
+ Hi{{customerName}},
2237
+
2238
+ This is your final notice. Your subscription will be automatically canceled in {{daysUntilCancellation}} days due to an unpaid balance of {{amount}}.
2239
+
2240
+ \u{1F6A8} Action required immediately
2241
+ After cancellation, your data may be permanently deleted according to our data retention policy.
2242
+
2243
+ Please pay now to keep your account and data.
2244
+
2245
+ Pay now: {{updatePaymentUrl}}
2246
+
2247
+ Questions? Contact us immediately: {{supportUrl}}`
2248
+ };
2249
+ var subscriptionCanceledTemplate = {
2250
+ id: "dunning-canceled",
2251
+ name: "Subscription Canceled",
2252
+ subject: "Your subscription has been canceled",
2253
+ html: `
2254
+ <h1>Your subscription has been canceled</h1>
2255
+ <p>Hi{{customerName}},</p>
2256
+ <p>Your subscription has been canceled due to non-payment of <strong>{{amount}}</strong>.</p>
2257
+ <p style="background: #f8f9fa; border-radius: 6px; padding: 16px; margin: 16px 0;">
2258
+ Your data will be retained for 30 days. After that, it may be permanently deleted.
2259
+ </p>
2260
+ <p>If you'd like to resubscribe, you can do so at any time:</p>
2261
+ <div style="text-align: center; margin: 24px 0;">
2262
+ <a href="{{updatePaymentUrl}}" style="display: inline-block; background: {{brandColor}}; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 6px; font-weight: 600;">Resubscribe</a>
2263
+ </div>
2264
+ <p>We're sorry to see you go. If there's anything we can do to help, please <a href="{{supportUrl}}">let us know</a>.</p>
2265
+ `,
2266
+ text: `Your subscription has been canceled
2267
+
2268
+ Hi{{customerName}},
2269
+
2270
+ Your subscription has been canceled due to non-payment of {{amount}}.
2271
+
2272
+ Your data will be retained for 30 days. After that, it may be permanently deleted.
2273
+
2274
+ If you'd like to resubscribe, you can do so at any time: {{updatePaymentUrl}}
2275
+
2276
+ We're sorry to see you go. If there's anything we can do to help, please let us know: {{supportUrl}}`
2277
+ };
2278
+ var paymentRecoveredTemplate = {
2279
+ id: "dunning-recovered",
2280
+ name: "Payment Recovered",
2281
+ subject: "Good news! Your payment was successful",
2282
+ html: `
2283
+ <h1>Your payment was successful! \u{1F389}</h1>
2284
+ <p>Hi{{customerName}},</p>
2285
+ <p>Great news! We've successfully processed your payment of <strong>{{amount}}</strong>.</p>
2286
+ <p style="background: #d4edda; border: 1px solid #c3e6cb; border-radius: 6px; padding: 16px; margin: 16px 0;">
2287
+ <strong>\u2713 Payment received:</strong> {{amount}}<br>
2288
+ <strong>\u2713 Account status:</strong> Active
2289
+ </p>
2290
+ <p>Your subscription is now fully active. Thank you for being a valued customer!</p>
2291
+ <p style="color: #666; font-size: 14px;">If you have any questions, we're always here to help at <a href="{{supportUrl}}">support</a>.</p>
2292
+ `,
2293
+ text: `Your payment was successful! \u{1F389}
2294
+
2295
+ Hi{{customerName}},
2296
+
2297
+ Great news! We've successfully processed your payment of {{amount}}.
2298
+
2299
+ \u2713 Payment received: {{amount}}
2300
+ \u2713 Account status: Active
2301
+
2302
+ Your subscription is now fully active. Thank you for being a valued customer!
2303
+
2304
+ If you have any questions, we're always here to help: {{supportUrl}}`
2305
+ };
2306
+ var dunningEmailTemplates = {
2307
+ "dunning-payment-failed": paymentFailedTemplate,
2308
+ "dunning-reminder": paymentReminderTemplate,
2309
+ "dunning-warning": paymentWarningTemplate,
2310
+ "dunning-feature-limit": featuresLimitedTemplate,
2311
+ "dunning-suspension": accountSuspendedTemplate,
2312
+ "dunning-final-warning": finalWarningTemplate,
2313
+ "dunning-canceled": subscriptionCanceledTemplate,
2314
+ "dunning-recovered": paymentRecoveredTemplate
2315
+ };
2316
+ function renderDunningEmail(templateId, data, customTemplates) {
2317
+ const templates = { ...dunningEmailTemplates, ...customTemplates };
2318
+ const template = templates[templateId];
2319
+ if (!template) {
2320
+ throw new Error(`Dunning email template not found: ${templateId}`);
2321
+ }
2322
+ const templateData = {
2323
+ ...data,
2324
+ customerName: data.customerName ? ` ${data.customerName}` : "",
2325
+ brandColor: data.brandColor ?? "#0070f3",
2326
+ brandName: data.brandName ?? "Your Service"
2327
+ };
2328
+ if (data.cardLast4 && data.cardBrand) {
2329
+ templateData["cardInfo"] = `<p style="color: #666;">Card ending in ${data.cardLast4} (${data.cardBrand})</p>`;
2330
+ } else {
2331
+ templateData["cardInfo"] = "";
2332
+ }
2333
+ return {
2334
+ subject: renderTemplate(template.subject, templateData),
2335
+ html: renderTemplate(template.html, templateData),
2336
+ text: renderTemplate(template.text, templateData)
2337
+ };
2338
+ }
2339
+ function buildTemplateData(context, options) {
2340
+ const result = {
2341
+ amount: formatAmount(context.amountOwed, context.currency),
2342
+ currency: context.currency,
2343
+ daysSinceFailure: context.daysSinceFailure
2344
+ };
2345
+ if (context.customer.name) result.customerName = context.customer.name;
2346
+ if (options?.daysUntilLimit !== void 0) result.daysUntilLimit = options.daysUntilLimit;
2347
+ if (options?.daysUntilSuspension !== void 0) result.daysUntilSuspension = options.daysUntilSuspension;
2348
+ if (options?.daysUntilCancellation !== void 0) result.daysUntilCancellation = options.daysUntilCancellation;
2349
+ if (options?.updatePaymentUrl) result.updatePaymentUrl = options.updatePaymentUrl;
2350
+ if (options?.invoiceUrl) result.invoiceUrl = options.invoiceUrl;
2351
+ if (options?.supportUrl) result.supportUrl = options.supportUrl;
2352
+ if (options?.brandName) result.brandName = options.brandName;
2353
+ if (options?.brandColor) result.brandColor = options.brandColor;
2354
+ return result;
2355
+ }
2356
+
2357
+ // src/dunning/email-integration.ts
2358
+ function createDunningEmailHandler(config) {
2359
+ const { emailService, logger } = config;
2360
+ const buildUrls = (notification) => {
2361
+ const { urls } = config;
2362
+ if (!urls) return {};
2363
+ const result = {};
2364
+ const customerId = notification.recipient.customerId;
2365
+ const invoiceId = notification.context.state.initialFailure.invoiceId;
2366
+ const updatePaymentUrl = typeof urls.updatePayment === "function" ? urls.updatePayment(customerId) : urls.updatePayment;
2367
+ if (updatePaymentUrl) result.updatePaymentUrl = updatePaymentUrl;
2368
+ if (invoiceId && typeof urls.viewInvoice === "function") {
2369
+ result.invoiceUrl = urls.viewInvoice(invoiceId);
2370
+ } else if (typeof urls.viewInvoice === "string") {
2371
+ result.invoiceUrl = urls.viewInvoice;
2372
+ }
2373
+ if (urls.support) result.supportUrl = urls.support;
2374
+ return result;
2375
+ };
2376
+ const calculateDaysUntil = (context) => {
2377
+ const result = {};
2378
+ const stepMeta = context.step.metadata;
2379
+ if (stepMeta) {
2380
+ if (typeof stepMeta["daysUntilLimit"] === "number") {
2381
+ result.daysUntilLimit = stepMeta["daysUntilLimit"];
2382
+ }
2383
+ if (typeof stepMeta["daysUntilSuspension"] === "number") {
2384
+ result.daysUntilSuspension = stepMeta["daysUntilSuspension"];
2385
+ }
2386
+ if (typeof stepMeta["daysUntilCancellation"] === "number") {
2387
+ result.daysUntilCancellation = stepMeta["daysUntilCancellation"];
2388
+ }
2389
+ }
2390
+ return result;
2391
+ };
2392
+ const sendEmail = async (templateId, to, context) => {
2393
+ try {
2394
+ const urls = buildUrls({
2395
+ channel: "email",
2396
+ templateId,
2397
+ recipient: { customerId: context.customer.id, email: to },
2398
+ variables: {
2399
+ amount: context.amountOwed,
2400
+ currency: context.currency,
2401
+ daysSinceFailure: context.daysSinceFailure
2402
+ },
2403
+ context
2404
+ });
2405
+ const daysUntil = calculateDaysUntil(context);
2406
+ const templateOptions = {};
2407
+ if (config.brand?.name) templateOptions.brandName = config.brand.name;
2408
+ if (config.brand?.color) templateOptions.brandColor = config.brand.color;
2409
+ if (urls.updatePaymentUrl) templateOptions.updatePaymentUrl = urls.updatePaymentUrl;
2410
+ if (urls.invoiceUrl) templateOptions.invoiceUrl = urls.invoiceUrl;
2411
+ if (urls.supportUrl) templateOptions.supportUrl = urls.supportUrl;
2412
+ if (daysUntil.daysUntilLimit !== void 0) templateOptions.daysUntilLimit = daysUntil.daysUntilLimit;
2413
+ if (daysUntil.daysUntilSuspension !== void 0) templateOptions.daysUntilSuspension = daysUntil.daysUntilSuspension;
2414
+ if (daysUntil.daysUntilCancellation !== void 0) templateOptions.daysUntilCancellation = daysUntil.daysUntilCancellation;
2415
+ let emailData = buildTemplateData(context, templateOptions);
2416
+ if (config.enrichData) {
2417
+ emailData = config.enrichData(emailData, context);
2418
+ }
2419
+ const rendered = renderDunningEmail(templateId, emailData, config.customTemplates);
2420
+ const sendOptions = {
2421
+ to,
2422
+ subject: rendered.subject,
2423
+ html: rendered.html,
2424
+ text: rendered.text,
2425
+ tags: config.tags ?? ["dunning"]
2426
+ };
2427
+ if (config.replyTo) sendOptions.replyTo = config.replyTo;
2428
+ const result = await emailService.send(sendOptions);
2429
+ logger?.debug("Dunning email sent", {
2430
+ templateId,
2431
+ to,
2432
+ success: result.success,
2433
+ messageId: result.messageId
2434
+ });
2435
+ const notificationResult = {
2436
+ success: result.success,
2437
+ channel: "email",
2438
+ sentAt: /* @__PURE__ */ new Date()
2439
+ };
2440
+ if (result.messageId) notificationResult.externalId = result.messageId;
2441
+ if (result.error) notificationResult.error = result.error;
2442
+ return notificationResult;
2443
+ } catch (error) {
2444
+ const errorMessage = error instanceof Error ? error.message : String(error);
2445
+ logger?.error("Failed to send dunning email", {
2446
+ templateId,
2447
+ to,
2448
+ error: errorMessage
2449
+ });
2450
+ return {
2451
+ success: false,
2452
+ channel: "email",
2453
+ error: errorMessage,
2454
+ sentAt: /* @__PURE__ */ new Date()
2455
+ };
2456
+ }
2457
+ };
2458
+ const handler = async (notification) => {
2459
+ if (notification.channel !== "email") {
2460
+ return {
2461
+ success: false,
2462
+ channel: notification.channel,
2463
+ error: `Channel ${notification.channel} not supported by email integration`,
2464
+ sentAt: /* @__PURE__ */ new Date()
2465
+ };
2466
+ }
2467
+ if (config.skip?.(notification)) {
2468
+ logger?.debug("Skipping dunning notification", {
2469
+ customerId: notification.recipient.customerId,
2470
+ templateId: notification.templateId
2471
+ });
2472
+ return {
2473
+ success: true,
2474
+ channel: "email",
2475
+ sentAt: /* @__PURE__ */ new Date()
2476
+ };
2477
+ }
2478
+ const email = notification.recipient.email;
2479
+ if (!email) {
2480
+ logger?.warn("No email address for dunning notification", {
2481
+ customerId: notification.recipient.customerId
2482
+ });
2483
+ return {
2484
+ success: false,
2485
+ channel: "email",
2486
+ error: "No email address available",
2487
+ sentAt: /* @__PURE__ */ new Date()
2488
+ };
2489
+ }
2490
+ return sendEmail(notification.templateId, email, notification.context);
2491
+ };
2492
+ const sendRecoveryEmail = async (to, context) => {
2493
+ return sendEmail("dunning-recovered", to, context);
2494
+ };
2495
+ return {
2496
+ handler,
2497
+ sendEmail,
2498
+ sendRecoveryEmail
2499
+ };
2500
+ }
2501
+ function createMultiChannelHandler(config) {
2502
+ const emailHandler = config.email ? createDunningEmailHandler(config.email) : void 0;
2503
+ return async (notification) => {
2504
+ const { channel } = notification;
2505
+ switch (channel) {
2506
+ case "email":
2507
+ if (!emailHandler) {
2508
+ config.logger?.warn("Email channel not configured", {
2509
+ templateId: notification.templateId
2510
+ });
2511
+ return {
2512
+ success: false,
2513
+ channel: "email",
2514
+ error: "Email channel not configured",
2515
+ sentAt: /* @__PURE__ */ new Date()
2516
+ };
2517
+ }
2518
+ return emailHandler.handler(notification);
2519
+ case "sms":
2520
+ if (!config.sms) {
2521
+ return {
2522
+ success: false,
2523
+ channel: "sms",
2524
+ error: "SMS channel not configured",
2525
+ sentAt: /* @__PURE__ */ new Date()
2526
+ };
2527
+ }
2528
+ const phone = notification.recipient.phone;
2529
+ if (!phone) {
2530
+ return {
2531
+ success: false,
2532
+ channel: "sms",
2533
+ error: "No phone number available",
2534
+ sentAt: /* @__PURE__ */ new Date()
2535
+ };
2536
+ }
2537
+ const template = config.sms.templates?.[notification.templateId];
2538
+ if (!template) {
2539
+ return {
2540
+ success: false,
2541
+ channel: "sms",
2542
+ error: `SMS template not found: ${notification.templateId}`,
2543
+ sentAt: /* @__PURE__ */ new Date()
2544
+ };
2545
+ }
2546
+ const smsResult = await config.sms.service.send({
2547
+ to: phone,
2548
+ message: template
2549
+ // TODO: render template
2550
+ });
2551
+ const smsNotificationResult = {
2552
+ success: smsResult.success,
2553
+ channel: "sms",
2554
+ sentAt: /* @__PURE__ */ new Date()
2555
+ };
2556
+ if (smsResult.messageId) smsNotificationResult.externalId = smsResult.messageId;
2557
+ if (smsResult.error) smsNotificationResult.error = smsResult.error;
2558
+ return smsNotificationResult;
2559
+ case "in_app":
2560
+ if (!config.inApp) {
2561
+ return {
2562
+ success: false,
2563
+ channel: "in_app",
2564
+ error: "In-app channel not configured",
2565
+ sentAt: /* @__PURE__ */ new Date()
2566
+ };
2567
+ }
2568
+ return config.inApp(notification);
2569
+ case "webhook":
2570
+ if (!config.webhook) {
2571
+ return {
2572
+ success: false,
2573
+ channel: "webhook",
2574
+ error: "Webhook channel not configured",
2575
+ sentAt: /* @__PURE__ */ new Date()
2576
+ };
2577
+ }
2578
+ return config.webhook(notification);
2579
+ case "push":
2580
+ if (!config.push) {
2581
+ return {
2582
+ success: false,
2583
+ channel: "push",
2584
+ error: "Push channel not configured",
2585
+ sentAt: /* @__PURE__ */ new Date()
2586
+ };
2587
+ }
2588
+ return config.push(notification);
2589
+ default:
2590
+ return {
2591
+ success: false,
2592
+ channel,
2593
+ error: `Unknown channel: ${channel}`,
2594
+ sentAt: /* @__PURE__ */ new Date()
2595
+ };
2596
+ }
2597
+ };
2598
+ }
2599
+ function simpleEmailHandler(emailService, options) {
2600
+ const config = {
2601
+ emailService
2602
+ };
2603
+ if (options?.brandName || options?.brandColor) {
2604
+ const brand = {};
2605
+ if (options.brandName) brand.name = options.brandName;
2606
+ if (options.brandColor) brand.color = options.brandColor;
2607
+ config.brand = brand;
2608
+ }
2609
+ if (options?.updatePaymentUrl || options?.supportUrl) {
2610
+ const urls = {};
2611
+ if (options.updatePaymentUrl) urls.updatePayment = options.updatePaymentUrl;
2612
+ if (options.supportUrl) urls.support = options.supportUrl;
2613
+ config.urls = urls;
2614
+ }
2615
+ if (options?.replyTo) {
2616
+ config.replyTo = options.replyTo;
2617
+ }
2618
+ const handler = createDunningEmailHandler(config);
2619
+ return handler.handler;
2620
+ }
2621
+ export {
2622
+ DrizzleDunningStorage,
2623
+ DunningManager,
2624
+ DunningScheduler,
2625
+ DunningSequenceBuilder,
2626
+ DunningStepBuilder,
2627
+ MemoryDunningStorage,
2628
+ PaymentRetrier,
2629
+ PaymentRetryCalculator,
2630
+ accountSuspendedTemplate,
2631
+ aggressiveSequence,
2632
+ allErrorCodeMappings,
2633
+ buildTemplateData,
2634
+ createDefaultDunningConfig,
2635
+ createDrizzleDunningStorage,
2636
+ createDunningCronHandler,
2637
+ createDunningEdgeHandler,
2638
+ createDunningEmailHandler,
2639
+ createDunningManager,
2640
+ createDunningScheduler,
2641
+ createMemoryDunningStorage,
2642
+ createMultiChannelHandler,
2643
+ createPaymentRetrier,
2644
+ createPaymentRetryCalculator,
2645
+ defaultRetryStrategies,
2646
+ defaultSequences,
2647
+ dunningEmailTemplates,
2648
+ dunningEvents,
2649
+ dunningSchema,
2650
+ dunningSequences,
2651
+ dunningStates,
2652
+ dunningSteps,
2653
+ executedSteps,
2654
+ featuresLimitedTemplate,
2655
+ finalWarningTemplate,
2656
+ formatAmount,
2657
+ getSequenceByTier,
2658
+ iyzicoErrorCodes,
2659
+ lenientSequence,
2660
+ minimalSequence,
2661
+ paddleErrorCodes,
2662
+ paymentFailedTemplate,
2663
+ paymentFailures,
2664
+ paymentRecoveredTemplate,
2665
+ paymentReminderTemplate,
2666
+ paymentWarningTemplate,
2667
+ renderDunningEmail,
2668
+ retryStrategies,
2669
+ scheduledSteps,
2670
+ sequence,
2671
+ simpleEmailHandler,
2672
+ standardSaasSequence,
2673
+ step,
2674
+ stripeErrorCodes,
2675
+ subscriptionCanceledTemplate
2676
+ };
2677
+ //# sourceMappingURL=index.js.map