backend-manager 5.0.83 → 5.0.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/CLAUDE.md +66 -3
  3. package/README.md +7 -5
  4. package/package.json +5 -4
  5. package/src/cli/commands/base-command.js +89 -0
  6. package/src/cli/commands/emulators.js +3 -0
  7. package/src/cli/commands/serve.js +5 -1
  8. package/src/cli/commands/stripe.js +14 -0
  9. package/src/cli/commands/test.js +11 -6
  10. package/src/cli/index.js +7 -0
  11. package/src/manager/cron/daily/reset-usage.js +56 -34
  12. package/src/manager/events/firestore/payments-webhooks/on-write.js +15 -13
  13. package/src/manager/functions/core/actions/api/user/get-subscription-info.js +1 -1
  14. package/src/manager/helpers/analytics.js +2 -2
  15. package/src/manager/helpers/api-manager.js +1 -1
  16. package/src/manager/helpers/middleware.js +1 -1
  17. package/src/manager/helpers/usage.js +51 -3
  18. package/src/manager/index.js +5 -19
  19. package/src/manager/libraries/stripe.js +12 -8
  20. package/src/manager/libraries/test.js +27 -0
  21. package/src/manager/routes/app/get.js +11 -8
  22. package/src/manager/routes/payments/intent/post.js +31 -16
  23. package/src/manager/routes/payments/intent/{providers → processors}/stripe.js +48 -4
  24. package/src/manager/routes/payments/intent/processors/test.js +106 -0
  25. package/src/manager/routes/payments/webhook/post.js +21 -8
  26. package/src/manager/routes/payments/webhook/{providers → processors}/stripe.js +16 -1
  27. package/src/manager/routes/payments/webhook/processors/test.js +15 -0
  28. package/src/manager/routes/user/subscription/get.js +1 -1
  29. package/src/manager/schemas/payments/webhook/post.js +1 -1
  30. package/src/test/test-accounts.js +18 -18
  31. package/templates/_.env +0 -2
  32. package/templates/backend-manager-config.json +50 -34
  33. package/test/events/payments/journey-payments-cancel.js +144 -0
  34. package/test/events/payments/journey-payments-suspend.js +143 -0
  35. package/test/events/payments/journey-payments-trial.js +120 -0
  36. package/test/events/payments/journey-payments-upgrade.js +99 -0
  37. package/test/fixtures/stripe/subscription-active.json +161 -0
  38. package/test/fixtures/stripe/subscription-canceled.json +161 -0
  39. package/test/fixtures/stripe/subscription-trialing.json +161 -0
  40. package/test/functions/user/get-subscription-info.js +2 -2
  41. package/test/helpers/stripe-to-unified.js +684 -0
  42. package/test/routes/payments/intent.js +189 -0
  43. package/test/{payments → routes/payments}/webhook.js +1 -1
  44. package/test/routes/test/usage.js +7 -6
  45. package/test/routes/user/subscription.js +2 -2
  46. package/test/payments/intent.js +0 -104
  47. package/test/payments/journey-payment-cancel.js +0 -166
  48. package/test/payments/journey-payment-suspend.js +0 -162
  49. package/test/payments/journey-payment-trial.js +0 -167
  50. package/test/payments/journey-payment-upgrade.js +0 -136
