payment-kit 1.20.12 → 1.20.14

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 (39) hide show
  1. package/api/src/crons/index.ts +8 -0
  2. package/api/src/libs/env.ts +1 -0
  3. package/api/src/libs/vendor-util/adapters/launcher-adapter.ts +1 -1
  4. package/api/src/libs/vendor-util/adapters/types.ts +1 -0
  5. package/api/src/libs/vendor-util/fulfillment.ts +1 -1
  6. package/api/src/queues/vendors/fulfillment-coordinator.ts +1 -29
  7. package/api/src/queues/vendors/return-processor.ts +184 -0
  8. package/api/src/queues/vendors/return-scanner.ts +119 -0
  9. package/api/src/queues/vendors/status-check.ts +1 -1
  10. package/api/src/routes/checkout-sessions.ts +15 -2
  11. package/api/src/routes/coupons.ts +7 -0
  12. package/api/src/routes/credit-grants.ts +8 -1
  13. package/api/src/routes/credit-transactions.ts +153 -13
  14. package/api/src/routes/invoices.ts +35 -1
  15. package/api/src/routes/meter-events.ts +31 -3
  16. package/api/src/routes/meters.ts +4 -0
  17. package/api/src/routes/payment-currencies.ts +2 -1
  18. package/api/src/routes/promotion-codes.ts +2 -2
  19. package/api/src/routes/subscription-items.ts +4 -0
  20. package/api/src/routes/vendor.ts +89 -2
  21. package/api/src/routes/webhook-endpoints.ts +4 -0
  22. package/api/src/store/migrations/20250918-add-vendor-extends.ts +20 -0
  23. package/api/src/store/migrations/20250919-add-source-data.ts +20 -0
  24. package/api/src/store/models/checkout-session.ts +5 -2
  25. package/api/src/store/models/credit-transaction.ts +5 -0
  26. package/api/src/store/models/meter-event.ts +22 -12
  27. package/api/src/store/models/product-vendor.ts +6 -0
  28. package/api/src/store/models/types.ts +18 -0
  29. package/blocklet.yml +1 -1
  30. package/package.json +5 -5
  31. package/src/components/customer/credit-overview.tsx +1 -1
  32. package/src/components/customer/related-credit-grants.tsx +194 -0
  33. package/src/components/meter/add-usage-dialog.tsx +8 -0
  34. package/src/components/meter/events-list.tsx +93 -96
  35. package/src/components/product/form.tsx +0 -1
  36. package/src/locales/en.tsx +9 -0
  37. package/src/locales/zh.tsx +9 -0
  38. package/src/pages/admin/billing/invoices/detail.tsx +21 -2
  39. package/src/pages/customer/invoice/detail.tsx +11 -2
@@ -3,9 +3,18 @@ import Joi from 'joi';
3
3
 
4
4
  import { Op } from 'sequelize';
5
5
  import { createListParamSchema, getOrder, getWhereFromKvQuery } from '../libs/api';
6
+ import { mergePaginate, type DataSource } from '../libs/pagination';
6
7
  import logger from '../libs/logger';
7
8
  import { authenticate } from '../libs/security';
8
- import { CreditTransaction, Customer, CreditGrant, Meter, Subscription, PaymentCurrency } from '../store/models';
9
+ import {
10
+ CreditTransaction,
11
+ Customer,
12
+ CreditGrant,
13
+ Meter,
14
+ MeterEvent,
15
+ Subscription,
16
+ PaymentCurrency,
17
+ } from '../store/models';
9
18
 
10
19
  const router = Router();
11
20
  const authMine = authenticate<CreditTransaction>({ component: true, roles: ['owner', 'admin'], mine: true });
