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,423 @@
1
+ /* eslint-disable @typescript-eslint/lines-between-class-members */
2
+ import {
3
+ CreationOptional,
4
+ DataTypes,
5
+ InferAttributes,
6
+ InferCreationAttributes,
7
+ Model,
8
+ Op,
9
+ QueryTypes,
10
+ WhereOptions,
11
+ } from 'sequelize';
12
+ import type { LiteralUnion } from 'type-fest';
13
+
14
+ import { BN } from '@ocap/util';
15
+ import { createEvent } from '../../libs/audit';
16
+ import { createIdGenerator } from '../../libs/util';
17
+ import { GroupedBN, GroupedStrList, MeterEventPayload, MeterEventStatus } from './types';
18
+ import { Customer } from './customer';
19
+ import { Subscription, type TSubscription } from './subscription';
20
+ import { Meter, type TMeter } from './meter';
21
+ import { PaymentCurrency, TPaymentCurrency } from './payment-currency';
22
+
23
+ export const nextMeterEventId = createIdGenerator('mevt', 14);
24
+
25
+ type TMeterEventExpanded = MeterEvent & {
26
+ customer: Customer;
27
+ subscription?: TSubscription;
28
+ meter?: TMeter;
29
+ paymentCurrency: TPaymentCurrency;
30
+ };
31
+
32
+ export class MeterEvent extends Model<InferAttributes<MeterEvent>, InferCreationAttributes<MeterEvent>> {
33
+ declare id: CreationOptional<string>;
34
+ declare event_name: string;
35
+ declare payload: MeterEventPayload;
36
+ declare identifier: string; // 防重复的唯一标识
37
+ declare timestamp: number;
38
+ declare livemode: boolean;
39
+ declare status: MeterEventStatus;
40
+ declare processed_at?: number; // 处理时间
41
+ declare attempt_count: number; // 重试次数
42
+ declare next_attempt?: number; // 下次尝试时间
43
+ declare credit_consumed: string; // 已消费的credit数量
44
+ declare credit_pending: string; // 待消费的credit数量(债务)
45
+ declare metadata?: Record<string, any>;
46
+
47
+ // 审计字段
48
+ declare created_by?: string;
49
+ declare created_via: LiteralUnion<'api' | 'dashboard' | 'webhook' | 'batch', string>;
50
+
51
+ declare created_at: CreationOptional<Date>;
52
+ declare updated_at: CreationOptional<Date>;
53
+
54
+ public static readonly GENESIS_ATTRIBUTES = {
55
+ id: {
56
+ type: DataTypes.STRING(18),
57
+ primaryKey: true,
58
+ allowNull: false,
59
+ defaultValue: nextMeterEventId,
60
+ },
61
+ event_name: {
62
+ type: DataTypes.STRING(128),
63
+ allowNull: false,
64
+ },
65
+ timestamp: {
66
+ type: DataTypes.INTEGER,
67
+ allowNull: false,
68
+ },
69
+ payload: {
70
+ type: DataTypes.JSON,
71
+ allowNull: false,
72
+ validate: {
73
+ hasRequiredFields(value: any) {
74
+ if (!value || typeof value !== 'object') {
75
+ throw new Error('Payload must be an object');
76
+ }
77
+ if (!value.customer_id || !value.value) {
78
+ throw new Error('Payload must contain customer_id, value');
79
+ }
80
+ },
81
+ },
82
+ },
83
+ identifier: {
84
+ type: DataTypes.STRING(255),
85
+ allowNull: false,
86
+ unique: true, // 确保幂等性
87
+ },
88
+ livemode: {
89
+ type: DataTypes.BOOLEAN,
90
+ allowNull: false,
91
+ },
92
+ status: {
93
+ type: DataTypes.ENUM('pending', 'processing', 'requires_action', 'requires_capture', 'completed', 'canceled'),
94
+ defaultValue: 'pending',
95
+ allowNull: false,
96
+ },
97
+ attempt_count: {
98
+ type: DataTypes.INTEGER,
99
+ defaultValue: 0,
100
+ allowNull: false,
101
+ },
102
+ next_attempt: {
103
+ type: DataTypes.INTEGER,
104
+ allowNull: true,
105
+ },
106
+ processed_at: {
107
+ type: DataTypes.INTEGER,
108
+ allowNull: true,
109
+ },
110
+ credit_consumed: {
111
+ type: DataTypes.STRING(40),
112
+ defaultValue: '0',
113
+ allowNull: false,
114
+ },
115
+ credit_pending: {
116
+ type: DataTypes.STRING(40),
117
+ defaultValue: '0',
118
+ allowNull: false,
119
+ },
120
+ metadata: {
121
+ type: DataTypes.JSON,
122
+ allowNull: true,
123
+ },
124
+ created_by: {
125
+ type: DataTypes.STRING(40),
126
+ allowNull: true,
127
+ },
128
+ created_via: {
129
+ type: DataTypes.ENUM('api', 'dashboard', 'webhook', 'batch'),
130
+ allowNull: false,
131
+ },
132
+ created_at: {
133
+ type: DataTypes.DATE,
134
+ defaultValue: DataTypes.NOW,
135
+ allowNull: false,
136
+ },
137
+ updated_at: {
138
+ type: DataTypes.DATE,
139
+ defaultValue: DataTypes.NOW,
140
+ allowNull: false,
141
+ },
142
+ };
143
+
144
+ // 标记为成功完成
145
+ public async markAsCompleted(): Promise<void> {
146
+ this.status = 'completed';
147
+ this.processed_at = Math.floor(Date.now() / 1000);
148
+ this.next_attempt = undefined;
149
+ // 清除错误信息
150
+ if (this.metadata?.last_error) {
151
+ this.metadata = {
152
+ ...this.metadata,
153
+ last_error: undefined,
154
+ failed_at: undefined,
155
+ };
156
+ }
157
+ await this.save();
158
+ }
159
+
160
+ // 标记为处理中
161
+ public async markAsProcessing(): Promise<void> {
162
+ this.status = 'processing';
163
+ await this.save();
164
+ }
165
+
166
+ // 标记为需要重试 (参考 payment.ts 的重试机制)
167
+ public async markAsRequiresCapture(errorMessage: string, nextAttemptTime: number): Promise<void> {
168
+ this.status = 'requires_capture';
169
+ this.attempt_count += 1;
170
+ this.next_attempt = nextAttemptTime;
171
+ this.metadata = {
172
+ ...this.metadata,
173
+ last_error: errorMessage,
174
+ failed_at: Math.floor(Date.now() / 1000),
175
+ };
176
+ await this.save();
177
+ }
178
+
179
+ // 标记为需要手动处理
180
+ public async markAsRequiresAction(errorMessage: string): Promise<void> {
181
+ this.status = 'requires_action';
182
+ this.metadata = {
183
+ ...this.metadata,
184
+ last_error: errorMessage,
185
+ failed_at: Math.floor(Date.now() / 1000),
186
+ requires_manual_intervention: true,
187
+ };
188
+ await this.save();
189
+ }
190
+
191
+ // 标记为取消
192
+ public async markAsCanceled(reason: string): Promise<void> {
193
+ this.status = 'canceled';
194
+ this.metadata = {
195
+ ...this.metadata,
196
+ cancel_reason: reason,
197
+ canceled_at: Math.floor(Date.now() / 1000),
198
+ };
199
+ await this.save();
200
+ }
201
+
202
+ // 获取客户ID
203
+ public getCustomerId(): string {
204
+ return this.payload.customer_id;
205
+ }
206
+
207
+ // 获取使用量
208
+ public getValue(): string {
209
+ return this.payload.value;
210
+ }
211
+
212
+ // 获取订阅ID
213
+ public getSubscriptionId(): string | undefined {
214
+ return this.payload.subscription_id;
215
+ }
216
+
217
+ public static initialize(sequelize: any) {
218
+ this.init(this.GENESIS_ATTRIBUTES, {
219
+ sequelize,
220
+ modelName: 'MeterEvent',
221
+ tableName: 'meter_events',
222
+ createdAt: 'created_at',
223
+ updatedAt: 'updated_at',
224
+ indexes: [{ fields: ['identifier'], unique: true }, { fields: ['status'] }, { fields: ['event_name'] }],
225
+ hooks: {
226
+ afterCreate: (model: MeterEvent, options) =>
227
+ createEvent('MeterEvent', 'billing.meter_event.created', model, options).catch(console.error),
228
+ },
229
+ });
230
+ }
231
+
232
+ // 批量处理未处理的事件
233
+ public static async processUnprocessedEvents(eventName?: string, limit: number = 100): Promise<MeterEvent[]> {
234
+ const whereClause: any = {
235
+ status: ['pending', 'requires_capture'],
236
+ };
237
+ if (eventName) {
238
+ whereClause.event_name = eventName;
239
+ }
240
+
241
+ const events = await this.findAll({
242
+ where: whereClause,
243
+ order: [['created_at', 'ASC']],
244
+ limit,
245
+ });
246
+
247
+ return events;
248
+ }
249
+
250
+ // 检查事件是否已存在(防重复)
251
+ public static async isEventExists(identifier: string): Promise<boolean> {
252
+ const count = await this.count({
253
+ where: { identifier },
254
+ });
255
+ return count > 0;
256
+ }
257
+
258
+ // 获取事件统计
259
+ public static async getEventStats(
260
+ eventName: string,
261
+ startTime?: Date,
262
+ endTime?: Date
263
+ ): Promise<{
264
+ total_events: number;
265
+ processed_events: number;
266
+ pending_events: number;
267
+ total_value: number;
268
+ }> {
269
+ const whereClause: any = { event_name: eventName };
270
+
271
+ if (startTime || endTime) {
272
+ whereClause.created_at = {};
273
+ if (startTime) {
274
+ whereClause.created_at[Op.gte] = startTime;
275
+ }
276
+ if (endTime) {
277
+ whereClause.created_at[Op.lte] = endTime;
278
+ }
279
+ }
280
+
281
+ const [totalEvents, processedEvents, totalValue] = await Promise.all([
282
+ this.count({ where: whereClause }),
283
+ this.count({ where: { ...whereClause, status: 'completed' } }),
284
+ // @ts-ignore
285
+ this.sequelize.query(
286
+ `SELECT COALESCE(SUM(CAST(payload->>'value' AS DECIMAL)), 0) as total
287
+ FROM meter_events
288
+ WHERE event_name = :eventName
289
+ ${startTime ? 'AND created_at >= :startTime' : ''}
290
+ ${endTime ? 'AND created_at <= :endTime' : ''}`,
291
+ {
292
+ replacements: {
293
+ eventName,
294
+ startTime: startTime?.toISOString(),
295
+ endTime: endTime?.toISOString(),
296
+ },
297
+ type: QueryTypes.SELECT,
298
+ }
299
+ ),
300
+ ]);
301
+
302
+ return {
303
+ total_events: totalEvents,
304
+ processed_events: processedEvents,
305
+ pending_events: totalEvents - processedEvents,
306
+ total_value: (totalValue as any)?.[0]?.total || 0,
307
+ };
308
+ }
309
+
310
+ public static async expand(
311
+ events: MeterEvent[],
312
+ livemode: boolean = true,
313
+ options: { customer?: boolean; subscription?: boolean; meter?: boolean } = {}
314
+ ): Promise<MeterEvent[]> {
315
+ const { customer = true, subscription = true, meter = true } = options;
316
+
317
+ let customers: Customer[] = [];
318
+ let subscriptions: TSubscription[] = [];
319
+ let meters: TMeter[] = [];
320
+
321
+ if (customer) {
322
+ const customerIds = events.map((x) => x.payload.customer_id);
323
+ customers = await Customer.findAll({
324
+ where: { [Op.or]: [{ id: { [Op.in]: customerIds } }, { did: { [Op.in]: customerIds } }] },
325
+ });
326
+ }
327
+ if (subscription) {
328
+ const subscriptionIds = events.map((x) => x.payload?.subscription_id).filter(Boolean) as string[];
329
+ subscriptions = await Subscription.findAll({
330
+ where: {
331
+ id: {
332
+ [Op.in]: subscriptionIds,
333
+ },
334
+ },
335
+ });
336
+ }
337
+ if (meter) {
338
+ const eventNames = events.map((x) => x.event_name);
339
+ meters = await Meter.findAll({
340
+ where: {
341
+ event_name: {
342
+ [Op.in]: eventNames,
343
+ },
344
+ livemode,
345
+ },
346
+ });
347
+ }
348
+
349
+ return events.map((x) => ({
350
+ ...x.toJSON(),
351
+ customer: customers.find((c) => c.id === x.payload.customer_id),
352
+ subscription: subscriptions.find((s) => s.id === x.payload.subscription_id),
353
+ meter: meters.find((m) => m.event_name === x.event_name),
354
+ })) as TMeterEventExpanded[];
355
+ }
356
+
357
+ private static async _getPendingAmounts(
358
+ where: WhereOptions<MeterEvent>,
359
+ searchCurrencyId?: string
360
+ ): Promise<[GroupedBN, GroupedStrList, MeterEvent[]]> {
361
+ const events = await this.findAll({ where });
362
+ const summary: GroupedBN = {};
363
+ const detail: GroupedStrList = {};
364
+
365
+ await Promise.all(
366
+ events.map(async (event) => {
367
+ const meter = await Meter.getMeterByEventName(event.event_name, event.livemode);
368
+ if (!meter) {
369
+ return;
370
+ }
371
+ const paymentCurrency = await PaymentCurrency.findByPk(meter.currency_id);
372
+ if (!paymentCurrency) {
373
+ return;
374
+ }
375
+ const currencyId = meter.currency_id as string;
376
+ if (searchCurrencyId && searchCurrencyId !== currencyId) {
377
+ return;
378
+ }
379
+ if (!detail[currencyId]) {
380
+ detail[currencyId] = [];
381
+ }
382
+ summary[currencyId] = new BN(summary[currencyId] || '0').add(new BN(event.credit_pending)).toString();
383
+ detail[currencyId]!.push(event.id);
384
+ })
385
+ );
386
+
387
+ return [summary, detail, events];
388
+ }
389
+
390
+ public static getPendingAmounts({
391
+ subscriptionId,
392
+ livemode,
393
+ currencyId,
394
+ status = ['requires_action', 'requires_capture'],
395
+ customerId,
396
+ }: {
397
+ subscriptionId?: string;
398
+ livemode?: boolean;
399
+ currencyId?: string;
400
+ status?: MeterEventStatus | MeterEventStatus[];
401
+ customerId?: string;
402
+ }): Promise<[GroupedBN, GroupedStrList, MeterEvent[]]> {
403
+ const where: any = {
404
+ status: {
405
+ [Op.in]: ['pending', 'requires_capture', 'requires_action'],
406
+ },
407
+ livemode: livemode ?? true,
408
+ };
409
+ if (status) {
410
+ where.status = Array.isArray(status) ? { [Op.in]: status } : status;
411
+ }
412
+ if (subscriptionId) {
413
+ where['payload.subscription_id'] = subscriptionId;
414
+ }
415
+ if (customerId) {
416
+ where['payload.customer_id'] = customerId;
417
+ }
418
+
419
+ return this._getPendingAmounts(where, currencyId);
420
+ }
421
+ }
422
+
423
+ export type TMeterEvent = InferAttributes<MeterEvent>;
@@ -0,0 +1,176 @@
1
+ /* eslint-disable @typescript-eslint/lines-between-class-members */
2
+ import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize';
3
+ import type { LiteralUnion } from 'type-fest';
4
+
5
+ import { createEvent } from '../../libs/audit';
6
+ import { createIdGenerator } from '../../libs/util';
7
+ import type { TPaymentCurrency } from './payment-currency';
8
+
9
+ export const nextMeterId = createIdGenerator('mtr', 14);
10
+
11
+ export type TMeterExpanded = TMeter & {
12
+ paymentCurrency: TPaymentCurrency;
13
+ };
14
+
15
+ export class Meter extends Model<InferAttributes<Meter>, InferCreationAttributes<Meter>> {
16
+ declare id: CreationOptional<string>;
17
+ declare object: CreationOptional<string>;
18
+ declare name: string;
19
+ declare event_name: string;
20
+ declare aggregation_method: LiteralUnion<'sum' | 'count' | 'last', string>;
21
+ declare status: LiteralUnion<'active' | 'inactive', string>;
22
+ declare unit: string;
23
+ declare description?: string;
24
+ declare component_did?: string;
25
+ declare livemode: boolean;
26
+
27
+ declare metadata?: Record<string, any>;
28
+ declare currency_id?: string;
29
+
30
+ // 审计字段
31
+ declare created_by?: string;
32
+ declare updated_by?: string;
33
+ declare created_via: LiteralUnion<'api' | 'dashboard' | 'portal', string>;
34
+
35
+ declare created_at: CreationOptional<Date>;
36
+ declare updated_at: CreationOptional<Date>;
37
+
38
+ public static readonly GENESIS_ATTRIBUTES = {
39
+ id: {
40
+ type: DataTypes.STRING(18),
41
+ primaryKey: true,
42
+ allowNull: false,
43
+ defaultValue: nextMeterId,
44
+ },
45
+ object: {
46
+ type: DataTypes.STRING(32),
47
+ defaultValue: 'meter',
48
+ allowNull: false,
49
+ },
50
+ name: {
51
+ type: DataTypes.STRING(255),
52
+ allowNull: false,
53
+ },
54
+ event_name: {
55
+ type: DataTypes.STRING(128),
56
+ allowNull: false,
57
+ unique: true,
58
+ },
59
+ aggregation_method: {
60
+ type: DataTypes.ENUM('sum', 'count', 'last'),
61
+ defaultValue: 'sum',
62
+ allowNull: false,
63
+ },
64
+ status: {
65
+ type: DataTypes.ENUM('active', 'inactive'),
66
+ defaultValue: 'active',
67
+ allowNull: false,
68
+ },
69
+ unit: {
70
+ type: DataTypes.STRING(32),
71
+ allowNull: false,
72
+ },
73
+ description: {
74
+ type: DataTypes.TEXT,
75
+ allowNull: true,
76
+ },
77
+ component_did: {
78
+ type: DataTypes.STRING(40),
79
+ allowNull: true,
80
+ },
81
+ currency_id: {
82
+ type: DataTypes.STRING(40),
83
+ allowNull: true,
84
+ },
85
+ livemode: {
86
+ type: DataTypes.BOOLEAN,
87
+ allowNull: false,
88
+ },
89
+ metadata: {
90
+ type: DataTypes.JSON,
91
+ allowNull: true,
92
+ },
93
+ created_by: {
94
+ type: DataTypes.STRING(40),
95
+ allowNull: true,
96
+ },
97
+ updated_by: {
98
+ type: DataTypes.STRING(40),
99
+ allowNull: true,
100
+ },
101
+ created_via: {
102
+ type: DataTypes.ENUM('api', 'dashboard', 'portal'),
103
+ allowNull: false,
104
+ },
105
+ created_at: {
106
+ type: DataTypes.DATE,
107
+ defaultValue: DataTypes.NOW,
108
+ allowNull: false,
109
+ },
110
+ updated_at: {
111
+ type: DataTypes.DATE,
112
+ defaultValue: DataTypes.NOW,
113
+ allowNull: false,
114
+ },
115
+ };
116
+
117
+ public static initialize(sequelize: any) {
118
+ this.init(this.GENESIS_ATTRIBUTES, {
119
+ sequelize,
120
+ modelName: 'Meter',
121
+ tableName: 'meters',
122
+ createdAt: 'created_at',
123
+ updatedAt: 'updated_at',
124
+ indexes: [{ fields: ['event_name'] }, { fields: ['currency_id'] }],
125
+ hooks: {
126
+ afterCreate: (model: Meter, options) =>
127
+ createEvent('Meter', 'meter.created', model, options).catch(console.error),
128
+ afterUpdate: (model: Meter, options) =>
129
+ createEvent('Meter', 'meter.updated', model, options).catch(console.error),
130
+ afterDestroy: (model: Meter, options) =>
131
+ createEvent('Meter', 'meter.deleted', model, options).catch(console.error),
132
+ },
133
+ });
134
+ }
135
+
136
+ public static associate(models: any) {
137
+ this.hasMany(models.CreditTransaction, {
138
+ foreignKey: 'meter_id',
139
+ as: 'transactions',
140
+ });
141
+
142
+ this.hasOne(models.PaymentCurrency, {
143
+ sourceKey: 'currency_id',
144
+ foreignKey: 'id',
145
+ as: 'paymentCurrency',
146
+ });
147
+ }
148
+
149
+ public static getMeterByEventName(eventName: string, livemode?: boolean): Promise<Meter | null> {
150
+ const whereClause: any = { event_name: eventName, livemode: true };
151
+ if (livemode !== undefined) {
152
+ whereClause.livemode = livemode;
153
+ }
154
+ return this.findOne({ where: whereClause });
155
+ }
156
+
157
+ public static async getMetricsForMeter(meterId: string, customerId?: string) {
158
+ // @ts-ignore
159
+ const { CreditTransaction } = this.sequelize.models;
160
+ const whereClause: any = { meter_id: meterId };
161
+ if (customerId) {
162
+ whereClause.customer_id = customerId;
163
+ }
164
+
165
+ // @ts-ignore
166
+ const totalUsage = await CreditTransaction.sum('credit_amount', {
167
+ where: whereClause,
168
+ });
169
+
170
+ return {
171
+ total_usage: totalUsage || 0,
172
+ };
173
+ }
174
+ }
175
+
176
+ export type TMeter = InferAttributes<Meter>;
@@ -9,7 +9,8 @@ import {
9
9
  Op,
10
10
  QueryTypes,
11
11
  } from 'sequelize';
