payment-kit 1.18.56 → 1.19.1

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 (214) hide show
  1. package/.eslintrc.js +6 -0
  2. package/api/src/crons/index.ts +8 -0
  3. package/api/src/index.ts +4 -0
  4. package/api/src/libs/credit-grant.ts +146 -0
  5. package/api/src/libs/env.ts +1 -0
  6. package/api/src/libs/invoice.ts +4 -3
  7. package/api/src/libs/notification/template/base.ts +388 -2
  8. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +149 -0
  9. package/api/src/libs/notification/template/customer-credit-grant-low-balance.ts +151 -0
  10. package/api/src/libs/notification/template/customer-credit-insufficient.ts +254 -0
  11. package/api/src/libs/notification/template/subscription-canceled.ts +193 -202
  12. package/api/src/libs/notification/template/subscription-refund-succeeded.ts +215 -237
  13. package/api/src/libs/notification/template/subscription-renewed.ts +130 -200
  14. package/api/src/libs/notification/template/subscription-succeeded.ts +100 -202
  15. package/api/src/libs/notification/template/subscription-trial-start.ts +142 -188
  16. package/api/src/libs/notification/template/subscription-trial-will-end.ts +146 -174
  17. package/api/src/libs/notification/template/subscription-upgraded.ts +96 -192
  18. package/api/src/libs/notification/template/subscription-will-canceled.ts +94 -135
  19. package/api/src/libs/notification/template/subscription-will-renew.ts +220 -245
  20. package/api/src/libs/payment.ts +69 -0
  21. package/api/src/libs/queue/index.ts +3 -2
  22. package/api/src/libs/session.ts +8 -0
  23. package/api/src/libs/subscription.ts +74 -3
  24. package/api/src/libs/ws.ts +23 -1
  25. package/api/src/locales/en.ts +33 -0
  26. package/api/src/locales/zh.ts +31 -0
  27. package/api/src/queues/credit-consume.ts +715 -0
  28. package/api/src/queues/credit-grant.ts +572 -0
  29. package/api/src/queues/notification.ts +173 -128
  30. package/api/src/queues/payment.ts +210 -122
  31. package/api/src/queues/subscription.ts +179 -0
  32. package/api/src/routes/checkout-sessions.ts +157 -9
  33. package/api/src/routes/connect/shared.ts +3 -2
  34. package/api/src/routes/credit-grants.ts +241 -0
  35. package/api/src/routes/credit-transactions.ts +208 -0
  36. package/api/src/routes/index.ts +8 -0
  37. package/api/src/routes/meter-events.ts +347 -0
  38. package/api/src/routes/meters.ts +219 -0
  39. package/api/src/routes/payment-currencies.ts +14 -2
  40. package/api/src/routes/payment-links.ts +1 -1
  41. package/api/src/routes/payment-methods.ts +14 -2
  42. package/api/src/routes/prices.ts +43 -0
  43. package/api/src/routes/pricing-table.ts +13 -7
  44. package/api/src/routes/products.ts +63 -4
  45. package/api/src/routes/settings.ts +1 -1
  46. package/api/src/routes/subscriptions.ts +4 -0
  47. package/api/src/store/migrations/20250610-billing-credit.ts +43 -0
  48. package/api/src/store/models/credit-grant.ts +486 -0
  49. package/api/src/store/models/credit-transaction.ts +268 -0
  50. package/api/src/store/models/customer.ts +8 -0
  51. package/api/src/store/models/index.ts +52 -1
  52. package/api/src/store/models/meter-event.ts +423 -0
  53. package/api/src/store/models/meter.ts +176 -0
  54. package/api/src/store/models/payment-currency.ts +66 -14
  55. package/api/src/store/models/price.ts +6 -0
  56. package/api/src/store/models/product.ts +2 -2
  57. package/api/src/store/models/subscription.ts +24 -0
  58. package/api/src/store/models/types.ts +28 -2
  59. package/api/tests/libs/subscription.spec.ts +53 -0
  60. package/blocklet.yml +9 -1
  61. package/package.json +57 -58
  62. package/scripts/sdk.js +233 -1
  63. package/src/app.tsx +10 -0
  64. package/src/components/actions.tsx +22 -9
  65. package/src/components/balance-list.tsx +40 -12
  66. package/src/components/collapse.tsx +33 -15
  67. package/src/components/copyable.tsx +8 -7
  68. package/src/components/currency.tsx +15 -7
  69. package/src/components/customer/actions.tsx +1 -5
  70. package/src/components/customer/credit-grant-item-list.tsx +99 -0
  71. package/src/components/customer/credit-overview.tsx +233 -0
  72. package/src/components/customer/form.tsx +7 -2
  73. package/src/components/customer/link.tsx +4 -12
  74. package/src/components/customer/notification-preference.tsx +18 -9
  75. package/src/components/customer/overdraft-protection.tsx +112 -41
  76. package/src/components/drawer-form.tsx +42 -18
  77. package/src/components/error.tsx +1 -5
  78. package/src/components/event/list.tsx +9 -10
  79. package/src/components/filter-toolbar.tsx +20 -19
  80. package/src/components/info-card.tsx +32 -18
  81. package/src/components/info-metric.tsx +16 -6
  82. package/src/components/info-row-group.tsx +1 -7
  83. package/src/components/info-row.tsx +30 -24
  84. package/src/components/invoice/action.tsx +1 -7
  85. package/src/components/invoice/list.tsx +34 -26
  86. package/src/components/invoice/recharge.tsx +5 -7
  87. package/src/components/invoice/table.tsx +17 -12
  88. package/src/components/layout/user.tsx +1 -1
  89. package/src/components/metadata/form.tsx +290 -94
  90. package/src/components/metadata/list.tsx +11 -3
  91. package/src/components/meter/actions.tsx +101 -0
  92. package/src/components/meter/add-usage-dialog.tsx +239 -0
  93. package/src/components/meter/events-list.tsx +657 -0
  94. package/src/components/meter/form.tsx +245 -0
  95. package/src/components/meter/products.tsx +264 -0
  96. package/src/components/meter/usage-guide.tsx +174 -0
  97. package/src/components/passport/actions.tsx +9 -4
  98. package/src/components/payment-currency/add.tsx +16 -3
  99. package/src/components/payment-currency/form.tsx +14 -6
  100. package/src/components/payment-intent/actions.tsx +24 -16
  101. package/src/components/payment-intent/list.tsx +30 -9
  102. package/src/components/payment-link/actions.tsx +1 -5
  103. package/src/components/payment-link/after-pay.tsx +4 -2
  104. package/src/components/payment-link/before-pay.tsx +14 -4
  105. package/src/components/payment-link/item.tsx +27 -6
  106. package/src/components/payment-link/preview.tsx +9 -9
  107. package/src/components/payment-link/product-select.tsx +69 -15
  108. package/src/components/payment-method/arcblock.tsx +8 -1
  109. package/src/components/payment-method/base.tsx +8 -1
  110. package/src/components/payment-method/bitcoin.tsx +8 -1
  111. package/src/components/payment-method/ethereum.tsx +8 -1
  112. package/src/components/payment-method/evm-rpc-input.tsx +11 -7
  113. package/src/components/payment-method/form.tsx +2 -7
  114. package/src/components/payment-method/stripe.tsx +2 -0
  115. package/src/components/payouts/actions.tsx +1 -5
  116. package/src/components/payouts/list.tsx +30 -10
  117. package/src/components/payouts/portal/list.tsx +11 -9
  118. package/src/components/price/currency-select.tsx +63 -32
  119. package/src/components/price/form.tsx +895 -370
  120. package/src/components/price/upsell-select.tsx +10 -2
  121. package/src/components/price/upsell.tsx +7 -2
  122. package/src/components/pricing-table/actions.tsx +1 -5
  123. package/src/components/pricing-table/customer-settings.tsx +5 -1
  124. package/src/components/pricing-table/payment-settings.tsx +14 -4
  125. package/src/components/pricing-table/preview.tsx +9 -9
  126. package/src/components/pricing-table/price-item.tsx +6 -1
  127. package/src/components/pricing-table/product-item.tsx +6 -1
  128. package/src/components/pricing-table/product-settings.tsx +17 -4
  129. package/src/components/product/actions.tsx +1 -5
  130. package/src/components/product/add-price.tsx +9 -7
  131. package/src/components/product/create.tsx +8 -9
  132. package/src/components/product/cross-sell-select.tsx +5 -1
  133. package/src/components/product/cross-sell.tsx +7 -2
  134. package/src/components/product/edit-price.tsx +21 -12
  135. package/src/components/product/features.tsx +26 -6
  136. package/src/components/product/form.tsx +115 -72
  137. package/src/components/progress-bar.tsx +1 -1
  138. package/src/components/refund/actions.tsx +1 -7
  139. package/src/components/refund/list.tsx +31 -18
  140. package/src/components/section/header.tsx +12 -14
  141. package/src/components/subscription/actions/cancel.tsx +22 -5
  142. package/src/components/subscription/actions/index.tsx +9 -10
  143. package/src/components/subscription/actions/pause.tsx +32 -6
  144. package/src/components/subscription/actions/slash-stake.tsx +5 -3
  145. package/src/components/subscription/description.tsx +12 -8
  146. package/src/components/subscription/items/index.tsx +31 -16
  147. package/src/components/subscription/items/usage-records.tsx +19 -5
  148. package/src/components/subscription/list.tsx +5 -7
  149. package/src/components/subscription/metrics.tsx +62 -15
  150. package/src/components/subscription/portal/actions.tsx +78 -71
  151. package/src/components/subscription/portal/cancel.tsx +10 -3
  152. package/src/components/subscription/portal/list.tsx +48 -26
  153. package/src/components/uploader.tsx +5 -13
  154. package/src/components/webhook/attempts.tsx +51 -16
  155. package/src/components/webhook/request-info.tsx +8 -6
  156. package/src/contexts/products.tsx +27 -10
  157. package/src/hooks/subscription.ts +34 -0
  158. package/src/libs/meter-utils.ts +196 -0
  159. package/src/libs/util.ts +4 -0
  160. package/src/locales/en.tsx +385 -4
  161. package/src/locales/zh.tsx +364 -0
  162. package/src/pages/admin/billing/index.tsx +61 -33
  163. package/src/pages/admin/billing/invoices/detail.tsx +49 -13
  164. package/src/pages/admin/billing/meters/create.tsx +60 -0
  165. package/src/pages/admin/billing/meters/detail.tsx +435 -0
  166. package/src/pages/admin/billing/meters/index.tsx +210 -0
  167. package/src/pages/admin/billing/meters/meter-event.tsx +346 -0
  168. package/src/pages/admin/billing/subscriptions/detail.tsx +90 -25
  169. package/src/pages/admin/customers/customers/credit-grant/detail.tsx +391 -0
  170. package/src/pages/admin/customers/customers/detail.tsx +67 -14
  171. package/src/pages/admin/customers/customers/index.tsx +6 -1
  172. package/src/pages/admin/customers/index.tsx +5 -0
  173. package/src/pages/admin/developers/events/detail.tsx +37 -11
  174. package/src/pages/admin/developers/index.tsx +1 -1
  175. package/src/pages/admin/developers/webhooks/detail.tsx +41 -11
  176. package/src/pages/admin/index.tsx +15 -2
  177. package/src/pages/admin/overview.tsx +107 -19
  178. package/src/pages/admin/payments/intents/detail.tsx +58 -14
  179. package/src/pages/admin/payments/payouts/detail.tsx +63 -15
  180. package/src/pages/admin/payments/refunds/detail.tsx +58 -14
  181. package/src/pages/admin/products/index.tsx +11 -4
  182. package/src/pages/admin/products/links/create.tsx +22 -4
  183. package/src/pages/admin/products/links/detail.tsx +43 -14
  184. package/src/pages/admin/products/passports/index.tsx +23 -4
  185. package/src/pages/admin/products/prices/actions.tsx +16 -9
  186. package/src/pages/admin/products/prices/detail.tsx +73 -14
  187. package/src/pages/admin/products/prices/list.tsx +15 -3
  188. package/src/pages/admin/products/pricing-tables/create.tsx +45 -12
  189. package/src/pages/admin/products/pricing-tables/detail.tsx +45 -14
  190. package/src/pages/admin/products/products/create.tsx +233 -54
  191. package/src/pages/admin/products/products/detail.tsx +74 -18
  192. package/src/pages/admin/settings/index.tsx +8 -1
  193. package/src/pages/admin/settings/payment-methods/index.tsx +87 -19
  194. package/src/pages/admin/settings/vault-config/edit-form.tsx +42 -28
  195. package/src/pages/admin/settings/vault-config/index.tsx +57 -10
  196. package/src/pages/customer/credit-grant/detail.tsx +308 -0
  197. package/src/pages/customer/index.tsx +76 -17
  198. package/src/pages/customer/invoice/detail.tsx +63 -14
  199. package/src/pages/customer/invoice/past-due.tsx +11 -3
  200. package/src/pages/customer/payout/detail.tsx +56 -13
  201. package/src/pages/customer/recharge/account.tsx +78 -18
  202. package/src/pages/customer/recharge/subscription.tsx +86 -25
  203. package/src/pages/customer/refund/list.tsx +60 -24
  204. package/src/pages/customer/subscription/change-payment.tsx +17 -6
  205. package/src/pages/customer/subscription/change-plan.tsx +34 -7
  206. package/src/pages/customer/subscription/detail.tsx +134 -34
  207. package/src/pages/customer/subscription/embed.tsx +25 -5
  208. package/src/pages/home.tsx +26 -4
  209. package/src/pages/integrations/donations/edit-form.tsx +25 -9
  210. package/src/pages/integrations/donations/index.tsx +26 -9
  211. package/src/pages/integrations/donations/preview.tsx +59 -15
  212. package/src/pages/integrations/index.tsx +10 -1
  213. package/src/pages/integrations/overview.tsx +78 -17
  214. package/vite.config.ts +60 -30