@@ -27,6 +36,7 @@ const listSchema = createListParamSchema<{
27
36
  start?: number;
28
37
  end?: number;
29
38
  source?: string;
39
+ include_grants?: boolean;
30
40
  }>({
31
41
  customer_id: Joi.string().empty(''),
32
42
  subscription_id: Joi.string().empty(''),
@@ -35,11 +45,13 @@ const listSchema = createListParamSchema<{
35
45
  start: Joi.number().integer().optional(),
36
46
  end: Joi.number().integer().optional(),
37
47
  source: Joi.string().empty(''),
48
+ include_grants: Joi.boolean().optional(),
38
49
  });
39
50
 
40
51
  router.get('/', authMine, async (req, res) => {
41
52
  try {
42
53
  const { page, pageSize, ...query } = await listSchema.validateAsync(req.query, { stripUnknown: true });
54
+ const includeGrants = !!query.include_grants;
43
55
  const where = getWhereFromKvQuery(query.q);
44
56
 
45
57
  if (query.meter_id) {
@@ -73,6 +85,124 @@ router.get('/', authMine, async (req, res) => {
73
85
  };
74
86
  }
75
87
 
88
+ if (query.start && query.end) {
89
+ where.created_at = {
90
+ [Op.between]: [new Date(query.start * 1000), new Date(query.end * 1000)],
91
+ };
92
+ }
93
+
94
+ if (includeGrants) {
95
+ if (!query.customer_id) {
96
+ return res.status(400).json({
97
+ error: 'customer_id is required when include_grants=true',
98
+ });
99
+ }
100
+
101
+ const orderDirection = query.o === 'asc' ? 'ASC' : 'DESC';
102
+
103
+ const transactionSource: DataSource<any> = {
104
+ async count() {
105
+ const count = await CreditTransaction.count({ where });
106
+ return count;
107
+ },
108
+ async fetch(limit, offset) {
109
+ const rows = await CreditTransaction.findAll({
110
+ where,
111
+ limit,
112
+ offset,
113
+ order: [['created_at', orderDirection]],
114
+ include: [
115
+ { model: Customer, as: 'customer', attributes: ['id', 'name', 'email', 'did'] },
116
+ { model: Meter, as: 'meter' },
117
+ { model: Subscription, as: 'subscription', attributes: ['id', 'description', 'status'], required: false },
118
+ { model: CreditGrant, as: 'creditGrant', attributes: ['id', 'name', 'currency_id'] },
119
+ { model: MeterEvent, as: 'meterEvent', attributes: ['id', 'source_data'], required: false },
120
+ ],
121
+ });
122
+ // Transform transactions
123
+ return rows.map((item: any) => ({
124
+ ...item.toJSON(),
125
+ activity_type: 'transaction',
126
+ }));
127
+ },
128
+ meta: { type: 'database' },
129
+ };
130
+
131
+ // Grant where conditions
132
+ const grantWhere: any = {
133
+ customer_id: query.customer_id,
134
+ status: ['granted', 'depleted'],
135
+ };
136
+ if (query.start) {
137
+ grantWhere.created_at = {
138
+ [Op.gte]: new Date(query.start * 1000),
139
+ };
140
+ }
141
+ if (typeof query.livemode === 'boolean') grantWhere.livemode = query.livemode;
142
+
143
+ const grantSource: DataSource<any> = {
144
+ async count() {
145
+ const { count } = await CreditGrant.findAndCountAll({ where: grantWhere });
146
+ return count;
147
+ },
148
+ async fetch(limit, offset) {
149
+ const { rows } = await CreditGrant.findAndCountAll({
150
+ where: grantWhere,
151
+ limit,
152
+ offset,
153
+ order: [['created_at', orderDirection]],
154
+ include: [
155
+ { model: Customer, as: 'customer', attributes: ['id', 'name', 'email', 'did'] },
156
+ {
157
+ model: PaymentCurrency,
158
+ as: 'paymentCurrency',
159
+ attributes: ['id', 'symbol', 'decimal', 'maximum_precision'],
160
+ },
161
+ ],
162
+ });
163
+ // Transform grants
164
+ return rows.map((item: any) => ({
165
+ ...item.toJSON(),
166
+ activity_type: 'grant',
167
+ }));
168
+ },
169
+ meta: { type: 'database' },
170
+ };
171
+
172
+ // Define sort function
173
+ const sortFn = (a: any, b: any) => {
174
+ const aDate = new Date(a.created_at).getTime();
175
+ const bDate = new Date(b.created_at).getTime();
176
+ return query.o === 'asc' ? aDate - bDate : bDate - aDate;
177
+ };
178
+
179
+ // Use mergePaginate
180
+ const result = await mergePaginate([transactionSource, grantSource], { page, pageSize }, sortFn);
181
+
182
+ // Load payment currencies for final result
183
+ const paymentCurrencies = await PaymentCurrency.findAll({
184
+ attributes: ['id', 'symbol', 'decimal', 'maximum_precision'],
185
+ where: { type: 'credit' },
186
+ });
187
+
188
+ const enhancedData = result.data.map((item) => ({
189
+ ...item,
190
+ paymentCurrency: paymentCurrencies.find(
191
+ (x) => x.id === (item.activity_type === 'grant' ? item.currency_id : item.creditGrant?.currency_id)
192
+ ),
193
+ }));
194
+
195
+ return res.json({
196
+ count: result.total,
197
+ list: enhancedData,
198
+ paging: result.paging,
199
+ meta: {
200
+ unified_cash_flow: true,
201
+ includes: ['transaction', 'grant'],
202
+ },
203
+ });
204
+ }
205
+
76
206
  const { rows: list, count } = await CreditTransaction.findAndCountAll({
77
207
  where,
78
208
  order: getOrder(query, [['created_at', query.o === 'asc' ? 'ASC' : 'DESC']]),
@@ -97,27 +227,37 @@ router.get('/', authMine, async (req, res) => {
97
227
  {
98
228
  model: CreditGrant,
99
229
  as: 'creditGrant',
100
- attributes: ['id', 'name'],
230
+ attributes: ['id', 'name', 'currency_id'],
231
+ },
232
+ {
233
+ model: MeterEvent,
234
+ as: 'meterEvent',
235
+ attributes: ['id', 'source_data'], // Get source_data from related MeterEvent
236
+ required: false,
101
237
  },
102
238
  ],
103
239
  });
104
240
 
105
241
  const paymentCurrencies = await PaymentCurrency.findAll({
106
242
  attributes: ['id', 'symbol', 'decimal', 'maximum_precision'],
107
- where: {
108
- type: 'credit',
109
- },
243
+ where: { type: 'credit' },
110
244
  });
111
245
 
112
- const result = list.map((item) => {
113
- return {
114
- ...item.toJSON(),
115
- // @ts-ignore
116
- paymentCurrency: paymentCurrencies.find((x) => x.id === item.meter?.currency_id),
117
- };
118
- });
246
+ const result = list.map((item) => ({
247
+ ...item.toJSON(),
248
+ activity_type: 'transaction',
249
+ paymentCurrency: paymentCurrencies.find((x) => x.id === (item as any).creditGrant?.currency_id),
250
+ }));
119
251
 
120
- return res.json({ count, list: result, paging: { page, pageSize } });
252
+ return res.json({
253
+ count,
254
+ list: result,
255
+ paging: { page, pageSize },
256
+ meta: {
257
+ unified_cash_flow: false,
258
+ includes: ['transaction'],
259
+ },
260
+ });
121
261
  } catch (err) {
122
262
  logger.error('Error listing credit transactions', err);
123
263
  return res.status(400).json({ error: err.message });
@@ -23,7 +23,15 @@ import { Price } from '../store/models/price';
23
23
  import { Product } from '../store/models/product';
24
24
  import { Subscription } from '../store/models/subscription';
25
25
  import { getReturnStakeInvoices, getStakingInvoices, retryUncollectibleInvoices } from '../libs/invoice';
26
- import { CheckoutSession, PaymentLink, TInvoiceExpanded, Discount, Coupon, PromotionCode } from '../store/models';
26
+ import {
27
+ CheckoutSession,
28
+ PaymentLink,
29
+ TInvoiceExpanded,
30
+ Discount,
31
+ Coupon,
32
+ PromotionCode,
33
+ CreditGrant,
34
+ } from '../store/models';
27
35
  import { mergePaginate, defaultTimeOrderBy, getCachedOrFetch, DataSource } from '../libs/pagination';
28
36
  import logger from '../libs/logger';
29
37
  import { returnOverdraftProtectionQueue, returnStakeQueue } from '../queues/subscription';
@@ -678,6 +686,30 @@ router.get('/:id', authPortal, async (req, res) => {
678
686
  }
679
687
  }
680
688
 
689
+ let relatedCreditGrants: any[] = [];
690
+ try {
691
+ relatedCreditGrants = await CreditGrant.findAll({
692
+ where: {
693
+ customer_id: doc.customer_id,
694
+ 'metadata.invoice_id': doc.id,
695
+ } as any,
696
+ include: [
697
+ {
698
+ model: PaymentCurrency,
699
+ as: 'paymentCurrency',
700
+ attributes: ['id', 'symbol', 'decimal', 'name', 'type'],
701
+ },
702
+ ],
703
+ order: [['created_at', 'DESC']],
704
+ });
705
+ } catch (error) {
706
+ logger.error('Failed to fetch related credit grants', {
707
+ error,
708
+ invoiceId: doc.id,
709
+ customerId: doc.customer_id,
710
+ });
711
+ }
712
+
681
713
  if (doc.metadata?.invoice_id || doc.metadata?.prev_invoice_id) {
682
714
  const relatedInvoice = await Invoice.findByPk(doc.metadata.invoice_id || doc.metadata.prev_invoice_id, {
683
715
  attributes: ['id', 'number', 'status', 'billing_reason'],
@@ -686,6 +718,7 @@ router.get('/:id', authPortal, async (req, res) => {
686
718
  ...json,
687
719
  discountDetails,
688
720
  relatedInvoice,
721
+ relatedCreditGrants,
689
722
  paymentLink,
690
723
  checkoutSession,
691
724
  });
@@ -693,6 +726,7 @@ router.get('/:id', authPortal, async (req, res) => {
693
726
  return res.json({
694
727
  ...json,
695
728
  discountDetails,
729
+ relatedCreditGrants,
696
730
  paymentLink,
697
731
  checkoutSession,
698
732
  });
@@ -14,6 +14,32 @@ const router = Router();
14
14
  const auth = authenticate<MeterEvent>({ component: true, roles: ['owner', 'admin'] });
15
15
  const authMine = authenticate<MeterEvent>({ component: true, roles: ['owner', 'admin'], mine: true });
16
16
 
17
+ const SourceDataSchema = Joi.alternatives()
18
+ .try(
19
+ Joi.object().pattern(Joi.string().max(40), Joi.string().max(256).allow('')).min(0),
20
+ Joi.array()
21
+ .items(
22
+ Joi.object({
23
+ key: Joi.string().max(40).required(),
24
+ label: Joi.alternatives()
25
+ .try(
26
+ Joi.string().max(100),
27
+ Joi.object({
28
+ zh: Joi.string().max(100).optional(),
29
+ en: Joi.string().max(100).optional(),
30
+ })
31
+ )
32
+ .required(),
33
+ value: Joi.string().max(256).allow('').optional(),
34
+ type: Joi.string().valid('text', 'image', 'url').optional(),
35
+ url: Joi.string().uri().optional(),
36
+ group: Joi.string().max(40).optional(),
37
+ })
38
+ )
39
+ .min(0)
40
+ )
41
+ .optional();
42
+
17
43
  const meterEventSchema = Joi.object({
18
44
  event_name: Joi.string().max(128).required(),
19
45
  payload: Joi.object({
@@ -24,6 +50,7 @@ const meterEventSchema = Joi.object({
24
50
  timestamp: Joi.number().integer().optional(),
25
51
  identifier: Joi.string().max(255).required(),
26
52
  metadata: MetadataSchema,
53
+ source_data: SourceDataSchema,
27
54
  });
28
55
 
29
56
  const listSchema = createListParamSchema<{
@@ -72,12 +99,12 @@ router.get('/', authMine, async (req, res) => {
72
99
  }
73
100
 
74
101
  if (query.start || query.end) {
75
- where.created_at = {};
102
+ where.timestamp = {};
76
103
  if (query.start) {
77
- where.created_at[Op.gte] = new Date(query.start * 1000);
104
+ where.timestamp[Op.gte] = Number(query.start);
78
105
  }
79
106
  if (query.end) {
80
- where.created_at[Op.lte] = new Date(query.end * 1000);
107
+ where.timestamp[Op.lte] = Number(query.end);
81
108
  }
82
109
  }
83
110
 
@@ -259,6 +286,7 @@ router.post('/', auth, async (req, res) => {
259
286
  credit_pending: fromTokenToUnit(value, paymentCurrency.decimal).toString(),
260
287
  created_via: req.user?.via || 'api',
261
288
  metadata: formatMetadata(req.body.metadata),
289
+ source_data: req.body.source_data,
262
290
  timestamp,
263
291
  };
264
292
 
@@ -156,6 +156,10 @@ router.put('/:id', auth, async (req, res) => {
156
156
  };
157
157
 
158
158
  if (req.body.metadata) {
159
+ const { error: metadataError } = MetadataSchema.validate(req.body.metadata);
160
+ if (metadataError) {
161
+ return res.status(400).json({ error: `metadata invalid: ${metadataError.message}` });
162
+ }
159
163
  updateData.metadata = formatMetadata(req.body.metadata);
160
164
  }
161
165
 
@@ -19,6 +19,7 @@ import { depositVaultQueue } from '../queues/payment';
19
19
  import { checkDepositVaultAmount } from '../libs/payment';
20
20
  import { getTokenSummaryByDid } from '../integrations/arcblock/stake';
21
21
  import { createPaymentLink } from './payment-links';
22
+ import { MetadataSchema } from '../libs/api';
22
23
 
23
24
  const router = Router();
24
25
 
@@ -311,7 +312,7 @@ const updateCurrencySchema = Joi.object({
311
312
  name: Joi.string().empty('').max(32).optional(),
312
313
  description: Joi.string().empty('').max(255).optional(),
313
314
  logo: Joi.string().empty('').optional(),
314
- metadata: Joi.object().optional(),
315
+ metadata: MetadataSchema,
315
316
  symbol: Joi.string().empty('').optional(),
316
317
  }).unknown(true);
317
318
  router.put('/:id', auth, async (req, res) => {
@@ -8,7 +8,7 @@ import { createIdGenerator, formatMetadata } from '../libs/util';
8
8
  import { authenticate } from '../libs/security';
9
9
  import { PromotionCode, Coupon, PaymentCurrency } from '../store/models';
10
10
  import { getRedemptionData } from '../libs/discount/redemption';
11
- import { createListParamSchema } from '../libs/api';
11
+ import { createListParamSchema, MetadataSchema } from '../libs/api';
12
12
  import logger from '../libs/logger';
13
13
 
14
14
  const router = Router();
@@ -249,7 +249,7 @@ router.put('/:id', authAdmin, async (req, res) => {
249
249
  minimum_amount: Joi.number().positive().optional(),
250
250
  minimum_amount_currency: Joi.string().optional(),
251
251
  }).optional(),
252
- metadata: Joi.object().optional(),
252
+ metadata: MetadataSchema,
253
253
  });
254
254
 
255
255
  const { error, value } = schema.validate(req.body, {
@@ -139,6 +139,10 @@ router.put('/:id', auth, async (req, res) => {
139
139
  }
140
140
 
141
141
  if (updates.metadata) {
142
+ const { error: metadataError } = MetadataSchema.validate(updates.metadata);
143
+ if (metadataError) {
144
+ return res.status(400).json({ error: `metadata invalid: ${metadataError.message}` });
145
+ }
142
146
  updates.metadata = formatMetadata(updates.metadata);
143
147
  }
144
148
 
@@ -1,7 +1,8 @@
1
+ import { getUrl } from '@blocklet/sdk/lib/component';
1
2
  import { Router } from 'express';
2
3
  import Joi from 'joi';
3
4
 
4
- import { VendorAuth } from '@blocklet/payment-vendor';
5
+ import { Auth as VendorAuth, middleware } from '@blocklet/payment-vendor';
5
6
  import { joinURL } from 'ufo';
6
7
  import { MetadataSchema } from '../libs/api';
7
8
  import { wallet } from '../libs/auth';
@@ -9,7 +10,8 @@ import dayjs from '../libs/dayjs';
9
10
  import logger from '../libs/logger';
10
11
  import { authenticate } from '../libs/security';
11
12
  import { formatToShortUrl } from '../libs/url';
12
- import { CheckoutSession } from '../store/models';
13
+ import { getBlockletJson } from '../libs/util';
14
+ import { CheckoutSession, Invoice, Subscription } from '../store/models';
13
15
  import { ProductVendor } from '../store/models/product-vendor';
14
16
 
15
17
  const authAdmin = authenticate<CheckoutSession>({ component: true, roles: ['owner', 'admin'] });
@@ -159,6 +161,8 @@ async function createVendor(req: any, res: any) {
159
161
  return res.status(400).json({ error: 'Vendor key already exists' });
160
162
  }
161
163
 
164
+ const blockletJson = await getBlockletJson(appUrl);
165
+
162
166
  const vendor = await ProductVendor.create({
163
167
  vendor_key: vendorKey,
164
168
  vendor_type: vendorType || 'launcher',
@@ -170,6 +174,10 @@ async function createVendor(req: any, res: any) {
170
174
  app_pid: appPid,
171
175
  app_logo: appLogo,
172
176
  metadata: metadata || {},
177
+ extends: {
178
+ appId: blockletJson?.appId,
179
+ appPk: blockletJson?.appPk,
180
+ },
173
181
  created_by: req.user?.did || 'admin',
174
182
  });
175
183
 
@@ -210,6 +218,8 @@ async function updateVendor(req: any, res: any) {
210
218
  app_logo: appLogo,
211
219
  } = value;
212
220
 
221
+ const blockletJson = await getBlockletJson(appUrl);
222
+
213
223
  if (req.body.vendorKey && req.body.vendorKey !== vendor.vendor_key) {
214
224
  const existingVendor = await ProductVendor.findOne({
215
225
  where: { vendor_key: req.body.vendorKey },
@@ -229,6 +239,10 @@ async function updateVendor(req: any, res: any) {
229
239
  app_pid: appPid,
230
240
  app_logo: appLogo,
231
241
  vendor_key: req.body.vendor_key,
242
+ extends: {
243
+ appId: blockletJson?.appId,
244
+ appPk: blockletJson?.appPk,
245
+ },
232
246
  };
233
247
 
234
248
  await vendor.update(Object.fromEntries(Object.entries(updates).filter(([, v]) => v !== undefined)));
@@ -362,9 +376,23 @@ async function getVendorStatus(sessionId: string, isDetail = false) {
362
376
  return getVendorStatusByVendorId(item.vendor_id, item.order_id, isDetail);
363
377
  });
364
378
 
379
+ const subscriptionId = doc.subscription_id;
380
+ let shortSubscriptionUrl = '';
381
+
382
+ if (isDetail && subscriptionId) {
383
+ const subscriptionUrl = getUrl(`/customer/subscription/${subscriptionId}`);
384
+
385
+ shortSubscriptionUrl = await formatToShortUrl({
386
+ url: subscriptionUrl,
387
+ maxVisits: 5,
388
+ validUntil: dayjs().add(20, 'minutes').format('YYYY-MM-DDTHH:mm:ss+00:00'),
389
+ });
390
+ }
391
+
365
392
  return {
366
393
  payment_status: doc.payment_status,
367
394
  session_status: doc.status,
395
+ subscriptionUrl: shortSubscriptionUrl,
368
396
  vendors: await Promise.all(vendors),
369
397
  error: null,
370
398
  };
@@ -443,12 +471,71 @@ async function redirectToVendor(req: any, res: any) {
443
471
  }
444
472
  }
445
473
 
474
+ async function getVendorSubscription(req: any, res: any) {
475
+ const { sessionId } = req.params;
476
+
477
+ const checkoutSession = await CheckoutSession.findByPk(sessionId);
478
+
479
+ if (!checkoutSession) {
480
+ return res.status(404).json({ error: 'Checkout session not found' });
481
+ }
482
+
483
+ const subscription = await Subscription.findByPk(checkoutSession.subscription_id);
484
+
485
+ if (!subscription) {
486
+ return res.status(404).json({ error: 'Subscription not found' });
487
+ }
488
+
489
+ const invoices = await Invoice.findAll({
490
+ where: { subscription_id: subscription.id },
491
+ order: [['created_at', 'DESC']],
492
+ attributes: [
493
+ 'id',
494
+ 'amount_due',
495
+ 'amount_paid',
496
+ 'amount_remaining',
497
+ 'status',
498
+ 'currency_id',
499
+ 'period_start',
500
+ 'period_end',
501
+ 'created_at',
502
+ 'due_date',
503
+ 'description',
504
+ 'invoice_pdf',
505
+ ],
506
+ limit: 20,
507
+ });
508
+
509
+ return res.json({
510
+ subscription: subscription.toJSON(),
511
+ billing_history: invoices,
512
+ });
513
+ }
514
+
515
+ async function handleSubscriptionRedirect(req: any, res: any) {
516
+ const { sessionId } = req.params;
517
+
518
+ const checkoutSession = await CheckoutSession.findByPk(sessionId);
519
+ if (!checkoutSession) {
520
+ return res.status(404).json({ error: 'Checkout session not found' });
521
+ }
522
+
523
+ return res.redirect(getUrl(`/customer/subscription/${checkoutSession.subscription_id}`));
524
+ }
525
+
446
526
  const router = Router();
447
527
 
528
+ const ensureVendorAuth = middleware.ensureVendorAuth((vendorPk: string) =>
529
+ ProductVendor.findOne({ where: { 'extends.appPk': vendorPk } }).then((v) => v as any)
530
+ );
531
+
448
532
  // FIXME: Authentication not yet added, awaiting implementation @Pengfei
449
533
  router.get('/order/:sessionId/status', validateParams(sessionIdParamSchema), getVendorFulfillmentStatus);
450
534
  router.get('/order/:sessionId/detail', validateParams(sessionIdParamSchema), getVendorFulfillmentDetail);
451
535
 
536
+ router.get('/subscription/:sessionId/redirect', handleSubscriptionRedirect);
537
+ router.get('/subscription/:sessionId', ensureVendorAuth, getVendorSubscription);
538
+
452
539
  router.get(
453
540
  '/open/:subscriptionId',
454
541
  authAdmin,
@@ -100,6 +100,10 @@ router.put('/:id', auth, async (req, res) => {
100
100
  'enabled_events',
101
101
  ]);
102
102
  if (updates.metadata) {
103
+ const { error: metadataError } = MetadataSchema.validate(updates.metadata);
104
+ if (metadataError) {
105
+ return res.status(400).json({ error: `metadata invalid: ${metadataError.message}` });
106
+ }
103
107
  updates.metadata = formatMetadata(updates.metadata);
104
108
  }
105
109
 
@@ -0,0 +1,20 @@
1
+ import { safeApplyColumnChanges, type Migration } from '../migrate';
2
+
3
+ export const up: Migration = async ({ context }) => {
4
+ // Add extends column to product_vendors table
5
+ await safeApplyColumnChanges(context, {
6
+ product_vendors: [
7
+ {
8
+ name: 'extends',
9
+ field: {
10
+ type: 'JSON',
11
+ allowNull: true,
12
+ },
13
+ },
14
+ ],
15
+ });
16
+ };
17
+
18
+ export const down: Migration = async ({ context }) => {
19
+ await context.removeColumn('product_vendors', 'extends');
20
+ };
@@ -0,0 +1,20 @@
1
+ import { DataTypes } from 'sequelize';
2
+ import { Migration, safeApplyColumnChanges } from '../migrate';
3
+
4
+ export const up: Migration = async ({ context }) => {
5
+ await safeApplyColumnChanges(context, {
6
+ meter_events: [
7
+ {
8
+ name: 'source_data',
9
+ field: {
10
+ type: DataTypes.JSON,
11
+ allowNull: true,
12
+ },
13
+ },
14
+ ],
15
+ });
16
+ };
17
+
18
+ export const down: Migration = async ({ context }) => {
19
+ await context.removeColumn('meter_events', 'source_data');
20
+ };
@@ -212,7 +212,9 @@ export class CheckoutSession extends Model<InferAttributes<CheckoutSession>, Inf
212
212
  | 'cancelled'
213
213
  | 'max_retries_exceeded'
214
214
  | 'return_requested'
215
- | 'sent',
215
+ | 'sent'
216
+ | 'returning'
217
+ | 'returned',
216
218
  string
217
219
  >;
218
220
  declare vendor_info?: Array<{
@@ -227,7 +229,8 @@ export class CheckoutSession extends Model<InferAttributes<CheckoutSession>, Inf
227
229
  | 'cancelled'
228
230
  | 'max_retries_exceeded'
229
231
  | 'return_requested'
230
- | 'sent';
232
+ | 'sent'
233
+ | 'returned';
231
234
  service_url?: string;
232
235
  app_url?: string;
233
236
  error_message?: string;
@@ -135,6 +135,11 @@ export class CreditTransaction extends Model<
135
135
  foreignKey: 'subscription_id',
136
136
  as: 'subscription',
137
137
  });
138
+
139
+ this.belongsTo(models.MeterEvent, {
140
+ foreignKey: 'source',
141
+ as: 'meterEvent',
142
+ });
138
143
  }
139
144
 
140
145
  public static async getUsageSummary({