feat(billing): guide parents to recharge the child account

This commit is contained in:
brother7 committed 2026-10-09 08:43:05 +08:00
1 parent fe1a3775c5
commit 281e6dc151
10 files changed
+105 -14

No files matched your search

+7 -5
View File
@@ -4,7 +4,7 @@ type WalletFixture = { requests: string[]; paid: boolean; created: boolean };
type FixtureGlobal = typeof globalThis & { __pointWalletE2E: WalletFixture };
test.describe('Permanent point wallet', () => {
test('reopens an unpaid order, keeps its frozen amount, and refreshes only after confirmed credit', async ({ launchElectronApp }, testInfo) => {
test('shows the child recipient, reopens its frozen unpaid order, and refreshes only after confirmed credit', async ({ launchElectronApp }, testInfo) => {
const app = await launchElectronApp({ skipSetup: true });
try {
const page = await getStableWindow(app);
@@ -40,12 +40,12 @@ test.describe('Permanent point wallet', () => {
});
if (path === '/api/auth/me') return result({
success: true,
user: { username: 'point-wallet', userId: 'point-wallet-user', tenantId: null, deptId: null, authorities: [] },
user: { username: 'child-wallet', userId: 'child-wallet-user', tenantId: null, deptId: null, authorities: [] },
moduleAccess: { programming: true, design: true, robot: true },
});
if (path === '/api/works/user/agent-profile') return result({
success: true, profile: {
display_name: '词元点数用户', age: null, gender: null, avatar_url: null,
display_name: '小麦', age: null, gender: null, avatar_url: null,
share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true,
completed: true, version: 1, updated_at: '2026-09-22T00:00:00Z',
},
@@ -78,13 +78,15 @@ test.describe('Permanent point wallet', () => {
await page.goto(applicationUrl, { waitUntil: 'domcontentloaded' });
await page.getByTestId('ai-module-option-programming').click();
await expect(page.getByTestId('main-layout')).toBeVisible();
await expect(page.getByTestId('sidebar-member-menu-trigger')).toContainText('词元点数用户');
await expect(page.getByTestId('sidebar-member-menu-trigger')).toContainText('小麦');
await page.getByTestId('sidebar-member-menu-trigger').click();
await expect(page.getByTestId('sidebar-total-token-points')).toHaveText('0 点');
await expect(page.getByText('点数已用尽,充值后可继续使用。')).toBeVisible();
await expect(page.getByText('点数已用尽,请由家长完成当前账号充值。')).toBeVisible();
await page.getByTestId('sidebar-point-wallet-open').click();
const wallet = page.getByTestId('point-wallet-dialog');
await expect(wallet).toContainText('1 元充值 50 点');
await expect(wallet.getByTestId('point-recharge-recipient')).toHaveText('充值到:小麦(child-wallet)');
await expect(wallet).toContainText('请由家长登录孩子的账号完成付款');
await expect(wallet).not.toContainText('重置卡');
await wallet.getByRole('button', { name: /500 点\s*¥10.00/ }).click();
await expect(wallet.getByAltText('充值支付二维码')).toBeVisible();
+19 -2
View File
@@ -27,14 +27,31 @@ describe('permanent point wallet', () => {
expect(dialog).toHaveTextContent('永久有效');
expect(screen.queryByText(/重置卡|升级订阅|本周剩余/)).not.toBeInTheDocument();
});
it('shows youth own precision and shared availability without enabling recharge', async () => {
it('honors unavailable recharge without asking the user to change account identity', async () => {
api.summary.mockResolvedValue(billingSummary({ can_recharge: false, payment_configured: false, own_points: permanentBalance({ can_recharge: false }), points: permanentBalance({ total: null, used: null, reserved: null, total_remaining: null, family_shared: true, entitlement_source: 'family_owner', shared_available: false, can_recharge: false }) }));
render(<PointWallet />); await screen.findByText('75 点');
expect(screen.getByText(/AI 编程使用共享点数/)).toHaveTextContent('已用尽');
fireEvent.click(screen.getByTestId('sidebar-point-wallet-open'));
await screen.findByText(/仅家长身份可以充值/);
await screen.findByText(/当前账号暂不能充值,请在社区账户页查看原因或联系支持/);
expect(screen.queryByText(/仅家长身份可以充值/)).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /500 点\s*¥10.00/ })).toBeDisabled();
});
it('recharges the displayed child account while leaving shared funding selected', async () => {
api.summary.mockResolvedValue(billingSummary({ points: permanentBalance({ total: null, used: null, reserved: null, total_remaining: null, family_shared: true, entitlement_source: 'shared_group', shared_available: true, can_recharge: false }) }));
render(<PointWallet accountName="小麦" accountUsername="child-account" />);
await screen.findByText('75 点');
fireEvent.click(screen.getByTestId('sidebar-point-wallet-open'));
const dialog = screen.getByTestId('point-wallet-dialog');
expect(screen.getByTestId('point-recharge-recipient')).toHaveTextContent('充值到:小麦(child-account)');
expect(dialog).toHaveTextContent('请由家长登录孩子的账号完成付款');
expect(dialog).toHaveTextContent('充值不会自动切换付款来源');
const buy = within(dialog).getByRole('button', { name: /500 点\s*¥10.00/ });
await waitFor(() => expect(buy).toBeEnabled());
fireEvent.click(buy);
await screen.findByTestId('point-recharge-checkout');
expect(api.create).toHaveBeenCalledWith('recharge-10', expect.any(String));
expect(api.external).not.toHaveBeenCalled();
});
it('does not mistake the family payer’s own shared wallet for an exhausted external wallet', async () => {
api.summary.mockResolvedValue(billingSummary({ points: permanentBalance({ family_shared: true, entitlement_source: 'shared_group' }) }));
await openWallet();
+1
View File
@@ -91,6 +91,7 @@ describe('Sidebar permanent point wallet', () => {
expect(screen.queryByText(/重置卡|升级订阅|会员等级|本周剩余/)).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('sidebar-point-wallet-open'));
expect(screen.getByTestId('point-wallet-dialog')).toHaveTextContent('1 元充值 50 点');
expect(screen.getByTestId('point-recharge-recipient')).toHaveTextContent('测试用户(member@example.com)');
});
it('shows a retryable failure without inventing a zero balance', async () => {
fetchBillingSummaryMock.mockRejectedValue(new Error('offline'));
+6
View File
@@ -101,6 +101,12 @@ describe('permanent points Main contract', () => {
const result = await call('/me');
expect(result.status).toBe(409); expect(result.body.data).toBeUndefined();
});
it('does not translate an unavailable current-account recharge into a parent-identity requirement', async () => {
reply({ detail: '当前账号暂不能充值词元点数' }, 403);
const result = await call('/recharge/orders', 'POST', { productId: 'p', requestId: 'r' });
expect(result.body).toMatchObject({ commandOutcome: 'definitive_failure', error: '当前账号暂不能充值,请在社区账户页查看原因或联系支持。' });
expect(result.body.error).not.toContain('联系家长');
});
it.each(['/plans', '/reset-cards', '/subscriptions', '/reset-card-orders'])('retires %s without contacting payment services', async (path) => {
expect((await call(path)).status).toBe(410); expect(net).not.toHaveBeenCalled();
});
@@ -6,7 +6,7 @@ import server from '../fixtures/permanent-points-server.json';
// an isolated SQLite database and a fake payment provider. All identities are synthetic.
// Covers the real API's serialized decimals, timestamps, optional fields and order envelopes.
describe('Works Square permanent-points API compatibility', () => {
it('projects old accounts at zero and genuinely registered youth at 100, without a client gift', () => {
it('projects balance and recharge permission from the captured pre-D01 server response without a client gift', () => {
expect(projectBillingSummary(server.before).own_points.total_remaining).toBe('0.00');
const youth = projectBillingSummary(server.youth);
expect(youth.own_points.total_remaining).toBe('100.00');