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.
- package/.eslintrc.js +6 -0
- package/api/src/crons/index.ts +8 -0
- package/api/src/index.ts +4 -0
- package/api/src/libs/credit-grant.ts +146 -0
- package/api/src/libs/env.ts +1 -0
- package/api/src/libs/invoice.ts +4 -3
- package/api/src/libs/notification/template/base.ts +388 -2
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +149 -0
- package/api/src/libs/notification/template/customer-credit-grant-low-balance.ts +151 -0
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +254 -0
- package/api/src/libs/notification/template/subscription-canceled.ts +193 -202
- package/api/src/libs/notification/template/subscription-refund-succeeded.ts +215 -237
- package/api/src/libs/notification/template/subscription-renewed.ts +130 -200
- package/api/src/libs/notification/template/subscription-succeeded.ts +100 -202
- package/api/src/libs/notification/template/subscription-trial-start.ts +142 -188
- package/api/src/libs/notification/template/subscription-trial-will-end.ts +146 -174
- package/api/src/libs/notification/template/subscription-upgraded.ts +96 -192
- package/api/src/libs/notification/template/subscription-will-canceled.ts +94 -135
- package/api/src/libs/notification/template/subscription-will-renew.ts +220 -245
- package/api/src/libs/payment.ts +69 -0
- package/api/src/libs/queue/index.ts +3 -2
- package/api/src/libs/session.ts +8 -0
- package/api/src/libs/subscription.ts +74 -3
- package/api/src/libs/ws.ts +23 -1
- package/api/src/locales/en.ts +33 -0
- package/api/src/locales/zh.ts +31 -0
- package/api/src/queues/credit-consume.ts +715 -0
- package/api/src/queues/credit-grant.ts +572 -0
- package/api/src/queues/notification.ts +173 -128
- package/api/src/queues/payment.ts +210 -122
- package/api/src/queues/subscription.ts +179 -0
- package/api/src/routes/checkout-sessions.ts +157 -9
- package/api/src/routes/connect/shared.ts +3 -2
- package/api/src/routes/credit-grants.ts +241 -0
- package/api/src/routes/credit-transactions.ts +208 -0
- package/api/src/routes/index.ts +8 -0
- package/api/src/routes/meter-events.ts +347 -0
- package/api/src/routes/meters.ts +219 -0
- package/api/src/routes/payment-currencies.ts +14 -2
- package/api/src/routes/payment-links.ts +1 -1
- package/api/src/routes/payment-methods.ts +14 -2
- package/api/src/routes/prices.ts +43 -0
- package/api/src/routes/pricing-table.ts +13 -7
- package/api/src/routes/products.ts +63 -4
- package/api/src/routes/settings.ts +1 -1
- package/api/src/routes/subscriptions.ts +4 -0
- package/api/src/store/migrations/20250610-billing-credit.ts +43 -0
- package/api/src/store/models/credit-grant.ts +486 -0
- package/api/src/store/models/credit-transaction.ts +268 -0
- package/api/src/store/models/customer.ts +8 -0
- package/api/src/store/models/index.ts +52 -1
- package/api/src/store/models/meter-event.ts +423 -0
- package/api/src/store/models/meter.ts +176 -0
- package/api/src/store/models/payment-currency.ts +66 -14
- package/api/src/store/models/price.ts +6 -0
- package/api/src/store/models/product.ts +2 -2
- package/api/src/store/models/subscription.ts +24 -0
- package/api/src/store/models/types.ts +28 -2
- package/api/tests/libs/subscription.spec.ts +53 -0
- package/blocklet.yml +9 -1
- package/package.json +57 -58
- package/scripts/sdk.js +233 -1
- package/src/app.tsx +10 -0
- package/src/components/actions.tsx +22 -9
- package/src/components/balance-list.tsx +40 -12
- package/src/components/collapse.tsx +33 -15
- package/src/components/copyable.tsx +8 -7
- package/src/components/currency.tsx +15 -7
- package/src/components/customer/actions.tsx +1 -5
- package/src/components/customer/credit-grant-item-list.tsx +99 -0
- package/src/components/customer/credit-overview.tsx +233 -0
- package/src/components/customer/form.tsx +7 -2
- package/src/components/customer/link.tsx +4 -12
- package/src/components/customer/notification-preference.tsx +18 -9
- package/src/components/customer/overdraft-protection.tsx +112 -41
- package/src/components/drawer-form.tsx +42 -18
- package/src/components/error.tsx +1 -5
- package/src/components/event/list.tsx +9 -10
- package/src/components/filter-toolbar.tsx +20 -19
- package/src/components/info-card.tsx +32 -18
- package/src/components/info-metric.tsx +16 -6
- package/src/components/info-row-group.tsx +1 -7
- package/src/components/info-row.tsx +30 -24
- package/src/components/invoice/action.tsx +1 -7
- package/src/components/invoice/list.tsx +34 -26
- package/src/components/invoice/recharge.tsx +5 -7
- package/src/components/invoice/table.tsx +17 -12
- package/src/components/layout/user.tsx +1 -1
- package/src/components/metadata/form.tsx +290 -94
- package/src/components/metadata/list.tsx +11 -3
- package/src/components/meter/actions.tsx +101 -0
- package/src/components/meter/add-usage-dialog.tsx +239 -0
- package/src/components/meter/events-list.tsx +657 -0
- package/src/components/meter/form.tsx +245 -0
- package/src/components/meter/products.tsx +264 -0
- package/src/components/meter/usage-guide.tsx +174 -0
- package/src/components/passport/actions.tsx +9 -4
- package/src/components/payment-currency/add.tsx +16 -3
- package/src/components/payment-currency/form.tsx +14 -6
- package/src/components/payment-intent/actions.tsx +24 -16
- package/src/components/payment-intent/list.tsx +30 -9
- package/src/components/payment-link/actions.tsx +1 -5
- package/src/components/payment-link/after-pay.tsx +4 -2
- package/src/components/payment-link/before-pay.tsx +14 -4
- package/src/components/payment-link/item.tsx +27 -6
- package/src/components/payment-link/preview.tsx +9 -9
- package/src/components/payment-link/product-select.tsx +69 -15
- package/src/components/payment-method/arcblock.tsx +8 -1
- package/src/components/payment-method/base.tsx +8 -1
- package/src/components/payment-method/bitcoin.tsx +8 -1
- package/src/components/payment-method/ethereum.tsx +8 -1
- package/src/components/payment-method/evm-rpc-input.tsx +11 -7
- package/src/components/payment-method/form.tsx +2 -7
- package/src/components/payment-method/stripe.tsx +2 -0
- package/src/components/payouts/actions.tsx +1 -5
- package/src/components/payouts/list.tsx +30 -10
- package/src/components/payouts/portal/list.tsx +11 -9
- package/src/components/price/currency-select.tsx +63 -32
- package/src/components/price/form.tsx +895 -370
- package/src/components/price/upsell-select.tsx +10 -2
- package/src/components/price/upsell.tsx +7 -2
- package/src/components/pricing-table/actions.tsx +1 -5
- package/src/components/pricing-table/customer-settings.tsx +5 -1
- package/src/components/pricing-table/payment-settings.tsx +14 -4
- package/src/components/pricing-table/preview.tsx +9 -9
- package/src/components/pricing-table/price-item.tsx +6 -1
- package/src/components/pricing-table/product-item.tsx +6 -1
- package/src/components/pricing-table/product-settings.tsx +17 -4
- package/src/components/product/actions.tsx +1 -5
- package/src/components/product/add-price.tsx +9 -7
- package/src/components/product/create.tsx +8 -9
- package/src/components/product/cross-sell-select.tsx +5 -1
- package/src/components/product/cross-sell.tsx +7 -2
- package/src/components/product/edit-price.tsx +21 -12
- package/src/components/product/features.tsx +26 -6
- package/src/components/product/form.tsx +115 -72
- package/src/components/progress-bar.tsx +1 -1
- package/src/components/refund/actions.tsx +1 -7
- package/src/components/refund/list.tsx +31 -18
- package/src/components/section/header.tsx +12 -14
- package/src/components/subscription/actions/cancel.tsx +22 -5
- package/src/components/subscription/actions/index.tsx +9 -10
- package/src/components/subscription/actions/pause.tsx +32 -6
- package/src/components/subscription/actions/slash-stake.tsx +5 -3
- package/src/components/subscription/description.tsx +12 -8
- package/src/components/subscription/items/index.tsx +31 -16
- package/src/components/subscription/items/usage-records.tsx +19 -5
- package/src/components/subscription/list.tsx +5 -7
- package/src/components/subscription/metrics.tsx +62 -15
- package/src/components/subscription/portal/actions.tsx +78 -71
- package/src/components/subscription/portal/cancel.tsx +10 -3
- package/src/components/subscription/portal/list.tsx +48 -26
- package/src/components/uploader.tsx +5 -13
- package/src/components/webhook/attempts.tsx +51 -16
- package/src/components/webhook/request-info.tsx +8 -6
- package/src/contexts/products.tsx +27 -10
- package/src/hooks/subscription.ts +34 -0
- package/src/libs/meter-utils.ts +196 -0
- package/src/libs/util.ts +4 -0
- package/src/locales/en.tsx +385 -4
- package/src/locales/zh.tsx +364 -0
- package/src/pages/admin/billing/index.tsx +61 -33
- package/src/pages/admin/billing/invoices/detail.tsx +49 -13
- package/src/pages/admin/billing/meters/create.tsx +60 -0
- package/src/pages/admin/billing/meters/detail.tsx +435 -0
- package/src/pages/admin/billing/meters/index.tsx +210 -0
- package/src/pages/admin/billing/meters/meter-event.tsx +346 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +90 -25
- package/src/pages/admin/customers/customers/credit-grant/detail.tsx +391 -0
- package/src/pages/admin/customers/customers/detail.tsx +67 -14
- package/src/pages/admin/customers/customers/index.tsx +6 -1
- package/src/pages/admin/customers/index.tsx +5 -0
- package/src/pages/admin/developers/events/detail.tsx +37 -11
- package/src/pages/admin/developers/index.tsx +1 -1
- package/src/pages/admin/developers/webhooks/detail.tsx +41 -11
- package/src/pages/admin/index.tsx +15 -2
- package/src/pages/admin/overview.tsx +107 -19
- package/src/pages/admin/payments/intents/detail.tsx +58 -14
- package/src/pages/admin/payments/payouts/detail.tsx +63 -15
- package/src/pages/admin/payments/refunds/detail.tsx +58 -14
- package/src/pages/admin/products/index.tsx +11 -4
- package/src/pages/admin/products/links/create.tsx +22 -4
- package/src/pages/admin/products/links/detail.tsx +43 -14
- package/src/pages/admin/products/passports/index.tsx +23 -4
- package/src/pages/admin/products/prices/actions.tsx +16 -9
- package/src/pages/admin/products/prices/detail.tsx +73 -14
- package/src/pages/admin/products/prices/list.tsx +15 -3
- package/src/pages/admin/products/pricing-tables/create.tsx +45 -12
- package/src/pages/admin/products/pricing-tables/detail.tsx +45 -14
- package/src/pages/admin/products/products/create.tsx +233 -54
- package/src/pages/admin/products/products/detail.tsx +74 -18
- package/src/pages/admin/settings/index.tsx +8 -1
- package/src/pages/admin/settings/payment-methods/index.tsx +87 -19
- package/src/pages/admin/settings/vault-config/edit-form.tsx +42 -28
- package/src/pages/admin/settings/vault-config/index.tsx +57 -10
- package/src/pages/customer/credit-grant/detail.tsx +308 -0
- package/src/pages/customer/index.tsx +76 -17
- package/src/pages/customer/invoice/detail.tsx +63 -14
- package/src/pages/customer/invoice/past-due.tsx +11 -3
- package/src/pages/customer/payout/detail.tsx +56 -13
- package/src/pages/customer/recharge/account.tsx +78 -18
- package/src/pages/customer/recharge/subscription.tsx +86 -25
- package/src/pages/customer/refund/list.tsx +60 -24
- package/src/pages/customer/subscription/change-payment.tsx +17 -6
- package/src/pages/customer/subscription/change-plan.tsx +34 -7
- package/src/pages/customer/subscription/detail.tsx +134 -34
- package/src/pages/customer/subscription/embed.tsx +25 -5
- package/src/pages/home.tsx +26 -4
- package/src/pages/integrations/donations/edit-form.tsx +25 -9
- package/src/pages/integrations/donations/index.tsx +26 -9
- package/src/pages/integrations/donations/preview.tsx +59 -15
- package/src/pages/integrations/index.tsx +10 -1
- package/src/pages/integrations/overview.tsx +78 -17
- package/vite.config.ts +60 -30
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/lines-between-class-members */
|
|
2
|
+
import { BN } from '@ocap/util';
|
|
3
|
+
import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, literal, Model, Op } from 'sequelize';
|
|
4
|
+
import type { LiteralUnion } from 'type-fest';
|
|
5
|
+
|
|
6
|
+
import { createEvent } from '../../libs/audit';
|
|
7
|
+
import { createIdGenerator } from '../../libs/util';
|
|
8
|
+
import dayjs from '../../libs/dayjs';
|
|
9
|
+
import { CreditGrantApplicabilityConfig } from './types';
|
|
10
|
+
import logger from '../../libs/logger';
|
|
11
|
+
import { PaymentCurrency, TPaymentCurrency } from './payment-currency';
|
|
12
|
+
|
|
13
|
+
const CREDIT_GRANT_STATUS_EVENTS = {
|
|
14
|
+
depleted: 'depleted',
|
|
15
|
+
expired: 'expired',
|
|
16
|
+
voided: 'voided',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type CreditGrantSummary = {
|
|
20
|
+
paymentCurrency: TPaymentCurrency;
|
|
21
|
+
totalAmount: string;
|
|
22
|
+
remainingAmount: string;
|
|
23
|
+
grantCount: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// 创建状态变化事件
|
|
27
|
+
async function createCreditGrantStatusEvent(model: CreditGrant, options: any): Promise<void> {
|
|
28
|
+
if (model.changed('status')) {
|
|
29
|
+
const previousStatus = model.previous('status');
|
|
30
|
+
const currentStatus = model.status;
|
|
31
|
+
if (previousStatus === 'pending' && currentStatus === 'granted') {
|
|
32
|
+
await createEvent('CreditGrant', 'customer.credit_grant.granted', model, options);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const statusEvent = CREDIT_GRANT_STATUS_EVENTS[currentStatus as keyof typeof CREDIT_GRANT_STATUS_EVENTS];
|
|
36
|
+
if (statusEvent) {
|
|
37
|
+
await createEvent('CreditGrant', `customer.credit_grant.${statusEvent}`, model, options);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const nextCreditGrantId = createIdGenerator('credgr', 14);
|
|
43
|
+
|
|
44
|
+
export class CreditGrant extends Model<InferAttributes<CreditGrant>, InferCreationAttributes<CreditGrant>> {
|
|
45
|
+
declare id: CreationOptional<string>;
|
|
46
|
+
declare object: CreationOptional<string>;
|
|
47
|
+
declare amount: string;
|
|
48
|
+
declare currency_id: string;
|
|
49
|
+
declare applicability_config?: CreditGrantApplicabilityConfig;
|
|
50
|
+
declare category: LiteralUnion<'paid' | 'promotional', string>;
|
|
51
|
+
declare customer_id: string;
|
|
52
|
+
declare effective_at?: number;
|
|
53
|
+
declare expires_at?: number;
|
|
54
|
+
declare livemode: boolean;
|
|
55
|
+
declare metadata?: Record<string, any>;
|
|
56
|
+
declare name?: string;
|
|
57
|
+
declare priority: number; // 0-100, 0为最高优先级
|
|
58
|
+
declare test_clock?: string;
|
|
59
|
+
declare voided_at?: number;
|
|
60
|
+
|
|
61
|
+
// 状态相关字段
|
|
62
|
+
declare status: LiteralUnion<'pending' | 'granted' | 'depleted' | 'expired' | 'voided', string>;
|
|
63
|
+
declare remaining_amount: string; // 剩余金额
|
|
64
|
+
|
|
65
|
+
// 审计字段
|
|
66
|
+
declare created_by?: string;
|
|
67
|
+
declare updated_by?: string;
|
|
68
|
+
declare created_via: LiteralUnion<'api' | 'dashboard' | 'portal', string>;
|
|
69
|
+
|
|
70
|
+
declare created_at: CreationOptional<Date>;
|
|
71
|
+
declare updated_at: CreationOptional<Date>;
|
|
72
|
+
|
|
73
|
+
public static readonly GENESIS_ATTRIBUTES = {
|
|
74
|
+
id: {
|
|
75
|
+
type: DataTypes.STRING(18),
|
|
76
|
+
primaryKey: true,
|
|
77
|
+
allowNull: false,
|
|
78
|
+
defaultValue: nextCreditGrantId,
|
|
79
|
+
},
|
|
80
|
+
object: {
|
|
81
|
+
type: DataTypes.STRING(32),
|
|
82
|
+
defaultValue: 'credit_grant',
|
|
83
|
+
allowNull: false,
|
|
84
|
+
},
|
|
85
|
+
amount: {
|
|
86
|
+
type: DataTypes.STRING(32),
|
|
87
|
+
allowNull: false,
|
|
88
|
+
},
|
|
89
|
+
currency_id: {
|
|
90
|
+
type: DataTypes.STRING(15),
|
|
91
|
+
allowNull: false,
|
|
92
|
+
},
|
|
93
|
+
applicability_config: {
|
|
94
|
+
type: DataTypes.JSON,
|
|
95
|
+
allowNull: true,
|
|
96
|
+
},
|
|
97
|
+
category: {
|
|
98
|
+
type: DataTypes.ENUM('paid', 'promotional'),
|
|
99
|
+
allowNull: false,
|
|
100
|
+
},
|
|
101
|
+
customer_id: {
|
|
102
|
+
type: DataTypes.STRING(18),
|
|
103
|
+
allowNull: false,
|
|
104
|
+
},
|
|
105
|
+
effective_at: {
|
|
106
|
+
type: DataTypes.INTEGER,
|
|
107
|
+
allowNull: true,
|
|
108
|
+
},
|
|
109
|
+
expires_at: {
|
|
110
|
+
type: DataTypes.INTEGER,
|
|
111
|
+
allowNull: true,
|
|
112
|
+
},
|
|
113
|
+
livemode: {
|
|
114
|
+
type: DataTypes.BOOLEAN,
|
|
115
|
+
allowNull: false,
|
|
116
|
+
},
|
|
117
|
+
metadata: {
|
|
118
|
+
type: DataTypes.JSON,
|
|
119
|
+
allowNull: true,
|
|
120
|
+
},
|
|
121
|
+
name: {
|
|
122
|
+
type: DataTypes.STRING(255),
|
|
123
|
+
allowNull: true,
|
|
124
|
+
},
|
|
125
|
+
priority: {
|
|
126
|
+
type: DataTypes.INTEGER,
|
|
127
|
+
defaultValue: 50,
|
|
128
|
+
allowNull: false,
|
|
129
|
+
validate: {
|
|
130
|
+
min: 0,
|
|
131
|
+
max: 100,
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
test_clock: {
|
|
135
|
+
type: DataTypes.STRING(32),
|
|
136
|
+
allowNull: true,
|
|
137
|
+
},
|
|
138
|
+
voided_at: {
|
|
139
|
+
type: DataTypes.INTEGER,
|
|
140
|
+
allowNull: true,
|
|
141
|
+
},
|
|
142
|
+
status: {
|
|
143
|
+
type: DataTypes.ENUM('pending', 'granted', 'depleted', 'expired', 'voided'),
|
|
144
|
+
defaultValue: 'granted',
|
|
145
|
+
allowNull: false,
|
|
146
|
+
},
|
|
147
|
+
remaining_amount: {
|
|
148
|
+
type: DataTypes.STRING(32),
|
|
149
|
+
allowNull: false,
|
|
150
|
+
},
|
|
151
|
+
created_by: {
|
|
152
|
+
type: DataTypes.STRING(40),
|
|
153
|
+
allowNull: true,
|
|
154
|
+
},
|
|
155
|
+
updated_by: {
|
|
156
|
+
type: DataTypes.STRING(40),
|
|
157
|
+
allowNull: true,
|
|
158
|
+
},
|
|
159
|
+
created_via: {
|
|
160
|
+
type: DataTypes.ENUM('api', 'dashboard', 'portal'),
|
|
161
|
+
allowNull: false,
|
|
162
|
+
},
|
|
163
|
+
created_at: {
|
|
164
|
+
type: DataTypes.DATE,
|
|
165
|
+
defaultValue: DataTypes.NOW,
|
|
166
|
+
allowNull: false,
|
|
167
|
+
},
|
|
168
|
+
updated_at: {
|
|
169
|
+
type: DataTypes.DATE,
|
|
170
|
+
defaultValue: DataTypes.NOW,
|
|
171
|
+
allowNull: false,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// check if Credit Grant is available
|
|
176
|
+
public isAvailable(): boolean {
|
|
177
|
+
const now = dayjs().unix();
|
|
178
|
+
|
|
179
|
+
if (this.status !== 'granted') {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (this.effective_at && this.effective_at > now) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (this.expires_at && this.expires_at <= now) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (new BN(this.remaining_amount).lte(new BN(0))) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// consume credit
|
|
199
|
+
public async consumeCredit(
|
|
200
|
+
amount: string,
|
|
201
|
+
context: {
|
|
202
|
+
subscription_id?: string;
|
|
203
|
+
meter_event_id?: string;
|
|
204
|
+
},
|
|
205
|
+
dryRun: boolean = false
|
|
206
|
+
): Promise<{
|
|
207
|
+
consumed: string;
|
|
208
|
+
remaining: string;
|
|
209
|
+
depleted: boolean;
|
|
210
|
+
}> {
|
|
211
|
+
const requestedAmount = new BN(amount);
|
|
212
|
+
const availableAmount = new BN(this.remaining_amount);
|
|
213
|
+
|
|
214
|
+
logger.debug('Consume credit in model', {
|
|
215
|
+
requestedAmount: requestedAmount.toString(),
|
|
216
|
+
availableAmount: availableAmount.toString(),
|
|
217
|
+
grantId: this.id,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
if (requestedAmount.lte(new BN(0))) {
|
|
221
|
+
throw new Error('Amount must be greater than 0');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!this.isAvailable()) {
|
|
225
|
+
throw new Error('Credit Grant is not available for consumption');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// calculate actual consumable amount
|
|
229
|
+
const consumedAmount = requestedAmount.lte(availableAmount) ? requestedAmount : availableAmount;
|
|
230
|
+
const newRemainingAmount = availableAmount.sub(consumedAmount);
|
|
231
|
+
const isDepleted = newRemainingAmount.lte(new BN(0));
|
|
232
|
+
|
|
233
|
+
if (!dryRun) {
|
|
234
|
+
// update remaining amount
|
|
235
|
+
this.remaining_amount = newRemainingAmount.toString();
|
|
236
|
+
|
|
237
|
+
// if depleted, update status
|
|
238
|
+
if (isDepleted) {
|
|
239
|
+
this.status = 'depleted';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
await this.save();
|
|
243
|
+
|
|
244
|
+
await createEvent('CreditGrant', 'customer.credit_grant.consumed', this).catch(console.error);
|
|
245
|
+
|
|
246
|
+
// check low balance warning
|
|
247
|
+
const originalAmount = new BN(this.amount);
|
|
248
|
+
const threshold = originalAmount.mul(new BN(10)).div(new BN(100)); // 10%
|
|
249
|
+
if (newRemainingAmount.gt(new BN(0)) && newRemainingAmount.lte(threshold)) {
|
|
250
|
+
await createEvent('CreditGrant', 'customer.credit_grant.low_balance', this, {
|
|
251
|
+
metadata: context,
|
|
252
|
+
}).catch(console.error);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
consumed: consumedAmount.toString(),
|
|
258
|
+
remaining: newRemainingAmount.toString(),
|
|
259
|
+
depleted: isDepleted,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// check if Credit Grant is applicable to specified price
|
|
264
|
+
public isApplicableToPrice(priceId: string): boolean {
|
|
265
|
+
if (!this.applicability_config) {
|
|
266
|
+
return true; // no limit config, default to applicable to all
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const { scope } = this.applicability_config;
|
|
270
|
+
|
|
271
|
+
// if specified price list, check if in the list
|
|
272
|
+
if (scope.prices && Array.isArray(scope.prices) && scope.prices.length > 0) {
|
|
273
|
+
return scope.prices.includes(priceId);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
public static initialize(sequelize: any) {
|
|
280
|
+
this.init(this.GENESIS_ATTRIBUTES, {
|
|
281
|
+
sequelize,
|
|
282
|
+
modelName: 'CreditGrant',
|
|
283
|
+
tableName: 'credit_grants',
|
|
284
|
+
createdAt: 'created_at',
|
|
285
|
+
updatedAt: 'updated_at',
|
|
286
|
+
indexes: [
|
|
287
|
+
{ fields: ['status'], name: 'idx_credit_grant_status' },
|
|
288
|
+
{ fields: ['effective_at'], name: 'idx_credit_grant_effective_at' },
|
|
289
|
+
{ fields: ['expires_at'], name: 'idx_credit_grant_expires_at' },
|
|
290
|
+
],
|
|
291
|
+
hooks: {
|
|
292
|
+
afterCreate: (model: CreditGrant, options) => {
|
|
293
|
+
createEvent('CreditGrant', 'customer.credit_grant.created', model, options).catch(console.error);
|
|
294
|
+
if (!model.effective_at || model.effective_at <= Math.floor(Date.now() / 1000)) {
|
|
295
|
+
createEvent('CreditGrant', 'customer.credit_grant.granted', model, options).catch(console.error);
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
afterUpdate: (model: CreditGrant, options) => {
|
|
299
|
+
createEvent('CreditGrant', 'customer.credit_grant.updated', model, options).catch(console.error);
|
|
300
|
+
|
|
301
|
+
if (model.changed('status')) {
|
|
302
|
+
createCreditGrantStatusEvent(model, options).catch(console.error);
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
afterDestroy: (model: CreditGrant, options) =>
|
|
306
|
+
createEvent('CreditGrant', 'customer.credit_grant.deleted', model, options).catch(console.error),
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
public static associate(models: any) {
|
|
312
|
+
this.belongsTo(models.Customer, {
|
|
313
|
+
foreignKey: 'customer_id',
|
|
314
|
+
as: 'customer',
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
this.hasOne(models.PaymentCurrency, {
|
|
318
|
+
foreignKey: 'id',
|
|
319
|
+
sourceKey: 'currency_id',
|
|
320
|
+
as: 'paymentCurrency',
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
this.hasMany(models.CreditTransaction, {
|
|
324
|
+
foreignKey: 'credit_grant_id',
|
|
325
|
+
as: 'transactions',
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// get available Credit Grants for customer (sorted by priority)
|
|
330
|
+
public static async getAvailableCreditsForCustomer(
|
|
331
|
+
customerId: string,
|
|
332
|
+
currencyId: string,
|
|
333
|
+
priceIds?: string[] | string
|
|
334
|
+
): Promise<CreditGrant[]> {
|
|
335
|
+
const now = dayjs().unix();
|
|
336
|
+
|
|
337
|
+
const whereClause: any = {
|
|
338
|
+
customer_id: customerId,
|
|
339
|
+
currency_id: currencyId,
|
|
340
|
+
status: 'granted',
|
|
341
|
+
remaining_amount: { [Op.gt]: '0' },
|
|
342
|
+
[Op.and]: [
|
|
343
|
+
// effective_at check: null表示立即生效,或者已经生效
|
|
344
|
+
{
|
|
345
|
+
[Op.or]: [{ effective_at: null }, { effective_at: { [Op.lte]: now } }],
|
|
346
|
+
},
|
|
347
|
+
// expires_at check: null表示永不过期,或者还未过期
|
|
348
|
+
{
|
|
349
|
+
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: now } }],
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const grants = await this.findAll({
|
|
355
|
+
where: whereClause,
|
|
356
|
+
order: [
|
|
357
|
+
// 1. 先应用较早过期的 credit grants (expires_at 为 null 视为永不过期,排在后面)
|
|
358
|
+
[literal('CASE WHEN expires_at IS NULL THEN 1 ELSE 0 END'), 'ASC'],
|
|
359
|
+
['expires_at', 'ASC'],
|
|
360
|
+
// 2. 然后是 promotional 类型的 credit grants
|
|
361
|
+
[literal("CASE WHEN category = 'promotional' THEN 0 ELSE 1 END"), 'ASC'],
|
|
362
|
+
// 3. 再是较早生效的 credit grants (effective_at 为 null 视为立即生效,排在前面)
|
|
363
|
+
[literal('CASE WHEN effective_at IS NULL THEN 0 ELSE 1 END'), 'ASC'],
|
|
364
|
+
['effective_at', 'ASC'],
|
|
365
|
+
// 4. 最后是较早创建的 credit grants
|
|
366
|
+
['created_at', 'ASC'],
|
|
367
|
+
],
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
if (!grants || grants.length === 0) {
|
|
371
|
+
return [];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const existPrice = Array.isArray(priceIds) ? priceIds.length > 0 : priceIds;
|
|
375
|
+
// if specified price id, further filter applicable grants
|
|
376
|
+
if (existPrice && priceIds) {
|
|
377
|
+
const priceIdList = Array.isArray(priceIds) ? priceIds : [priceIds];
|
|
378
|
+
return grants.filter((grant) => priceIdList.some((priceId) => grant.isApplicableToPrice(priceId)));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return grants;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// batch set expired
|
|
385
|
+
public static async expireCredits(grantIds: string[]): Promise<number> {
|
|
386
|
+
const now = dayjs().unix();
|
|
387
|
+
|
|
388
|
+
const [affectedCount] = await this.update(
|
|
389
|
+
{
|
|
390
|
+
status: 'expired',
|
|
391
|
+
expires_at: now,
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
where: {
|
|
395
|
+
id: { [Op.in]: grantIds },
|
|
396
|
+
status: { [Op.in]: ['pending', 'granted'] },
|
|
397
|
+
},
|
|
398
|
+
}
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
return affectedCount;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* 获取客户的有效信用额度总额(按货币分组)
|
|
406
|
+
*/
|
|
407
|
+
public static async getEffectiveCreditSummary({
|
|
408
|
+
customerId,
|
|
409
|
+
currencyId: searchCurrencyId,
|
|
410
|
+
priceIds,
|
|
411
|
+
}: {
|
|
412
|
+
customerId: string;
|
|
413
|
+
currencyId?: string[] | string;
|
|
414
|
+
priceIds?: string[];
|
|
415
|
+
}): Promise<Record<string, CreditGrantSummary>> {
|
|
416
|
+
const summary: Record<
|
|
417
|
+
string,
|
|
418
|
+
{ paymentCurrency: TPaymentCurrency; totalAmount: string; remainingAmount: string; grantCount: number }
|
|
419
|
+
> = {};
|
|
420
|
+
|
|
421
|
+
let targetCurrencyIds: string[] = [];
|
|
422
|
+
if (searchCurrencyId) {
|
|
423
|
+
targetCurrencyIds = typeof searchCurrencyId === 'string' ? [searchCurrencyId] : searchCurrencyId;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (!searchCurrencyId) {
|
|
427
|
+
const grantsWithCurrency = await this.findAll({
|
|
428
|
+
where: {
|
|
429
|
+
customer_id: customerId,
|
|
430
|
+
},
|
|
431
|
+
attributes: ['currency_id'],
|
|
432
|
+
group: ['currency_id'],
|
|
433
|
+
raw: true,
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
targetCurrencyIds = grantsWithCurrency.map((grant: any) => grant.currency_id);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
await Promise.all(
|
|
440
|
+
targetCurrencyIds.map(async (currencyId: string) => {
|
|
441
|
+
const paymentCurrency = await PaymentCurrency.findByPk(currencyId);
|
|
442
|
+
if (!paymentCurrency) {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
if (!paymentCurrency.isCredit()) {
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const availableGrants = await this.getAvailableCreditsForCustomer(customerId, currencyId, priceIds);
|
|
450
|
+
|
|
451
|
+
if (availableGrants.length > 0) {
|
|
452
|
+
let totalAmount = '0';
|
|
453
|
+
let remainingAmount = '0';
|
|
454
|
+
let grantCount = 0;
|
|
455
|
+
|
|
456
|
+
availableGrants.forEach((grant) => {
|
|
457
|
+
totalAmount = new BN(totalAmount).add(new BN(grant.amount)).toString();
|
|
458
|
+
remainingAmount = new BN(remainingAmount).add(new BN(grant.remaining_amount)).toString();
|
|
459
|
+
grantCount += 1;
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const result = {
|
|
463
|
+
paymentCurrency,
|
|
464
|
+
totalAmount,
|
|
465
|
+
remainingAmount,
|
|
466
|
+
grantCount,
|
|
467
|
+
};
|
|
468
|
+
summary[currencyId] = result;
|
|
469
|
+
|
|
470
|
+
return result;
|
|
471
|
+
}
|
|
472
|
+
summary[currencyId] = {
|
|
473
|
+
paymentCurrency,
|
|
474
|
+
totalAmount: '0',
|
|
475
|
+
remainingAmount: '0',
|
|
476
|
+
grantCount: 0,
|
|
477
|
+
};
|
|
478
|
+
return null;
|
|
479
|
+
})
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
return summary;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export type TCreditGrant = InferAttributes<CreditGrant>;
|