fix: support user recharge record cancellation
This commit is contained in:
+189
-9
@@ -25,7 +25,15 @@ pub fn routes() -> Router<AppState> {
|
||||
.route("/api/user/logs", get(get_user_logs))
|
||||
.route("/api/user/summary", get(get_user_summary))
|
||||
.route("/api/user/dashboard", get(get_user_dashboard))
|
||||
.route("/api/user/recharge-records", get(list_recharge_records))
|
||||
.route(
|
||||
"/api/user/recharge-records",
|
||||
get(list_recharge_records).delete(clear_recharge_records),
|
||||
)
|
||||
.route("/api/user/recharge-records/clear", post(clear_recharge_records))
|
||||
.route(
|
||||
"/api/user/recharge-records/{public_id}/cancel",
|
||||
post(cancel_recharge_record),
|
||||
)
|
||||
.route("/api/user/recharge/checkout", post(create_recharge_checkout))
|
||||
.route(
|
||||
"/api/user/models/marketplace",
|
||||
@@ -89,6 +97,7 @@ struct UserRechargeRecord {
|
||||
}
|
||||
|
||||
const USER_RECHARGE_ARRIVAL_NOTE: &str = "已创建系统内充值记录。跳转 Comfy Checkout 完成付款后,记录会保持待确认;到账需要管理员按付款记录核对并为用户 API Key 增加额度。";
|
||||
const USER_RECHARGE_CANCEL_NOTE: &str = "用户已取消该充值记录。";
|
||||
const USER_RECHARGE_PROVIDER: &str = "comfy_checkout";
|
||||
|
||||
fn resolve_recharge_option(payload: &CreateRechargeCheckoutPayload) -> AppResult<RechargeCheckoutOption> {
|
||||
@@ -216,6 +225,63 @@ async fn list_user_recharge_records(db: &SqlitePool, user_id: i64) -> AppResult<
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
async fn fetch_user_recharge_record(
|
||||
db: &SqlitePool,
|
||||
user_id: i64,
|
||||
public_id: &str,
|
||||
) -> AppResult<UserRechargeRecord> {
|
||||
ensure_user_recharge_schema(db).await?;
|
||||
let record = sqlx::query_as::<_, UserRechargeRecord>(
|
||||
"SELECT *
|
||||
FROM user_recharge_records
|
||||
WHERE user_id = ? AND public_id = ?
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(public_id)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Recharge record not found".into()))?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
async fn cancel_user_recharge_record(
|
||||
db: &SqlitePool,
|
||||
user_id: i64,
|
||||
public_id: &str,
|
||||
) -> AppResult<UserRechargeRecord> {
|
||||
ensure_user_recharge_schema(db).await?;
|
||||
let result = sqlx::query(
|
||||
"UPDATE user_recharge_records
|
||||
SET status = 'cancelled', note = ?, updated_at = datetime('now')
|
||||
WHERE user_id = ? AND public_id = ? AND status = 'pending'",
|
||||
)
|
||||
.bind(USER_RECHARGE_CANCEL_NOTE)
|
||||
.bind(user_id)
|
||||
.bind(public_id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
let existing = fetch_user_recharge_record(db, user_id, public_id).await?;
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Recharge record cannot be cancelled from status {}",
|
||||
existing.status
|
||||
)));
|
||||
}
|
||||
|
||||
fetch_user_recharge_record(db, user_id, public_id).await
|
||||
}
|
||||
|
||||
async fn clear_user_recharge_records(db: &SqlitePool, user_id: i64) -> AppResult<u64> {
|
||||
ensure_user_recharge_schema(db).await?;
|
||||
let result = sqlx::query("DELETE FROM user_recharge_records WHERE user_id = ?")
|
||||
.bind(user_id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn user_billing_summary(db: &SqlitePool, user_id: i64) -> AppResult<Value> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
@@ -313,15 +379,28 @@ fn configured_recharge_options_from_env() -> Vec<RechargeCheckoutOption> {
|
||||
.filter(|value| !value.is_empty());
|
||||
let checkout_url = override_url.unwrap_or_else(|| DEFAULT_USER_RECHARGE_CHECKOUT_URL.to_string());
|
||||
validate_checkout_url(&checkout_url)
|
||||
.map(|checkout_url| RechargeCheckoutOption {
|
||||
id: "usd-50".to_string(),
|
||||
label: "$50".to_string(),
|
||||
title: "团队高频".to_string(),
|
||||
amount_usd: Some(50.0),
|
||||
checkout_url,
|
||||
recommended: true,
|
||||
})
|
||||
.map(|checkout_url| default_recharge_options(&checkout_url))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn default_recharge_options(checkout_url: &str) -> Vec<RechargeCheckoutOption> {
|
||||
[
|
||||
("usd-5", "$5", "轻量试用", 5.0, false),
|
||||
("usd-10", "$10", "日常调用", 10.0, false),
|
||||
("usd-20", "$20", "创作加量", 20.0, false),
|
||||
("usd-50", "$50", "团队高频", 50.0, true),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(id, label, title, amount, recommended)| RechargeCheckoutOption {
|
||||
id: id.to_string(),
|
||||
label: label.to_string(),
|
||||
title: title.to_string(),
|
||||
amount_usd: Some(amount),
|
||||
checkout_url: checkout_url.to_string(),
|
||||
recommended,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -1406,6 +1485,47 @@ async fn list_recharge_records(
|
||||
})))
|
||||
}
|
||||
|
||||
async fn cancel_recharge_record(
|
||||
State(state): State<AppState>,
|
||||
Path(public_id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let user_id = current_user_id(&headers, &state)?;
|
||||
let record = cancel_user_recharge_record(&state.db, user_id, public_id.trim()).await?;
|
||||
let billing = user_billing_summary(&state.db, user_id).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"record": record,
|
||||
"billing": billing,
|
||||
"arrivalNote": USER_RECHARGE_ARRIVAL_NOTE,
|
||||
"checkoutProvider": "Comfy Checkout",
|
||||
"usagePath": "/user/usage",
|
||||
"rechargePath": "/user/recharge",
|
||||
})))
|
||||
}
|
||||
|
||||
async fn clear_recharge_records(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let user_id = current_user_id(&headers, &state)?;
|
||||
let cleared = clear_user_recharge_records(&state.db, user_id).await?;
|
||||
let billing = user_billing_summary(&state.db, user_id).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": true,
|
||||
"cleared": cleared,
|
||||
"records": [],
|
||||
"items": [],
|
||||
"billing": billing,
|
||||
"arrivalNote": USER_RECHARGE_ARRIVAL_NOTE,
|
||||
"checkoutProvider": "Comfy Checkout",
|
||||
"usagePath": "/user/usage",
|
||||
"rechargePath": "/user/recharge",
|
||||
})))
|
||||
}
|
||||
|
||||
async fn create_recharge_checkout(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
@@ -1651,6 +1771,37 @@ mod tests {
|
||||
assert_eq!(billing["usagePath"], "/user/usage");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recharge_records_can_be_cancelled_and_cleared() {
|
||||
let pool = recharge_test_pool().await;
|
||||
let option = RechargeCheckoutOption {
|
||||
id: "usd-10".to_string(),
|
||||
label: "$10".to_string(),
|
||||
title: "日常调用".to_string(),
|
||||
amount_usd: Some(10.0),
|
||||
checkout_url: DEFAULT_USER_RECHARGE_CHECKOUT_URL.to_string(),
|
||||
recommended: false,
|
||||
};
|
||||
let record = create_user_recharge_record(&pool, 9, &option, "203.0.113.11", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let cancelled = cancel_user_recharge_record(&pool, 9, &record.public_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cancelled.status, "cancelled");
|
||||
assert_eq!(cancelled.note.as_deref(), Some(USER_RECHARGE_CANCEL_NOTE));
|
||||
assert!(matches!(
|
||||
cancel_user_recharge_record(&pool, 9, &record.public_id).await,
|
||||
Err(AppError::BadRequest(_))
|
||||
));
|
||||
|
||||
let cleared = clear_user_recharge_records(&pool, 9).await.unwrap();
|
||||
assert_eq!(cleared, 1);
|
||||
let records = list_user_recharge_records(&pool, 9).await.unwrap();
|
||||
assert!(records.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recharge_checkout_rejects_invalid_or_unconfigured_amount() {
|
||||
let bad_amount = CreateRechargeCheckoutPayload {
|
||||
@@ -1677,6 +1828,35 @@ mod tests {
|
||||
assert!(find_recharge_option(&options, &unconfigured_amount).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_recharge_options_share_comfy_checkout_but_keep_amounts() {
|
||||
let options = default_recharge_options(DEFAULT_USER_RECHARGE_CHECKOUT_URL);
|
||||
|
||||
assert_eq!(options.len(), 4);
|
||||
assert_eq!(
|
||||
options.iter().map(|option| option.label.as_str()).collect::<Vec<_>>(),
|
||||
vec!["$5", "$10", "$20", "$50"],
|
||||
);
|
||||
assert_eq!(
|
||||
options.iter().map(|option| option.amount_usd).collect::<Vec<_>>(),
|
||||
vec![Some(5.0), Some(10.0), Some(20.0), Some(50.0)],
|
||||
);
|
||||
assert!(options
|
||||
.iter()
|
||||
.all(|option| option.checkout_url == DEFAULT_USER_RECHARGE_CHECKOUT_URL));
|
||||
assert_eq!(
|
||||
find_recharge_option(
|
||||
&options,
|
||||
&CreateRechargeCheckoutPayload {
|
||||
option_id: None,
|
||||
amount_usd: Some(10.0),
|
||||
},
|
||||
)
|
||||
.map(|option| option.id),
|
||||
Some("usd-10".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_log_filters_include_full_date_to_day() {
|
||||
let item = json!({
|
||||
|
||||
@@ -508,6 +508,37 @@ describe('user recharge api', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('can cancel and clear user recharge records', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
success: true,
|
||||
record: {
|
||||
id: 1,
|
||||
publicId: 'rch_test',
|
||||
provider: 'comfy_checkout',
|
||||
checkoutUrl: 'https://checkout.comfy.org/c/pay/session',
|
||||
status: 'cancelled',
|
||||
},
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
success: true,
|
||||
cleared: 2,
|
||||
records: [],
|
||||
items: [],
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const cancelled = await userApi.cancelRechargeRecord('rch_test');
|
||||
const cleared = await userApi.clearRechargeRecords();
|
||||
|
||||
expect(cancelled.record.status).toBe('cancelled');
|
||||
expect(cleared.cleared).toBe(2);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/user/recharge-records/rch_test/cancel');
|
||||
expect((fetchMock.mock.calls[0]?.[1] as RequestInit)?.method).toBe('POST');
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/user/recharge-records');
|
||||
expect((fetchMock.mock.calls[1]?.[1] as RequestInit)?.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('keeps billing guidance on quota errors', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
error: { message: '下游密钥成本额度已用完' },
|
||||
|
||||
@@ -1526,6 +1526,28 @@ export type CreateUserRechargeCheckoutResponse = {
|
||||
rechargePath?: string;
|
||||
};
|
||||
|
||||
export type UserRechargeRecordActionResponse = {
|
||||
success: boolean;
|
||||
record: UserRechargeRecord;
|
||||
billing?: UserBillingSummary;
|
||||
arrivalNote?: string;
|
||||
checkoutProvider?: string;
|
||||
usagePath?: string;
|
||||
rechargePath?: string;
|
||||
};
|
||||
|
||||
export type ClearUserRechargeRecordsResponse = {
|
||||
success: boolean;
|
||||
cleared: number;
|
||||
records: UserRechargeRecord[];
|
||||
items: UserRechargeRecord[];
|
||||
billing?: UserBillingSummary;
|
||||
arrivalNote?: string;
|
||||
checkoutProvider?: string;
|
||||
usagePath?: string;
|
||||
rechargePath?: string;
|
||||
};
|
||||
|
||||
export type WorkflowModelItem = {
|
||||
name: string;
|
||||
pathIndex?: number;
|
||||
@@ -2503,6 +2525,12 @@ export const userApi = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
cancelRechargeRecord: (publicId: string) =>
|
||||
userRequest<UserRechargeRecordActionResponse>(`/api/user/recharge-records/${encodeURIComponent(publicId)}/cancel`, {
|
||||
method: "POST",
|
||||
}),
|
||||
clearRechargeRecords: () =>
|
||||
userRequest<ClearUserRechargeRecordsResponse>("/api/user/recharge-records", { method: "DELETE" }),
|
||||
getModelsMarketplace: (options?: {
|
||||
refresh?: boolean;
|
||||
includePricing?: boolean;
|
||||
|
||||
+16
-1
@@ -15129,7 +15129,7 @@ button.user-recharge-option {
|
||||
|
||||
.user-recharge-record {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(156px, auto);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
@@ -15144,6 +15144,21 @@ button.user-recharge-option {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-recharge-record-toolbar,
|
||||
.user-recharge-record-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.user-recharge-record-muted {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-recharge-record span,
|
||||
.user-recharge-record small {
|
||||
overflow: hidden;
|
||||
|
||||
@@ -8,6 +8,8 @@ const { userApiMock } = vi.hoisted(() => ({
|
||||
userApiMock: {
|
||||
getRechargeRecords: vi.fn(),
|
||||
createRechargeCheckout: vi.fn(),
|
||||
cancelRechargeRecord: vi.fn(),
|
||||
clearRechargeRecords: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -62,6 +64,26 @@ describe('UserRecharge page', () => {
|
||||
createdAt: '2026-06-09T09:00:00Z',
|
||||
},
|
||||
});
|
||||
userApiMock.cancelRechargeRecord.mockImplementation(async (publicId: string) => ({
|
||||
success: true,
|
||||
record: {
|
||||
id: 1,
|
||||
publicId,
|
||||
label: '$50',
|
||||
title: '团队高频',
|
||||
amountUsd: 50,
|
||||
provider: 'comfy_checkout',
|
||||
checkoutUrl: USER_RECHARGE_CONFIG.checkoutUrl,
|
||||
status: 'cancelled',
|
||||
createdAt: '2026-06-09T08:00:00Z',
|
||||
},
|
||||
}));
|
||||
userApiMock.clearRechargeRecords.mockResolvedValue({
|
||||
success: true,
|
||||
cleared: 2,
|
||||
records: [],
|
||||
items: [],
|
||||
});
|
||||
vi.stubGlobal('window', {
|
||||
open: vi.fn(() => ({
|
||||
location: { href: '' },
|
||||
@@ -111,8 +133,8 @@ describe('UserRecharge page', () => {
|
||||
expect(text).toContain('rch_test_1');
|
||||
expect(text).toContain('付款后由管理员核对到账。');
|
||||
expect(optionButtons).toHaveLength(payableOptions.length);
|
||||
expect(disabledOptions).toHaveLength(USER_RECHARGE_CONFIG.options.length - payableOptions.length);
|
||||
expect(text).toContain('待配置');
|
||||
expect(disabledOptions).toHaveLength(0);
|
||||
expect(text).not.toContain('待配置');
|
||||
|
||||
await act(async () => {
|
||||
primaryButton.props.onClick();
|
||||
@@ -140,6 +162,31 @@ describe('UserRecharge page', () => {
|
||||
link.props.href === '/user/usage'
|
||||
&& collectText(link).includes('查看使用记录')
|
||||
))).toBe(true);
|
||||
|
||||
const cancelButton = root!.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& collectText(node) === '取消'
|
||||
))[0];
|
||||
await act(async () => {
|
||||
cancelButton.props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(userApiMock.cancelRechargeRecord).toHaveBeenCalledWith('rch_created');
|
||||
expect(collectText(root!.root)).toContain('已取消');
|
||||
expect(collectText(root!.root)).toContain('无需支付');
|
||||
|
||||
const clearButton = root!.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& collectText(node).includes('清空记录')
|
||||
))[0];
|
||||
await act(async () => {
|
||||
clearButton.props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(userApiMock.clearRechargeRecords).toHaveBeenCalled();
|
||||
expect(collectText(root!.root)).toContain('暂无充值记录');
|
||||
} finally {
|
||||
root!.unmount();
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ export default function UserRecharge() {
|
||||
const [arrivalNote, setArrivalNote] = useState('付款完成后返回 BossAI,到账由管理员按充值记录核对。');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pendingOptionId, setPendingOptionId] = useState<string | null>(null);
|
||||
const [cancellingId, setCancellingId] = useState<string | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
@@ -91,6 +93,42 @@ export default function UserRecharge() {
|
||||
}
|
||||
};
|
||||
|
||||
const cancelRecord = async (record: UserRechargeRecord) => {
|
||||
if (!record.publicId || record.status !== 'pending' || cancellingId) return;
|
||||
setCancellingId(record.publicId);
|
||||
setError('');
|
||||
try {
|
||||
const payload = await userApi.cancelRechargeRecord(record.publicId);
|
||||
if (payload.record) {
|
||||
setRecords((current) => current.map((item) => (
|
||||
item.publicId === payload.record.publicId ? payload.record : item
|
||||
)));
|
||||
}
|
||||
if (payload.billing) setBilling(payload.billing);
|
||||
if (payload.arrivalNote) setArrivalNote(payload.arrivalNote);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '取消充值记录失败');
|
||||
} finally {
|
||||
setCancellingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const clearRecords = async () => {
|
||||
if (records.length === 0 || clearing) return;
|
||||
setClearing(true);
|
||||
setError('');
|
||||
try {
|
||||
const payload = await userApi.clearRechargeRecords();
|
||||
setRecords([]);
|
||||
if (payload.billing) setBilling(payload.billing);
|
||||
if (payload.arrivalNote) setArrivalNote(payload.arrivalNote);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '清空充值记录失败');
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const defaultOption = recharge.defaultOption;
|
||||
|
||||
return (
|
||||
@@ -156,9 +194,14 @@ export default function UserRecharge() {
|
||||
<span>充值记录</span>
|
||||
<strong>{loading ? '正在加载' : `${records.length} 条最近记录`}</strong>
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => void refresh()} disabled={loading}>
|
||||
<div className="user-recharge-record-toolbar">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => void refresh()} disabled={loading || clearing}>
|
||||
刷新
|
||||
</button>
|
||||
<button type="button" className="btn btn-link btn-link-danger" onClick={() => void clearRecords()} disabled={records.length === 0 || loading || clearing}>
|
||||
{clearing ? '清空中...' : '清空记录'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{records.length === 0 ? (
|
||||
<div className="user-recharge-record-empty">
|
||||
@@ -179,9 +222,25 @@ export default function UserRecharge() {
|
||||
{statusText(record.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="user-recharge-record-actions">
|
||||
{record.status === 'pending' ? (
|
||||
<>
|
||||
<a href={record.checkoutUrl} target="_blank" rel="noopener noreferrer" className="btn btn-ghost">
|
||||
继续支付
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link btn-link-danger"
|
||||
onClick={() => void cancelRecord(record)}
|
||||
disabled={cancellingId === record.publicId}
|
||||
>
|
||||
{cancellingId === record.publicId ? '取消中...' : '取消'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="user-recharge-record-muted">无需支付</span>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -15,9 +15,8 @@ describe('user recharge config', () => {
|
||||
expect(config.opensInNewTab).toBe(true);
|
||||
expect(config.defaultOption.label).toBe('$50');
|
||||
expect(config.options.map((option) => option.label)).toEqual(['$5', '$10', '$20', '$50']);
|
||||
expect(config.options.filter(isUserRechargeOptionPayable).map((option) => option.label)).toEqual(['$50']);
|
||||
expect(config.options.filter((option) => !isUserRechargeOptionPayable(option)).map((option) => option.label)).toEqual(['$5', '$10', '$20']);
|
||||
expect(config.options.filter((option) => !isUserRechargeOptionPayable(option)).every((option) => !option.checkoutUrl)).toBe(true);
|
||||
expect(config.options.filter(isUserRechargeOptionPayable).map((option) => option.label)).toEqual(['$5', '$10', '$20', '$50']);
|
||||
expect(config.options.every((option) => option.checkoutUrl === USER_RECHARGE_CHECKOUT_URL)).toBe(true);
|
||||
});
|
||||
|
||||
it('allows deployment-specific checkout URL overrides', () => {
|
||||
@@ -26,9 +25,8 @@ describe('user recharge config', () => {
|
||||
});
|
||||
|
||||
expect(config.checkoutUrl).toBe('https://pay.example.test/session/123');
|
||||
expect(config.options).toHaveLength(1);
|
||||
expect(config.options[0].checkoutUrl).toBe('https://pay.example.test/session/123');
|
||||
expect(config.options[0].label).toBe('充值');
|
||||
expect(config.options.map((option) => option.label)).toEqual(['$5', '$10', '$20', '$50']);
|
||||
expect(config.options.every((option) => option.checkoutUrl === 'https://pay.example.test/session/123')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the default checkout URL for blank overrides', () => {
|
||||
|
||||
@@ -32,9 +32,9 @@ export type UserRechargeConfig = {
|
||||
const UNCONFIGURED_CHECKOUT_REASON = '该额度支付链接未配置';
|
||||
|
||||
const DEFAULT_USER_RECHARGE_AMOUNTS: UserRechargeOption[] = [
|
||||
{ id: 'usd-5', label: '$5', title: '轻量试用', amountUsd: 5, disabled: true, unavailableReason: UNCONFIGURED_CHECKOUT_REASON },
|
||||
{ id: 'usd-10', label: '$10', title: '日常调用', amountUsd: 10, disabled: true, unavailableReason: UNCONFIGURED_CHECKOUT_REASON },
|
||||
{ id: 'usd-20', label: '$20', title: '创作加量', amountUsd: 20, disabled: true, unavailableReason: UNCONFIGURED_CHECKOUT_REASON },
|
||||
{ id: 'usd-5', label: '$5', title: '轻量试用', amountUsd: 5, checkoutUrl: DEFAULT_USER_RECHARGE_CHECKOUT_URL },
|
||||
{ id: 'usd-10', label: '$10', title: '日常调用', amountUsd: 10, checkoutUrl: DEFAULT_USER_RECHARGE_CHECKOUT_URL },
|
||||
{ id: 'usd-20', label: '$20', title: '创作加量', amountUsd: 20, checkoutUrl: DEFAULT_USER_RECHARGE_CHECKOUT_URL },
|
||||
{ id: 'usd-50', label: '$50', title: '团队高频', amountUsd: 50, checkoutUrl: DEFAULT_USER_RECHARGE_CHECKOUT_URL, recommended: true },
|
||||
];
|
||||
|
||||
@@ -86,13 +86,10 @@ function optionsFromEnv(raw: string | undefined): UserRechargeOption[] {
|
||||
|
||||
function defaultOptionsFor(checkoutUrl: string, hasCheckoutOverride: boolean): UserRechargeOption[] {
|
||||
if (hasCheckoutOverride) {
|
||||
return [{
|
||||
id: 'configured-checkout',
|
||||
label: '充值',
|
||||
title: '已配置支付页',
|
||||
return DEFAULT_USER_RECHARGE_AMOUNTS.map((item) => ({
|
||||
...item,
|
||||
checkoutUrl,
|
||||
recommended: true,
|
||||
}];
|
||||
}));
|
||||
}
|
||||
|
||||
return DEFAULT_USER_RECHARGE_AMOUNTS.map((item) => ({ ...item }));
|
||||
|
||||
Reference in New Issue
Block a user