payment-kit 1.26.4 → 1.27.0
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/api/src/libs/payment.ts +113 -22
- package/api/src/libs/queue/index.ts +20 -9
- package/api/src/libs/queue/store.ts +11 -7
- package/api/src/libs/reference-cache.ts +115 -0
- package/api/src/queues/auto-recharge.ts +68 -21
- package/api/src/queues/credit-consume.ts +835 -206
- package/api/src/routes/checkout-sessions.ts +78 -1
- package/api/src/routes/customers.ts +15 -3
- package/api/src/routes/donations.ts +4 -4
- package/api/src/routes/index.ts +37 -8
- package/api/src/routes/invoices.ts +14 -3
- package/api/src/routes/meter-events.ts +41 -15
- package/api/src/routes/payment-links.ts +2 -2
- package/api/src/routes/prices.ts +1 -1
- package/api/src/routes/pricing-table.ts +3 -2
- package/api/src/routes/products.ts +2 -2
- package/api/src/routes/subscription-items.ts +12 -3
- package/api/src/routes/subscriptions.ts +27 -9
- package/api/src/store/migrations/20260306-checkout-session-indexes.ts +23 -0
- package/api/src/store/models/checkout-session.ts +3 -2
- package/api/src/store/models/coupon.ts +9 -6
- package/api/src/store/models/credit-grant.ts +4 -1
- package/api/src/store/models/credit-transaction.ts +3 -2
- package/api/src/store/models/customer.ts +9 -6
- package/api/src/store/models/exchange-rate-provider.ts +9 -6
- package/api/src/store/models/invoice.ts +3 -2
- package/api/src/store/models/meter-event.ts +6 -4
- package/api/src/store/models/meter.ts +9 -6
- package/api/src/store/models/payment-intent.ts +9 -6
- package/api/src/store/models/payment-link.ts +9 -6
- package/api/src/store/models/payout.ts +3 -2
- package/api/src/store/models/price.ts +9 -6
- package/api/src/store/models/pricing-table.ts +9 -6
- package/api/src/store/models/product.ts +9 -6
- package/api/src/store/models/promotion-code.ts +9 -6
- package/api/src/store/models/refund.ts +9 -6
- package/api/src/store/models/setup-intent.ts +6 -4
- package/api/src/store/sequelize.ts +8 -3
- package/api/tests/queues/credit-consume-batch.spec.ts +438 -0
- package/api/tests/queues/credit-consume.spec.ts +505 -0
- package/api/third.d.ts +1 -1
- package/blocklet.yml +1 -1
- package/package.json +8 -7
- package/scripts/benchmark-seed.js +247 -0
- package/src/components/customer/credit-overview.tsx +31 -42
- package/src/components/invoice-pdf/template.tsx +5 -4
- package/src/components/payment-link/actions.tsx +45 -0
- package/src/components/payment-link/before-pay.tsx +24 -0
- package/src/components/subscription/payment-method-info.tsx +23 -6
- package/src/components/subscription/portal/actions.tsx +2 -0
- package/src/locales/en.tsx +11 -0
- package/src/locales/zh.tsx +10 -0
- package/src/pages/admin/products/links/detail.tsx +8 -0
- package/src/pages/customer/subscription/detail.tsx +21 -18
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
/**
|
|
3
|
+
* Benchmark Seed Script
|
|
4
|
+
*
|
|
5
|
+
* Creates test data required for Payment Kit benchmarks:
|
|
6
|
+
* 1. Meter (credit type, with currency)
|
|
7
|
+
* 2. Product + metered Price
|
|
8
|
+
* 3. Customer (via checkout or existing)
|
|
9
|
+
* 4. Credit Grants (large balance for sustained testing)
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* pnpm run benchmark-seed --app-id <APP_ID>
|
|
13
|
+
*
|
|
14
|
+
* Or with blocklet exec:
|
|
15
|
+
* blocklet exec /scripts/benchmark-seed.js --app-id <APP_ID>
|
|
16
|
+
*
|
|
17
|
+
* After running, copy the output env vars to benchmarks/.env
|
|
18
|
+
*/
|
|
19
|
+
const { spawn } = require('child_process');
|
|
20
|
+
const payment = require('../../../packages/client/lib').default;
|
|
21
|
+
|
|
22
|
+
function getAppId() {
|
|
23
|
+
const args = process.argv.slice(2);
|
|
24
|
+
let appId = '';
|
|
25
|
+
for (let i = 0; i < args.length; i++) {
|
|
26
|
+
if (args[i] === '--app-id' && i + 1 < args.length) {
|
|
27
|
+
appId = args[i + 1];
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (!appId) {
|
|
32
|
+
appId =
|
|
33
|
+
process.env.BLOCKLET_DEV_APP_DID?.split(':').pop() ||
|
|
34
|
+
process.env.BLOCKLET_APP_ID ||
|
|
35
|
+
'zNKuN3XwXN7xq2NsJQjjfwujyqCxx26DhwgV';
|
|
36
|
+
}
|
|
37
|
+
return appId;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Configuration
|
|
41
|
+
const CREDIT_GRANT_AMOUNT = 1000000; // 1M credits - enough for thousands of benchmark events
|
|
42
|
+
const CUSTOMER_COUNT = 3; // Multi-customer for parallelism testing
|
|
43
|
+
const METER_EVENT_NAME = 'benchmark_credits';
|
|
44
|
+
const METER_UNIT = 'Token';
|
|
45
|
+
|
|
46
|
+
async function findOrCreateMeter() {
|
|
47
|
+
console.log('\n── Step 1: Find or create meter ──');
|
|
48
|
+
|
|
49
|
+
// Try to find existing meter
|
|
50
|
+
try {
|
|
51
|
+
const meters = await payment.meters.list({ limit: 50 });
|
|
52
|
+
const items = meters?.list || meters?.data || [];
|
|
53
|
+
const existing = items.find((m) => m.event_name === METER_EVENT_NAME);
|
|
54
|
+
if (existing) {
|
|
55
|
+
console.log(` Found existing meter: ${existing.id} (event_name=${existing.event_name}, currency=${existing.currency_id})`);
|
|
56
|
+
return existing;
|
|
57
|
+
}
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.log(` Could not list meters: ${err.message}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Create new meter
|
|
63
|
+
const meter = await payment.meters.create({
|
|
64
|
+
name: 'Benchmark Credits',
|
|
65
|
+
event_name: METER_EVENT_NAME,
|
|
66
|
+
aggregation_method: 'sum',
|
|
67
|
+
unit: METER_UNIT,
|
|
68
|
+
description: 'Benchmark test meter for credit consumption',
|
|
69
|
+
created_via: 'api',
|
|
70
|
+
});
|
|
71
|
+
console.log(` Created meter: ${meter.id} (event_name=${meter.event_name}, currency=${meter.currency_id})`);
|
|
72
|
+
return meter;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function findOrCreateProduct(meter) {
|
|
76
|
+
console.log('\n── Step 2: Find or create metered product + price ──');
|
|
77
|
+
|
|
78
|
+
// Try to find existing
|
|
79
|
+
try {
|
|
80
|
+
const products = await payment.products.list({ limit: 50 });
|
|
81
|
+
const items = products?.list || products?.data || [];
|
|
82
|
+
const existing = items.find((p) => p.name === 'Benchmark Metered Service');
|
|
83
|
+
if (existing) {
|
|
84
|
+
console.log(` Found existing product: ${existing.id}`);
|
|
85
|
+
// Find price for this product
|
|
86
|
+
const prices = await payment.prices.list({ product_id: existing.id, limit: 10 });
|
|
87
|
+
const priceList = prices?.list || prices?.data || [];
|
|
88
|
+
if (priceList.length > 0) {
|
|
89
|
+
console.log(` Found existing price: ${priceList[0].id}`);
|
|
90
|
+
return { product: existing, price: priceList[0] };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch (err) {
|
|
94
|
+
console.log(` Could not list products: ${err.message}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const product = await payment.products.create({
|
|
98
|
+
name: 'Benchmark Metered Service',
|
|
99
|
+
description: 'Product for benchmark testing',
|
|
100
|
+
type: 'credit',
|
|
101
|
+
prices: [
|
|
102
|
+
{
|
|
103
|
+
type: 'recurring',
|
|
104
|
+
unit_amount: '0.001',
|
|
105
|
+
currency_id: meter.currency_id,
|
|
106
|
+
nickname: 'Benchmark metered price',
|
|
107
|
+
recurring: {
|
|
108
|
+
interval: 'month',
|
|
109
|
+
interval_count: 1,
|
|
110
|
+
usage_type: 'metered',
|
|
111
|
+
meter_id: meter.id,
|
|
112
|
+
aggregate_usage: 'sum',
|
|
113
|
+
},
|
|
114
|
+
metadata: {
|
|
115
|
+
credit_config: {
|
|
116
|
+
priority: 50,
|
|
117
|
+
valid_duration_value: 0,
|
|
118
|
+
valid_duration_unit: 'days',
|
|
119
|
+
currency_id: meter.currency_id,
|
|
120
|
+
credit_amount: '1',
|
|
121
|
+
},
|
|
122
|
+
meter_id: meter.id,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const prices = product.prices || [];
|
|
129
|
+
const price = prices[0];
|
|
130
|
+
console.log(` Created product: ${product.id}`);
|
|
131
|
+
console.log(` Created price: ${price?.id}`);
|
|
132
|
+
return { product, price };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function getCustomers() {
|
|
136
|
+
console.log('\n── Step 3: Find customers ──');
|
|
137
|
+
|
|
138
|
+
const result = await payment.customers.list({ limit: CUSTOMER_COUNT });
|
|
139
|
+
const items = result?.list || result?.data || [];
|
|
140
|
+
|
|
141
|
+
if (items.length === 0) {
|
|
142
|
+
console.log(' No customers found. Please create customers first (e.g. via checkout).');
|
|
143
|
+
console.log(' Tip: Log in to the blocklet UI as different users to auto-create customers.');
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log(` Found ${items.length} customer(s):`);
|
|
148
|
+
for (const c of items) {
|
|
149
|
+
console.log(` ${c.id}: ${c.name || c.email || c.did || '(unnamed)'}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return items.slice(0, CUSTOMER_COUNT);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function ensureCreditGrants(customers, meter) {
|
|
156
|
+
console.log('\n── Step 4: Ensure credit grants ──');
|
|
157
|
+
|
|
158
|
+
for (const customer of customers) {
|
|
159
|
+
// Check existing balance
|
|
160
|
+
let existingBalance = '0';
|
|
161
|
+
try {
|
|
162
|
+
const summary = await payment.creditGrants.summary({ customer_id: customer.id });
|
|
163
|
+
const balances = summary?.balances || summary?.data || [];
|
|
164
|
+
const match = balances.find((b) => b.currency_id === meter.currency_id);
|
|
165
|
+
existingBalance = match?.available || match?.remaining_amount || '0';
|
|
166
|
+
} catch {
|
|
167
|
+
// ignore
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const needsGrant = Number(existingBalance) < CREDIT_GRANT_AMOUNT / 2;
|
|
171
|
+
|
|
172
|
+
if (needsGrant) {
|
|
173
|
+
const grant = await payment.creditGrants.create({
|
|
174
|
+
customer_id: customer.id,
|
|
175
|
+
amount: CREDIT_GRANT_AMOUNT,
|
|
176
|
+
currency_id: meter.currency_id,
|
|
177
|
+
category: 'promotional',
|
|
178
|
+
name: 'Benchmark credit grant',
|
|
179
|
+
applicability_config: {
|
|
180
|
+
scope: {
|
|
181
|
+
price_type: 'metered',
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
console.log(` Created grant for ${customer.id}: ${grant.id} (amount=${CREDIT_GRANT_AMOUNT})`);
|
|
186
|
+
} else {
|
|
187
|
+
console.log(` ${customer.id}: balance=${existingBalance}, sufficient`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function runSeed() {
|
|
193
|
+
payment.environments.setTestMode(true);
|
|
194
|
+
|
|
195
|
+
console.log('=== Payment Kit Benchmark Seed ===');
|
|
196
|
+
console.log(` Grant amount per customer: ${CREDIT_GRANT_AMOUNT}`);
|
|
197
|
+
console.log(` Meter event name: ${METER_EVENT_NAME}`);
|
|
198
|
+
|
|
199
|
+
const meter = await findOrCreateMeter();
|
|
200
|
+
const { price } = await findOrCreateProduct(meter);
|
|
201
|
+
const customers = await getCustomers();
|
|
202
|
+
await ensureCreditGrants(customers, meter);
|
|
203
|
+
|
|
204
|
+
// Output env vars for benchmarks/.env
|
|
205
|
+
const customerIds = customers.map((c) => c.id);
|
|
206
|
+
console.log('\n\n=== Copy to benchmarks/.env ===\n');
|
|
207
|
+
console.log(`METER_EVENT_NAME=${METER_EVENT_NAME}`);
|
|
208
|
+
console.log(`CUSTOMER_ID=${customerIds[0]}`);
|
|
209
|
+
if (customerIds.length > 1) {
|
|
210
|
+
console.log(`CUSTOMER_IDS=${customerIds.join(',')}`);
|
|
211
|
+
}
|
|
212
|
+
if (price) {
|
|
213
|
+
console.log(`# PRICE_ID=${price.id}`);
|
|
214
|
+
}
|
|
215
|
+
console.log(`# METER_ID=${meter.id}`);
|
|
216
|
+
console.log(`# CURRENCY_ID=${meter.currency_id}`);
|
|
217
|
+
console.log(`# CREDIT_GRANT_AMOUNT=${CREDIT_GRANT_AMOUNT}`);
|
|
218
|
+
|
|
219
|
+
console.log('\n=== Seed complete ===');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function main() {
|
|
223
|
+
if (process.env.BLOCKLET_APP_ID) {
|
|
224
|
+
await runSeed();
|
|
225
|
+
process.exit(0);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const appId = getAppId();
|
|
229
|
+
const args = process.argv.slice(2).filter((arg) => arg !== '--app-id' && arg !== appId);
|
|
230
|
+
|
|
231
|
+
console.log(`Running with blocklet exec, app-id: ${appId}`);
|
|
232
|
+
|
|
233
|
+
const child = spawn('blocklet', ['exec', '/scripts/benchmark-seed.js', '--app-id', appId, ...args], {
|
|
234
|
+
stdio: 'inherit',
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
child.on('exit', (code) => {
|
|
238
|
+
process.exit(code || 0);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (require.main === module) {
|
|
243
|
+
main().catch((error) => {
|
|
244
|
+
console.error('Seed failed:', error);
|
|
245
|
+
process.exit(1);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
@@ -13,6 +13,7 @@ import { useSearchParams } from 'react-router-dom';
|
|
|
13
13
|
import type { TPaymentCurrency } from '@blocklet/payment-types';
|
|
14
14
|
import { useRequest } from 'ahooks';
|
|
15
15
|
import { AddOutlined, AutoModeOutlined } from '@mui/icons-material';
|
|
16
|
+
import { BN } from '@ocap/util';
|
|
16
17
|
import Toast from '@arcblock/ux/lib/Toast';
|
|
17
18
|
import { formatError } from '@blocklet/error';
|
|
18
19
|
import SplitButton from '@arcblock/ux/lib/SplitButton';
|
|
@@ -228,45 +229,37 @@ export default function CreditOverview({ customerId, settings, mode = 'portal' }
|
|
|
228
229
|
)}
|
|
229
230
|
</Stack>
|
|
230
231
|
|
|
231
|
-
{/*
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
{totalAmount === '0' && remainingAmount === '0' ? (
|
|
243
|
-
<>0</>
|
|
244
|
-
) : (
|
|
245
|
-
<>{formatCreditAmount(formatBNStr(remainingAmount, currency.decimal), currency.symbol, false)}</>
|
|
246
|
-
)}
|
|
247
|
-
</Typography>
|
|
248
|
-
</Box>
|
|
232
|
+
{/* 可用额度(净余额 = 剩余 - 欠费) */}
|
|
233
|
+
{(() => {
|
|
234
|
+
const remaining = new BN(remainingAmount || '0');
|
|
235
|
+
const pending = new BN(pendingAmount || '0');
|
|
236
|
+
const netBalance = remaining.sub(pending);
|
|
237
|
+
const isNegative = netBalance.isNeg();
|
|
238
|
+
const absBalance = isNegative ? netBalance.abs().toString() : netBalance.toString();
|
|
239
|
+
const formattedBalance =
|
|
240
|
+
totalAmount === '0' && remainingAmount === '0' && pendingAmount === '0'
|
|
241
|
+
? '0'
|
|
242
|
+
: formatCreditAmount(formatBNStr(absBalance, currency.decimal), currency.symbol, false);
|
|
249
243
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
)}
|
|
244
|
+
return (
|
|
245
|
+
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
246
|
+
<Typography
|
|
247
|
+
variant="body2"
|
|
248
|
+
gutterBottom
|
|
249
|
+
sx={{
|
|
250
|
+
color: 'text.secondary',
|
|
251
|
+
}}>
|
|
252
|
+
{t('admin.customer.creditGrants.creditBalance')}
|
|
253
|
+
</Typography>
|
|
254
|
+
<Typography
|
|
255
|
+
variant="h5"
|
|
256
|
+
component="div"
|
|
257
|
+
sx={{ fontWeight: 'normal', color: isNegative ? 'error.main' : 'text.primary' }}>
|
|
258
|
+
{isNegative ? `-${formattedBalance}` : formattedBalance}
|
|
259
|
+
</Typography>
|
|
260
|
+
</Box>
|
|
261
|
+
);
|
|
262
|
+
})()}
|
|
270
263
|
</Stack>
|
|
271
264
|
</CardContent>
|
|
272
265
|
</Card>
|
|
@@ -277,10 +270,6 @@ export default function CreditOverview({ customerId, settings, mode = 'portal' }
|
|
|
277
270
|
return creditCurrencies.filter((currency: TPaymentCurrency) => {
|
|
278
271
|
const grantData = creditSummary?.grants?.[currency.id];
|
|
279
272
|
if (!grantData) return false;
|
|
280
|
-
// Filter out credits with zero balance
|
|
281
|
-
if (grantData.remainingAmount === '0') {
|
|
282
|
-
return false;
|
|
283
|
-
}
|
|
284
273
|
return true;
|
|
285
274
|
});
|
|
286
275
|
}, [creditCurrencies, creditSummary?.grants]);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BN } from '@ocap/util';
|
|
1
2
|
import { formatNumber, formatTime, formatAmount } from '@blocklet/payment-react';
|
|
2
3
|
import { useEffect } from 'react';
|
|
3
4
|
import type { InvoicePDFProps } from './types';
|
|
@@ -99,12 +100,12 @@ export function InvoiceTemplate({ data, t }: InvoicePDFProps) {
|
|
|
99
100
|
|
|
100
101
|
{/* Table Content */}
|
|
101
102
|
{detail.map((line) => {
|
|
102
|
-
// Calculate discount amount for this line item
|
|
103
|
-
let itemDiscountAmount = 0;
|
|
103
|
+
// Calculate discount amount for this line item using BN to avoid scientific notation (e.g. 1e+21)
|
|
104
|
+
let itemDiscountAmount = new BN(0);
|
|
104
105
|
if (line.raw.discount_amounts && line.raw.discount_amounts.length > 0) {
|
|
105
106
|
line.raw.discount_amounts.forEach((discount: any) => {
|
|
106
107
|
if (discount.amount) {
|
|
107
|
-
itemDiscountAmount
|
|
108
|
+
itemDiscountAmount = itemDiscountAmount.add(new BN(String(discount.amount)));
|
|
108
109
|
}
|
|
109
110
|
});
|
|
110
111
|
}
|
|
@@ -129,7 +130,7 @@ export function InvoiceTemplate({ data, t }: InvoicePDFProps) {
|
|
|
129
130
|
</div>
|
|
130
131
|
<div style={composeStyles('w-15 p-4-8 pb-15')}>
|
|
131
132
|
<span style={composeStyles('dark right')}>
|
|
132
|
-
{itemDiscountAmount
|
|
133
|
+
{!itemDiscountAmount.isZero()
|
|
133
134
|
? `-${formatAmount(itemDiscountAmount.toString(), data.paymentCurrency.decimal)} ${
|
|
134
135
|
data.paymentCurrency.symbol
|
|
135
136
|
}`
|
|
@@ -82,6 +82,26 @@ export default function PaymentLinkActions({ data, variant = 'compact', onChange
|
|
|
82
82
|
setState({ loading: false, action: '' });
|
|
83
83
|
}
|
|
84
84
|
};
|
|
85
|
+
const onToggleSkipPaymentMethod = async () => {
|
|
86
|
+
const currentMetadata = (data.metadata || {}) as Record<string, any>;
|
|
87
|
+
const isEnabled = currentMetadata.allow_skip_payment_method === 'true';
|
|
88
|
+
try {
|
|
89
|
+
setState({ loading: true });
|
|
90
|
+
await api
|
|
91
|
+
.put(`/api/payment-links/${data.id}`, {
|
|
92
|
+
metadata: { ...currentMetadata, allow_skip_payment_method: isEnabled ? 'false' : 'true' },
|
|
93
|
+
})
|
|
94
|
+
.then((res: any) => res.data);
|
|
95
|
+
Toast.success(t('common.saved'));
|
|
96
|
+
onChange(state.action);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error(err);
|
|
99
|
+
Toast.error(formatError(err));
|
|
100
|
+
} finally {
|
|
101
|
+
setState({ loading: false, action: '' });
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
85
105
|
const onCopyLink = () => {
|
|
86
106
|
Copy(joinURL(window.blocklet.appUrl, window.blocklet.prefix, `/checkout/pay/${data.id}`));
|
|
87
107
|
Toast.success(t('common.copied'));
|
|
@@ -127,6 +147,14 @@ export default function PaymentLinkActions({ data, variant = 'compact', onChange
|
|
|
127
147
|
handler: () => setState({ action: 'togglePromotionCodes' }),
|
|
128
148
|
color: 'primary',
|
|
129
149
|
},
|
|
150
|
+
{
|
|
151
|
+
label:
|
|
152
|
+
(data.metadata as Record<string, any>)?.allow_skip_payment_method === 'true'
|
|
153
|
+
? t('admin.paymentLink.disableSkipPaymentMethod')
|
|
154
|
+
: t('admin.paymentLink.enableSkipPaymentMethod'),
|
|
155
|
+
handler: () => setState({ action: 'toggleSkipPaymentMethod' }),
|
|
156
|
+
color: 'primary',
|
|
157
|
+
},
|
|
130
158
|
{
|
|
131
159
|
label: t('admin.passport.assign'),
|
|
132
160
|
handler: () => setState({ action: 'assign' }),
|
|
@@ -162,6 +190,23 @@ export default function PaymentLinkActions({ data, variant = 'compact', onChange
|
|
|
162
190
|
loading={state.loading}
|
|
163
191
|
/>
|
|
164
192
|
)}
|
|
193
|
+
{state.action === 'toggleSkipPaymentMethod' && (
|
|
194
|
+
<ConfirmDialog
|
|
195
|
+
onConfirm={onToggleSkipPaymentMethod}
|
|
196
|
+
onCancel={() => setState({ action: '' })}
|
|
197
|
+
title={
|
|
198
|
+
(data.metadata as Record<string, any>)?.allow_skip_payment_method === 'true'
|
|
199
|
+
? t('admin.paymentLink.disableSkipPaymentMethod')
|
|
200
|
+
: t('admin.paymentLink.enableSkipPaymentMethod')
|
|
201
|
+
}
|
|
202
|
+
message={
|
|
203
|
+
(data.metadata as Record<string, any>)?.allow_skip_payment_method === 'true'
|
|
204
|
+
? t('admin.paymentLink.disableSkipPaymentMethodTip')
|
|
205
|
+
: t('admin.paymentLink.enableSkipPaymentMethodTip')
|
|
206
|
+
}
|
|
207
|
+
loading={state.loading}
|
|
208
|
+
/>
|
|
209
|
+
)}
|
|
165
210
|
{state.action === 'togglePromotionCodes' && (
|
|
166
211
|
<ConfirmDialog
|
|
167
212
|
onConfirm={onTogglePromotionCodes}
|
|
@@ -279,6 +279,30 @@ export default function BeforePay({
|
|
|
279
279
|
);
|
|
280
280
|
}}
|
|
281
281
|
/>
|
|
282
|
+
<Controller
|
|
283
|
+
name="metadata"
|
|
284
|
+
control={control}
|
|
285
|
+
render={({ field }) => {
|
|
286
|
+
const metadata = field.value || {};
|
|
287
|
+
const isChecked = metadata.allow_skip_payment_method === 'true';
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<FormControlLabel
|
|
291
|
+
control={
|
|
292
|
+
<Checkbox
|
|
293
|
+
checked={isChecked}
|
|
294
|
+
onChange={(_, checked) => {
|
|
295
|
+
const newMetadata = { ...metadata };
|
|
296
|
+
newMetadata.allow_skip_payment_method = checked ? 'true' : 'false';
|
|
297
|
+
field.onChange(newMetadata);
|
|
298
|
+
}}
|
|
299
|
+
/>
|
|
300
|
+
}
|
|
301
|
+
label={t('admin.paymentLink.allowSkipPaymentMethod')}
|
|
302
|
+
/>
|
|
303
|
+
);
|
|
304
|
+
}}
|
|
305
|
+
/>
|
|
282
306
|
{includeFreeTrial && (
|
|
283
307
|
<Controller
|
|
284
308
|
name="subscription_data.trial_period_days"
|
|
@@ -133,12 +133,7 @@ export default function PaymentMethodInfo({
|
|
|
133
133
|
setState({ editing: false, clientSecret: null, publishableKey: null, setupIntentId: null });
|
|
134
134
|
};
|
|
135
135
|
|
|
136
|
-
|
|
137
|
-
return null;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const { card, link, us_bank_account: usBankAccount, payer, type } = paymentMethodDetails;
|
|
141
|
-
|
|
136
|
+
// Stripe form for editing/binding — must be checked before early returns
|
|
142
137
|
if (state.editing && state.clientSecret && state.publishableKey) {
|
|
143
138
|
return (
|
|
144
139
|
<Box>
|
|
@@ -156,6 +151,28 @@ export default function PaymentMethodInfo({
|
|
|
156
151
|
);
|
|
157
152
|
}
|
|
158
153
|
|
|
154
|
+
if (!paymentMethodDetails) {
|
|
155
|
+
if (!editable) {
|
|
156
|
+
return (
|
|
157
|
+
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
158
|
+
{t('admin.subscription.noPaymentMethod', { defaultValue: 'Not bound' })}
|
|
159
|
+
</Typography>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return (
|
|
163
|
+
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
|
164
|
+
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
165
|
+
{t('admin.subscription.noPaymentMethod', { defaultValue: 'Not bound' })}
|
|
166
|
+
</Typography>
|
|
167
|
+
<Button variant="text" size="small" sx={{ color: 'text.link' }} loading={state.submitting} onClick={handleEdit}>
|
|
168
|
+
{t('admin.subscription.bindPaymentMethod', { defaultValue: 'Bind now' })}
|
|
169
|
+
</Button>
|
|
170
|
+
</Stack>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const { card, link, us_bank_account: usBankAccount, payer, type } = paymentMethodDetails;
|
|
175
|
+
|
|
159
176
|
const renderPaymentMethodInfo = () => {
|
|
160
177
|
if (type === 'card' && card) {
|
|
161
178
|
return (
|
|
@@ -534,6 +534,8 @@ export function SubscriptionActionsInner({
|
|
|
534
534
|
e?.stopPropagation();
|
|
535
535
|
if (action?.action === 'pastDue') {
|
|
536
536
|
navigate(`/customer/invoice/past-due?subscription=${subscription.id}`);
|
|
537
|
+
} else if (action?.action === 'recover' && !(subscription as any).paymentMethodDetails) {
|
|
538
|
+
Toast.error(t('admin.subscription.bindPaymentMethodFirst'));
|
|
537
539
|
} else {
|
|
538
540
|
// @ts-ignore
|
|
539
541
|
setState({ action: action?.action, subscription: subscription.id });
|
package/src/locales/en.tsx
CHANGED
|
@@ -1080,6 +1080,12 @@ export default flat({
|
|
|
1080
1080
|
includeFreeTrial: 'Include a free trial',
|
|
1081
1081
|
noStakeRequired: 'No stake required',
|
|
1082
1082
|
showProductFeatures: 'Show product features',
|
|
1083
|
+
allowSkipPaymentMethod: 'Allow skipping payment method for $0 checkout',
|
|
1084
|
+
enableSkipPaymentMethod: 'Allow skipping payment method',
|
|
1085
|
+
disableSkipPaymentMethod: 'Require payment method',
|
|
1086
|
+
enableSkipPaymentMethodTip:
|
|
1087
|
+
'Allow customers to skip binding a payment method when the checkout amount is $0. The subscription will auto-cancel at the end of the current period if no payment method is added.',
|
|
1088
|
+
disableSkipPaymentMethodTip: 'Require customers to provide a payment method even when the checkout amount is $0.',
|
|
1083
1089
|
freeTrialDaysPositive: 'Free trial days must be positive',
|
|
1084
1090
|
includeCustomFields: 'Add custom fields',
|
|
1085
1091
|
confirmPage: 'Confirmation Page',
|
|
@@ -1568,6 +1574,11 @@ export default flat({
|
|
|
1568
1574
|
button: 'Pay due invoices',
|
|
1569
1575
|
},
|
|
1570
1576
|
payerAddress: 'Payment Address',
|
|
1577
|
+
noPaymentMethod: 'Not bound',
|
|
1578
|
+
bindPaymentMethod: 'Bind now',
|
|
1579
|
+
bindPaymentMethodFirst: 'Please bind a payment method first before resuming the subscription',
|
|
1580
|
+
noPaymentMethodWarning:
|
|
1581
|
+
'No payment method is bound to this subscription. Please bind one to avoid cancellation at the end of the current period.',
|
|
1571
1582
|
changePayer: {
|
|
1572
1583
|
btn: 'Change',
|
|
1573
1584
|
stripe: {
|
package/src/locales/zh.tsx
CHANGED
|
@@ -1041,6 +1041,12 @@ export default flat({
|
|
|
1041
1041
|
includeFreeTrial: '包含免费试用',
|
|
1042
1042
|
noStakeRequired: '无需质押',
|
|
1043
1043
|
showProductFeatures: '显示产品特性',
|
|
1044
|
+
allowSkipPaymentMethod: '允许 $0 结账时跳过绑定支付方式',
|
|
1045
|
+
enableSkipPaymentMethod: '允许跳过支付方式',
|
|
1046
|
+
disableSkipPaymentMethod: '要求绑定支付方式',
|
|
1047
|
+
enableSkipPaymentMethodTip:
|
|
1048
|
+
'当结账金额为 $0 时,允许客户跳过绑定支付方式。若未绑定,订阅将在当前周期结束时自动取消。',
|
|
1049
|
+
disableSkipPaymentMethodTip: '即使结账金额为 $0,也要求客户提供支付方式。',
|
|
1044
1050
|
freeTrialDaysPositive: '免费试用天数必须是正数',
|
|
1045
1051
|
includeCustomFields: '添加自定义字段',
|
|
1046
1052
|
requireCrossSell: '用户必须选择交叉销售的商品(如果有的话)',
|
|
@@ -1533,6 +1539,10 @@ export default flat({
|
|
|
1533
1539
|
button: '批量付款',
|
|
1534
1540
|
},
|
|
1535
1541
|
payerAddress: '扣费地址',
|
|
1542
|
+
noPaymentMethod: '未绑定',
|
|
1543
|
+
bindPaymentMethod: '立即绑定',
|
|
1544
|
+
bindPaymentMethodFirst: '请先绑定支付方式后再恢复订阅',
|
|
1545
|
+
noPaymentMethodWarning: '当前订阅未绑定支付方式,请尽快绑定,否则订阅将在当前周期结束时自动取消。',
|
|
1536
1546
|
changePayer: {
|
|
1537
1547
|
btn: '变更',
|
|
1538
1548
|
stripe: {
|
|
@@ -372,6 +372,14 @@ export default function PaymentLinkDetail(props: { id: string }) {
|
|
|
372
372
|
value={data.phone_number_collection?.enabled ? t('common.yes') : t('common.no')}
|
|
373
373
|
/>
|
|
374
374
|
|
|
375
|
+
<InfoRow
|
|
376
|
+
label={t('admin.paymentLink.allowSkipPaymentMethod')}
|
|
377
|
+
value={
|
|
378
|
+
(data.metadata as Record<string, any>)?.allow_skip_payment_method === 'true'
|
|
379
|
+
? t('common.yes')
|
|
380
|
+
: t('common.no')
|
|
381
|
+
}
|
|
382
|
+
/>
|
|
375
383
|
<InfoRow
|
|
376
384
|
label={t('admin.paymentLink.showConfirmPage')}
|
|
377
385
|
value={renderConfirmPage(data.after_completion)}
|
|
@@ -454,6 +454,11 @@ export default function CustomerSubscriptionDetail() {
|
|
|
454
454
|
<>
|
|
455
455
|
<Root>
|
|
456
456
|
<Box>
|
|
457
|
+
{!(data as any).paymentMethodDetails && ['active', 'trialing'].includes(data.status) && (
|
|
458
|
+
<Alert severity="warning" sx={{ mb: 2 }}>
|
|
459
|
+
{t('admin.subscription.noPaymentMethodWarning')}
|
|
460
|
+
</Alert>
|
|
461
|
+
)}
|
|
457
462
|
{hasUnpaid && (
|
|
458
463
|
<Alert severity="error" sx={{ mb: 2 }}>
|
|
459
464
|
{t('customer.unpaidInvoicesWarningTip')}
|
|
@@ -820,24 +825,22 @@ export default function CustomerSubscriptionDetail() {
|
|
|
820
825
|
}
|
|
821
826
|
/>
|
|
822
827
|
)}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
/>
|
|
840
|
-
)}
|
|
828
|
+
<InfoRow
|
|
829
|
+
label={t('admin.subscription.payerAddress')}
|
|
830
|
+
value={
|
|
831
|
+
<PaymentMethodInfo
|
|
832
|
+
subscriptionId={id}
|
|
833
|
+
customer={data.customer}
|
|
834
|
+
paymentMethodDetails={(data as any).paymentMethodDetails}
|
|
835
|
+
editable={['active', 'trialing', 'past_due'].includes(data.status)}
|
|
836
|
+
onUpdate={() => {
|
|
837
|
+
refresh();
|
|
838
|
+
checkUnpaidInvoices();
|
|
839
|
+
}}
|
|
840
|
+
paymentMethodType={data.paymentMethod?.type}
|
|
841
|
+
/>
|
|
842
|
+
}
|
|
843
|
+
/>
|
|
841
844
|
|
|
842
845
|
{data.payment_details && hasDelegateTxHash(data.payment_details, data.paymentMethod) && (
|
|
843
846
|
<InfoRow
|