diff --git a/apps/api/src/portfolio.ts b/apps/api/src/portfolio.ts index 7116a05..56b63be 100644 --- a/apps/api/src/portfolio.ts +++ b/apps/api/src/portfolio.ts @@ -160,7 +160,6 @@ export class PortfolioBusinessService { } async deletion(r: UserRequest, id: string) { const p = await this.own(r.userId, id, r.revealed); - if (p.kind !== 'account') throw new BadRequestException('此功能仅用于删除账户'); const [historyCount, movementCount, scheduleCount, linkCount] = await Promise.all([ this.db.revision.count({ where: { positionId: id } }), this.db.transfer.count({ @@ -192,23 +191,24 @@ export class PortfolioBusinessService { const p = await tx.position.findFirst({ where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) }, }); - if (!p) throw new NotFoundException('账户不存在'); - if (p.kind !== 'account') throw new BadRequestException('此功能仅用于删除账户'); + if (!p) throw new NotFoundException('项目不存在'); if (input.confirmation !== p.name) - throw new BadRequestException('请输入完整账户名称确认删除'); + throw new BadRequestException('请输入完整项目名称确认删除'); // Paired movements must be removed through replay, never via cascade. if ( await tx.transfer.count({ where: { userId: r.userId, OR: [{ sourceId: id }, { targetId: id }] }, }) ) - throw new ConflictException('账户存在资金往来,请先撤销相关记录,或选择归档保留历史'); + throw new ConflictException( + '项目存在资金往来,请先撤销相关转账、借贷或还款记录,或选择归档保留历史', + ); if ( await tx.schedule.count({ where: { userId: r.userId, OR: [{ sourceId: id }, { targetId: id }] }, }) ) - throw new ConflictException('账户被定时计划使用,请先删除相关计划,或选择归档'); + throw new ConflictException('项目被定时计划使用,请先删除相关计划,或选择归档'); await tx.position.delete({ where: { id } }); return { ok: true }; }); diff --git a/apps/api/test/account-deletion-integration.test.ts b/apps/api/test/account-deletion-integration.test.ts index 3c61e6f..1965453 100644 --- a/apps/api/test/account-deletion-integration.test.ts +++ b/apps/api/test/account-deletion-integration.test.ts @@ -155,7 +155,7 @@ test('real REST account deletion preserves paired balances, blocks schedules, ca ); assert.equal( (await call(a, '/positions/' + debt.data.id, 'DELETE', { confirmation: '关联债务' })).status, - 400, + 200, ); } finally { await db.user.deleteMany({ where: { id: { in: users } } }); @@ -247,3 +247,140 @@ test('draft review resolves only referenced visible names and canonical standalo await db.$disconnect(); } }); + +test('standalone asset and both debt sides delete history and links; paired debts preserve account balances', async () => { + const db = new PrismaClient(); + let userId = ''; + try { + const u = await db.user.create({ + data: { username: 'delete_position_' + randomUUID(), passwordHash: 'unused', idleMinutes: 0 }, + }); + userId = u.id; + const token = randomBytes(32).toString('hex'), + sid = createHash('sha256').update(token).digest('hex'); + await db.session.create({ + data: { id: sid, userId, expiresAt: new Date(Date.now() + 3600000) }, + }); + async function call(path: string, method = 'GET', data?: unknown) { + const r = await fetch(base + path, { + method, + headers: { + Cookie: 'wp_session=' + token, + Origin: origin, + ...(data ? { 'Content-Type': 'application/json' } : {}), + }, + body: data ? JSON.stringify(data) : undefined, + }); + return { status: r.status, data: await r.json() }; + } + async function create( + name: string, + kind: string, + side: string, + amount: string, + hidden = false, + ) { + const r = await call('/positions', 'POST', { + name, + kind, + side, + amount, + hidden, + currency: 'CNY', + category: 'other', + date: today(), + }); + assert.equal(r.status, 201); + return r.data.id as string; + } + const cash = await create('关联测试账户', 'account', 'asset', '100'); + const asset = await create('独立资产删除测试', 'asset', 'asset', '42'); + const owed = await create('欠款删除测试', 'debt', 'liability', '10'); + const lent = await create('借出删除测试', 'debt', 'asset', '5'); + assert.equal( + (await call('/positions/' + owed + '/links', 'PUT', { targetIds: [asset, cash] })).status, + 200, + ); + assert.equal((await call('/positions/' + asset + '/deletion')).data.linkCount, 1); + assert.equal( + (await call('/positions/' + asset, 'DELETE', { confirmation: '独立资产删除测试' })).status, + 200, + ); + assert.equal(await db.revision.count({ where: { positionId: asset } }), 0); + assert.equal(await db.positionLink.count({ where: { targetId: asset } }), 0); + assert.equal((await call('/positions/' + owed)).data.amount, '10'); + assert.equal((await call('/overview')).data.assets, '105.00'); + assert.equal( + (await call('/positions/' + lent, 'DELETE', { confirmation: '借出删除测试' })).status, + 200, + ); + assert.equal((await call('/overview')).data.assets, '100.00'); + const makeMovement = async (operation: string, amount: string) => { + const r = await call('/transfers', 'POST', { + operation, + sourceId: cash, + targetId: owed, + amount, + received: amount, + fee: '0', + date: today(), + requestId: randomUUID(), + }); + assert.equal(r.status, 201); + return r.data.id as string; + }; + const borrowed = await makeMovement('borrow', '20'); + const repayment = await makeMovement('repay', '5'); + assert.equal((await call('/positions/' + owed + '/deletion')).data.movementCount, 2); + assert.equal( + (await call('/positions/' + owed, 'DELETE', { confirmation: '欠款删除测试' })).status, + 409, + ); + assert.equal((await call('/positions/' + cash)).data.amount, '115'); + assert.equal((await call('/positions/' + owed)).data.amount, '25'); + assert.equal((await call('/transfers/' + repayment, 'DELETE')).status, 200); + assert.equal((await call('/transfers/' + borrowed, 'DELETE')).status, 200); + assert.equal((await call('/positions/' + cash)).data.amount, '100'); + assert.equal( + (await call('/positions/' + owed, 'DELETE', { confirmation: '欠款删除测试' })).status, + 200, + ); + assert.equal(await db.revision.count({ where: { positionId: owed } }), 0); + assert.equal(await db.positionLink.count({ where: { sourceId: owed } }), 0); + assert.equal((await call('/overview')).data.liabilities, '0.00'); + assert.ok( + !(await call('/history')).data.items.some((h: any) => + [asset, lent, owed].includes(h.positionId), + ), + ); + assert.ok( + !(await call('/calendar/day?date=' + today())).data.items.some((h: any) => + [asset, lent, owed].includes(h.positionId), + ), + ); + for (const [kind, side] of [ + ['asset', 'asset'], + ['debt', 'liability'], + ]) { + const name = '隐藏归档删除-' + kind, + id = await create(name, kind, side, '3', true); + await db.position.update({ where: { id }, data: { archived: true } }); + assert.equal((await call('/positions/' + id + '/deletion')).status, 404); + assert.equal((await call('/positions/' + id, 'DELETE', { confirmation: name })).status, 404); + await db.session.update({ + where: { id: sid }, + data: { revealUntil: new Date(Date.now() + 600000) }, + }); + assert.equal((await call('/positions/' + id + '/deletion')).data.canDelete, true); + assert.equal( + (await call('/positions/' + id, 'DELETE', { confirmation: '错误名称' })).status, + 400, + ); + assert.equal((await call('/positions/' + id, 'DELETE', { confirmation: name })).status, 200); + await db.session.update({ where: { id: sid }, data: { revealUntil: null } }); + } + } finally { + if (userId) await db.user.deleteMany({ where: { id: userId } }); + await db.$disconnect(); + } +}); diff --git a/apps/api/test/account-deletion.test.ts b/apps/api/test/account-deletion.test.ts index 1fd67ee..044122a 100644 --- a/apps/api/test/account-deletion.test.ts +++ b/apps/api/test/account-deletion.test.ts @@ -65,18 +65,12 @@ test('account deletion checks ownership and hidden visibility before deleting or await f.service.remove({ ...owner, revealed: true }, 'account', { confirmation: '测试账户' }); assert.deepEqual(f.state(), { deleted: true, invalidated: true }); }); -test('wrong confirmation, non-accounts, paired movements and schedules cannot cascade', async () => { +test('wrong confirmation, paired movements and schedules cannot cascade', async () => { const f = fixture(); await assert.rejects( () => f.service.remove(owner, 'account', { confirmation: '错误名称' }), BadRequestException, ); - f.row.kind = 'asset'; - await assert.rejects( - () => f.service.remove(owner, 'account', { confirmation: '测试账户' }), - BadRequestException, - ); - f.row.kind = 'account'; f.setMovements(1); await assert.rejects( () => f.service.remove(owner, 'account', { confirmation: '测试账户' }), @@ -95,3 +89,14 @@ test('wrong confirmation, non-accounts, paired movements and schedules cannot ca }); assert.deepEqual(f.state(), { deleted: true, invalidated: true }); }); + +test('standalone assets and debts support deletion and invalidate totals', async () => { + for (const kind of ['asset', 'debt']) { + const f = fixture(); + f.row.kind = kind; + assert.deepEqual(await f.service.remove(owner, 'account', { confirmation: '测试账户' }), { + ok: true, + }); + assert.deepEqual(f.state(), { deleted: true, invalidated: true }); + } +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a37953f..fa96d2b 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -55,7 +55,7 @@ import { Calendar } from './Calendar'; import { SchedulePanel } from './SchedulePanel'; import { AgentConnections } from './AgentConnections'; import { AgentAuthorization, AgentOperation } from './AgentReview'; -import { AccountDeletion } from './AccountDeletion'; +import { PositionDeletion, deletionLabel } from './PositionDeletion'; import { IconPicker } from './IconPicker'; import { MetalPanel } from './MetalPanel'; import { QuickTransfer } from './QuickTransfer'; @@ -1367,15 +1367,13 @@ export default function App() { )} - {p.kind === 'account' && ( - - )} + @@ -2607,8 +2605,8 @@ export default function App() { ? modal.p?.kind === 'debt' ? operationLabel(modal.p.side === 'asset' ? 'collect' : 'repay') : tr('收款 / 还款') - : modal.kind === 'delete-account' - ? '删除账户' + : modal.kind === 'delete-position' + ? '删除' + deletionLabel(modal.p!) : modal.kind === 'transfer' ? tr('转账') : modal.kind === 'reveal' @@ -2627,16 +2625,19 @@ export default function App() { if (!busy) setModal(null); }} > - {modal.kind === 'delete-account' && modal.p ? ( - setModal(null)} remove={(confirmation) => - void act(async () => { - await api('/positions/' + modal.p!.id, 'DELETE', { confirmation }); - setSelected(null); - }, '账户已删除') + void act( + async () => { + await api('/positions/' + modal.p!.id, 'DELETE', { confirmation }); + setSelected(null); + }, + deletionLabel(modal.p!) + '已删除', + ) } /> ) : ['transfer', 'debt-payment'].includes(modal.kind) ? ( diff --git a/apps/web/src/AccountDeletion.tsx b/apps/web/src/PositionDeletion.tsx similarity index 73% rename from apps/web/src/AccountDeletion.tsx rename to apps/web/src/PositionDeletion.tsx index 43a2391..75f90a3 100644 --- a/apps/web/src/AccountDeletion.tsx +++ b/apps/web/src/PositionDeletion.tsx @@ -9,7 +9,10 @@ type Impact = { linkCount: number; canDelete: boolean; }; -export function AccountDeletion({ +export function deletionLabel(position: Pick) { + return position.kind === 'asset' ? '独立资产' : position.kind === 'debt' ? '债务' : '账户'; +} +export function PositionDeletion({ position, busy, cancel, @@ -35,6 +38,9 @@ export function AccountDeletion({ active = false; }; }, [position.id]); + const label = deletionLabel(position); + const history = + position.kind === 'asset' ? '估值历史' : position.kind === 'debt' ? '借贷历史' : '余额历史'; return (
{ @@ -42,26 +48,29 @@ export function AccountDeletion({ if (impact?.canDelete && name === impact.name && !busy) remove(name); }} > -

删除账户「{position.name}」

+

+ 删除{label}「{position.name}」 +

{!impact ? (

正在检查关联记录…

) : ( <>

- 删除后将移除该账户、{impact.historyCount} 条余额历史及 {impact.linkCount}{' '} + 删除后将移除该{label}、{impact.historyCount} 条{history}及 {impact.linkCount}{' '} 个债务关联。资产总览、趋势和收支日历将同步更新,无法撤销。

{!impact.canDelete ? (
- 请先处理账户引用 + 请先处理关联记录

还有 {impact.movementCount} 条资金往来、{impact.scheduleCount}{' '} - 个定时计划。请先撤销相关资金往来并删除相关计划,再删除账户。若需要保留历史,可改用归档。 + 个定时计划。请先撤销相关资金往来并删除相关计划,再删除{label} + 。若需要保留历史,可改用归档。

) : (