payment-kit 1.20.20 → 1.20.22

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 (37) hide show
  1. package/api/src/integrations/stripe/resource.ts +1 -1
  2. package/api/src/libs/discount/coupon.ts +41 -73
  3. package/api/src/libs/invoice.ts +17 -0
  4. package/api/src/libs/notification/template/subscription-renew-failed.ts +22 -1
  5. package/api/src/libs/notification/template/subscription-will-renew.ts +22 -0
  6. package/api/src/locales/en.ts +1 -0
  7. package/api/src/locales/zh.ts +1 -0
  8. package/api/src/queues/checkout-session.ts +2 -2
  9. package/api/src/queues/vendors/fulfillment-coordinator.ts +11 -2
  10. package/api/src/queues/vendors/status-check.ts +1 -3
  11. package/api/src/routes/checkout-sessions.ts +84 -0
  12. package/api/src/routes/connect/collect-batch.ts +2 -2
  13. package/api/src/routes/connect/pay.ts +1 -1
  14. package/api/src/routes/vendor.ts +93 -47
  15. package/api/src/store/migrations/20250926-change-customer-did-unique.ts +49 -0
  16. package/api/src/store/models/checkout-session.ts +1 -0
  17. package/api/src/store/models/customer.ts +1 -0
  18. package/api/tests/libs/coupon.spec.ts +219 -0
  19. package/api/tests/libs/discount.spec.ts +250 -0
  20. package/blocklet.yml +1 -1
  21. package/package.json +7 -7
  22. package/src/components/discount/discount-info.tsx +0 -1
  23. package/src/components/invoice/action.tsx +26 -0
  24. package/src/components/invoice/table.tsx +2 -9
  25. package/src/components/invoice-pdf/styles.ts +2 -0
  26. package/src/components/invoice-pdf/template.tsx +44 -12
  27. package/src/components/metadata/list.tsx +1 -0
  28. package/src/components/subscription/metrics.tsx +7 -3
  29. package/src/components/subscription/vendor-service-list.tsx +56 -58
  30. package/src/locales/en.tsx +9 -2
  31. package/src/locales/zh.tsx +9 -2
  32. package/src/pages/admin/billing/subscriptions/detail.tsx +43 -4
  33. package/src/pages/admin/products/coupons/applicable-products.tsx +20 -37
  34. package/src/pages/admin/products/products/detail.tsx +4 -14
  35. package/src/pages/admin/products/vendors/index.tsx +57 -48
  36. package/src/pages/customer/invoice/detail.tsx +1 -1
  37. package/src/pages/customer/subscription/detail.tsx +17 -4
@@ -146,13 +146,19 @@ async function createVendor(req: any, res: any) {
146
146
  vendor_type: type,
147
147
  name,
148
148
  description,
149
- app_url: appUrl,
150
149
  metadata,
151
150
  app_pid: appPid,
152
151
  app_logo: appLogo,
153
152
  status,
154
153
  } = value;
155
154
 
155
+ let appUrl = '';
156
+ try {
157
+ appUrl = new URL(value.app_url).origin;
158
+ } catch {
159
+ return res.status(400).json({ error: 'Invalid app URL' });
160
+ }
161
+
156
162
  const vendorType = (type || 'launcher') as 'launcher' | 'didnames';
157
163
  const vendorDid = VENDOR_DID[vendorType];
158
164
 
@@ -215,16 +221,14 @@ async function updateVendor(req: any, res: any) {
215
221
  });
216
222
  }
217
223
 
218
- const {
219
- vendor_type: type,
220
- name,
221
- description,
222
- app_url: appUrl,
223
- status,
224
- metadata,
225
- app_pid: appPid,
226
- app_logo: appLogo,
227
- } = value;
224
+ const { vendor_type: type, name, description, status, metadata, app_pid: appPid, app_logo: appLogo } = value;
225
+
226
+ let appUrl = '';
227
+ try {
228
+ appUrl = new URL(value.app_url).origin;
229
+ } catch {
230
+ return res.status(400).json({ error: 'Invalid app URL' });
231
+ }
228
232
 
229
233
  const vendorType = (type || 'launcher') as 'launcher' | 'didnames';
230
234
  const vendorDid = VENDOR_DID[vendorType];