@@ -0,0 +1,219 @@
1
+ import { Router } from 'express';
2
+ import Joi from 'joi';
3
+ import pick from 'lodash/pick';
4
+
5
+ import { Op } from 'sequelize';
6
+ import { createListParamSchema, getOrder, getWhereFromKvQuery, MetadataSchema } from '../libs/api';
7
+ import logger from '../libs/logger';
8
+ import { authenticate } from '../libs/security';
9
+ import { formatMetadata } from '../libs/util';
10
+ import { Meter, PaymentCurrency, PaymentMethod } from '../store/models';
11
+
12
+ const router = Router();
13
+ const auth = authenticate<Meter>({ component: true, roles: ['owner', 'admin'] });
14
+
15
+ const meterSchema = Joi.object({
16
+ name: Joi.string().max(64).required(),
17
+ event_name: Joi.string().max(64).required(),
18
+ aggregation_method: Joi.string().valid('sum', 'count', 'last').default('sum'),
19
+ unit: Joi.string().max(32).required(),
20
+ currency_id: Joi.string().max(40).optional(),
21
+ description: Joi.string().max(255).allow('').optional(),
22
+ metadata: MetadataSchema,
23
+ component_did: Joi.string().max(40).optional(),
24
+ }).unknown(true);
25
+
26
+ const updateMeterSchema = Joi.object({
27
+ name: Joi.string().max(64).optional(),
28
+ description: Joi.string().max(255).allow('').optional(),
29
+ status: Joi.string().valid('active', 'inactive').optional(),
30
+ });
31
+
32
+ const listSchema = createListParamSchema<{ event_name?: string }>({
33
+ event_name: Joi.string().empty(''),
34
+ });
35
+
36
+ router.get('/', auth, async (req, res) => {
37
+ try {
38
+ const { page, pageSize, ...query } = await listSchema.validateAsync(req.query, { stripUnknown: true });
39
+ const where = getWhereFromKvQuery(query.q);
40
+
41
+ if (typeof query.livemode === 'boolean') {
42
+ where.livemode = query.livemode;
43
+ }
44
+ if (query.event_name) {
45
+ where.event_name = query.event_name;
46
+ }
47
+
48
+ const { rows: list, count } = await Meter.findAndCountAll({
49
+ where,
50
+ order: getOrder(query, [['created_at', query.o === 'asc' ? 'ASC' : 'DESC']]),
51
+ offset: (page - 1) * pageSize,
52
+ limit: pageSize,
53
+ include: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
54
+ });
55
+
56
+ res.json({ count, list, paging: { page, pageSize } });
57
+ } catch (err) {
58
+ logger.error('Error listing meters', err);
59
+ res.status(400).json({ error: err?.message });
60
+ }
61
+ });
62
+
63
+ router.post('/', auth, async (req, res) => {
64
+ try {
65
+ const { error } = meterSchema.validate(req.body);
66
+ if (error) {
67
+ return res.status(400).json({ error: `Meter create request invalid: ${error.message}` });
68
+ }
69
+
70
+ const existing = await Meter.findOne({
71
+ where: { event_name: req.body.event_name, livemode: !!req.livemode },
72
+ });
73
+ if (existing) {
74
+ return res.status(409).json({ error: `Meter with event_name "${req.body.event_name}" already exists` });
75
+ }
76
+
77
+ if (['count', 'last'].includes(req.body.aggregation_method)) {
78
+ return res.status(400).json({ error: 'Aggregation method is not supported' });
79
+ }
80
+
81
+ const meterData = {
82
+ ...pick(req.body, ['name', 'event_name', 'aggregation_method', 'unit', 'currency_id', 'description', 'metadata']),
83
+ livemode: !!req.livemode,
84
+ created_via: req.user?.via || 'api',
85
+ status: req.body.status || 'active',
86
+ metadata: formatMetadata(req.body.metadata),
87
+ };
88
+
89
+ if (!meterData.currency_id) {
90
+ const paymentMethod = await PaymentMethod.findOne({
91
+ where: {
92
+ livemode: !!req.livemode,
93
+ type: 'arcblock',
94
+ },
95
+ });
96
+ if (!paymentMethod) {
97
+ return res.status(400).json({ error: 'Payment method not found' });
98
+ }
99
+
100
+ const paymentCurrency = await PaymentCurrency.createForMeter(meterData, paymentMethod.id);
101
+ meterData.currency_id = paymentCurrency.id;
102
+ }
103
+
104
+ const meter = await Meter.create(meterData);
105
+
106
+ const result = await Meter.findByPk(meter.id, {
107
+ include: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
108
+ });
109
+
110
+ logger.info('Meter created', { meterId: meter.id, eventName: meter.event_name });
111
+ return res.json(result);
112
+ } catch (err) {
113
+ logger.error('create meter failed', { error: err?.message, request: req.body });
114
+ return res.status(400).json({ error: err?.message });
115
+ }
116
+ });
117
+
118
+ router.get('/:id', auth, async (req, res) => {
119
+ try {
120
+ const meter = await Meter.findOne({
121
+ where: {
122
+ [Op.or]: [{ id: req.params.id }, { event_name: req.params.id }],
123
+ },
124
+ include: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
125
+ });
126
+
127
+ if (!meter) {
128
+ return res.status(404).json({ error: 'Meter not found' });
129
+ }
130
+
131
+ return res.json(meter);
132
+ } catch (err) {
133
+ logger.error('get meter failed', { error: err?.message, meterId: req.params.id });
134
+ return res.status(400).json({ error: err?.message });
135
+ }
136
+ });
137
+
138
+ router.put('/:id', auth, async (req, res) => {
139
+ try {
140
+ const { error } = updateMeterSchema.validate(pick(req.body, ['name', 'description', 'status']));
141
+ if (error) {
142
+ return res.status(400).json({ error: `Meter update request invalid: ${error.message}` });
143
+ }
144
+
145
+ const meter = await Meter.findByPk(req.params.id, {
146
+ include: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
147
+ });
148
+ if (!meter) {
149
+ return res.status(404).json({ error: 'Meter not found' });
150
+ }
151
+
152
+ const updateData: any = {
153
+ ...pick(req.body, ['name', 'description', 'status']),
154
+ updated_by: req.user?.did,
155
+ };
156
+
157
+ if (req.body.metadata) {
158
+ updateData.metadata = formatMetadata(req.body.metadata);
159
+ }
160
+
161
+ await meter.update(updateData);
162
+
163
+ return res.json(meter);
164
+ } catch (err) {
165
+ logger.error('update meter failed', { error: err?.message, meterId: req.params.id });
166
+ return res.status(400).json({ error: err?.message });
167
+ }
168
+ });
169
+
170
+ router.put('/:id/activate', auth, async (req, res) => {
171
+ try {
172
+ const meter = await Meter.findByPk(req.params.id, {
173
+ include: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
174
+ });
175
+ if (!meter) {
176
+ return res.status(404).json({ error: 'Meter not found' });
177
+ }
178
+
179
+ if (meter.status === 'active') {
180
+ return res.status(400).json({ error: 'Meter is already active' });
181
+ }
182
+
183
+ await meter.update({
184
+ status: 'active',
185
+ updated_by: req.user?.did,
186
+ });
187
+ return res.json(meter);
188
+ } catch (err) {
189
+ logger.error('activate meter failed', { error: err?.message, meterId: req.params.id });
190
+ return res.status(400).json({ error: err?.message });
191
+ }
192
+ });
193
+
194
+ router.put('/:id/deactivate', auth, async (req, res) => {
195
+ try {
196
+ const meter = await Meter.findByPk(req.params.id, {
197
+ include: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
198
+ });
199
+ if (!meter) {
200
+ return res.status(404).json({ error: 'Meter not found' });
201
+ }
202
+
203
+ if (meter.status === 'inactive') {
204
+ return res.status(400).json({ error: 'Meter is already inactive' });
205
+ }
206
+
207
+ await meter.update({
208
+ status: 'inactive',
209
+ updated_by: req.user?.did,
210
+ });
211
+
212
+ return res.json(meter);
213
+ } catch (err) {
214
+ logger.error('deactivate meter failed', { error: err?.message, meterId: req.params.id });
215
+ return res.status(400).json({ error: err?.message });
216
+ }
217
+ });
218
+
219
+ export default router;
@@ -3,6 +3,7 @@ import { Router } from 'express';
3
3
  import { InferAttributes, Op, WhereOptions } from 'sequelize';