12
-
12
+ import { getUrl } from '@blocklet/sdk';
13
+ import type { LiteralUnion } from 'type-fest';
13
14
  import { createIdGenerator } from '../../libs/util';
14
15
  import { VaultConfig } from './types';
15
16
 
@@ -43,6 +44,7 @@ export class PaymentCurrency extends Model<InferAttributes<PaymentCurrency>, Inf
43
44
  declare created_at: CreationOptional<Date>;
44
45
  declare updated_at: CreationOptional<Date>;
45
46
  declare vault_config?: VaultConfig;
47
+ declare type: LiteralUnion<'standard' | 'credit', string>;
46
48
 
47
49
  public static readonly GENESIS_ATTRIBUTES = {
48
50
  id: {
@@ -129,21 +131,31 @@ export class PaymentCurrency extends Model<InferAttributes<PaymentCurrency>, Inf
129
131
  };
130
132
 
131
133
  public static initialize(sequelize: any) {
132
- this.init(PaymentCurrency.GENESIS_ATTRIBUTES, {
133
- sequelize,
134
- modelName: 'PaymentCurrency',
135
- tableName: 'payment_currencies',
136
- createdAt: 'created_at',
137
- updatedAt: 'updated_at',
138
- defaultScope: {
139
- attributes: { exclude: ['vault_config'] },
140
- },
141
- scopes: {
142
- withVaultConfig: {
143
- attributes: { include: ['vault_config'] },
134
+ this.init(
135
+ {
136
+ ...PaymentCurrency.GENESIS_ATTRIBUTES,
137
+ type: {
138
+ type: DataTypes.ENUM('standard', 'credit'),
139
+ defaultValue: 'standard',
140
+ allowNull: false,
144
141
  },
145
142
  },
146
- });
143
+ {
144
+ sequelize,
145
+ modelName: 'PaymentCurrency',
146
+ tableName: 'payment_currencies',
147
+ createdAt: 'created_at',
148
+ updatedAt: 'updated_at',
149
+ defaultScope: {
150
+ attributes: { exclude: ['vault_config'] },
151
+ },
152
+ scopes: {
153
+ withVaultConfig: {
154
+ attributes: { include: ['vault_config'] },
155
+ },
156
+ },
157
+ }
158
+ );
147
159
  }
148
160
 
149
161
  public static associate(models: any) {
@@ -190,6 +202,46 @@ export class PaymentCurrency extends Model<InferAttributes<PaymentCurrency>, Inf
190
202
  );
191
203
  return count > 0;
192
204
  }
205
+ public static async createForMeter(meter: any, paymentMethodId: string) {
206
+ const existingCurrency = await this.findOne({
207
+ where: {
208
+ type: 'credit',
209
+ symbol: meter.unit,
210
+ livemode: meter.livemode || false,
211
+ },
212
+ });
213
+
214
+ if (existingCurrency) {
215
+ return existingCurrency;
216
+ }
217
+ const currency = await this.create({
218
+ type: 'credit',
219
+ payment_method_id: paymentMethodId,
220
+ name: meter.unit,
221
+ description: `Credit for ${meter.unit}`,
222
+ symbol: meter.unit,
223
+ logo: getUrl('/methods/arcblock.png'), // 默认credit图标
224
+ decimal: 2,
225
+ maximum_precision: 2,
226
+ minimum_payment_amount: '1',
227
+ maximum_payment_amount: '100000000000',
228
+ active: true,
229
+ livemode: meter.livemode || false,
230
+ is_base_currency: false,
231
+ locked: false,
232
+ metadata: {
233
+ meter_id: meter.id,
234
+ meter_event_name: meter.event_name,
235
+ created_by_meter: true,
236
+ },
237
+ });
238
+
239
+ return currency;
240
+ }
241
+
242
+ public isCredit(): boolean {
243
+ return this.type === 'credit';
244
+ }
193
245
  }
194
246
 
195
247
  export type TPaymentCurrency = InferAttributes<PaymentCurrency>;