@@ -312,42 +316,46 @@ async function testVendorConnection(req: any, res: any) {
312
316
  }
313
317
  }
314
318
 
315
- const getVendorById = async (vendorId: string, orderId: string) => {
319
+ async function executeVendorOperation(vendorId: string, orderId: string, operation: 'getOrder' | 'getOrderStatus') {
316
320
  if (!vendorId || !orderId) {
317
- throw new Error(`vendorId or orderId is required, vendorId: ${vendorId}, orderId: ${orderId}`);
321
+ return {
322
+ error: 'Bad Request',
323
+ message: `vendorId or orderId is required, vendorId: ${vendorId}, orderId: ${orderId}`,
324
+ code: 400,
325
+ };
318
326
  }
319
327
 
320
328
  const vendor = await ProductVendor.findByPk(vendorId);
321
329
  if (!vendor) {
322
- throw new Error(`vendor not found, vendorId: ${vendorId}`);
323
- }
324
-
325
- const vendorAdapter = await VendorFulfillmentService.getVendorAdapter(vendor.vendor_key);
326
-
327
- const data = await vendorAdapter.getOrder(vendor, orderId);
328
-
329
- return data;
330
- };
331
-
332
- async function getVendorStatusById(vendorId: string, orderId: string) {
333
- if (!vendorId || !orderId) {
334
- throw new Error(`vendorId or orderId is required, vendorId: ${vendorId}, orderId: ${orderId}`);
330
+ return {
331
+ error: 'Not Found',
332
+ message: `vendor not found, vendorId: ${vendorId}`,
333
+ code: 404,
334
+ };
335
335
  }
336
336
 
337
- const vendor = await ProductVendor.findByPk(vendorId);
337
+ try {
338
+ const vendorAdapter = await VendorFulfillmentService.getVendorAdapter(vendor.vendor_key);
339
+ const data = await vendorAdapter[operation](vendor, orderId);
338
340
 
339
- if (!vendor) {
340
- throw new Error(`vendor not found, vendorId: ${vendorId}`);
341
+ return { data: { ...data, vendorType: vendor.vendor_type }, code: 200 };
342
+ } catch (error: any) {
343
+ const operationName = operation === 'getOrder' ? 'order' : 'order status';
344
+ logger.error(`Failed to get vendor ${operationName}`, {
345
+ error,
346
+ vendorId,
347
+ orderId,
348
+ vendorKey: vendor.vendor_key,
349
+ });
350
+ return {
351
+ error: 'Service Unavailable',
352
+ message: `Failed to get vendor ${operationName}`,
353
+ code: 503,
354
+ };
341
355
  }
342
-
343
- const vendorAdapter = await VendorFulfillmentService.getVendorAdapter(vendor.vendor_key);
344
-
345
- const data = await vendorAdapter.getOrderStatus(vendor, orderId);
346
-
347
- return { ...data, vendorType: vendor.vendor_type };
348
356
  }
349
357
 
350
- async function doRequestVendor(sessionId: string, func: (vendorId: string, orderId: string) => Promise<any>) {
358
+ async function processVendorOrders(sessionId: string, operation: 'getOrder' | 'getOrderStatus') {
351
359
  const doc = await CheckoutSession.findByPk(sessionId);
352
360
 
353
361
  if (!doc) {
@@ -378,8 +386,39 @@ async function doRequestVendor(sessionId: string, func: (vendorId: string, order
378
386
  };
379
387
  }
380
388
 
381
- const vendors = doc.vendor_info.map((item) => {
382
- return func(item.vendor_id, item.order_id);
389
+ const vendors = doc.vendor_info.map(async (item) => {
390
+ if (!item.order_id) {
391
+ return {
392
+ key: item.vendor_key,
393
+ progress: 0,
394
+ status: 'pending',
395
+ vendorType: item.vendor_type,
396
+ appUrl: item.app_url,
397
+ };
398
+ }
399
+
400
+ const result = await executeVendorOperation(item.vendor_id, item.order_id, operation);
401
+
402
+ // Handle error responses from vendor functions
403
+ if (result.error) {
404
+ logger.warn('Vendor operation returned error', {
405
+ vendorId: item.vendor_id,
406
+ orderId: item.order_id,
407
+ operation,
408
+ error: result.error,
409
+ message: result.message,
410
+ });
411
+ return {
412
+ key: item.vendor_key,
413
+ error: result.error,
414
+ message: result.message,
415
+ status: 'error',
416
+ vendorType: item.vendor_type,
417
+ appUrl: item.app_url,
418
+ };
419
+ }
420
+
421
+ return result.data;
383
422
  });
384
423
 
385
424
  return {
@@ -392,7 +431,7 @@ async function doRequestVendor(sessionId: string, func: (vendorId: string, order
392
431
  }
393
432
 
394
433
  async function getVendorStatus(sessionId: string) {
395
- const result: any = await doRequestVendor(sessionId, getVendorStatusById);
434
+ const result: any = await processVendorOrders(sessionId, 'getOrderStatus');
396
435
 
397
436
  if (result.subscriptionId) {
398
437
  const subscriptionUrl = getUrl(`/customer/subscription/${result.subscriptionId}`);
@@ -407,10 +446,6 @@ async function getVendorStatus(sessionId: string) {
407
446
  return result;
408
447
  }
409
448
 
410
- function getVendor(sessionId: string) {
411
- return doRequestVendor(sessionId, getVendorById);
412
- }
413
-
414
449
  async function getVendorFulfillmentStatus(req: any, res: any) {
415
450
  const { sessionId } = req.params;
416
451
 
@@ -430,7 +465,7 @@ async function getVendorFulfillmentDetail(req: any, res: any) {
430
465
  const { sessionId } = req.params;
431
466
 
432
467
  try {
433
- const detail = await getVendor(sessionId);
468
+ const detail = await processVendorOrders(sessionId, 'getOrder');
434
469
  if (detail.code) {
435
470
  return res.status(detail.code).json({ error: detail.error });
436
471
  }
@@ -461,17 +496,28 @@ async function redirectToVendor(req: any, res: any) {
461
496
  return res.redirect('/404');
462
497
  }
463
498
 
464
- const detail = await getVendorById(vendorId, order.order_id || '');
465
- if (!detail) {
499
+ const isOwner = req.user.did === checkoutSession.customer_did;
500
+
501
+ if (!isOwner) {
502
+ if (order.app_url) {
503
+ return res.redirect(order.app_url);
504
+ }
505
+ return res.redirect('/404');
506
+ }
507
+
508
+ const result = await executeVendorOperation(vendorId, order.order_id || '', 'getOrder');
509
+ if (result.error || !result.data) {
466
510
  logger.warn('Vendor status detail not found', {
467
511
  subscriptionId,
468
512
  vendorId,
469
513
  orderId: order.order_id,
514
+ error: result.error,
515
+ message: result.message,
470
516
  });
471
517
  return res.redirect('/404');
472
518
  }
473
519
 
474
- const redirectUrl = target === 'dashboard' ? detail.dashboardUrl : detail.homeUrl;
520
+ const redirectUrl = target === 'dashboard' ? result.data.dashboardUrl : result.data.homeUrl;
475
521
  return res.redirect(redirectUrl);
476
522
  } catch (error: any) {
477
523
  logger.error('Failed to redirect to vendor service', {
@@ -0,0 +1,49 @@
1
+ /* eslint-disable no-console */
2
+ import { DataTypes, QueryTypes } from 'sequelize';
3
+ import { Migration } from '../migrate';
4
+
5
+ export const up: Migration = async ({ context }) => {
6
+ try {
7
+ await context.changeColumn('customers', 'did', {
8
+ type: DataTypes.STRING(40),
9
+ allowNull: false,
10
+ unique: true,
11
+ });
12
+
13
+ console.log('Successfully added unique constraint to customers.did');
14
+ } catch (error) {
15
+ try {
16
+ const duplicates = await context.sequelize.query(
17
+ `SELECT did, COUNT(*) as count
18
+ FROM customers
19
+ GROUP BY did
20
+ HAVING COUNT(*) > 1
21
+ LIMIT 5`,
22
+ { type: QueryTypes.SELECT }
23
+ );
24
+
25
+ if (duplicates.length > 0) {
26
+ console.warn('Found duplicate DIDs in customers table (showing first 5):', duplicates);
27
+ console.warn('Skipping unique constraint addition. Please clean up duplicate data manually if needed.');
28
+ } else {
29
+ console.error('Failed to add unique constraint for unknown reason:', error.message);
30
+ }
31
+ } catch (checkError) {
32
+ console.error('Failed to check for duplicates:', checkError.message);
33
+ }
34
+ }
35
+ };
36
+
37
+ export const down: Migration = async ({ context }) => {
38
+ try {
39
+ await context.changeColumn('customers', 'did', {
40
+ type: DataTypes.STRING(40),
41
+ allowNull: false,
42
+ unique: false,
43
+ });
44
+
45
+ console.log('Successfully removed unique constraint from customers.did');
46
+ } catch (error) {
47
+ console.warn('Failed to remove unique constraint from customers.did:', error.message);
48
+ }
49
+ };
@@ -220,6 +220,7 @@ export class CheckoutSession extends Model<InferAttributes<CheckoutSession>, Inf
220
220
  declare vendor_info?: Array<{
221
221
  vendor_id: string;
222
222
  vendor_key: string;
223
+ vendor_type: string;
223
224
  order_id: string;
224
225
  status:
225
226
  | 'pending'
@@ -81,6 +81,7 @@ export class Customer extends Model<InferAttributes<Customer>, InferCreationAttr
81
81
  did: {
82
82
  type: DataTypes.STRING(40),
83
83
  allowNull: false,
84
+ unique: true,
84
85
  },
85
86
  address: {
86
87
  type: DataTypes.JSON,
@@ -0,0 +1,219 @@
1
+ import {
2
+ validCoupon,
3
+ validPromotionCode,
4
+ calculateDiscountAmount,
5
+ checkPromotionCodeEligibility,
6
+ getValidDiscountsForSubscriptionBilling,
7
+ expandLineItemsWithCouponInfo,
8
+ expandDiscountsWithDetails,
9
+ createDiscountRecordsForCheckout,
10
+ updateSubscriptionDiscountReferences,
11
+ } from '../../src/libs/discount/coupon';
12
+ import { Coupon, Customer, Discount, PromotionCode, Subscription, PaymentCurrency } from '../../src/store/models';
13
+
14
+ jest.mock('../../src/libs/logger', () => ({
15
+ __esModule: true,
16
+ default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
17
+ }));
18
+
19
+ jest.mock('../../src/libs/event', () => ({ emitAsync: jest.fn() }));
20
+
21
+ describe('libs/discount/coupon.ts', () => {
22
+ beforeEach(() => {
23
+ jest.clearAllMocks();
24
+ jest.restoreAllMocks();
25
+ });
26
+
27
+ describe('validCoupon', () => {
28
+ it('rejects invalid or expired or fully redeemed coupon', () => {
29
+ expect(validCoupon({ valid: false } as any).valid).toBe(false);
30
+ const expired = Math.floor(Date.now() / 1000) - 10;
31
+ expect(validCoupon({ valid: true, redeem_by: expired } as any).valid).toBe(false);
32
+ expect(validCoupon({ valid: true, max_redemptions: 1, times_redeemed: 1 } as any).valid).toBe(false);
33
+ });
34
+
35
+ it('enforces applies_to products if provided', () => {
36
+ const coupon = { valid: true, applies_to: { products: ['p1'] } } as any;
37
+ const lineItems = [{ price: { product_id: 'p2' } }] as any;
38
+ const res = validCoupon(coupon, lineItems);
39
+ expect(res.valid).toBe(false);
40
+ });
41
+
42
+ it('passes when valid and applicable', () => {
43
+ const coupon = { valid: true } as any;
44
+ expect(validCoupon(coupon).valid).toBe(true);
45
+ });
46
+ });
47
+
48
+ describe('validPromotionCode', () => {
49
+ it('rejects inactive/expired/max redeemed', async () => {
50
+ const now = Math.floor(Date.now() / 1000);
51
+ await expect(validPromotionCode({ active: false } as any, {})).resolves.toEqual({
52
+ valid: false,
53
+ reason: expect.any(String),
54
+ });
55
+ await expect(validPromotionCode({ active: true, expires_at: now - 30 } as any, {})).resolves.toEqual({
56
+ valid: false,
57
+ reason: expect.any(String),
58
+ });
59
+ await expect(
60
+ validPromotionCode({ active: true, max_redemptions: 1, times_redeemed: 1 } as any, {})
61
+ ).resolves.toEqual({ valid: false, reason: expect.any(String) });
62
+ });
63
+
64
+ it('checks user restriction and first time transaction', async () => {
65
+ jest.spyOn(Customer, 'findByPkOrDid').mockResolvedValue({ did: 'did:user:1' } as any);
66
+ const pc: any = { active: true, verification_type: 'user_restricted', customer_dids: ['did:user:1'] };
67
+ await expect(validPromotionCode(pc, { customerId: 'c1' })).resolves.toEqual({ valid: true });
68
+
69
+ jest.spyOn(Discount, 'count').mockResolvedValue(1 as any);
70
+ const pc2: any = {
71
+ active: true,
72
+ restrictions: { first_time_transaction: true },
73
+ };
74
+ await expect(validPromotionCode(pc2, { customerId: 'c1' })).resolves.toEqual({
75
+ valid: false,
76
+ reason: expect.any(String),
77
+ });
78
+ });
79
+
80
+ it('checks minimum amount by currency', async () => {
81
+ jest.spyOn(PaymentCurrency, 'findByPk').mockResolvedValue({ id: 'usd', decimal: 2, symbol: 'USD' } as any);
82
+ const pc: any = {
83
+ active: true,
84
+ restrictions: { minimum_amount: '200', minimum_amount_currency: 'usd' },
85
+ };
86
+ await expect(validPromotionCode(pc, { amount: '100', currencyId: 'usd' })).resolves.toEqual({
87
+ valid: false,
88
+ reason: expect.stringContaining('minimum purchase'),
89
+ });
90
+ });
91
+ });
92
+
93
+ describe('calculateDiscountAmount', () => {
94
+ it('handles percent_off and amount_off with cap', () => {
95
+ expect(
96
+ calculateDiscountAmount({ percent_off: 25 } as any, '400', { id: 'usd', decimal: 2, symbol: 'USD' } as any)
97
+ ).toBe('100');
98
+
99
+ expect(
100
+ calculateDiscountAmount({ amount_off: '150', currency_id: 'usd' } as any, '100', {
101
+ id: 'usd',
102
+ decimal: 2,
103
+ symbol: 'USD',
104
+ } as any)
105
+ ).toBe('100');
106
+ });
107
+ });
108
+
109
+ describe('checkPromotionCodeEligibility', () => {
110
+ it('validates promotion and coupon together', async () => {
111
+ jest.spyOn(Coupon, 'findByPk').mockResolvedValue({ valid: true } as any);
112
+ jest.spyOn(Customer, 'findByPkOrDid').mockResolvedValue({ did: 'did:user:1' } as any);
113
+ jest.spyOn(PaymentCurrency, 'findByPk').mockResolvedValue({ id: 'usd', decimal: 2, symbol: 'USD' } as any);
114
+ jest.spyOn(Discount, 'count').mockResolvedValue(0 as any);
115
+ const couponModule = await import('../../src/libs/discount/coupon');
116
+ jest.spyOn(couponModule, 'validCoupon').mockReturnValue({ valid: true } as any);
117
+ const result = await checkPromotionCodeEligibility({
118
+ promotionCode: { active: true } as any,
119
+ couponId: 'c1',
120
+ customerId: 'u1',
121
+ amount: '100',
122
+ currencyId: 'usd',
123
+ });
124
+ expect(result.eligible).toBe(true);
125
+ });
126
+ });
127
+
128
+ describe('validateDiscountForBilling & getValidDiscountsForSubscriptionBilling', () => {
129
+ it('filters discounts by duration logic', async () => {
130
+ const now = Math.floor(Date.now() / 1000);
131
+ jest.spyOn(Discount, 'findAll').mockResolvedValue([
132
+ { id: 'd_once', coupon_id: 'c_once' },
133
+ { id: 'd_repired', coupon_id: 'c_repired', end: now - 1 },
134
+ { id: 'd_forever', coupon_id: 'c_forever' },
135
+ ] as any);
136
+
137
+ jest.spyOn(Coupon, 'findByPk').mockImplementation((id) => {
138
+ if (id === 'c_once') return { duration: 'once' } as any;
139
+ if (id === 'c_repired') return { duration: 'repeating' } as any;
140
+ return { duration: 'forever' } as any;
141
+ });
142
+
143
+ const { validDiscounts, expiredDiscounts } = await getValidDiscountsForSubscriptionBilling({
144
+ subscriptionId: 'sub_1',
145
+ customerId: 'u1',
146
+ });
147
+
148
+ expect(validDiscounts.map((d) => d.id)).toEqual(['d_forever']);
149
+ expect(expiredDiscounts.map((d) => d.id).sort()).toEqual(['d_once', 'd_repired'].sort());
150
+ });
151
+ });
152
+
153
+ describe('expandLineItemsWithCouponInfo & expandDiscountsWithDetails', () => {
154
+ it('expands discount info on line items', async () => {
155
+ jest.spyOn(Coupon, 'findByPk').mockResolvedValue({
156
+ id: 'c1',
157
+ name: 'c',
158
+ amount_off: '10',
159
+ percent_off: 0,
160
+ currency_id: 'usd',
161
+ duration: 'once',
162
+ } as any);
163
+ jest.spyOn(PromotionCode, 'findByPk').mockResolvedValue({ id: 'pc1', code: 'CODE1' } as any);
164
+
165
+ const items = [{ id: 'i1', discount_amounts: [{ amount: '10', coupon: 'c1' }] }] as any;
166
+
167
+ const res = await expandLineItemsWithCouponInfo(items, [{ coupon: 'c1', promotion_code: 'pc1' }], 'usd');
168
+ expect(res[0]?.discount_amounts[0]?.coupon.id).toBe('c1');
169
+ expect(res[0]?.discount_amounts[0]?.promotion_code.id).toBe('pc1');
170
+ });
171
+
172
+ it('expands discounts array with details', async () => {
173
+ jest.spyOn(Coupon, 'findByPk').mockResolvedValue({ id: 'c1', name: 'c' } as any);
174
+ jest.spyOn(PromotionCode, 'findByPk').mockResolvedValue({ id: 'pc1', code: 'CODE1' } as any);
175
+ const res = await expandDiscountsWithDetails([{ coupon: 'c1', promotion_code: 'pc1' }]);
176
+ expect(res[0].coupon_details.id).toBe('c1');
177
+ expect(res[0].promotion_code_details.id).toBe('pc1');
178
+ });
179
+ });
180
+
181
+ describe('createDiscountRecordsForCheckout & updateSubscriptionDiscountReferences', () => {
182
+ it('creates/updates records and usage counts per discount config', async () => {
183
+ const checkoutSession: any = {
184
+ id: 'cs_1',
185
+ discounts: [{ coupon: 'c1', promotion_code: 'pc1', discount_amount: '50' }],
186
+ };
187
+
188
+ jest.spyOn(Discount, 'findAll').mockResolvedValue([] as any);
189
+ jest.spyOn(Coupon, 'findByPk').mockResolvedValue({ id: 'c1', livemode: false, duration: 'once' } as any);
190
+ jest.spyOn(PromotionCode, 'findByPk').mockResolvedValue({ id: 'pc1', verification_type: 'none' } as any);
191
+ jest
192
+ .spyOn(Discount, 'create')
193
+ .mockImplementation(
194
+ (data: any) => Promise.resolve({ id: `d_${Date.now()}`, update: jest.fn(), ...data }) as any
195
+ );
196
+
197
+ // Stubs for usage update path
198
+ jest.spyOn(Coupon.prototype as any, 'update').mockResolvedValue(undefined);
199
+ jest.spyOn(PromotionCode.prototype as any, 'update').mockResolvedValue(undefined);
200
+
201
+ const { discountRecords, subscriptionsUpdated } = await createDiscountRecordsForCheckout({
202
+ checkoutSession,
203
+ customerId: 'u1',
204
+ subscriptionIds: ['sub_1'],
205
+ });
206
+
207
+ expect(discountRecords.length).toBeGreaterThan(0);
208
+ expect(subscriptionsUpdated).toEqual(['sub_1']);
209
+
210
+ // updateSubscriptionDiscountReferences
211
+ jest.spyOn(Subscription, 'update').mockResolvedValue([1] as any);
212
+ const { updatedSubscriptions } = await updateSubscriptionDiscountReferences({
213
+ discountRecords,
214
+ subscriptionsUpdated,
215
+ });
216
+ expect(updatedSubscriptions).toEqual(['sub_1']);
217
+ });
218
+ });
219
+ });