4
4
 
5
5
  import Joi from 'joi';
6
+ import pick from 'lodash/pick';
6
7
  import { fetchErc20Meta } from '../integrations/ethereum/token';
7
8
  import logger from '../libs/logger';
8
9
  import { authenticate } from '../libs/security';
@@ -19,9 +20,18 @@ const router = Router();
19
20
 
20
21
  const auth = authenticate<PaymentCurrency>({ component: true, roles: ['owner', 'admin'] });
21
22
  const authOwner = authenticate<PaymentCurrency>({ component: true, roles: ['owner'] });
23
+ const paymentCurrencyCreateSchema = Joi.object({
24
+ name: Joi.string().max(32).required(),
25
+ description: Joi.string().max(255).required(),
26
+ }).unknown(true);
22
27
  router.post('/', auth, async (req, res) => {
23
28
  const raw: Partial<TPaymentCurrency> = req.body;
24
29
 
30
+ const { error } = paymentCurrencyCreateSchema.validate(pick(req.body, ['name', 'description']));
31
+ if (error) {
32
+ return res.status(400).json({ error: error.message });
33
+ }
34
+
25
35
  if (!raw.payment_method_id) {
26
36
  return res.status(400).json({ error: 'payment_method_id is required' });
27
37
  }
@@ -73,6 +83,7 @@ router.post('/', auth, async (req, res) => {
73
83
  logo: raw.logo,
74
84
  symbol: info.symbol,
75
85
  decimal: info.decimal,
86
+ type: 'standard',
76
87
 
77
88
  // FIXME: make these configurable
78
89
  minimum_payment_amount: fromTokenToUnit(0.000001, info.decimal).toString(),
@@ -109,6 +120,7 @@ router.post('/', auth, async (req, res) => {
109
120
  logo: raw.logo,
110
121
  symbol: state.symbol,
111
122
  decimal: state.decimal,
123
+ type: 'standard',
112
124
 
113
125
  // FIXME: make these configurable
114
126
  minimum_payment_amount: fromTokenToUnit(0.000001, state.decimal).toString(),
@@ -283,8 +295,8 @@ router.put('/:id/vault-config', authOwner, async (req, res) => {
283
295
  });
284
296
 
285
297
  const updateCurrencySchema = Joi.object({
286
- name: Joi.string().empty('').optional(),
287
- description: Joi.string().empty('').optional(),
298
+ name: Joi.string().empty('').max(32).optional(),
299
+ description: Joi.string().empty('').max(255).optional(),
288
300
  logo: Joi.string().empty('').optional(),
289
301
  }).unknown(true);
290
302
  router.put('/:id', auth, async (req, res) => {
@@ -132,7 +132,7 @@ export async function createPaymentLink(payload: any) {
132
132
  }
133
133
 
134
134
  const items = await Price.expand(raw.line_items);
135
- if (items.find((x) => x.price.custom_unit_amount) && items.length > 1) {
135
+ if (items.find((x) => x.price?.custom_unit_amount) && items.length > 1) {
136
136
  throw new Error('Multiple items with custom unit amount are not supported in payment link');
137
137
  }
138
138
 
@@ -21,12 +21,21 @@ const router = Router();
21
21
 
22
22
  const auth = authenticate<PaymentMethod>({ component: true, roles: ['owner', 'admin'] });
23
23
 
24
+ const paymentMethodCreateSchema = Joi.object({
25
+ name: Joi.string().max(32).required(),
26
+ description: Joi.string().max(255).required(),
27
+ }).unknown(true);
28
+
24
29
  router.post('/', auth, async (req, res) => {
25
30
  const raw: Partial<TPaymentMethod> = req.body;
26
31
 
27
32
  raw.livemode = req.livemode;
28
33
  raw.locked = false;
29
34
  raw.active = true;
35
+ const { error } = paymentMethodCreateSchema.validate(pick(req.body, ['name', 'description']));
36
+ if (error) {
37
+ return res.status(400).json({ error: error.message });
38
+ }
30
39
 
31
40
  if (!raw.name) {
32
41
  return res.status(400).json({ error: 'payment method name is required' });
@@ -77,12 +86,14 @@ router.post('/', auth, async (req, res) => {
77
86
  locked: true,
78
87
  is_base_currency: false,
79
88
  payment_method_id: method.id,
89
+ type: 'standard',
80
90
 
81
91
  name: 'Dollar',
82
92
  description: 'US Dollar',
83
93
  logo: getUrl('/currencies/dollar.png'),
84
94
  symbol: 'USD', // same currency code as stripe
85
95
  decimal: 2,
96
+ maximum_precision: 2,
86
97
 
87
98
  minimum_payment_amount: '1', // cent
88
99
  maximum_payment_amount: '100000000000', // billion
@@ -153,6 +164,7 @@ router.post('/', auth, async (req, res) => {
153
164
  locked: true,
154
165
  is_base_currency: false,
155
166
  payment_method_id: method.id,
167
+ type: 'standard',
156
168
 
157
169
  name: symbol,
158
170
  description: symbol,
@@ -285,8 +297,8 @@ router.put('/:id/settings', auth, async (req, res) => {
285
297
  });
286
298
 
287
299
  const updateMethodSchema = Joi.object({
288
- name: Joi.string().empty('').optional(),
289
- description: Joi.string().empty('').optional(),
300
+ name: Joi.string().empty('').max(32).optional(),
301
+ description: Joi.string().empty('').max(255).optional(),
290
302
  logo: Joi.string().empty('').optional(),
291
303
  }).unknown(true);
292
304
  router.put('/:id', auth, async (req, res) => {
@@ -12,11 +12,21 @@ import { PaymentCurrency } from '../store/models/payment-currency';
12
12
  import { Price } from '../store/models/price';
13
13
  import { Product } from '../store/models/product';
14
14
  import { checkCurrencySupportRecurring } from '../libs/product';
15
+ import { Meter } from '../store/models';
15
16
 
16
17
  const router = Router();
17
18
 
18
19
  const auth = authenticate<Price>({ component: true, roles: ['owner', 'admin'] });
19
20
 
21
+ const CreditConfigSchema = Joi.object({
22
+ valid_duration_value: Joi.number().default(0).optional(),
23
+ valid_duration_unit: Joi.string().valid('hours', 'days', 'weeks', 'months', 'years').default('days').optional(),
24
+ priority: Joi.number().min(0).max(100).default(50).optional(),
25
+ applicable_prices: Joi.array().items(Joi.string()).default([]).optional(),
26
+ credit_amount: Joi.number().greater(0).required(),
27
+ currency_id: Joi.string().required(),
28
+ });
29
+
20
30
  export async function getExpandedPrice(id: string) {
21
31
  const price = await Price.findByPkOrLookupKey(id, {
22
32
  include: [
@@ -37,6 +47,10 @@ export async function getExpandedPrice(id: string) {
37
47
  }
38
48
  }
39
49
 
50
+ if (doc.recurring?.meter_id) {
51
+ // @ts-ignore
52
+ doc.meter = await Meter.findByPk(doc.recurring?.meter_id);
53
+ }
40
54
  return doc;
41
55
  }
42
56
 
@@ -143,6 +157,18 @@ export async function createPrice(payload: any) {
143
157
  throw new Error(`product ${raw.product_id} not found for price`);
144
158
  }
145
159
 
160
+ if (product.type === 'credit') {
161
+ const creditConfig = raw.metadata.credit_config;
162
+ if (!creditConfig) {
163
+ throw new Error('credit_config is required');
164
+ }
165
+ const { error, value: creditConfigValue } = CreditConfigSchema.validate(creditConfig);
166
+ if (error) {
167
+ throw new Error(`credit_config is invalid: ${error.message}`);
168
+ }
169
+ raw.metadata.credit_config = creditConfigValue;
170
+ }
171
+
146
172
  const currencies = await PaymentCurrency.findAll({ where: { active: true } });
147
173
  const currency = currencies.find((x) => x.id === raw.currency_id);
148
174
  if (!currency) {
@@ -296,6 +322,11 @@ router.put('/:id', auth, async (req, res) => {
296
322
  return res.status(404).json({ error: 'price not found' });
297
323
  }
298
324
 
325
+ const product = await Product.findByPk(doc.product_id);
326
+ if (!product) {
327
+ return res.status(404).json({ error: 'product not found' });
328
+ }
329
+
299
330
  if (doc.active === false) {
300
331
  return res.status(403).json({ error: 'price archived' });
301
332
  }
@@ -326,6 +357,18 @@ router.put('/:id', auth, async (req, res) => {
326
357
  }
327
358
  }
328
359
 
360
+ if (product.type === 'credit' && updates.metadata) {
361
+ const creditConfig = updates.metadata.credit_config;
362
+ if (!creditConfig) {
363
+ return res.status(400).json({ error: 'credit_config is required' });
364
+ }
365
+ const { error: creditConfigError, value: creditConfigValue } = CreditConfigSchema.validate(creditConfig);
366
+ if (creditConfigError) {
367
+ return res.status(400).json({ error: `credit_config is invalid: ${creditConfigError.message}` });
368
+ }
369
+ updates.metadata.credit_config = creditConfigValue;
370
+ }
371
+
329
372
  const currencies = await PaymentCurrency.findAll({ where: { active: true } });
330
373
  const currency =
331
374
  currencies.find((x) => x.id === updates?.currency_id || '') || currencies.find((x) => x.id === doc.currency_id);
@@ -11,7 +11,7 @@ import { checkPassportForPricingTable } from '../integrations/blocklet/passport'
11
11
  import { createListParamSchema, getOrder, MetadataSchema } from '../libs/api';
12
12
  import logger from '../libs/logger';
13
13
  import { authenticate } from '../libs/security';
14
- import { getBillingThreshold, getMinStakeAmount, isLineItemCurrencyAligned } from '../libs/session';
14
+ import { getBillingThreshold, getMinStakeAmount, isCreditMetered, isLineItemCurrencyAligned } from '../libs/session';
15
15
  import { getDaysUntilCancel, getDaysUntilDue } from '../libs/subscription';
16
16
  import { getDataObjectFromQuery } from '../libs/util';
17
17
  import { CheckoutSession } from '../store/models/checkout-session';
@@ -292,13 +292,18 @@ router.post('/:id/checkout/:priceId', async (req, res) => {
292
292
  return res.status(403).json({ error: 'pricing table locked' });
293
293
  }
294
294
 
295
- const price = await doc.items.find((x) => x.price_id === req.params.priceId);
296
- if (!price) {
295
+ const lineItem = await doc.items.find((x) => x.price_id === req.params.priceId);
296
+ if (!lineItem) {
297
297
  return res.status(403).json({ error: 'pricing table item not valid' });
298
298
  }
299
299
 
300
+ const price = await Price.findByPk(lineItem.price_id);
301
+ if (!price) {
302
+ return res.status(403).json({ error: 'price not found' });
303
+ }
304
+
300
305
  const raw: Partial<CheckoutSession> = await formatCheckoutSession({
301
- line_items: [{ price_id: price.price_id, quantity: 1, adjustable_quantity: price.adjustable_quantity }],
306
+ line_items: [{ price_id: lineItem.price_id, quantity: 1, adjustable_quantity: lineItem.adjustable_quantity }],
302
307
  ...pick(price, [
303
308
  'allow_promotion_codes',
304
309
  'consent_collection',
@@ -311,9 +316,10 @@ router.post('/:id/checkout/:priceId', async (req, res) => {
311
316
  'cross_sell_behavior',
312
317
  'nft_mint_settings',
313
318
  ]),
314
- subscription_data: merge(price.subscription_data || {}, getDataObjectFromQuery(req.query, 'subscription_data'), {
315
- billing_threshold_amount: getBillingThreshold(price.subscription_data),
316
- min_stake_amount: getMinStakeAmount(price.subscription_data),
319
+ subscription_data: merge(lineItem.subscription_data || {}, getDataObjectFromQuery(req.query, 'subscription_data'), {
320
+ billing_threshold_amount: getBillingThreshold(lineItem.subscription_data),
321
+ min_stake_amount: getMinStakeAmount(lineItem.subscription_data),
322
+ no_stake: isCreditMetered(price),
317
323
  }),
318
324
  metadata: {
319
325
  ...doc.metadata,
@@ -21,7 +21,7 @@ const auth = authenticate<Product>({ component: true, roles: ['owner', 'admin']
21
21
 
22
22
  const ProductAndPriceSchema = Joi.object({
23
23
  name: Joi.string().max(64).empty('').optional(),
24
- type: Joi.string().valid('service', 'good').empty('').optional(),
24
+ type: Joi.string().valid('service', 'good', 'credit').empty('').optional(),
25
25
  description: Joi.string().max(250).empty('').optional(),
26
26
  images: Joi.any().optional(),
27
27
  metadata: MetadataSchema,
@@ -57,6 +57,15 @@ const ProductAndPriceSchema = Joi.object({
57
57
  .optional(),
58
58
  }).unknown(true);
59
59
 
60
+ const CreditConfigSchema = Joi.object({
61
+ valid_duration_value: Joi.number().default(0).optional(),
62
+ valid_duration_unit: Joi.string().valid('hours', 'days', 'weeks', 'months', 'years').default('days').optional(),
63
+ priority: Joi.number().min(0).max(100).default(50).optional(),
64
+ applicable_prices: Joi.array().items(Joi.string()).default([]).optional(),
65
+ credit_amount: Joi.number().greater(0).required(),
66
+ currency_id: Joi.string().required(),
67
+ });
68
+
60
69
  export async function createProductAndPrices(payload: any) {
61
70
  // 1. 准备 product 数据
62
71
  const raw: Partial<Product> = pick(payload, [
@@ -96,6 +105,18 @@ export async function createProductAndPrices(payload: any) {
96
105
  throw new Error(`currency ${newPrice.currency_id} used in price not found or inactive`);
97
106
  }
98
107
 
108
+ if (raw.type === 'credit') {
109
+ const creditConfig = newPrice.metadata.credit_config;
110
+ if (!creditConfig) {
111
+ throw new Error('credit_config is required');
112
+ }
113
+ const { error, value: creditConfigValue } = CreditConfigSchema.validate(creditConfig);
114
+ if (error) {
115
+ throw new Error(`credit_config is invalid: ${error.message}`);
116
+ }
117
+ newPrice.metadata.credit_config = creditConfigValue;
118
+ }
119
+
99
120
  if (newPrice.custom_unit_amount) {
100
121
  // @ts-ignore
101
122
  ['preset', 'maximum', 'minimum'].forEach((key: keyof CustomUnitAmount) => {
@@ -213,12 +234,16 @@ const paginationSchema = createListParamSchema<{
213
234
  name?: string;
214
235
  description?: string;
215
236
  donation?: string;
237
+ meter_id?: string;
238
+ type?: string;
216
239
  }>({
217
240
  active: Joi.boolean().empty(''),
218
241
  status: Joi.string().empty(''),
219
242
  name: Joi.string().empty(''),
220
243
  description: Joi.string().empty(''),
221
244
  donation: Joi.string().empty(''),
245
+ meter_id: Joi.string().empty(''),
246
+ type: Joi.string().empty(''),
222
247
  });
223
248
  router.get('/', auth, async (req, res) => {
224
249
  const { page, pageSize, active, livemode, name, description, ...query } = await paginationSchema.validateAsync(
@@ -247,7 +272,6 @@ router.get('/', auth, async (req, res) => {
247
272
  if (query.donation === 'hide') {
248
273
  where.created_via = { [Op.not]: 'donation' };
249
274
  }
250
-
251
275
  Object.keys(query)
252
276
  .filter((x) => x.startsWith('metadata.'))
253
277
  .forEach((key: string) => {
@@ -255,14 +279,49 @@ router.get('/', auth, async (req, res) => {
255
279
  where[key] = query[key];
256
280
  });
257
281
 
258
- const { rows: list, count } = await Product.findAndCountAll({
282
+ if (query.type === 'credit') {
283
+ where.type = 'credit';
284
+ }
285
+ const findOptions: any = {
259
286
  where,
260
287
  order: getOrder(req.query, [['created_at', query.o === 'asc' ? 'ASC' : 'DESC']]),
261
288
  offset: (page - 1) * pageSize,
262
289
  limit: pageSize,
263
290
  include: [{ model: Price, as: 'prices' }],
264
291
  distinct: true,
265
- });
292
+ };
293
+
294
+ if (query.type !== 'credit' && query.meter_id) {
295
+ findOptions.include = [
296
+ {
297
+ model: Price,
298
+ as: 'prices',
299
+ where: {
300
+ recurring: {
301
+ meter_id: query.meter_id,
302
+ },
303
+ },
304
+ required: true,
305
+ },
306
+ ];
307
+ }
308
+
309
+ if (query.type === 'credit' && query.meter_id) {
310
+ findOptions.include = [
311
+ {
312
+ model: Price,
313
+ as: 'prices',
314
+ where: {
315
+ metadata: {
316
+ meter_id: query.meter_id,
317
+ },
318
+ },
319
+ required: true,
320
+ },
321
+ ];
322
+ }
323
+
324
+ const { rows: list, count } = await Product.findAndCountAll(findOptions);
266
325
 
267
326
  res.json({ count, list, paging: { page, pageSize } });
268
327
  });
@@ -14,7 +14,7 @@ import { createListParamSchema, getOrder, getWhereFromKvQuery } from '../libs/ap
14
14
  const router = Router();
15
15
  const authAdmin = authenticate<Setting>({ component: true, roles: ['owner', 'admin'] });
16
16
  router.get('/', async (req, res) => {
17
- const attributes = ['id', 'name', 'symbol', 'decimal', 'logo', 'payment_method_id', 'maximum_precision'];
17
+ const attributes = ['id', 'name', 'symbol', 'decimal', 'logo', 'payment_method_id', 'maximum_precision', 'type'];
18
18
  const where: WhereOptions<PaymentMethod> = { livemode: req.livemode, active: true };
19
19
 
20
20
  const methods = await PaymentMethod.findAll({
@@ -249,10 +249,14 @@ router.get('/:id', authPortal, async (req, res) => {
249
249
 
250
250
  if (doc) {
251
251
  const json = doc.toJSON();
252
+ const isConsumesCredit = await doc.isConsumesCredit();
253
+ const serviceType = isConsumesCredit ? 'credit' : 'standard';
252
254
  const products = (await Product.findAll()).map((x) => x.toJSON());
253
255
  const prices = (await Price.findAll()).map((x) => x.toJSON());
254
256
  // @ts-ignore
255
257
  expandLineItems(json.items, products, prices);
258
+ // @ts-ignore
259
+ json.serviceType = serviceType;
256
260
  res.json(json);
257
261
  } else {
258
262
  res.status(404).json(null);
@@ -0,0 +1,43 @@
1
+ import { DataTypes } from 'sequelize';
2
+
3
+ import { safeApplyColumnChanges, type Migration } from '../migrate';
4
+ import models from '../models';
5
+
6
+ export const up: Migration = async ({ context }) => {
7
+ await context.createTable('meters', models.Meter.GENESIS_ATTRIBUTES);
8
+ await context.createTable('meter_events', models.MeterEvent.GENESIS_ATTRIBUTES);
9
+ await context.createTable('credit_grants', models.CreditGrant.GENESIS_ATTRIBUTES);
10
+ await context.createTable('credit_transactions', models.CreditTransaction.GENESIS_ATTRIBUTES);
11
+
12
+ await safeApplyColumnChanges(context, {
13
+ payment_currencies: [
14
+ {
15
+ name: 'type',
16
+ field: {
17
+ type: DataTypes.ENUM('standard', 'credit'),
18
+ defaultValue: 'standard',
19
+ allowNull: false,
20
+ },
21
+ },
22
+ ],
23
+ });
24
+ await context.sequelize.query(`
25
+ UPDATE payment_currencies SET maximum_precision = 2 WHERE type = 'standard';
26
+ `);
27
+
28
+ await context.sequelize.query(`
29
+ UPDATE payment_currencies
30
+ SET maximum_precision = decimal
31
+ WHERE payment_method_id IN (
32
+ SELECT id FROM payment_methods WHERE type = 'stripe'
33
+ );
34
+ `);
35
+ };
36
+
37
+ export const down: Migration = async ({ context }) => {
38
+ await context.removeColumn('payment_currencies', 'type');
39
+ await context.dropTable('credit_transactions');
40
+ await context.dropTable('credit_grants');
41
+ await context.dropTable('meter_events');
42
+ await context.dropTable('meters');
43
+ };