@@ -0,0 +1,684 @@
1
+ /**
2
+ * Test: Stripe toUnified()
3
+ * Unit tests for the Stripe library's raw subscription → unified subscription transformation
4
+ *
5
+ * Tests the pure function directly — no emulator, no Firestore, no HTTP
6
+ */
7
+ const Stripe = require('../../src/manager/libraries/stripe.js');
8
+
9
+ // Real Stripe CLI fixtures (generated via `stripe trigger`)
10
+ const FIXTURE_ACTIVE = require('../fixtures/stripe/subscription-active.json');
11
+ const FIXTURE_CANCELED = require('../fixtures/stripe/subscription-canceled.json');
12
+ const FIXTURE_TRIALING = require('../fixtures/stripe/subscription-trialing.json');
13
+
14
+ // Mock config matching the BEM template
15
+ const MOCK_CONFIG = {
16
+ payment: {
17
+ products: [
18
+ { id: 'basic', name: 'Basic', type: 'subscription', limits: { requests: 100 } },
19
+ {
20
+ id: 'plus', name: 'Plus', type: 'subscription',
21
+ prices: {
22
+ monthly: { amount: 9.99, stripe: 'price_plus_monthly' },
23
+ annually: { amount: 99.99, stripe: 'price_plus_annually' },
24
+ },
25
+ },
26
+ {
27
+ id: 'pro', name: 'Pro', type: 'subscription',
28
+ prices: {
29
+ monthly: { amount: 29.99, stripe: 'price_pro_monthly' },
30
+ annually: { amount: 299.99, stripe: 'price_pro_annually' },
31
+ },
32
+ },
33
+ ],
34
+ },
35
+ };
36
+
37
+ function toUnified(rawSubscription, options) {
38
+ return Stripe.toUnified(rawSubscription, { config: MOCK_CONFIG, ...options });
39
+ }
40
+
41
+ module.exports = {
42
+ description: 'Stripe toUnified() transformation',
43
+ type: 'group',
44
+
45
+ tests: [
46
+ // ─── Status mapping ───
47
+
48
+ {
49
+ name: 'status-active',
50
+ async run({ assert }) {
51
+ const result = toUnified({ status: 'active' });
52
+ assert.equal(result.status, 'active', 'Stripe active → unified active');
53
+ },
54
+ },
55
+
56
+ {
57
+ name: 'status-trialing',
58
+ async run({ assert }) {
59
+ const result = toUnified({ status: 'trialing', trial_start: 1000, trial_end: 2000 });
60
+ assert.equal(result.status, 'active', 'Stripe trialing → unified active');
61
+ },
62
+ },
63
+
64
+ {
65
+ name: 'status-past-due',
66
+ async run({ assert }) {
67
+ const result = toUnified({ status: 'past_due' });
68
+ assert.equal(result.status, 'suspended', 'Stripe past_due → unified suspended');
69
+ },
70
+ },
71
+
72
+ {
73
+ name: 'status-unpaid',
74
+ async run({ assert }) {
75
+ const result = toUnified({ status: 'unpaid' });
76
+ assert.equal(result.status, 'suspended', 'Stripe unpaid → unified suspended');
77
+ },
78
+ },
79
+
80
+ {
81
+ name: 'status-canceled',
82
+ async run({ assert }) {
83
+ const result = toUnified({ status: 'canceled' });
84
+ assert.equal(result.status, 'cancelled', 'Stripe canceled → unified cancelled');
85
+ },
86
+ },
87
+
88
+ {
89
+ name: 'status-incomplete',
90
+ async run({ assert }) {
91
+ const result = toUnified({ status: 'incomplete' });
92
+ assert.equal(result.status, 'cancelled', 'Stripe incomplete → unified cancelled');
93
+ },
94
+ },
95
+
96
+ {
97
+ name: 'status-incomplete-expired',
98
+ async run({ assert }) {
99
+ const result = toUnified({ status: 'incomplete_expired' });
100
+ assert.equal(result.status, 'cancelled', 'Stripe incomplete_expired → unified cancelled');
101
+ },
102
+ },
103
+
104
+ {
105
+ name: 'status-unknown-defaults-to-cancelled',
106
+ async run({ assert }) {
107
+ const result = toUnified({ status: 'some_future_status' });
108
+ assert.equal(result.status, 'cancelled', 'Unknown status → cancelled');
109
+ },
110
+ },
111
+
112
+ // ─── Product resolution ───
113
+
114
+ {
115
+ name: 'product-resolves-monthly-price',
116
+ async run({ assert }) {
117
+ const result = toUnified({ plan: { id: 'price_plus_monthly' } });
118
+ assert.equal(result.product.id, 'plus', 'Should resolve to plus');
119
+ assert.equal(result.product.name, 'Plus', 'Should have correct name');
120
+ },
121
+ },
122
+
123
+ {
124
+ name: 'product-resolves-annual-price',
125
+ async run({ assert }) {
126
+ const result = toUnified({ plan: { id: 'price_pro_annually' } });
127
+ assert.equal(result.product.id, 'pro', 'Should resolve to pro');
128
+ assert.equal(result.product.name, 'Pro', 'Should have correct name');
129
+ },
130
+ },
131
+
132
+ {
133
+ name: 'product-resolves-from-items-array',
134
+ async run({ assert }) {
135
+ const result = toUnified({
136
+ items: { data: [{ price: { id: 'price_plus_monthly' } }] },
137
+ });
138
+ assert.equal(result.product.id, 'plus', 'Should resolve from items.data[0].price.id');
139
+ },
140
+ },
141
+
142
+ {
143
+ name: 'product-falls-back-to-basic-on-unknown-price',
144
+ async run({ assert }) {
145
+ const result = toUnified({ plan: { id: 'price_nonexistent' } });
146
+ assert.equal(result.product.id, 'basic', 'Unknown price → basic');
147
+ assert.equal(result.product.name, 'Basic', 'Unknown price → Basic name');
148
+ },
149
+ },
150
+
151
+ {
152
+ name: 'product-falls-back-to-basic-on-missing-plan',
153
+ async run({ assert }) {
154
+ const result = toUnified({});
155
+ assert.equal(result.product.id, 'basic', 'No plan → basic');
156
+ },
157
+ },
158
+
159
+ {
160
+ name: 'product-falls-back-to-basic-without-config',
161
+ async run({ assert }) {
162
+ const result = Stripe.toUnified({ plan: { id: 'price_plus_monthly' } }, {});
163
+ assert.equal(result.product.id, 'basic', 'No config → basic');
164
+ },
165
+ },
166
+
167
+ // ─── Frequency resolution ───
168
+
169
+ {
170
+ name: 'frequency-month',
171
+ async run({ assert }) {
172
+ const result = toUnified({ plan: { interval: 'month' } });
173
+ assert.equal(result.payment.frequency, 'monthly', 'month → monthly');
174
+ },
175
+ },
176
+
177
+ {
178
+ name: 'frequency-year',
179
+ async run({ assert }) {
180
+ const result = toUnified({ plan: { interval: 'year' } });
181
+ assert.equal(result.payment.frequency, 'annually', 'year → annually');
182
+ },
183
+ },
184
+
185
+ {
186
+ name: 'frequency-week',
187
+ async run({ assert }) {
188
+ const result = toUnified({ plan: { interval: 'week' } });
189
+ assert.equal(result.payment.frequency, 'weekly', 'week → weekly');
190
+ },
191
+ },
192
+
193
+ {
194
+ name: 'frequency-day',
195
+ async run({ assert }) {
196
+ const result = toUnified({ plan: { interval: 'day' } });
197
+ assert.equal(result.payment.frequency, 'daily', 'day → daily');
198
+ },
199
+ },
200
+
201
+ {
202
+ name: 'frequency-null-when-missing',
203
+ async run({ assert }) {
204
+ const result = toUnified({});
205
+ assert.equal(result.payment.frequency, null, 'Missing interval → null');
206
+ },
207
+ },
208
+
209
+ {
210
+ name: 'frequency-from-items-recurring',
211
+ async run({ assert }) {
212
+ const result = toUnified({
213
+ items: { data: [{ price: { recurring: { interval: 'year' } } }] },
214
+ });
215
+ assert.equal(result.payment.frequency, 'annually', 'items recurring year → annually');
216
+ },
217
+ },
218
+
219
+ // ─── Trial resolution ───
220
+
221
+ {
222
+ name: 'trial-claimed-when-both-dates-present',
223
+ async run({ assert }) {
224
+ const result = toUnified({ trial_start: 1700000000, trial_end: 1701209600 });
225
+ assert.equal(result.trial.claimed, true, 'Both trial dates → claimed');
226
+ assert.ok(result.trial.expires.timestampUNIX > 0, 'Trial expires should be set');
227
+ },
228
+ },
229
+
230
+ {
231
+ name: 'trial-not-claimed-when-no-dates',
232
+ async run({ assert }) {
233
+ const result = toUnified({});
234
+ assert.equal(result.trial.claimed, false, 'No trial dates → not claimed');
235
+ },
236
+ },
237
+
238
+ {
239
+ name: 'trial-not-claimed-when-only-start',
240
+ async run({ assert }) {
241
+ const result = toUnified({ trial_start: 1700000000 });
242
+ assert.equal(result.trial.claimed, false, 'Only trial_start → not claimed');
243
+ },
244
+ },
245
+
246
+ {
247
+ name: 'trial-not-claimed-when-null-dates',
248
+ async run({ assert }) {
249
+ const result = toUnified({ trial_start: null, trial_end: null });
250
+ assert.equal(result.trial.claimed, false, 'Null trial dates → not claimed');
251
+ },
252
+ },
253
+
254
+ // ─── Cancellation resolution ───
255
+
256
+ {
257
+ name: 'cancellation-pending-when-cancel-at-period-end',
258
+ async run({ assert }) {
259
+ const futureTimestamp = Math.floor(Date.now() / 1000) + 86400 * 30;
260
+ const result = toUnified({
261
+ cancel_at_period_end: true,
262
+ cancel_at: futureTimestamp,
263
+ current_period_end: futureTimestamp,
264
+ });
265
+ assert.equal(result.cancellation.pending, true, 'cancel_at_period_end → pending');
266
+ assert.ok(result.cancellation.date.timestampUNIX > 0, 'Should have cancellation date');
267
+ },
268
+ },
269
+
270
+ {
271
+ name: 'cancellation-pending-uses-period-end-when-no-cancel-at',
272
+ async run({ assert }) {
273
+ const futureTimestamp = Math.floor(Date.now() / 1000) + 86400 * 30;
274
+ const result = toUnified({
275
+ cancel_at_period_end: true,
276
+ current_period_end: futureTimestamp,
277
+ });
278
+ assert.equal(result.cancellation.pending, true, 'Should be pending');
279
+ },
280
+ },
281
+
282
+ {
283
+ name: 'cancellation-already-cancelled',
284
+ async run({ assert }) {
285
+ const pastTimestamp = Math.floor(Date.now() / 1000) - 86400;
286
+ const result = toUnified({
287
+ cancel_at_period_end: false,
288
+ canceled_at: pastTimestamp,
289
+ });
290
+ assert.equal(result.cancellation.pending, false, 'Already cancelled → not pending');
291
+ assert.ok(result.cancellation.date.timestampUNIX > 0, 'Should have cancellation date');
292
+ },
293
+ },
294
+
295
+ {
296
+ name: 'cancellation-none',
297
+ async run({ assert }) {
298
+ const result = toUnified({
299
+ cancel_at_period_end: false,
300
+ canceled_at: null,
301
+ });
302
+ assert.equal(result.cancellation.pending, false, 'No cancellation → not pending');
303
+ },
304
+ },
305
+
306
+ // ─── Expiration and start date ───
307
+
308
+ {
309
+ name: 'expires-from-period-end',
310
+ async run({ assert }) {
311
+ const futureTimestamp = Math.floor(Date.now() / 1000) + 86400 * 30;
312
+ const result = toUnified({ current_period_end: futureTimestamp });
313
+ assert.ok(result.expires.timestampUNIX > 0, 'Should have expiration');
314
+ },
315
+ },
316
+
317
+ {
318
+ name: 'expires-defaults-to-epoch-when-missing',
319
+ async run({ assert }) {
320
+ const result = toUnified({});
321
+ assert.equal(result.expires.timestampUNIX, 0, 'Missing period_end → epoch');
322
+ },
323
+ },
324
+
325
+ {
326
+ name: 'start-date-from-raw',
327
+ async run({ assert }) {
328
+ const startTimestamp = Math.floor(Date.now() / 1000) - 86400 * 30;
329
+ const result = toUnified({ start_date: startTimestamp });
330
+ assert.ok(result.payment.startDate.timestampUNIX > 0, 'Should have start date');
331
+ },
332
+ },
333
+
334
+ {
335
+ name: 'start-date-defaults-to-epoch-when-missing',
336
+ async run({ assert }) {
337
+ const result = toUnified({});
338
+ assert.equal(result.payment.startDate.timestampUNIX, 0, 'Missing start_date → epoch');
339
+ },
340
+ },
341
+
342
+ // ─── Payment metadata ───
343
+
344
+ {
345
+ name: 'payment-processor-always-stripe',
346
+ async run({ assert }) {
347
+ const result = toUnified({});
348
+ assert.equal(result.payment.processor, 'stripe', 'Processor should always be stripe');
349
+ },
350
+ },
351
+
352
+ {
353
+ name: 'payment-resource-id-from-subscription-id',
354
+ async run({ assert }) {
355
+ const result = toUnified({ id: 'sub_abc123' });
356
+ assert.equal(result.payment.resourceId, 'sub_abc123', 'resourceId should be subscription ID');
357
+ },
358
+ },
359
+
360
+ {
361
+ name: 'payment-resource-id-null-when-missing',
362
+ async run({ assert }) {
363
+ const result = toUnified({});
364
+ assert.equal(result.payment.resourceId, null, 'Missing ID → null resourceId');
365
+ },
366
+ },
367
+
368
+ {
369
+ name: 'payment-event-metadata-passed-through',
370
+ async run({ assert }) {
371
+ const result = toUnified({}, { eventName: 'customer.subscription.created', eventId: 'evt_123' });
372
+ assert.equal(result.payment.updatedBy.event.name, 'customer.subscription.created', 'Event name passed through');
373
+ assert.equal(result.payment.updatedBy.event.id, 'evt_123', 'Event ID passed through');
374
+ },
375
+ },
376
+
377
+ {
378
+ name: 'payment-event-metadata-null-when-missing',
379
+ async run({ assert }) {
380
+ const result = toUnified({});
381
+ assert.equal(result.payment.updatedBy.event.name, null, 'Missing event name → null');
382
+ assert.equal(result.payment.updatedBy.event.id, null, 'Missing event ID → null');
383
+ },
384
+ },
385
+
386
+ // ─── Full unified shape ───
387
+
388
+ {
389
+ name: 'full-active-subscription-shape',
390
+ async run({ assert }) {
391
+ const now = Math.floor(Date.now() / 1000);
392
+ const result = toUnified({
393
+ id: 'sub_full_test',
394
+ status: 'active',
395
+ plan: { id: 'price_pro_monthly', interval: 'month' },
396
+ current_period_end: now + 86400 * 30,
397
+ current_period_start: now,
398
+ start_date: now - 86400 * 60,
399
+ cancel_at_period_end: false,
400
+ canceled_at: null,
401
+ trial_start: null,
402
+ trial_end: null,
403
+ }, { eventName: 'customer.subscription.updated', eventId: 'evt_full' });
404
+
405
+ // Verify all top-level keys exist
406
+ assert.ok(result.product, 'Should have product');
407
+ assert.ok(result.status, 'Should have status');
408
+ assert.ok(result.expires, 'Should have expires');
409
+ assert.ok(result.trial, 'Should have trial');
410
+ assert.ok(result.cancellation, 'Should have cancellation');
411
+ assert.ok(result.payment, 'Should have payment');
412
+
413
+ // Verify values
414
+ assert.equal(result.product.id, 'pro', 'Product should be pro');
415
+ assert.equal(result.status, 'active', 'Status should be active');
416
+ assert.equal(result.trial.claimed, false, 'Trial should not be claimed');
417
+ assert.equal(result.cancellation.pending, false, 'Should not be pending cancellation');
418
+ assert.equal(result.payment.processor, 'stripe', 'Processor should be stripe');
419
+ assert.equal(result.payment.resourceId, 'sub_full_test', 'Resource ID should match');
420
+ assert.equal(result.payment.frequency, 'monthly', 'Frequency should be monthly');
421
+ assert.equal(result.payment.updatedBy.event.name, 'customer.subscription.updated', 'Event name should match');
422
+ },
423
+ },
424
+
425
+ {
426
+ name: 'empty-subscription-gets-safe-defaults',
427
+ async run({ assert }) {
428
+ const result = toUnified({});
429
+
430
+ assert.equal(result.product.id, 'basic', 'Empty → basic product');
431
+ assert.equal(result.status, 'cancelled', 'Empty → cancelled (no status field)');
432
+ assert.equal(result.trial.claimed, false, 'Empty → trial not claimed');
433
+ assert.equal(result.cancellation.pending, false, 'Empty → not pending');
434
+ assert.equal(result.payment.processor, 'stripe', 'Empty → still stripe');
435
+ assert.equal(result.payment.resourceId, null, 'Empty → null resourceId');
436
+ assert.equal(result.payment.frequency, null, 'Empty → null frequency');
437
+ },
438
+ },
439
+
440
+ // ─── Real Stripe fixtures (via `stripe trigger`) ───
441
+
442
+ {
443
+ name: 'fixture-active-status',
444
+ async run({ assert }) {
445
+ const result = toUnified(FIXTURE_ACTIVE);
446
+ assert.equal(result.status, 'active', 'Real active fixture → active');
447
+ assert.equal(result.payment.processor, 'stripe', 'Processor is stripe');
448
+ assert.equal(result.payment.resourceId, FIXTURE_ACTIVE.id, 'resourceId matches fixture ID');
449
+ },
450
+ },
451
+
452
+ {
453
+ name: 'fixture-active-frequency',
454
+ async run({ assert }) {
455
+ const result = toUnified(FIXTURE_ACTIVE);
456
+ assert.equal(result.payment.frequency, 'monthly', 'Real active fixture → monthly');
457
+ },
458
+ },
459
+
460
+ {
461
+ name: 'fixture-active-dates',
462
+ async run({ assert }) {
463
+ const result = toUnified(FIXTURE_ACTIVE);
464
+ assert.ok(result.expires.timestampUNIX > 0, 'Should have real expiration');
465
+ assert.ok(result.payment.startDate.timestampUNIX > 0, 'Should have real start date');
466
+ assert.equal(result.trial.claimed, false, 'No trial on active fixture');
467
+ assert.equal(result.cancellation.pending, false, 'No cancellation on active fixture');
468
+ },
469
+ },
470
+
471
+ {
472
+ name: 'fixture-active-product-falls-back',
473
+ async run({ assert }) {
474
+ // Fixture price IDs won't match our mock config, so it should fall back to basic
475
+ const result = toUnified(FIXTURE_ACTIVE);
476
+ assert.equal(result.product.id, 'basic', 'Unknown price → basic fallback');
477
+ },
478
+ },
479
+
480
+ {
481
+ name: 'fixture-canceled-status',
482
+ async run({ assert }) {
483
+ const result = toUnified(FIXTURE_CANCELED);
484
+ assert.equal(result.status, 'cancelled', 'Real canceled fixture → cancelled');
485
+ assert.equal(result.cancellation.pending, false, 'Not pending — already cancelled');
486
+ assert.ok(result.cancellation.date.timestampUNIX > 0, 'Should have cancellation date');
487
+ },
488
+ },
489
+
490
+ {
491
+ name: 'fixture-canceled-has-ended-at',
492
+ async run({ assert }) {
493
+ assert.ok(FIXTURE_CANCELED.ended_at, 'Canceled fixture should have ended_at');
494
+ assert.ok(FIXTURE_CANCELED.canceled_at, 'Canceled fixture should have canceled_at');
495
+ const result = toUnified(FIXTURE_CANCELED);
496
+ assert.equal(result.payment.resourceId, FIXTURE_CANCELED.id, 'resourceId matches');
497
+ },
498
+ },
499
+
500
+ {
501
+ name: 'fixture-trialing-status',
502
+ async run({ assert }) {
503
+ const result = toUnified(FIXTURE_TRIALING);
504
+ assert.equal(result.status, 'active', 'Real trialing fixture → active');
505
+ assert.equal(result.trial.claimed, true, 'Trialing fixture → trial claimed');
506
+ assert.ok(result.trial.expires.timestampUNIX > 0, 'Trial expiration should be set');
507
+ },
508
+ },
509
+
510
+ {
511
+ name: 'fixture-trialing-dates',
512
+ async run({ assert }) {
513
+ assert.ok(FIXTURE_TRIALING.trial_start, 'Trialing fixture should have trial_start');
514
+ assert.ok(FIXTURE_TRIALING.trial_end, 'Trialing fixture should have trial_end');
515
+ const result = toUnified(FIXTURE_TRIALING);
516
+ assert.equal(result.cancellation.pending, false, 'No cancellation on trialing fixture');
517
+ assert.equal(result.payment.frequency, 'monthly', 'Trialing fixture → monthly');
518
+ },
519
+ },
520
+
521
+ {
522
+ name: 'fixture-all-have-unified-shape',
523
+ async run({ assert }) {
524
+ const fixtures = [FIXTURE_ACTIVE, FIXTURE_CANCELED, FIXTURE_TRIALING];
525
+ const names = ['active', 'canceled', 'trialing'];
526
+
527
+ for (let i = 0; i < fixtures.length; i++) {
528
+ const result = toUnified(fixtures[i]);
529
+ const label = names[i];
530
+
531
+ assert.ok(result.product, `${label}: should have product`);
532
+ assert.ok(result.product.id, `${label}: should have product.id`);
533
+ assert.ok(result.product.name, `${label}: should have product.name`);
534
+ assert.isType(result.status, 'string', `${label}: status should be string`);
535
+ assert.ok(result.expires, `${label}: should have expires`);
536
+ assert.ok(result.trial, `${label}: should have trial`);
537
+ assert.ok(result.cancellation, `${label}: should have cancellation`);
538
+ assert.ok(result.payment, `${label}: should have payment`);
539
+ assert.equal(result.payment.processor, 'stripe', `${label}: processor should be stripe`);
540
+ assert.ok(result.payment.updatedBy, `${label}: should have updatedBy`);
541
+ assert.ok(result.payment.updatedBy.date, `${label}: should have updatedBy.date`);
542
+ }
543
+ },
544
+ },
545
+
546
+ // ─── Combination / edge-case scenarios ───
547
+
548
+ {
549
+ name: 'combo-trialing-with-pending-cancel',
550
+ async run({ assert }) {
551
+ const now = Math.floor(Date.now() / 1000);
552
+ const result = toUnified({
553
+ id: 'sub_trial_cancel',
554
+ status: 'trialing',
555
+ trial_start: now - 86400 * 3,
556
+ trial_end: now + 86400 * 11,
557
+ cancel_at_period_end: true,
558
+ cancel_at: now + 86400 * 11,
559
+ canceled_at: null,
560
+ current_period_end: now + 86400 * 11,
561
+ start_date: now - 86400 * 3,
562
+ plan: { id: 'price_plus_monthly', interval: 'month' },
563
+ });
564
+
565
+ assert.equal(result.status, 'active', 'Trialing + cancel → still active');
566
+ assert.equal(result.trial.claimed, true, 'Trial should be claimed');
567
+ assert.equal(result.cancellation.pending, true, 'Cancel should be pending');
568
+ assert.ok(result.cancellation.date.timestampUNIX > 0, 'Should have cancel date');
569
+ },
570
+ },
571
+
572
+ {
573
+ name: 'combo-trial-payment-fails',
574
+ async run({ assert }) {
575
+ const now = Math.floor(Date.now() / 1000);
576
+ const result = toUnified({
577
+ id: 'sub_trial_fail',
578
+ status: 'incomplete_expired',
579
+ trial_start: now - 86400 * 14,
580
+ trial_end: now - 86400,
581
+ cancel_at_period_end: false,
582
+ canceled_at: now,
583
+ current_period_end: now - 86400,
584
+ start_date: now - 86400 * 14,
585
+ plan: { id: 'price_plus_monthly', interval: 'month' },
586
+ });
587
+
588
+ assert.equal(result.status, 'cancelled', 'Failed trial → cancelled');
589
+ assert.equal(result.trial.claimed, true, 'Trial was still claimed');
590
+ assert.equal(result.cancellation.pending, false, 'Not pending — already done');
591
+ assert.ok(result.cancellation.date.timestampUNIX > 0, 'Should have cancellation date');
592
+ },
593
+ },
594
+
595
+ {
596
+ name: 'combo-active-with-past-trial',
597
+ async run({ assert }) {
598
+ const now = Math.floor(Date.now() / 1000);
599
+ const result = toUnified({
600
+ id: 'sub_past_trial',
601
+ status: 'active',
602
+ trial_start: now - 86400 * 30,
603
+ trial_end: now - 86400 * 16,
604
+ cancel_at_period_end: false,
605
+ canceled_at: null,
606
+ current_period_end: now + 86400 * 14,
607
+ start_date: now - 86400 * 30,
608
+ plan: { id: 'price_pro_monthly', interval: 'month' },
609
+ });
610
+
611
+ assert.equal(result.status, 'active', 'Active with past trial → active');
612
+ assert.equal(result.trial.claimed, true, 'Past trial → still claimed');
613
+ assert.equal(result.cancellation.pending, false, 'No cancellation');
614
+ assert.equal(result.product.id, 'pro', 'Should resolve product');
615
+ },
616
+ },
617
+
618
+ {
619
+ name: 'combo-pending-cancel-reactivated',
620
+ async run({ assert }) {
621
+ const now = Math.floor(Date.now() / 1000);
622
+ const result = toUnified({
623
+ id: 'sub_reactivated',
624
+ status: 'active',
625
+ cancel_at_period_end: false,
626
+ cancel_at: null,
627
+ canceled_at: null,
628
+ current_period_end: now + 86400 * 20,
629
+ start_date: now - 86400 * 40,
630
+ trial_start: null,
631
+ trial_end: null,
632
+ plan: { id: 'price_pro_monthly', interval: 'month' },
633
+ });
634
+
635
+ assert.equal(result.status, 'active', 'Reactivated → active');
636
+ assert.equal(result.cancellation.pending, false, 'Cancel reverted → not pending');
637
+ },
638
+ },
639
+
640
+ {
641
+ name: 'combo-suspended-with-pending-cancel',
642
+ async run({ assert }) {
643
+ const now = Math.floor(Date.now() / 1000);
644
+ const result = toUnified({
645
+ id: 'sub_suspended_cancel',
646
+ status: 'past_due',
647
+ cancel_at_period_end: true,
648
+ cancel_at: now + 86400 * 5,
649
+ canceled_at: null,
650
+ current_period_end: now + 86400 * 5,
651
+ start_date: now - 86400 * 60,
652
+ trial_start: null,
653
+ trial_end: null,
654
+ plan: { id: 'price_plus_monthly', interval: 'month' },
655
+ });
656
+
657
+ assert.equal(result.status, 'suspended', 'Past due → suspended');
658
+ assert.equal(result.cancellation.pending, true, 'Cancel still pending');
659
+ },
660
+ },
661
+
662
+ {
663
+ name: 'combo-trialing-past-due',
664
+ async run({ assert }) {
665
+ const now = Math.floor(Date.now() / 1000);
666
+ const result = toUnified({
667
+ id: 'sub_trial_past_due',
668
+ status: 'past_due',
669
+ trial_start: now - 86400 * 14,
670
+ trial_end: now - 86400,
671
+ cancel_at_period_end: false,
672
+ canceled_at: null,
673
+ current_period_end: now + 86400,
674
+ start_date: now - 86400 * 14,
675
+ plan: { id: 'price_plus_monthly', interval: 'month' },
676
+ });
677
+
678
+ assert.equal(result.status, 'suspended', 'Trial ended + payment failed → suspended');
679
+ assert.equal(result.trial.claimed, true, 'Trial was claimed');
680
+ assert.equal(result.cancellation.pending, false, 'No cancellation pending');
681
+ },
682
+ },
683
+ ],
684
+ };