@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.
- package/README.md +184 -0
- package/dist/billing/index.d.ts +121 -0
- package/dist/billing/index.js +1082 -0
- package/dist/billing/index.js.map +1 -0
- package/dist/billing-service-LsAFesou.d.ts +578 -0
- package/dist/dunning/index.d.ts +310 -0
- package/dist/dunning/index.js +2677 -0
- package/dist/dunning/index.js.map +1 -0
- package/dist/index.d.ts +185 -0
- package/dist/index.js +7698 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/index.d.ts +5 -0
- package/dist/providers/index.js +1396 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/iyzico.d.ts +250 -0
- package/dist/providers/iyzico.js +469 -0
- package/dist/providers/iyzico.js.map +1 -0
- package/dist/providers/paddle.d.ts +66 -0
- package/dist/providers/paddle.js +437 -0
- package/dist/providers/paddle.js.map +1 -0
- package/dist/providers/stripe.d.ts +122 -0
- package/dist/providers/stripe.js +586 -0
- package/dist/providers/stripe.js.map +1 -0
- package/dist/schema-C5Zcju_j.d.ts +4191 -0
- package/dist/types.d.ts +388 -0
- package/dist/types.js +74 -0
- package/dist/types.js.map +1 -0
- package/dist/usage/index.d.ts +2674 -0
- package/dist/usage/index.js +2916 -0
- package/dist/usage/index.js.map +1 -0
- package/dist/webhooks/index.d.ts +89 -0
- package/dist/webhooks/index.js +188 -0
- package/dist/webhooks/index.js.map +1 -0
- package/package.json +91 -0
|
@@ -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
|