feat: add account groups, scheduled payments and cash flow calendar
No files matched your search
@@ -76,3 +76,11 @@ Invoke-RestMethod http://localhost:5173/api/openapi.json
|
|||||||
```
|
```
|
||||||
|
|
||||||
接口定义由实际控制器路由和表单校验模式生成。文档本身不包含用户财务数据;除注册、登录及健康检查外,业务接口需登录会话 Cookie。建议先在同一站点登录,再通过前端代理地址使用文档;写入接口仍受来源校验和数据隔离约束。部署时将上述地址中的主机替换为自己的站点,并将 `/api` 代理到后端。实现使用 [NestJS Swagger](https://docs.nestjs.com/openapi/introduction)。
|
接口定义由实际控制器路由和表单校验模式生成。文档本身不包含用户财务数据;除注册、登录及健康检查外,业务接口需登录会话 Cookie。建议先在同一站点登录,再通过前端代理地址使用文档;写入接口仍受来源校验和数据隔离约束。部署时将上述地址中的主机替换为自己的站点,并将 `/api` 代理到后端。实现使用 [NestJS Swagger](https://docs.nestjs.com/openapi/introduction)。
|
||||||
|
|
||||||
|
## 账户分组、定时计划与收支日历
|
||||||
|
|
||||||
|
账户可自定义分组,并通过弹窗选择全部或分组账户;快速记账模式点击账户直接录入当前余额。资产账户允许透支,欠债显示负数,负债/支出显示红色。
|
||||||
|
|
||||||
|
“定时计划”支持支出及转账,打开账户页时按需执行,每批最多 20 项;仅在本应用内记账。新增“收支日历”默认今天,月历与当日明细在同页上下展示,按账户余额变化估算,排除内部转账、借贷本金及初始余额。
|
||||||
|
|
||||||
|
本次需要新增数据库迁移并运行图库初始化,备份导出升级至 ZIP v7。操作步骤、31 个内置图标与验证边界见 [更新说明](docs/update-2026-10-02.md)。
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 580 B |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 702 B |
|
After Width: | Height: | Size: 645 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 511 B |
@@ -118,5 +118,71 @@
|
|||||||
"source": "https://jr.jd.com/logo.png",
|
"source": "https://jr.jd.com/logo.png",
|
||||||
"file": "20.png",
|
"file": "20.png",
|
||||||
"sha256": "8dc9703f571e605df552dc7eb14ae074d9ddab6b27a9140b610e87b1c2a5f693"
|
"sha256": "8dc9703f571e605df552dc7eb14ae074d9ddab6b27a9140b610e87b1c2a5f693"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "汇丰香港",
|
||||||
|
"source": "https://www.hsbc.com.hk/etc.clientlibs/dpws/clientlibs-public/clientlib-site/resources/favicons/apple-touch-icon.png",
|
||||||
|
"file": "21.png",
|
||||||
|
"sha256": "d258f7e2ad662d73ec5c4a2c9e0b7d514b353df351bb8880b9b4731682788766"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "恒生银行",
|
||||||
|
"source": "https://www.hangseng.com/etc.clientlibs/hase/clientlibs/clientlib-header/resources/favicon.ico",
|
||||||
|
"file": "22.png",
|
||||||
|
"sha256": "9d40bc7de25856427ba5ca9bcb6951652765cb0344ed14974d4ddc4ea17f9e65"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "渣打香港",
|
||||||
|
"source": "https://av.sc.com/hk/content/images/content/images/cropped-512x512-1-150x150.png",
|
||||||
|
"file": "23.png",
|
||||||
|
"sha256": "1fdfd03637318e0ae31931a09a29bc5725342daab40e03ee715d635c0740c1a7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "东亚银行",
|
||||||
|
"source": "https://www.hkbea.com/images/favicon.ico",
|
||||||
|
"file": "24.png",
|
||||||
|
"sha256": "b99768b3931c08f69af569a9a662f490b5665d7203fbf1eeb49baa44ed511c1c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "星展香港",
|
||||||
|
"source": "https://www.dbs.com.hk/_next/public/favicon.ico?q=fa411ced-9fa6-4c3e-b208-d878769c84c9",
|
||||||
|
"file": "25.png",
|
||||||
|
"sha256": "3bcee651ede82d04774636cad1e14bbe448155ea17ac5f9a019bb7301b8811b2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "花旗香港",
|
||||||
|
"source": "https://www.citibank.com.hk/views/images/favicon.ico",
|
||||||
|
"file": "26.png",
|
||||||
|
"sha256": "a208032884b564421a9c5355c3806d4d33ef6616045444d58ec4595cb73abfa0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Gate",
|
||||||
|
"source": "https://www.gate.com/favicon.ico",
|
||||||
|
"file": "27.png",
|
||||||
|
"sha256": "d8900cbf6d55a20ae77d5e83f224a10e051c06dc43a804960a86073c5c89d2a9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bybit",
|
||||||
|
"source": "https://www.bybit.com/favicon.ico",
|
||||||
|
"file": "28.png",
|
||||||
|
"sha256": "e6ca34b0ddfddd7fb2e32f4e137704f7e392463f817ac35fca47b30cd0885cc0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bitget",
|
||||||
|
"source": "https://www.bitget.com/baseasset/favicon4.png",
|
||||||
|
"file": "29.png",
|
||||||
|
"sha256": "96566dc4bc8606740f518de4126b24537e7d287ed7e8181eb9e641cfb80847a7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "中银香港",
|
||||||
|
"source": "https://www.bochk.com/etc/designs/bochk_web/images/icon/boc-icon-32.ico",
|
||||||
|
"file": "30.png",
|
||||||
|
"sha256": "8dd2451e41db10e438ae7708bae0b0090fa350da66e7a46571293427311268ef"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "币安 Binance",
|
||||||
|
"source": "https://bin.bnbstatic.com/static/images/common/favicon.ico",
|
||||||
|
"file": "31.png",
|
||||||
|
"sha256": "8318ebbcb1cb4729eb0f78bb058dc618c3b63f9f9f0070a1a7a3265fdc79b833"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -5,11 +5,11 @@
|
|||||||
"dev": "node scripts/dev.cjs",
|
"dev": "node scripts/dev.cjs",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/zip.test.ts ../web/test/i18n.test.ts",
|
"test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts ../web/test/i18n.test.ts",
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:migrate": "node scripts/database.cjs deploy",
|
"db:migrate": "node scripts/database.cjs deploy",
|
||||||
"db:status": "node scripts/database.cjs status",
|
"db:status": "node scripts/database.cjs status",
|
||||||
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts",
|
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts",
|
||||||
"test:performance": "tsx scripts/performance.ts after",
|
"test:performance": "tsx scripts/performance.ts after",
|
||||||
"icons:seed": "node scripts/seed-icons.cjs"
|
"icons:seed": "node scripts/seed-icons.cjs"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
ALTER TABLE `Position` ADD COLUMN `groupName` VARCHAR(60) NOT NULL DEFAULT '' COMMENT '自定义账户分组';
|
||||||
|
CREATE TABLE `Schedule` (
|
||||||
|
`id` CHAR(36) NOT NULL, `userId` CHAR(36) NOT NULL, `importedFromId` CHAR(36) NULL,
|
||||||
|
`name` VARCHAR(100) NOT NULL, `operation` VARCHAR(16) NOT NULL,
|
||||||
|
`sourceId` CHAR(36) NOT NULL, `targetId` CHAR(36) NULL,
|
||||||
|
`amount` DECIMAL(24,8) NOT NULL, `received` DECIMAL(24,8) NOT NULL,
|
||||||
|
`nextAt` DATETIME(3) NOT NULL, `intervalDays` INTEGER NOT NULL,
|
||||||
|
`enabled` BOOLEAN NOT NULL DEFAULT true, `completed` BOOLEAN NOT NULL DEFAULT false, `notes` TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (`id`), INDEX `Schedule_userId_enabled_nextAt_idx` (`userId`,`enabled`,`nextAt`),
|
||||||
|
UNIQUE INDEX `Schedule_userId_importedFromId_key` (`userId`,`importedFromId`),
|
||||||
|
CONSTRAINT `Schedule_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
@@ -30,6 +30,7 @@ model User {
|
|||||||
sessions Session[]
|
sessions Session[]
|
||||||
icons Icon[]
|
icons Icon[]
|
||||||
transfers Transfer[]
|
transfers Transfer[]
|
||||||
|
schedules Schedule[]
|
||||||
}
|
}
|
||||||
/// 登录会话与隐藏项目查看授权
|
/// 登录会话与隐藏项目查看授权
|
||||||
model Session {
|
model Session {
|
||||||
@@ -67,6 +68,8 @@ model Position {
|
|||||||
name String @db.VarChar(100)
|
name String @db.VarChar(100)
|
||||||
/// 项目分类代码
|
/// 项目分类代码
|
||||||
category String @db.VarChar(40)
|
category String @db.VarChar(40)
|
||||||
|
/// 自定义账户分组;空字符串表示未分组
|
||||||
|
groupName String @default("") @db.VarChar(60)
|
||||||
/// 原始币种代码
|
/// 原始币种代码
|
||||||
currency String @db.Char(3)
|
currency String @db.Char(3)
|
||||||
/// 用户备注,原文保留
|
/// 用户备注,原文保留
|
||||||
@@ -210,3 +213,24 @@ model Transfer {
|
|||||||
@@unique([userId,importedFromId])
|
@@unique([userId,importedFromId])
|
||||||
@@index([userId,effectiveDate,id])
|
@@index([userId,effectiveDate,id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按需执行的定时支出或转账计划
|
||||||
|
model Schedule {
|
||||||
|
id String @id @default(uuid()) @db.Char(36)
|
||||||
|
userId String @db.Char(36)
|
||||||
|
user User @relation(fields:[userId],references:[id],onDelete:Cascade)
|
||||||
|
importedFromId String? @db.Char(36)
|
||||||
|
name String @db.VarChar(100)
|
||||||
|
operation String @db.VarChar(16)
|
||||||
|
sourceId String @db.Char(36)
|
||||||
|
targetId String? @db.Char(36)
|
||||||
|
amount Decimal @db.Decimal(24,8)
|
||||||
|
received Decimal @db.Decimal(24,8)
|
||||||
|
nextAt DateTime @db.DateTime(3)
|
||||||
|
intervalDays Int
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
completed Boolean @default(false)
|
||||||
|
notes String @db.Text
|
||||||
|
@@index([userId,enabled,nextAt])
|
||||||
|
@@unique([userId,importedFromId])
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Fetch public brand icons from the listed official websites; keep existing catalog."""
|
||||||
|
import hashlib, io, json, re, urllib.request, urllib.parse
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image
|
||||||
|
folder = Path(__file__).resolve().parent.parent / 'assets' / 'icons'
|
||||||
|
catalog = json.loads((folder / 'sources.json').read_text(encoding='utf-8'))
|
||||||
|
brands = [('汇丰香港', 'https://www.hsbc.com.hk'), ('恒生银行', 'https://www.hangseng.com'), ('中银香港', 'https://www.bochk.com/en'), ('渣打香港', 'https://www.sc.com/hk'), ('东亚银行', 'https://www.hkbea.com'), ('星展香港', 'https://www.dbs.com.hk'), ('花旗香港', 'https://www.citibank.com.hk'), ('币安 Binance', 'https://www.binance.com'), ('Gate', 'https://www.gate.com'), ('Bybit', 'https://www.bybit.com'), ('Bitget', 'https://www.bitget.com')]
|
||||||
|
def get(url):
|
||||||
|
return urllib.request.urlopen(urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}), timeout=20).read()
|
||||||
|
failed = []
|
||||||
|
for name, site in brands:
|
||||||
|
if any(v['name'] == name for v in catalog): continue
|
||||||
|
urls = {'币安 Binance': ['https://bin.bnbstatic.com/static/images/common/favicon.ico', 'https://bin.bnbstatic.com/static/images/common/favicon.png'], '中银香港': ['https://www.bochk.com/dam/bochk/desktop/top/favicon.ico', 'https://www.bochk.com/favicon.ico']}.get(name, [])
|
||||||
|
try:
|
||||||
|
html = get(site).decode('utf-8', errors='replace')
|
||||||
|
for tag in re.findall(r'<link\b[^>]*>', html, re.I):
|
||||||
|
attrs = dict(re.findall(r'([\w-]+)=["\']([^"\']*)["\']', tag))
|
||||||
|
if 'icon' in attrs.get('rel', '').lower() and attrs.get('href'): urls.append(urllib.parse.urljoin(site + '/', attrs['href']))
|
||||||
|
except Exception: pass
|
||||||
|
urls += [urllib.parse.urljoin(site + '/', '/favicon.ico'), urllib.parse.urljoin(site + '/', '/favicon.png')]
|
||||||
|
for url in dict.fromkeys(urls):
|
||||||
|
try:
|
||||||
|
raw = get(url)
|
||||||
|
im = Image.open(io.BytesIO(raw)).convert('RGBA')
|
||||||
|
im.thumbnail((256,256))
|
||||||
|
out = io.BytesIO(); im.save(out, 'PNG'); png = out.getvalue()
|
||||||
|
file = str(len(catalog) + 1).zfill(2) + '.png'
|
||||||
|
(folder / file).write_bytes(png)
|
||||||
|
catalog.append({'name':name,'source':url,'file':file,'sha256':hashlib.sha256(raw).hexdigest()})
|
||||||
|
print('Added:', name)
|
||||||
|
break
|
||||||
|
except Exception: continue
|
||||||
|
else: failed.append(name)
|
||||||
|
(folder / 'sources.json').write_text(json.dumps(catalog, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||||
|
if failed: print('Unavailable:', ', '.join(failed)); raise SystemExit(1)
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { scheduleInput } from './schedules';
|
||||||
import { movementDeltas } from './movement';
|
import { movementDeltas } from './movement';
|
||||||
import { pairedReasons } from './validation';
|
import { pairedReasons } from './validation';
|
||||||
import {
|
import {
|
||||||
@@ -109,6 +110,16 @@ const backupSchema = z
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
schedules: z
|
||||||
|
.array(
|
||||||
|
scheduleInput.safeExtend({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
importedFromId: z.string().uuid().nullable().optional(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
completed: z.boolean().default(false),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
positions: z.array(record),
|
positions: z.array(record),
|
||||||
links: z.array(z.object({ sourceId: z.string().uuid(), targetId: z.string().uuid() }).strict()),
|
links: z.array(z.object({ sourceId: z.string().uuid(), targetId: z.string().uuid() }).strict()),
|
||||||
rates: z.array(rateInput.safeExtend({ source: z.enum(['manual', 'frankfurter']) })),
|
rates: z.array(rateInput.safeExtend({ source: z.enum(['manual', 'frankfurter']) })),
|
||||||
@@ -132,6 +143,7 @@ export function validateBackup(raw: unknown) {
|
|||||||
positionInput.parse({
|
positionInput.parse({
|
||||||
name: p.name,
|
name: p.name,
|
||||||
category: p.category,
|
category: p.category,
|
||||||
|
groupName: p.groupName,
|
||||||
kind: p.kind,
|
kind: p.kind,
|
||||||
side: p.side,
|
side: p.side,
|
||||||
currency: p.currency,
|
currency: p.currency,
|
||||||
@@ -150,6 +162,31 @@ export function validateBackup(raw: unknown) {
|
|||||||
}
|
}
|
||||||
if (!b.currencies.includes(p.currency)) throw new BadRequestException('币种清单不完整');
|
if (!b.currencies.includes(p.currency)) throw new BadRequestException('币种清单不完整');
|
||||||
}
|
}
|
||||||
|
for (const p of b.positions)
|
||||||
|
if (
|
||||||
|
(p.kind !== 'account' || p.side !== 'asset') &&
|
||||||
|
p.revisions.some((r) => r.amount.startsWith('-'))
|
||||||
|
)
|
||||||
|
throw new BadRequestException('仅资产账户支持负余额');
|
||||||
|
const planIds = new Set<string>();
|
||||||
|
for (const plan of b.schedules || []) {
|
||||||
|
const source = ids.get(plan.sourceId),
|
||||||
|
target = plan.targetId ? ids.get(plan.targetId) : null;
|
||||||
|
if (
|
||||||
|
planIds.has(plan.importedFromId || plan.id) ||
|
||||||
|
!source ||
|
||||||
|
source.kind !== 'account' ||
|
||||||
|
source.side !== 'asset' ||
|
||||||
|
(plan.operation === 'expense'
|
||||||
|
? !!plan.targetId
|
||||||
|
: !target ||
|
||||||
|
target.kind !== 'account' ||
|
||||||
|
target.side !== 'asset' ||
|
||||||
|
(source.currency === target.currency && !new Decimal(plan.amount).eq(plan.received)))
|
||||||
|
)
|
||||||
|
throw new BadRequestException('计划账户关联无效');
|
||||||
|
planIds.add(plan.importedFromId || plan.id);
|
||||||
|
}
|
||||||
const transferIds = new Set<string>(),
|
const transferIds = new Set<string>(),
|
||||||
usedRevisions = new Set<string>();
|
usedRevisions = new Set<string>();
|
||||||
for (const t of b.transfers || []) {
|
for (const t of b.transfers || []) {
|
||||||
@@ -288,6 +325,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
kind: p.kind,
|
kind: p.kind,
|
||||||
side: p.side,
|
side: p.side,
|
||||||
category: p.category,
|
category: p.category,
|
||||||
|
groupName: p.groupName,
|
||||||
currency: p.currency,
|
currency: p.currency,
|
||||||
notes: p.notes,
|
notes: p.notes,
|
||||||
archived: p.archived,
|
archived: p.archived,
|
||||||
@@ -317,6 +355,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const transfers = await client.transfer.findMany({ where: { userId } });
|
const transfers = await client.transfer.findMany({ where: { userId } });
|
||||||
|
const schedules = await client.schedule.findMany({ where: { userId } });
|
||||||
return backupSchema.parse({
|
return backupSchema.parse({
|
||||||
format: 'worthpath',
|
format: 'worthpath',
|
||||||
version: 2,
|
version: 2,
|
||||||
@@ -349,6 +388,12 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
date: businessTime(effectiveDate),
|
date: businessTime(effectiveDate),
|
||||||
createdAt: t.createdAt.toISOString(),
|
createdAt: t.createdAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
|
schedules: schedules.map(({ userId, ...v }) => ({
|
||||||
|
...v,
|
||||||
|
amount: v.amount.toString(),
|
||||||
|
received: v.received.toString(),
|
||||||
|
nextAt: businessTime(v.nextAt),
|
||||||
|
})),
|
||||||
positions,
|
positions,
|
||||||
links: ps.flatMap((p) =>
|
links: ps.flatMap((p) =>
|
||||||
p.outgoing.map((l) => ({ sourceId: l.sourceId, targetId: l.targetId })),
|
p.outgoing.map((l) => ({ sourceId: l.sourceId, targetId: l.targetId })),
|
||||||
@@ -467,6 +512,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
throw new BadRequestException('请先下载当前账号备份,再进入下一步(10 分钟内有效)');
|
throw new BadRequestException('请先下载当前账号备份,再进入下一步(10 分钟内有效)');
|
||||||
if (this.fingerprint(await this.data(r.userId, tx)) !== s.backupDigest)
|
if (this.fingerprint(await this.data(r.userId, tx)) !== s.backupDigest)
|
||||||
throw new ConflictException('数据已变化,请重新下载备份');
|
throw new ConflictException('数据已变化,请重新下载备份');
|
||||||
|
await tx.schedule.deleteMany({ where: { userId: r.userId } });
|
||||||
await tx.position.deleteMany({ where: { userId: r.userId } });
|
await tx.position.deleteMany({ where: { userId: r.userId } });
|
||||||
await tx.icon.deleteMany({ where: { ownerId: r.userId, shared: false } });
|
await tx.icon.deleteMany({ where: { ownerId: r.userId, shared: false } });
|
||||||
await tx.exchangeRate.deleteMany({ where: { userId: r.userId } });
|
await tx.exchangeRate.deleteMany({ where: { userId: r.userId } });
|
||||||
@@ -490,6 +536,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
rates: b.rates.length,
|
rates: b.rates.length,
|
||||||
icons: (b.icons || []).length,
|
icons: (b.icons || []).length,
|
||||||
transfers: (b.transfers || []).length,
|
transfers: (b.transfers || []).length,
|
||||||
|
schedules: (b.schedules || []).length,
|
||||||
baseCurrency: b.baseCurrency,
|
baseCurrency: b.baseCurrency,
|
||||||
currentBaseCurrency: existing.baseCurrency,
|
currentBaseCurrency: existing.baseCurrency,
|
||||||
message:
|
message:
|
||||||
@@ -567,6 +614,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
kind: p.kind,
|
kind: p.kind,
|
||||||
side: p.side,
|
side: p.side,
|
||||||
category: p.category,
|
category: p.category,
|
||||||
|
groupName: p.groupName,
|
||||||
currency: p.currency,
|
currency: p.currency,
|
||||||
notes: p.notes,
|
notes: p.notes,
|
||||||
archived: p.archived,
|
archived: p.archived,
|
||||||
@@ -625,6 +673,19 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
createdAt: new Date(t.createdAt),
|
createdAt: new Date(t.createdAt),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
for (const plan of b.schedules || []) {
|
||||||
|
const { id, importedFromId, ...v } = plan;
|
||||||
|
await tx.schedule.create({
|
||||||
|
data: {
|
||||||
|
...v,
|
||||||
|
userId: r.userId,
|
||||||
|
importedFromId: importedFromId || id,
|
||||||
|
sourceId: mapping.get(plan.sourceId)!,
|
||||||
|
targetId: plan.targetId ? mapping.get(plan.targetId)! : null,
|
||||||
|
nextAt: new Date(plan.nextAt + ':00+08:00'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
for (const l of b.links)
|
for (const l of b.links)
|
||||||
await tx.positionLink.create({
|
await tx.positionLink.create({
|
||||||
data: { sourceId: mapping.get(l.sourceId)!, targetId: mapping.get(l.targetId)! },
|
data: { sourceId: mapping.get(l.sourceId)!, targetId: mapping.get(l.targetId)! },
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export function history(p: Holding) {
|
|||||||
positionId: p.id,
|
positionId: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
kind: p.kind,
|
kind: p.kind,
|
||||||
|
side: p.side,
|
||||||
currency: p.currency,
|
currency: p.currency,
|
||||||
date: businessDay(r.effectiveDate),
|
date: businessDay(r.effectiveDate),
|
||||||
time: businessTime(r.effectiveDate),
|
time: businessTime(r.effectiveDate),
|
||||||
@@ -153,6 +154,11 @@ export function trend(
|
|||||||
to: string,
|
to: string,
|
||||||
grain: 'day' | 'week' | 'month' = 'day',
|
grain: 'day' | 'week' | 'month' = 'day',
|
||||||
) {
|
) {
|
||||||
|
const dates = positions
|
||||||
|
.flatMap((p) => p.revisions.map((r) => businessDay(r.effectiveDate)))
|
||||||
|
.sort();
|
||||||
|
if (!dates.length || dates[0] > to) return [];
|
||||||
|
from = from < dates[0] ? dates[0] : from;
|
||||||
const streams = positions.map((p) => ({
|
const streams = positions.map((p) => ({
|
||||||
p,
|
p,
|
||||||
rows: [...p.revisions].sort(compareRevisions),
|
rows: [...p.revisions].sort(compareRevisions),
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { Controller, Get, Query, Req } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Database } from './database';
|
||||||
|
import { UserRequest } from './auth';
|
||||||
|
import { date, today, toBusinessDate } from './validation';
|
||||||
|
import { businessDay, rateAt } from './calculation';
|
||||||
|
import { currentRates, latestRevisions, endOfDay } from './queries';
|
||||||
|
export function cashflowDelta(side: string, reason: string, before: string, after: string) {
|
||||||
|
if (!['balance', 'correction', 'scheduled_expense'].includes(reason)) return new Decimal(0);
|
||||||
|
const delta = new Decimal(after).minus(before);
|
||||||
|
return side === 'liability' ? delta.neg() : delta;
|
||||||
|
}
|
||||||
|
export function calendarMonth(value: unknown) {
|
||||||
|
const month = z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-(0[1-9]|1[0-2])$/)
|
||||||
|
.refine((s) => s >= '1900-01' && s <= today().slice(0, 7))
|
||||||
|
.parse(value || today().slice(0, 7));
|
||||||
|
const from = month + '-01',
|
||||||
|
next = new Date(from + 'T00:00:00Z');
|
||||||
|
next.setUTCMonth(next.getUTCMonth() + 1);
|
||||||
|
const last = new Date(+next - 86400000).toISOString().slice(0, 10);
|
||||||
|
return { month, from, to: last > today() ? today() : last, days: Number(last.slice(-2)) };
|
||||||
|
}
|
||||||
|
@Controller('api/calendar')
|
||||||
|
export class CalendarController {
|
||||||
|
constructor(private db: Database) {}
|
||||||
|
private async replay(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
r: UserRequest,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
details: boolean,
|
||||||
|
) {
|
||||||
|
const accounts = await tx.position.findMany({
|
||||||
|
where: { userId: r.userId, kind: 'account', ...(r.revealed ? {} : { hidden: false }) },
|
||||||
|
select: { id: true, name: true, side: true, currency: true },
|
||||||
|
});
|
||||||
|
const ids = accounts.map((p) => p.id),
|
||||||
|
seeds = await latestRevisions(tx, ids, toBusinessDate(from));
|
||||||
|
const rows = ids.length
|
||||||
|
? await tx.revision.findMany({
|
||||||
|
where: {
|
||||||
|
positionId: { in: ids },
|
||||||
|
effectiveDate: { gte: toBusinessDate(from), lt: endOfDay(to) },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
positionId: true,
|
||||||
|
amount: true,
|
||||||
|
effectiveDate: true,
|
||||||
|
sequence: true,
|
||||||
|
reason: true,
|
||||||
|
...(details ? { notes: true } : {}),
|
||||||
|
},
|
||||||
|
orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }],
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const prior = new Map(seeds.map((v) => [v.positionId, v.amount.toString()]));
|
||||||
|
const meta = new Map(accounts.map((v) => [v.id, v]));
|
||||||
|
const changes = rows
|
||||||
|
.map((v) => {
|
||||||
|
const p = meta.get(v.positionId)!;
|
||||||
|
const hasBefore = prior.has(v.positionId);
|
||||||
|
const before = prior.get(v.positionId) || '0';
|
||||||
|
prior.set(v.positionId, v.amount.toString());
|
||||||
|
return {
|
||||||
|
id: v.id,
|
||||||
|
name: p.name,
|
||||||
|
currency: p.currency,
|
||||||
|
date: businessDay(v.effectiveDate),
|
||||||
|
delta: hasBefore
|
||||||
|
? cashflowDelta(p.side, v.reason, before, v.amount.toString())
|
||||||
|
: new Decimal(0),
|
||||||
|
notes: details ? (v as typeof v & { notes?: string }).notes || '' : '',
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((v) => !v.delta.isZero());
|
||||||
|
const user = await tx.user.findUniqueOrThrow({
|
||||||
|
where: { id: r.userId },
|
||||||
|
select: { baseCurrency: true },
|
||||||
|
});
|
||||||
|
const currencies = [...new Set(accounts.map((p) => p.currency))];
|
||||||
|
const rates = await tx.exchangeRate.findMany({
|
||||||
|
where: {
|
||||||
|
userId: r.userId,
|
||||||
|
currency: { in: currencies },
|
||||||
|
baseCurrency: user.baseCurrency,
|
||||||
|
date: { gte: new Date(from), lte: new Date(to) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const priorRates = await currentRates(
|
||||||
|
tx,
|
||||||
|
r.userId,
|
||||||
|
currencies,
|
||||||
|
user.baseCurrency,
|
||||||
|
new Date(+new Date(from) - 86400000).toISOString().slice(0, 10),
|
||||||
|
);
|
||||||
|
const allRates = [...priorRates, ...rates];
|
||||||
|
return {
|
||||||
|
baseCurrency: user.baseCurrency,
|
||||||
|
changes: changes.map((v) => {
|
||||||
|
const rate = rateAt(allRates, v.currency, user.baseCurrency, v.date);
|
||||||
|
return {
|
||||||
|
...v,
|
||||||
|
amount: v.delta.abs().toFixed(),
|
||||||
|
direction: v.delta.isNegative() ? 'expense' : 'income',
|
||||||
|
converted: rate ? v.delta.abs().mul(rate.value).toFixed() : null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@Get() async month(@Req() r: UserRequest, @Query('month') input?: string) {
|
||||||
|
const q = calendarMonth(input);
|
||||||
|
return this.db.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const data = await this.replay(tx, r, q.from, q.to, false);
|
||||||
|
const items = Array.from({ length: q.days }, (_, i) => ({
|
||||||
|
date: q.month + '-' + String(i + 1).padStart(2, '0'),
|
||||||
|
income: new Decimal(0),
|
||||||
|
expense: new Decimal(0),
|
||||||
|
complete: true,
|
||||||
|
}));
|
||||||
|
for (const v of data.changes) {
|
||||||
|
const item = items[Number(v.date.slice(-2)) - 1];
|
||||||
|
if (v.converted === null) item.complete = false;
|
||||||
|
else if (v.direction === 'income') item.income = item.income.plus(v.converted);
|
||||||
|
else item.expense = item.expense.plus(v.converted);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
month: q.month,
|
||||||
|
baseCurrency: data.baseCurrency,
|
||||||
|
revealed: r.revealed,
|
||||||
|
items: items.map((v) => ({
|
||||||
|
...v,
|
||||||
|
income: v.income.toFixed(2),
|
||||||
|
expense: v.expense.toFixed(2),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ timeout: 30000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@Get('day') async day(@Req() r: UserRequest, @Query('date') input?: string) {
|
||||||
|
const day = date.parse(input || today());
|
||||||
|
return this.db.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const data = await this.replay(tx, r, day, day, true);
|
||||||
|
return {
|
||||||
|
date: day,
|
||||||
|
baseCurrency: data.baseCurrency,
|
||||||
|
revealed: r.revealed,
|
||||||
|
items: data.changes.map(({ delta, ...v }) => v),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ timeout: 30000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import cookieParser from 'cookie-parser';
|
|||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import { json } from 'express';
|
import { json } from 'express';
|
||||||
import { AuthController, AuthGuard, AuthService } from './auth';
|
import { AuthController, AuthGuard, AuthService } from './auth';
|
||||||
|
import { CalendarController } from './calendar';
|
||||||
|
import { SchedulesController } from './schedules';
|
||||||
import { TransfersController } from './transfers';
|
import { TransfersController } from './transfers';
|
||||||
import { PortfolioController } from './portfolio';
|
import { PortfolioController } from './portfolio';
|
||||||
import { BackupController } from './backup';
|
import { BackupController } from './backup';
|
||||||
@@ -53,6 +55,8 @@ class SafeErrors implements ExceptionFilter {
|
|||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
TransfersController,
|
TransfersController,
|
||||||
|
SchedulesController,
|
||||||
|
CalendarController,
|
||||||
IconsController,
|
IconsController,
|
||||||
AuthController,
|
AuthController,
|
||||||
PortfolioController,
|
PortfolioController,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import { scheduleInput } from './schedules';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import {
|
import {
|
||||||
credentials,
|
credentials,
|
||||||
@@ -35,6 +36,8 @@ export function setupOpenApi(app: INestApplication) {
|
|||||||
.object({ targetIds: z.array(z.string().uuid()).max(20) })
|
.object({ targetIds: z.array(z.string().uuid()).max(20) })
|
||||||
.strict(),
|
.strict(),
|
||||||
'POST /api/transfers': transferInput,
|
'POST /api/transfers': transferInput,
|
||||||
|
'POST /api/schedules': scheduleInput,
|
||||||
|
'PATCH /api/schedules/{id}': z.object({ enabled: z.boolean() }).strict(),
|
||||||
'PATCH /api/settings': z
|
'PATCH /api/settings': z
|
||||||
.object({
|
.object({
|
||||||
baseCurrency: currency.optional(),
|
baseCurrency: currency.optional(),
|
||||||
|
|||||||
@@ -156,6 +156,8 @@ export class PortfolioController {
|
|||||||
});
|
});
|
||||||
if (!p) throw new NotFoundException('项目不存在');
|
if (!p) throw new NotFoundException('项目不存在');
|
||||||
if (p.archived) throw new ConflictException('请先恢复归档项目');
|
if (p.archived) throw new ConflictException('请先恢复归档项目');
|
||||||
|
if (v.amount.startsWith('-') && (p.kind !== 'account' || p.side !== 'asset'))
|
||||||
|
throw new BadRequestException('仅资产账户支持负余额');
|
||||||
const lastTransfer = await tx.revision.findFirst({
|
const lastTransfer = await tx.revision.findFirst({
|
||||||
where: { positionId: p.id, reason: { in: pairedReasons } },
|
where: { positionId: p.id, reason: { in: pairedReasons } },
|
||||||
orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }],
|
orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }],
|
||||||
@@ -198,6 +200,8 @@ export class PortfolioController {
|
|||||||
});
|
});
|
||||||
if (!p) throw new NotFoundException('项目不存在');
|
if (!p) throw new NotFoundException('项目不存在');
|
||||||
if (p.archived) throw new ConflictException('请先恢复归档项目');
|
if (p.archived) throw new ConflictException('请先恢复归档项目');
|
||||||
|
if (v.amount.startsWith('-') && (p.kind !== 'account' || p.side !== 'asset'))
|
||||||
|
throw new BadRequestException('仅资产账户支持负余额');
|
||||||
const original = await tx.revision.findFirst({ where: { id: revisionId, positionId: p.id } });
|
const original = await tx.revision.findFirst({ where: { id: revisionId, positionId: p.id } });
|
||||||
if (!original) throw new NotFoundException('历史记录不存在');
|
if (!original) throw new NotFoundException('历史记录不存在');
|
||||||
const transfer = await tx.revision.findFirst({
|
const transfer = await tx.revision.findFirst({
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export async function historyPage(db: Reader, userId: string, revealed: boolean,
|
|||||||
...(revealed ? {} : { hidden: false }),
|
...(revealed ? {} : { hidden: false }),
|
||||||
...(q.positionId ? { id: q.positionId } : {}),
|
...(q.positionId ? { id: q.positionId } : {}),
|
||||||
},
|
},
|
||||||
select: { id: true, name: true, kind: true, currency: true },
|
select: { id: true, name: true, kind: true, side: true, currency: true },
|
||||||
});
|
});
|
||||||
// Bound every account's index scan before merging. A global JOIN + ORDER BY can
|
// Bound every account's index scan before merging. A global JOIN + ORDER BY can
|
||||||
// sort an entire user's history (and evaluate the predecessor for every row).
|
// sort an entire user's history (and evaluate the predecessor for every row).
|
||||||
@@ -172,7 +172,13 @@ export async function historyPage(db: Reader, userId: string, revealed: boolean,
|
|||||||
take: q.limit + 1,
|
take: q.limit + 1,
|
||||||
})
|
})
|
||||||
.then((rows) =>
|
.then((rows) =>
|
||||||
rows.map((r) => ({ ...r, name: p.name, kind: p.kind, currency: p.currency })),
|
rows.map((r) => ({
|
||||||
|
...r,
|
||||||
|
name: p.name,
|
||||||
|
kind: p.kind,
|
||||||
|
side: p.side,
|
||||||
|
currency: p.currency,
|
||||||
|
})),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -203,7 +209,7 @@ export async function historyPage(db: Reader, userId: string, revealed: boolean,
|
|||||||
id: r.positionId,
|
id: r.positionId,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
kind: r.kind,
|
kind: r.kind,
|
||||||
side: '',
|
side: r.side,
|
||||||
currency: r.currency,
|
currency: r.currency,
|
||||||
revisions: [r],
|
revisions: [r],
|
||||||
})[0],
|
})[0],
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Req,
|
||||||
|
BadRequestException,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { Database } from './database';
|
||||||
|
import { UserRequest } from './auth';
|
||||||
|
import { amount, notes, transferInput } from './validation';
|
||||||
|
import { businessTime } from './calculation';
|
||||||
|
import { executeMovement } from './transfers';
|
||||||
|
export const scheduleInput = z
|
||||||
|
.object({
|
||||||
|
name: z.string().trim().min(1).max(100),
|
||||||
|
operation: z.enum(['expense', 'transfer']),
|
||||||
|
sourceId: z.string().uuid(),
|
||||||
|
targetId: z.string().uuid().nullable().default(null),
|
||||||
|
amount: amount.refine((v) => new Decimal(v).gt(0), '金额必须大于零'),
|
||||||
|
received: amount.default('0'),
|
||||||
|
nextAt: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/)
|
||||||
|
.refine((s) => {
|
||||||
|
const d = new Date(s + ':00+08:00');
|
||||||
|
return (
|
||||||
|
Number.isFinite(+d) && businessTime(d) === s && s >= '1900-01-01' && s < '9999-01-01'
|
||||||
|
);
|
||||||
|
}, '计划时间无效'),
|
||||||
|
intervalDays: z.coerce.number().int().min(0).max(3650),
|
||||||
|
notes,
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine(
|
||||||
|
(v) =>
|
||||||
|
v.operation !== 'transfer' ||
|
||||||
|
(!!v.targetId && v.sourceId !== v.targetId && new Decimal(v.received).gt(0)),
|
||||||
|
'转账计划须选择不同账户并填写到账金额',
|
||||||
|
);
|
||||||
|
export function occurrenceId(id: string, when: Date) {
|
||||||
|
const hex = createHash('sha256')
|
||||||
|
.update(id + ':' + when.toISOString())
|
||||||
|
.digest('hex');
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
||||||
|
}
|
||||||
|
export function nextOccurrence(when: Date, intervalDays: number) {
|
||||||
|
return new Date(+when + intervalDays * 86400000);
|
||||||
|
}
|
||||||
|
@Controller('api/schedules')
|
||||||
|
export class SchedulesController {
|
||||||
|
constructor(private db: Database) {}
|
||||||
|
private async visible(r: UserRequest, tx: Prisma.TransactionClient = this.db) {
|
||||||
|
return (
|
||||||
|
await tx.position.findMany({
|
||||||
|
where: {
|
||||||
|
userId: r.userId,
|
||||||
|
kind: 'account',
|
||||||
|
side: 'asset',
|
||||||
|
...(r.revealed ? {} : { hidden: false }),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).map((p) => p.id);
|
||||||
|
}
|
||||||
|
@Get() async list(@Req() r: UserRequest) {
|
||||||
|
const ids = await this.visible(r);
|
||||||
|
const rows = await this.db.schedule.findMany({
|
||||||
|
where: {
|
||||||
|
userId: r.userId,
|
||||||
|
sourceId: { in: ids },
|
||||||
|
OR: [{ targetId: null }, { targetId: { in: ids } }],
|
||||||
|
},
|
||||||
|
orderBy: [{ nextAt: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
return rows.map(({ userId, ...v }) => ({ ...v, nextAt: businessTime(v.nextAt) }));
|
||||||
|
}
|
||||||
|
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
|
||||||
|
const v = scheduleInput.parse(body);
|
||||||
|
return this.db.serial(async (tx) => {
|
||||||
|
const ids = [v.sourceId, ...(v.targetId ? [v.targetId] : [])].sort();
|
||||||
|
await tx.$queryRaw(
|
||||||
|
Prisma.sql`SELECT id FROM Position WHERE userId = ${r.userId} AND id IN (${Prisma.join(ids)}) ORDER BY id FOR UPDATE`,
|
||||||
|
);
|
||||||
|
const accounts = await tx.position.findMany({
|
||||||
|
where: {
|
||||||
|
userId: r.userId,
|
||||||
|
id: { in: ids },
|
||||||
|
kind: 'account',
|
||||||
|
side: 'asset',
|
||||||
|
archived: false,
|
||||||
|
...(r.revealed ? {} : { hidden: false }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (accounts.length !== ids.length) throw new BadRequestException('请选择启用的资产账户');
|
||||||
|
if (v.operation === 'expense' && v.targetId)
|
||||||
|
throw new BadRequestException('支出计划无需转入账户');
|
||||||
|
if (
|
||||||
|
v.operation === 'transfer' &&
|
||||||
|
accounts[0].currency === accounts[1].currency &&
|
||||||
|
!new Decimal(v.amount).eq(v.received)
|
||||||
|
)
|
||||||
|
throw new BadRequestException('同币种转出与到账金额必须一致');
|
||||||
|
return tx.schedule.create({
|
||||||
|
data: { ...v, userId: r.userId, nextAt: new Date(v.nextAt + ':00+08:00') },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
@Patch(':id') async toggle(
|
||||||
|
@Req() r: UserRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: unknown,
|
||||||
|
) {
|
||||||
|
const v = z.object({ enabled: z.boolean() }).strict().parse(body);
|
||||||
|
const ids = await this.visible(r);
|
||||||
|
const row = await this.db.schedule.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
userId: r.userId,
|
||||||
|
sourceId: { in: ids },
|
||||||
|
OR: [{ targetId: null }, { targetId: { in: ids } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (row?.completed && v.enabled) throw new BadRequestException('一次性计划已完成,请新建计划');
|
||||||
|
const result = await this.db.schedule.updateMany({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
userId: r.userId,
|
||||||
|
sourceId: { in: ids },
|
||||||
|
OR: [{ targetId: null }, { targetId: { in: ids } }],
|
||||||
|
},
|
||||||
|
data: v,
|
||||||
|
});
|
||||||
|
if (!result.count) throw new NotFoundException('计划不存在');
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
@Delete(':id') async remove(@Req() r: UserRequest, @Param('id') id: string) {
|
||||||
|
const ids = await this.visible(r);
|
||||||
|
const result = await this.db.schedule.deleteMany({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
userId: r.userId,
|
||||||
|
sourceId: { in: ids },
|
||||||
|
OR: [{ targetId: null }, { targetId: { in: ids } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!result.count) throw new NotFoundException('计划不存在');
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
@Post('run') async run(@Req() r: UserRequest) {
|
||||||
|
const ids = await this.visible(r),
|
||||||
|
now = new Date();
|
||||||
|
const due = await this.db.schedule.findMany({
|
||||||
|
where: {
|
||||||
|
userId: r.userId,
|
||||||
|
enabled: true,
|
||||||
|
nextAt: { lte: now },
|
||||||
|
sourceId: { in: ids },
|
||||||
|
OR: [{ targetId: null }, { targetId: { in: ids } }],
|
||||||
|
},
|
||||||
|
orderBy: [{ nextAt: 'asc' }, { id: 'asc' }],
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
let executed = 0;
|
||||||
|
const errors: { id: string; message: string }[] = [];
|
||||||
|
for (const candidate of due) {
|
||||||
|
try {
|
||||||
|
const applied = await this.db.serial(async (tx) => {
|
||||||
|
await tx.$queryRaw(
|
||||||
|
Prisma.sql`SELECT id FROM Schedule WHERE id = ${candidate.id} AND userId = ${r.userId} FOR UPDATE`,
|
||||||
|
);
|
||||||
|
const plan = await tx.schedule.findFirst({
|
||||||
|
where: { id: candidate.id, userId: r.userId, enabled: true, nextAt: { lte: now } },
|
||||||
|
});
|
||||||
|
if (!plan || +plan.nextAt !== +candidate.nextAt) return false;
|
||||||
|
// Execute against current balances; retain the intended time in the note.
|
||||||
|
const date = businessTime(now),
|
||||||
|
requestId = occurrenceId(plan.id, plan.nextAt);
|
||||||
|
const memo =
|
||||||
|
`计划:${plan.name} · 应执行时间 ${businessTime(plan.nextAt)}\n${plan.notes}`.slice(
|
||||||
|
0,
|
||||||
|
2000,
|
||||||
|
);
|
||||||
|
if (plan.operation === 'transfer') {
|
||||||
|
await executeMovement(
|
||||||
|
tx,
|
||||||
|
r,
|
||||||
|
transferInput.parse({
|
||||||
|
sourceId: plan.sourceId,
|
||||||
|
targetId: plan.targetId,
|
||||||
|
amount: plan.amount.toString(),
|
||||||
|
received: plan.received.toString(),
|
||||||
|
fee: '0',
|
||||||
|
date,
|
||||||
|
notes: memo,
|
||||||
|
requestId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await tx.$queryRaw(
|
||||||
|
Prisma.sql`SELECT id FROM Position WHERE id = ${plan.sourceId} AND userId = ${r.userId} FOR UPDATE`,
|
||||||
|
);
|
||||||
|
const account = await tx.position.findFirst({
|
||||||
|
where: {
|
||||||
|
id: plan.sourceId,
|
||||||
|
userId: r.userId,
|
||||||
|
kind: 'account',
|
||||||
|
side: 'asset',
|
||||||
|
archived: false,
|
||||||
|
...(r.revealed ? {} : { hidden: false }),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
revisions: { orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }], take: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!account?.revisions[0]) throw new BadRequestException('计划账户已归档或不可用');
|
||||||
|
const after = new Decimal(account.revisions[0].amount.toString()).minus(
|
||||||
|
plan.amount.toString(),
|
||||||
|
);
|
||||||
|
if (after.abs().gte('10000000000000000'))
|
||||||
|
throw new BadRequestException('变更后的金额超出支持范围');
|
||||||
|
await tx.revision.create({
|
||||||
|
data: {
|
||||||
|
id: requestId,
|
||||||
|
positionId: account.id,
|
||||||
|
amount: after.toFixed(),
|
||||||
|
effectiveDate: new Date(date + ':00+08:00'),
|
||||||
|
notes: memo,
|
||||||
|
reason: 'scheduled_expense',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const nextAt = nextOccurrence(plan.nextAt, plan.intervalDays);
|
||||||
|
await tx.schedule.update({
|
||||||
|
where: { id: plan.id },
|
||||||
|
data: plan.intervalDays ? { nextAt } : { enabled: false, completed: true },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (applied) executed++;
|
||||||
|
} catch (e) {
|
||||||
|
errors.push({
|
||||||
|
id: candidate.id,
|
||||||
|
message:
|
||||||
|
e instanceof BadRequestException ? e.message : '计划未执行,请刷新或检查账户状态后重试',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
executed,
|
||||||
|
errors,
|
||||||
|
hasMore:
|
||||||
|
due.length === 20 ||
|
||||||
|
due.some((p) => p.intervalDays > 0 && +nextOccurrence(p.nextAt, p.intervalDays) <= +now),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,116 +44,121 @@ export class TransfersController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
|
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
|
||||||
const v = transferInput.parse(body),
|
const v = transferInput.parse(body);
|
||||||
when = toBusinessDate(v.date);
|
return this.db.serial((tx) => executeMovement(tx, r, v));
|
||||||
return this.db.serial(async (tx) => {
|
|
||||||
// Lock in a consistent order before reading balances or idempotency state.
|
|
||||||
await tx.$queryRaw(Prisma.sql`SELECT id FROM Position WHERE userId = ${r.userId}
|
|
||||||
AND id IN (${Prisma.join([v.sourceId, v.targetId].sort())}) ORDER BY id FOR UPDATE`);
|
|
||||||
if (v.requestId) {
|
|
||||||
const existing = await tx.transfer.findFirst({
|
|
||||||
where: { id: v.requestId, userId: r.userId },
|
|
||||||
});
|
|
||||||
if (existing) {
|
|
||||||
if (
|
|
||||||
existing.operation !== v.operation ||
|
|
||||||
existing.sourceId !== v.sourceId ||
|
|
||||||
existing.targetId !== v.targetId ||
|
|
||||||
!new Decimal(existing.amount.toString()).eq(v.amount) ||
|
|
||||||
!new Decimal(existing.received.toString()).eq(v.received) ||
|
|
||||||
!new Decimal(existing.fee.toString()).eq(v.fee) ||
|
|
||||||
+existing.effectiveDate !== +when ||
|
|
||||||
existing.notes !== v.notes
|
|
||||||
)
|
|
||||||
throw new ConflictException('转账请求标识已使用,请刷新后重试');
|
|
||||||
return { id: existing.id };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const metadata = await tx.position.findMany({
|
|
||||||
where: {
|
|
||||||
id: { in: [v.sourceId, v.targetId] },
|
|
||||||
userId: r.userId,
|
|
||||||
archived: false,
|
|
||||||
...(r.revealed ? {} : { hidden: false }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const latest = await latestRevisions(
|
|
||||||
tx,
|
|
||||||
metadata.map((p) => p.id),
|
|
||||||
new Date('9999-01-01'),
|
|
||||||
);
|
|
||||||
const accounts = metadata.map((p) => ({
|
|
||||||
...p,
|
|
||||||
revisions: latest.filter((r) => r.positionId === p.id),
|
|
||||||
}));
|
|
||||||
if (accounts.length !== 2)
|
|
||||||
throw new BadRequestException('只能在自己的启用资产账户之间转账(隐藏账户须先解锁)');
|
|
||||||
const source = accounts.find((p) => p.id === v.sourceId)!,
|
|
||||||
target = accounts.find((p) => p.id === v.targetId)!;
|
|
||||||
if (
|
|
||||||
source.kind !== 'account' ||
|
|
||||||
source.side !== 'asset' ||
|
|
||||||
(v.operation === 'transfer'
|
|
||||||
? target.kind !== 'account' || target.side !== 'asset'
|
|
||||||
: target.kind !== 'debt' ||
|
|
||||||
target.side !== (['borrow', 'repay'].includes(v.operation) ? 'liability' : 'asset'))
|
|
||||||
)
|
|
||||||
throw new BadRequestException('请选择有效的资产账户和对应借入或借出债务');
|
|
||||||
if (accounts.some((p) => !p.revisions[0] || +p.revisions[0].effectiveDate > +when))
|
|
||||||
throw new ConflictException('转账时间不能早于任一账户的最新余额记录,请以当前余额转账');
|
|
||||||
if (source.currency === target.currency && !new Decimal(v.amount).eq(v.received))
|
|
||||||
throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写');
|
|
||||||
const deltas = movementDeltas(v.operation, v.amount, v.received, v.fee);
|
|
||||||
const before = new Decimal(source.revisions[0].amount.toString());
|
|
||||||
const sourceAfter = before.plus(deltas.source);
|
|
||||||
const after = new Decimal(target.revisions[0].amount.toString()).plus(deltas.target);
|
|
||||||
if (sourceAfter.isNegative()) throw new BadRequestException('转出账户余额不足(含手续费)');
|
|
||||||
if (after.isNegative()) throw new BadRequestException('收款或还款不能超过剩余债务');
|
|
||||||
if (after.gte('10000000000000000') || sourceAfter.gte('10000000000000000'))
|
|
||||||
throw new BadRequestException('变更后的金额超出支持范围');
|
|
||||||
const outgoing = await tx.revision.create({
|
|
||||||
data: {
|
|
||||||
positionId: source.id,
|
|
||||||
amount: sourceAfter.toFixed(),
|
|
||||||
effectiveDate: when,
|
|
||||||
notes: v.notes,
|
|
||||||
reason: deltas.sourceReason,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const incoming = await tx.revision.create({
|
|
||||||
data: {
|
|
||||||
positionId: target.id,
|
|
||||||
amount: after.toFixed(),
|
|
||||||
effectiveDate: when,
|
|
||||||
notes: v.notes,
|
|
||||||
reason: deltas.targetReason,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const row = await tx.transfer.create({
|
|
||||||
data: {
|
|
||||||
id: v.requestId,
|
|
||||||
userId: r.userId,
|
|
||||||
operation: v.operation,
|
|
||||||
sourceId: source.id,
|
|
||||||
targetId: target.id,
|
|
||||||
sourceRevisionId: outgoing.id,
|
|
||||||
targetRevisionId: incoming.id,
|
|
||||||
sourceCurrency: source.currency,
|
|
||||||
targetCurrency: target.currency,
|
|
||||||
amount: v.amount,
|
|
||||||
received: v.received,
|
|
||||||
fee: v.fee,
|
|
||||||
effectiveDate: when,
|
|
||||||
notes: v.notes,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (v.operation !== 'transfer')
|
|
||||||
await tx.positionLink.upsert({
|
|
||||||
where: { sourceId_targetId: { sourceId: target.id, targetId: source.id } },
|
|
||||||
create: { sourceId: target.id, targetId: source.id },
|
|
||||||
update: {},
|
|
||||||
});
|
|
||||||
return { id: row.id };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export async function executeMovement(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
r: Pick<UserRequest, 'userId' | 'revealed'>,
|
||||||
|
v: ReturnType<typeof transferInput.parse>,
|
||||||
|
) {
|
||||||
|
const when = toBusinessDate(v.date);
|
||||||
|
// Lock in a consistent order before reading balances or idempotency state.
|
||||||
|
await tx.$queryRaw(Prisma.sql`SELECT id FROM Position WHERE userId = ${r.userId}
|
||||||
|
AND id IN (${Prisma.join([v.sourceId, v.targetId].sort())}) ORDER BY id FOR UPDATE`);
|
||||||
|
if (v.requestId) {
|
||||||
|
const existing = await tx.transfer.findFirst({
|
||||||
|
where: { id: v.requestId, userId: r.userId },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
if (
|
||||||
|
existing.operation !== v.operation ||
|
||||||
|
existing.sourceId !== v.sourceId ||
|
||||||
|
existing.targetId !== v.targetId ||
|
||||||
|
!new Decimal(existing.amount.toString()).eq(v.amount) ||
|
||||||
|
!new Decimal(existing.received.toString()).eq(v.received) ||
|
||||||
|
!new Decimal(existing.fee.toString()).eq(v.fee) ||
|
||||||
|
+existing.effectiveDate !== +when ||
|
||||||
|
existing.notes !== v.notes
|
||||||
|
)
|
||||||
|
throw new ConflictException('转账请求标识已使用,请刷新后重试');
|
||||||
|
return { id: existing.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const metadata = await tx.position.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: [v.sourceId, v.targetId] },
|
||||||
|
userId: r.userId,
|
||||||
|
archived: false,
|
||||||
|
...(r.revealed ? {} : { hidden: false }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const latest = await latestRevisions(
|
||||||
|
tx,
|
||||||
|
metadata.map((p) => p.id),
|
||||||
|
new Date('9999-01-01'),
|
||||||
|
);
|
||||||
|
const accounts = metadata.map((p) => ({
|
||||||
|
...p,
|
||||||
|
revisions: latest.filter((r) => r.positionId === p.id),
|
||||||
|
}));
|
||||||
|
if (accounts.length !== 2)
|
||||||
|
throw new BadRequestException('只能在自己的启用资产账户之间转账(隐藏账户须先解锁)');
|
||||||
|
const source = accounts.find((p) => p.id === v.sourceId)!,
|
||||||
|
target = accounts.find((p) => p.id === v.targetId)!;
|
||||||
|
if (
|
||||||
|
source.kind !== 'account' ||
|
||||||
|
source.side !== 'asset' ||
|
||||||
|
(v.operation === 'transfer'
|
||||||
|
? target.kind !== 'account' || target.side !== 'asset'
|
||||||
|
: target.kind !== 'debt' ||
|
||||||
|
target.side !== (['borrow', 'repay'].includes(v.operation) ? 'liability' : 'asset'))
|
||||||
|
)
|
||||||
|
throw new BadRequestException('请选择有效的资产账户和对应借入或借出债务');
|
||||||
|
if (accounts.some((p) => !p.revisions[0] || +p.revisions[0].effectiveDate > +when))
|
||||||
|
throw new ConflictException('转账时间不能早于任一账户的最新余额记录,请以当前余额转账');
|
||||||
|
if (source.currency === target.currency && !new Decimal(v.amount).eq(v.received))
|
||||||
|
throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写');
|
||||||
|
const deltas = movementDeltas(v.operation, v.amount, v.received, v.fee);
|
||||||
|
const before = new Decimal(source.revisions[0].amount.toString());
|
||||||
|
const sourceAfter = before.plus(deltas.source);
|
||||||
|
const after = new Decimal(target.revisions[0].amount.toString()).plus(deltas.target);
|
||||||
|
if (target.kind === 'debt' && after.isNegative())
|
||||||
|
throw new BadRequestException('收款或还款不能超过剩余债务');
|
||||||
|
if (after.abs().gte('10000000000000000') || sourceAfter.abs().gte('10000000000000000'))
|
||||||
|
throw new BadRequestException('变更后的金额超出支持范围');
|
||||||
|
const outgoing = await tx.revision.create({
|
||||||
|
data: {
|
||||||
|
positionId: source.id,
|
||||||
|
amount: sourceAfter.toFixed(),
|
||||||
|
effectiveDate: when,
|
||||||
|
notes: v.notes,
|
||||||
|
reason: deltas.sourceReason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const incoming = await tx.revision.create({
|
||||||
|
data: {
|
||||||
|
positionId: target.id,
|
||||||
|
amount: after.toFixed(),
|
||||||
|
effectiveDate: when,
|
||||||
|
notes: v.notes,
|
||||||
|
reason: deltas.targetReason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const row = await tx.transfer.create({
|
||||||
|
data: {
|
||||||
|
id: v.requestId,
|
||||||
|
userId: r.userId,
|
||||||
|
operation: v.operation,
|
||||||
|
sourceId: source.id,
|
||||||
|
targetId: target.id,
|
||||||
|
sourceRevisionId: outgoing.id,
|
||||||
|
targetRevisionId: incoming.id,
|
||||||
|
sourceCurrency: source.currency,
|
||||||
|
targetCurrency: target.currency,
|
||||||
|
amount: v.amount,
|
||||||
|
received: v.received,
|
||||||
|
fee: v.fee,
|
||||||
|
effectiveDate: when,
|
||||||
|
notes: v.notes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (v.operation !== 'transfer')
|
||||||
|
await tx.positionLink.upsert({
|
||||||
|
where: { sourceId_targetId: { sourceId: target.id, targetId: source.id } },
|
||||||
|
create: { sourceId: target.id, targetId: source.id },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
return { id: row.id };
|
||||||
|
}
|
||||||
@@ -51,14 +51,18 @@ export const rateValue = z
|
|||||||
.regex(/^(0|[1-9]\d{0,11})(\.\d{1,12})?$/)
|
.regex(/^(0|[1-9]\d{0,11})(\.\d{1,12})?$/)
|
||||||
.refine((s) => /[1-9]/.test(s), '汇率必须大于零');
|
.refine((s) => /[1-9]/.test(s), '汇率必须大于零');
|
||||||
export const notes = z.string().max(2000).default('');
|
export const notes = z.string().max(2000).default('');
|
||||||
|
export const signedAmount = z
|
||||||
|
.string()
|
||||||
|
.regex(/^-?(0|[1-9]\d{0,15})(\.\d{1,8})?$/, '金额最多 16 位整数和 8 位小数');
|
||||||
export const revisionInput = z
|
export const revisionInput = z
|
||||||
.object({
|
.object({
|
||||||
amount,
|
amount: signedAmount,
|
||||||
date: businessDate,
|
date: businessDate,
|
||||||
notes,
|
notes,
|
||||||
reason: z
|
reason: z
|
||||||
.enum([
|
.enum([
|
||||||
'initial',
|
'initial',
|
||||||
|
'scheduled_expense',
|
||||||
'balance',
|
'balance',
|
||||||
'valuation',
|
'valuation',
|
||||||
'repayment',
|
'repayment',
|
||||||
@@ -75,6 +79,7 @@ export const revisionInput = z
|
|||||||
.strict();
|
.strict();
|
||||||
export const positionMeta = z
|
export const positionMeta = z
|
||||||
.object({
|
.object({
|
||||||
|
groupName: z.string().trim().max(60).optional(),
|
||||||
iconId: z.string().uuid().nullable().optional(),
|
iconId: z.string().uuid().nullable().optional(),
|
||||||
name: z.string().trim().min(1).max(100),
|
name: z.string().trim().min(1).max(100),
|
||||||
category: z.string().trim().min(1).max(40),
|
category: z.string().trim().min(1).max(40),
|
||||||
@@ -88,11 +93,13 @@ export const positionInput = positionMeta
|
|||||||
kind: z.enum(['account', 'asset', 'debt']),
|
kind: z.enum(['account', 'asset', 'debt']),
|
||||||
side: z.enum(['asset', 'liability']),
|
side: z.enum(['asset', 'liability']),
|
||||||
currency,
|
currency,
|
||||||
amount,
|
amount: signedAmount,
|
||||||
date: businessDate,
|
date: businessDate,
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.superRefine((p, c) => {
|
.superRefine((p, c) => {
|
||||||
|
if (p.amount.startsWith('-') && (p.kind !== 'account' || p.side !== 'asset'))
|
||||||
|
c.addIssue({ code: 'custom', message: '仅资产账户支持负余额' });
|
||||||
if (
|
if (
|
||||||
(p.kind === 'asset' && p.side !== 'asset') ||
|
(p.kind === 'asset' && p.side !== 'asset') ||
|
||||||
(p.kind === 'account' &&
|
(p.kind === 'account' &&
|
||||||
@@ -121,10 +128,10 @@ export const credentials = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
export const menuKey = z.enum(['overview', 'account', 'asset', 'debt', 'history']);
|
export const menuKey = z.enum(['overview', 'account', 'asset', 'debt', 'history', 'calendar']);
|
||||||
export const hiddenMenus = z
|
export const hiddenMenus = z
|
||||||
.array(menuKey)
|
.array(menuKey)
|
||||||
.max(5)
|
.max(6)
|
||||||
.refine((v) => new Set(v).size === v.length, '菜单不可重复');
|
.refine((v) => new Set(v).size === v.length, '菜单不可重复');
|
||||||
export const transferInput = z
|
export const transferInput = z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const files = [
|
|||||||
'rates.json',
|
'rates.json',
|
||||||
'icons.json',
|
'icons.json',
|
||||||
'transfers.json',
|
'transfers.json',
|
||||||
|
'schedules.json',
|
||||||
] as const;
|
] as const;
|
||||||
const sha = (s: Buffer | string) => createHash('sha256').update(s).digest('hex');
|
const sha = (s: Buffer | string) => createHash('sha256').update(s).digest('hex');
|
||||||
export function packBackup(b: Backup) {
|
export function packBackup(b: Backup) {
|
||||||
@@ -34,6 +35,7 @@ export function packBackup(b: Backup) {
|
|||||||
'rates.json': b.rates,
|
'rates.json': b.rates,
|
||||||
'icons.json': b.icons || [],
|
'icons.json': b.icons || [],
|
||||||
'transfers.json': b.transfers || [],
|
'transfers.json': b.transfers || [],
|
||||||
|
'schedules.json': b.schedules || [],
|
||||||
};
|
};
|
||||||
const contents = Object.fromEntries(
|
const contents = Object.fromEntries(
|
||||||
files.map((name) => [name, JSON.stringify(data[name], null, 2)]),
|
files.map((name) => [name, JSON.stringify(data[name], null, 2)]),
|
||||||
@@ -41,7 +43,7 @@ export function packBackup(b: Backup) {
|
|||||||
contents['manifest.json'] = JSON.stringify(
|
contents['manifest.json'] = JSON.stringify(
|
||||||
{
|
{
|
||||||
format: 'worthpath',
|
format: 'worthpath',
|
||||||
version: 6,
|
version: 7,
|
||||||
exportedAt: b.exportedAt,
|
exportedAt: b.exportedAt,
|
||||||
files: files.map((name) => ({ name, sha256: sha(contents[name]) })),
|
files: files.map((name) => ({ name, sha256: sha(contents[name]) })),
|
||||||
},
|
},
|
||||||
@@ -116,13 +118,13 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
|
|||||||
const manifest = z
|
const manifest = z
|
||||||
.object({
|
.object({
|
||||||
format: z.literal('worthpath'),
|
format: z.literal('worthpath'),
|
||||||
version: z.union([z.literal(3), z.literal(4), z.literal(5), z.literal(6)]),
|
version: z.union([z.literal(3), z.literal(4), z.literal(5), z.literal(6), z.literal(7)]),
|
||||||
exportedAt: z.iso.datetime(),
|
exportedAt: z.iso.datetime(),
|
||||||
files: z
|
files: z
|
||||||
.array(
|
.array(
|
||||||
z.object({ name: z.enum(files), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict(),
|
z.object({ name: z.enum(files), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict(),
|
||||||
)
|
)
|
||||||
.min(files.length - 2)
|
.min(files.length - 3)
|
||||||
.max(files.length),
|
.max(files.length),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
@@ -130,7 +132,8 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
|
|||||||
const expected = files.filter(
|
const expected = files.filter(
|
||||||
(f) =>
|
(f) =>
|
||||||
(manifest.version >= 4 || f !== 'icons.json') &&
|
(manifest.version >= 4 || f !== 'icons.json') &&
|
||||||
(manifest.version >= 5 || f !== 'transfers.json'),
|
(manifest.version >= 5 || f !== 'transfers.json') &&
|
||||||
|
(manifest.version >= 7 || f !== 'schedules.json'),
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
contents.size !== expected.length + 1 ||
|
contents.size !== expected.length + 1 ||
|
||||||
@@ -181,6 +184,7 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
|
|||||||
links: parse('links.json'),
|
links: parse('links.json'),
|
||||||
rates: parse('rates.json'),
|
rates: parse('rates.json'),
|
||||||
...(manifest.version >= 5 ? { transfers: parse('transfers.json') } : {}),
|
...(manifest.version >= 5 ? { transfers: parse('transfers.json') } : {}),
|
||||||
|
...(manifest.version >= 7 ? { schedules: parse('schedules.json') } : {}),
|
||||||
...(manifest.version >= 4 ? { icons: parse('icons.json') } : {}),
|
...(manifest.version >= 4 ? { icons: parse('icons.json') } : {}),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -85,8 +85,13 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
|
|||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(await call('/transfers', a.cookie, 'POST', { ...request, amount: '100', received: '100' }))
|
(
|
||||||
.status,
|
await call('/transfers', a.cookie, 'POST', {
|
||||||
|
...request,
|
||||||
|
amount: '10000000000000000',
|
||||||
|
received: '10000000000000000',
|
||||||
|
})
|
||||||
|
).status,
|
||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
assert.equal(await db.transfer.count({ where: { userId: a.id } }), 0);
|
assert.equal(await db.transfer.count({ where: { userId: a.id } }), 0);
|
||||||
@@ -190,10 +195,14 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
|
|||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
race.filter((r) => r.status === 201).length,
|
race.filter((r) => r.status === 201).length,
|
||||||
1,
|
2,
|
||||||
JSON.stringify(race.map((r) => ({ status: r.status, data: r.data }))),
|
JSON.stringify(race.map((r) => ({ status: r.status, data: r.data }))),
|
||||||
);
|
);
|
||||||
assert.equal(race.filter((r) => [400, 409].includes(r.status)).length, 1);
|
assert.equal(race.filter((r) => [400, 409].includes(r.status)).length, 0);
|
||||||
|
assert.equal(
|
||||||
|
(await call('/positions', a.cookie)).data.find((p: any) => p.id === usd).amount,
|
||||||
|
'-11.00000001',
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(
|
(
|
||||||
await call('/settings', a.cookie, 'PATCH', {
|
await call('/settings', a.cookie, 'PATCH', {
|
||||||
@@ -212,7 +221,7 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
|
|||||||
);
|
);
|
||||||
assert.equal((await call('/settings', a.cookie, 'PATCH', { showSidebar: false })).status, 400);
|
assert.equal((await call('/settings', a.cookie, 'PATCH', { showSidebar: false })).status, 400);
|
||||||
const backup = (await call('/backup', a.cookie)).data;
|
const backup = (await call('/backup', a.cookie)).data;
|
||||||
assert.equal(backup.transfers.length, 3);
|
assert.equal(backup.transfers.length, 4);
|
||||||
assert.equal(backup.preferences.showNotes, false);
|
assert.equal(backup.preferences.showNotes, false);
|
||||||
assert.deepEqual(backup.preferences.hiddenMenus, ['asset', 'debt']);
|
assert.deepEqual(backup.preferences.hiddenMenus, ['asset', 'debt']);
|
||||||
const broken = structuredClone(backup);
|
const broken = structuredClone(backup);
|
||||||
@@ -227,7 +236,7 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
|
|||||||
201,
|
201,
|
||||||
);
|
);
|
||||||
const restored = (await call('/backup', b.cookie)).data;
|
const restored = (await call('/backup', b.cookie)).data;
|
||||||
assert.equal(restored.transfers.length, 3);
|
assert.equal(restored.transfers.length, 4);
|
||||||
// Appending into an existing space preserves its display preferences.
|
// Appending into an existing space preserves its display preferences.
|
||||||
assert.equal((await call('/settings', b.cookie)).data.showNotes, true);
|
assert.equal((await call('/settings', b.cookie)).data.showNotes, true);
|
||||||
const c = await account();
|
const c = await account();
|
||||||
@@ -254,7 +263,7 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
|
|||||||
hidden: true,
|
hidden: true,
|
||||||
});
|
});
|
||||||
const visibleTransfers = (await call('/transfers', a.cookie)).data.items;
|
const visibleTransfers = (await call('/transfers', a.cookie)).data.items;
|
||||||
assert.equal(visibleTransfers.length, 2);
|
assert.equal(visibleTransfers.length, 3);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
visibleTransfers.every((t: any) => t.sourceId !== sourceId && t.targetId !== sourceId),
|
visibleTransfers.every((t: any) => t.sourceId !== sourceId && t.targetId !== sourceId),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { randomUUID, randomBytes } from 'node:crypto';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { today } from '../src/validation';
|
||||||
|
import { readBackupZip } from '../src/zip';
|
||||||
|
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
|
||||||
|
test('real MySQL: groups, scheduled atomic execution, calendar, privacy and ZIP recovery', async () => {
|
||||||
|
const db = new PrismaClient(),
|
||||||
|
names: string[] = [];
|
||||||
|
const minute = (delta = 0) =>
|
||||||
|
new Date(Date.now() + delta + 8 * 3600000).toISOString().slice(0, 16);
|
||||||
|
async function call(path: string, cookie = '', method = 'GET', body?: unknown) {
|
||||||
|
const res = await fetch(base + path, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Cookie: cookie,
|
||||||
|
Origin: process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!,
|
||||||
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: res.status,
|
||||||
|
data: res.headers.get('content-type')?.includes('application/zip')
|
||||||
|
? ((await readBackupZip(Buffer.from(await res.arrayBuffer()))) as any)
|
||||||
|
: await res.json(),
|
||||||
|
cookie: res.headers.get('set-cookie')?.split(';')[0] || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function user() {
|
||||||
|
const username = 'wp_update_' + randomUUID(),
|
||||||
|
password = randomBytes(18).toString('hex');
|
||||||
|
names.push(username);
|
||||||
|
const result = await call('/auth/register', '', 'POST', { username, password });
|
||||||
|
assert.equal(result.status, 201);
|
||||||
|
return {
|
||||||
|
cookie: result.cookie,
|
||||||
|
password,
|
||||||
|
id: (await db.user.findUniqueOrThrow({ where: { username } })).id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const a = await user(),
|
||||||
|
b = await user();
|
||||||
|
const create = async (name: string, amount: string, groupName: string) => {
|
||||||
|
const result = await call('/positions', a.cookie, 'POST', {
|
||||||
|
name,
|
||||||
|
amount,
|
||||||
|
groupName,
|
||||||
|
kind: 'account',
|
||||||
|
side: 'asset',
|
||||||
|
category: 'bank',
|
||||||
|
currency: 'CNY',
|
||||||
|
date: '2026-09-01T10:00',
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 201);
|
||||||
|
return result.data.id;
|
||||||
|
};
|
||||||
|
const sourceId = await create('Daily', '10', '日常'),
|
||||||
|
targetId = await create('Invest', '0', '理财');
|
||||||
|
assert.equal(
|
||||||
|
(await call('/positions', a.cookie)).data.find((p: any) => p.id === sourceId).groupName,
|
||||||
|
'日常',
|
||||||
|
);
|
||||||
|
const request = {
|
||||||
|
sourceId,
|
||||||
|
targetId,
|
||||||
|
amount: '25',
|
||||||
|
received: '25',
|
||||||
|
fee: '0',
|
||||||
|
date: minute(),
|
||||||
|
requestId: randomUUID(),
|
||||||
|
};
|
||||||
|
assert.equal((await call('/transfers', a.cookie, 'POST', request)).status, 201);
|
||||||
|
assert.equal(
|
||||||
|
(await call('/positions', a.cookie)).data.find((p: any) => p.id === sourceId).amount,
|
||||||
|
'-15',
|
||||||
|
);
|
||||||
|
const input = {
|
||||||
|
name: 'Rent',
|
||||||
|
operation: 'expense',
|
||||||
|
sourceId,
|
||||||
|
targetId: null,
|
||||||
|
amount: '2.5',
|
||||||
|
received: '0',
|
||||||
|
nextAt: minute(-60000),
|
||||||
|
intervalDays: 0,
|
||||||
|
notes: '',
|
||||||
|
};
|
||||||
|
const plan = await call('/schedules', a.cookie, 'POST', input);
|
||||||
|
assert.equal(plan.status, 201);
|
||||||
|
assert.equal((await call('/schedules', b.cookie)).data.length, 0);
|
||||||
|
assert.equal(
|
||||||
|
(await call('/schedules/' + plan.data.id, b.cookie, 'PATCH', { enabled: false })).status,
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
const race = await Promise.all([
|
||||||
|
call('/schedules/run', a.cookie, 'POST', {}),
|
||||||
|
call('/schedules/run', a.cookie, 'POST', {}),
|
||||||
|
]);
|
||||||
|
assert.equal(
|
||||||
|
race.reduce((n, v) => n + v.data.executed, 0),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await call('/positions', a.cookie)).data.find((p: any) => p.id === sourceId).amount,
|
||||||
|
'-17.5',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await call('/schedules/' + plan.data.id, a.cookie, 'PATCH', { enabled: true })).status,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
assert.equal((await call('/schedules/run', a.cookie, 'POST', {})).data.executed, 0);
|
||||||
|
const recurring = await call('/schedules', a.cookie, 'POST', {
|
||||||
|
...input,
|
||||||
|
name: 'Daily expense',
|
||||||
|
amount: '1',
|
||||||
|
intervalDays: 1,
|
||||||
|
nextAt: minute(-2 * 86400000),
|
||||||
|
});
|
||||||
|
assert.equal(recurring.status, 201);
|
||||||
|
for (let i = 0; i < 3; i++)
|
||||||
|
assert.equal((await call('/schedules/run', a.cookie, 'POST', {})).data.executed, 1);
|
||||||
|
assert.equal((await call('/schedules/run', a.cookie, 'POST', {})).data.executed, 0);
|
||||||
|
const calendar = await call('/calendar?month=' + today().slice(0, 7), a.cookie);
|
||||||
|
const day = calendar.data.items.find((v: any) => v.date === today());
|
||||||
|
assert.equal(day.expense, '5.50');
|
||||||
|
assert.equal(day.income, '0.00');
|
||||||
|
assert.equal((await call('/calendar/day?date=' + today(), a.cookie)).data.items.length, 4);
|
||||||
|
const backup = (await call('/backup', a.cookie)).data;
|
||||||
|
assert.equal(backup.schedules.length, 2);
|
||||||
|
assert.equal(
|
||||||
|
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
assert.equal((await call('/schedules/run', b.cookie, 'POST', {})).data.executed, 0);
|
||||||
|
assert.deepEqual(
|
||||||
|
(await call('/positions', b.cookie)).data.map((v: any) => v.groupName).sort(),
|
||||||
|
['日常', '理财'].sort(),
|
||||||
|
);
|
||||||
|
const restored = (await call('/backup', b.cookie)).data;
|
||||||
|
assert.equal(restored.schedules.filter((p: any) => p.completed).length, 1);
|
||||||
|
await call('/positions/' + sourceId, a.cookie, 'PATCH', {
|
||||||
|
name: 'Daily',
|
||||||
|
category: 'bank',
|
||||||
|
groupName: '日常',
|
||||||
|
hidden: true,
|
||||||
|
});
|
||||||
|
assert.equal((await call('/schedules', a.cookie)).data.length, 0);
|
||||||
|
assert.equal((await call('/calendar/day?date=' + today(), a.cookie)).data.items.length, 0);
|
||||||
|
} finally {
|
||||||
|
await db.user.deleteMany({ where: { username: { in: names } } });
|
||||||
|
await db.$disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { cashflowDelta, calendarMonth, CalendarController } from '../src/calendar';
|
||||||
|
import { executeMovement } from '../src/transfers';
|
||||||
|
import { transferInput, positionInput, revisionInput } from '../src/validation';
|
||||||
|
import { scheduleInput, occurrenceId, nextOccurrence, SchedulesController } from '../src/schedules';
|
||||||
|
import { trend, totals, type Holding } from '../src/calculation';
|
||||||
|
import { positionAmount, money, type Position } from '../../web/src/api';
|
||||||
|
const now = new Date(Date.now() - 60000);
|
||||||
|
const date = new Date(+now + 8 * 3600000).toISOString().slice(0, 16);
|
||||||
|
function fixture(
|
||||||
|
targetKind = 'account',
|
||||||
|
targetSide = 'asset',
|
||||||
|
sourceBalance = '10',
|
||||||
|
targetBalance = '0',
|
||||||
|
) {
|
||||||
|
const owner = randomUUID(),
|
||||||
|
sourceId = randomUUID(),
|
||||||
|
targetId = randomUUID();
|
||||||
|
const accounts = [
|
||||||
|
{ id: sourceId, kind: 'account', side: 'asset', currency: 'CNY', userId: owner },
|
||||||
|
{ id: targetId, kind: targetKind, side: targetSide, currency: 'CNY', userId: owner },
|
||||||
|
];
|
||||||
|
const balances = new Map([
|
||||||
|
[sourceId, sourceBalance],
|
||||||
|
[targetId, targetBalance],
|
||||||
|
]),
|
||||||
|
entries: any[] = [],
|
||||||
|
transfers: any[] = [];
|
||||||
|
const tx: any = {
|
||||||
|
$queryRaw: async (query: any) =>
|
||||||
|
query.sql.includes('SELECT r.*')
|
||||||
|
? accounts.map((p) => ({
|
||||||
|
positionId: p.id,
|
||||||
|
amount: balances.get(p.id),
|
||||||
|
effectiveDate: new Date('2026-09-01'),
|
||||||
|
id: randomUUID(),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
position: { findMany: async () => accounts },
|
||||||
|
revision: {
|
||||||
|
create: async ({ data }: any) => {
|
||||||
|
const row = { id: randomUUID(), ...data };
|
||||||
|
entries.push(row);
|
||||||
|
balances.set(data.positionId, data.amount);
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transfer: {
|
||||||
|
findFirst: async ({ where }: any) => transfers.find((v) => v.id === where.id),
|
||||||
|
create: async ({ data }: any) => {
|
||||||
|
transfers.push(data);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
positionLink: { upsert: async () => ({}) },
|
||||||
|
};
|
||||||
|
const request = transferInput.parse({
|
||||||
|
requestId: randomUUID(),
|
||||||
|
sourceId,
|
||||||
|
targetId,
|
||||||
|
amount: '25',
|
||||||
|
received: '25',
|
||||||
|
fee: '1',
|
||||||
|
date,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
tx,
|
||||||
|
r: { userId: owner, revealed: false },
|
||||||
|
request,
|
||||||
|
balances,
|
||||||
|
entries,
|
||||||
|
transfers,
|
||||||
|
sourceId,
|
||||||
|
targetId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
test('overdrafts preserve exact paired balances and idempotency', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
await executeMovement(f.tx, f.r, f.request);
|
||||||
|
assert.equal(f.balances.get(f.sourceId), '-16');
|
||||||
|
assert.equal(f.balances.get(f.targetId), '25');
|
||||||
|
assert.equal(f.entries.length, 2);
|
||||||
|
await executeMovement(f.tx, f.r, f.request);
|
||||||
|
assert.equal(f.entries.length, 2);
|
||||||
|
await assert.rejects(() => executeMovement(f.tx, f.r, { ...f.request, amount: '26' }));
|
||||||
|
const p: Holding = {
|
||||||
|
id: f.sourceId,
|
||||||
|
name: '',
|
||||||
|
kind: 'account',
|
||||||
|
side: 'asset',
|
||||||
|
currency: 'CNY',
|
||||||
|
revisions: [{ id: '', amount: '-16', effectiveDate: now, reason: 'balance', notes: '' }],
|
||||||
|
};
|
||||||
|
assert.equal(totals([p], [], 'CNY', date.slice(0, 10)).net, '-16.00');
|
||||||
|
});
|
||||||
|
test('transfer into an overdrawn account is allowed; debt overpayment still fails before writing', async () => {
|
||||||
|
const f = fixture('account', 'asset', '10', '-100');
|
||||||
|
await executeMovement(f.tx, f.r, f.request);
|
||||||
|
assert.equal(f.balances.get(f.targetId), '-75');
|
||||||
|
const debt = fixture('debt', 'liability', '0', '10');
|
||||||
|
await assert.rejects(() =>
|
||||||
|
executeMovement(debt.tx, debt.r, { ...debt.request, operation: 'repay' }),
|
||||||
|
);
|
||||||
|
assert.equal(debt.entries.length, 0);
|
||||||
|
await executeMovement(debt.tx, debt.r, {
|
||||||
|
...debt.request,
|
||||||
|
operation: 'repay',
|
||||||
|
amount: '10',
|
||||||
|
received: '10',
|
||||||
|
});
|
||||||
|
assert.equal(debt.balances.get(debt.sourceId), '-11');
|
||||||
|
assert.equal(debt.balances.get(debt.targetId), '0');
|
||||||
|
});
|
||||||
|
test('negative balances are confined to asset accounts; group names and principal remain validated', () => {
|
||||||
|
const data = {
|
||||||
|
name: 'test',
|
||||||
|
category: 'bank',
|
||||||
|
kind: 'account',
|
||||||
|
side: 'asset',
|
||||||
|
amount: '-12.12345678',
|
||||||
|
currency: 'CNY',
|
||||||
|
date,
|
||||||
|
groupName: '日常',
|
||||||
|
};
|
||||||
|
assert.equal(positionInput.parse(data).groupName, '日常');
|
||||||
|
assert.equal(positionInput.safeParse({ ...data, kind: 'debt' }).success, false);
|
||||||
|
assert.equal(positionInput.safeParse({ ...data, side: 'liability' }).success, false);
|
||||||
|
assert.equal(positionInput.safeParse({ ...data, groupName: 'a'.repeat(61) }).success, false);
|
||||||
|
assert.equal(revisionInput.parse({ amount: '-0.00000001', date }).amount, '-0.00000001');
|
||||||
|
assert.equal(transferInput.safeParse({ ...fixture().request, amount: '-1' }).success, false);
|
||||||
|
});
|
||||||
|
test('trend starts at the first balance, retains valid zero days and handles an empty portfolio', () => {
|
||||||
|
const p: Holding = {
|
||||||
|
id: randomUUID(),
|
||||||
|
name: '',
|
||||||
|
kind: 'account',
|
||||||
|
side: 'asset',
|
||||||
|
currency: 'CNY',
|
||||||
|
revisions: [
|
||||||
|
{
|
||||||
|
id: '',
|
||||||
|
amount: '0',
|
||||||
|
effectiveDate: new Date('2026-09-15T02:00:00Z'),
|
||||||
|
notes: '',
|
||||||
|
reason: 'initial',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const rows = trend([p], [], 'CNY', '2026-09-01', '2026-09-17');
|
||||||
|
assert.deepEqual(
|
||||||
|
rows.map((r) => r.date),
|
||||||
|
['2026-09-15', '2026-09-16', '2026-09-17'],
|
||||||
|
);
|
||||||
|
assert.equal(rows[0].net, '0.00');
|
||||||
|
assert.deepEqual(trend([], [], 'CNY', '2026-09-01', '2026-09-17'), []);
|
||||||
|
assert.deepEqual(trend([p], [], 'CNY', '2026-09-01', '2026-09-14'), []);
|
||||||
|
});
|
||||||
|
test('debt signs and currency symbols preserve the original decimal strings', () => {
|
||||||
|
const p = { kind: 'debt', side: 'liability', amount: '9999999999999999.12345678' } as Position;
|
||||||
|
assert.equal(positionAmount(p), '-9999999999999999.12345678');
|
||||||
|
assert.equal(money(positionAmount(p), 'HKD'), 'HK$ -9,999,999,999,999,999.12345678');
|
||||||
|
assert.equal(positionAmount({ ...p, amount: '0.00000000' }), '0.00000000');
|
||||||
|
assert.equal(positionAmount({ ...p, side: 'asset' }), p.amount);
|
||||||
|
});
|
||||||
|
test('schedule validation rejects malformed times and creates stable occurrence identifiers', () => {
|
||||||
|
const data = {
|
||||||
|
name: '租金',
|
||||||
|
operation: 'expense',
|
||||||
|
sourceId: randomUUID(),
|
||||||
|
amount: '100',
|
||||||
|
nextAt: '2026-11-01T09:00',
|
||||||
|
intervalDays: 30,
|
||||||
|
};
|
||||||
|
assert.equal(scheduleInput.safeParse(data).success, true);
|
||||||
|
assert.equal(scheduleInput.safeParse({ ...data, nextAt: '2026-02-30T09:00' }).success, false);
|
||||||
|
assert.equal(scheduleInput.safeParse({ ...data, operation: 'transfer' }).success, false);
|
||||||
|
assert.equal(scheduleInput.safeParse({ ...data, intervalDays: -1 }).success, false);
|
||||||
|
assert.equal(occurrenceId('plan', now), occurrenceId('plan', now));
|
||||||
|
assert.notEqual(occurrenceId('plan', now), occurrenceId('plan', nextOccurrence(now, 1)));
|
||||||
|
assert.equal(+nextOccurrence(now, 30) - +now, 30 * 86400000);
|
||||||
|
});
|
||||||
|
test('due expense runs once, updates balance exactly and advances a recurring plan only after success', async () => {
|
||||||
|
const owner = randomUUID(),
|
||||||
|
sourceId = randomUUID();
|
||||||
|
const plan: any = {
|
||||||
|
id: randomUUID(),
|
||||||
|
userId: owner,
|
||||||
|
name: 'Rent',
|
||||||
|
operation: 'expense',
|
||||||
|
sourceId,
|
||||||
|
targetId: null,
|
||||||
|
amount: '12.00000001',
|
||||||
|
received: '0',
|
||||||
|
nextAt: new Date(Date.now() - 3600000),
|
||||||
|
intervalDays: 0,
|
||||||
|
enabled: true,
|
||||||
|
notes: '',
|
||||||
|
};
|
||||||
|
const entries: any[] = [];
|
||||||
|
let balance = '10';
|
||||||
|
const tx: any = {
|
||||||
|
$queryRaw: async () => [],
|
||||||
|
position: {
|
||||||
|
findMany: async () => [{ id: sourceId }],
|
||||||
|
findFirst: async () => ({ id: sourceId, revisions: [{ amount: balance }] }),
|
||||||
|
},
|
||||||
|
schedule: {
|
||||||
|
findMany: async () => (plan.enabled ? [{ ...plan }] : []),
|
||||||
|
findFirst: async () => (plan.enabled ? { ...plan } : null),
|
||||||
|
update: async ({ data }: any) => Object.assign(plan, data),
|
||||||
|
},
|
||||||
|
revision: {
|
||||||
|
create: async ({ data }: any) => {
|
||||||
|
entries.push(data);
|
||||||
|
balance = data.amount;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const db: any = { ...tx, serial: async (work: any) => work(tx) };
|
||||||
|
const c = new SchedulesController(db),
|
||||||
|
r: any = { userId: owner, revealed: false };
|
||||||
|
assert.equal((await c.run(r)).executed, 1);
|
||||||
|
assert.equal(balance, '-2.00000001');
|
||||||
|
assert.equal(plan.enabled, false);
|
||||||
|
assert.equal(plan.completed, true);
|
||||||
|
assert.equal((await c.run(r)).executed, 0);
|
||||||
|
assert.equal(entries.length, 1);
|
||||||
|
const before = new Date(Date.now() - 3600000);
|
||||||
|
Object.assign(plan, { enabled: true, nextAt: before, intervalDays: 30 });
|
||||||
|
assert.equal((await c.run(r)).executed, 1);
|
||||||
|
assert.equal(+plan.nextAt, +before + 30 * 86400000);
|
||||||
|
const next = plan.nextAt;
|
||||||
|
tx.position.findFirst = async () => null;
|
||||||
|
plan.nextAt = before;
|
||||||
|
const result = await c.run(r);
|
||||||
|
assert.equal(result.executed, 0);
|
||||||
|
assert.equal(result.errors.length, 1);
|
||||||
|
assert.equal(+plan.nextAt, +before);
|
||||||
|
assert.equal(entries.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calendar excludes transfers, loans and initial balances and reverses liability changes', () => {
|
||||||
|
for (const reason of [
|
||||||
|
'transfer_out',
|
||||||
|
'transfer_in',
|
||||||
|
'loan_repay',
|
||||||
|
'loan_collect',
|
||||||
|
'loan_borrow',
|
||||||
|
'loan_lend',
|
||||||
|
'initial',
|
||||||
|
'valuation',
|
||||||
|
])
|
||||||
|
assert.equal(cashflowDelta('asset', reason, '100', '50').toFixed(), '0');
|
||||||
|
assert.equal(cashflowDelta('asset', 'balance', '100', '150').toFixed(), '50');
|
||||||
|
assert.equal(cashflowDelta('asset', 'scheduled_expense', '100', '50').toFixed(), '-50');
|
||||||
|
assert.equal(cashflowDelta('liability', 'balance', '100', '150').toFixed(), '-50');
|
||||||
|
assert.equal(calendarMonth('2024-02').days, 29);
|
||||||
|
assert.throws(() => calendarMonth('2026-13'));
|
||||||
|
});
|
||||||
|
test('calendar replays only the range, preserves fractional totals, and hides incomplete FX totals', async () => {
|
||||||
|
const accounts = [
|
||||||
|
{ id: 'cash', side: 'asset', name: '现金', currency: 'CNY' },
|
||||||
|
{ id: 'usd', side: 'asset', name: '外币', currency: 'USD' },
|
||||||
|
];
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
positionId: 'cash',
|
||||||
|
amount: '10',
|
||||||
|
effectiveDate: new Date('2026-09-02T01:00:00Z'),
|
||||||
|
reason: 'correction',
|
||||||
|
sequence: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
positionId: 'cash',
|
||||||
|
amount: '10.004',
|
||||||
|
effectiveDate: new Date('2026-09-02T02:00:00Z'),
|
||||||
|
reason: 'balance',
|
||||||
|
sequence: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
positionId: 'cash',
|
||||||
|
amount: '10.008',
|
||||||
|
effectiveDate: new Date('2026-09-02T03:00:00Z'),
|
||||||
|
reason: 'balance',
|
||||||
|
sequence: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
positionId: 'cash',
|
||||||
|
amount: '8.008',
|
||||||
|
effectiveDate: new Date('2026-09-02T04:00:00Z'),
|
||||||
|
reason: 'scheduled_expense',
|
||||||
|
sequence: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
positionId: 'usd',
|
||||||
|
amount: '10',
|
||||||
|
effectiveDate: new Date('2026-09-03T01:00:00Z'),
|
||||||
|
reason: 'initial',
|
||||||
|
sequence: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
positionId: 'usd',
|
||||||
|
amount: '11',
|
||||||
|
effectiveDate: new Date('2026-09-03T02:00:00Z'),
|
||||||
|
reason: 'balance',
|
||||||
|
sequence: 2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const tx: any = {
|
||||||
|
position: {
|
||||||
|
findMany: async (input: any) => {
|
||||||
|
assert.equal(input.where.userId, 'owner');
|
||||||
|
assert.equal(input.where.hidden, false);
|
||||||
|
return accounts;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
revision: {
|
||||||
|
findMany: async (input: any) => {
|
||||||
|
assert.equal(+input.where.effectiveDate.gte, +new Date('2026-08-31T16:00:00Z'));
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: { findUniqueOrThrow: async () => ({ baseCurrency: 'CNY' }) },
|
||||||
|
exchangeRate: { findMany: async () => [], findFirst: async () => null },
|
||||||
|
$queryRaw: async () => [],
|
||||||
|
};
|
||||||
|
const db: any = { $transaction: async (work: any) => work(tx) };
|
||||||
|
const c = new CalendarController(db),
|
||||||
|
r: any = { userId: 'owner', revealed: false };
|
||||||
|
const month = await c.month(r, '2026-09');
|
||||||
|
assert.equal(month.items[1].income, '0.01');
|
||||||
|
assert.equal(month.items[1].expense, '2.00');
|
||||||
|
assert.equal(month.items[2].complete, false);
|
||||||
|
assert.equal(month.items.length, 30);
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ const empty = () =>
|
|||||||
currencies: ['CNY'],
|
currencies: ['CNY'],
|
||||||
icons: [],
|
icons: [],
|
||||||
transfers: [],
|
transfers: [],
|
||||||
|
schedules: [],
|
||||||
positions: [],
|
positions: [],
|
||||||
rates: [],
|
rates: [],
|
||||||
links: [],
|
links: [],
|
||||||
@@ -43,6 +44,7 @@ test('ZIP contains separate JSON files and restores settings without authenticat
|
|||||||
'links.json',
|
'links.json',
|
||||||
'manifest.json',
|
'manifest.json',
|
||||||
'rates.json',
|
'rates.json',
|
||||||
|
'schedules.json',
|
||||||
'settings.json',
|
'settings.json',
|
||||||
'transfers.json',
|
'transfers.json',
|
||||||
]);
|
]);
|
||||||
@@ -102,10 +104,11 @@ test('legacy v3 ZIP remains readable without icons', async () => {
|
|||||||
const contents = packBackup(empty());
|
const contents = packBackup(empty());
|
||||||
delete contents['icons.json'];
|
delete contents['icons.json'];
|
||||||
delete contents['transfers.json'];
|
delete contents['transfers.json'];
|
||||||
|
delete contents['schedules.json'];
|
||||||
const manifest = JSON.parse(contents['manifest.json']);
|
const manifest = JSON.parse(contents['manifest.json']);
|
||||||
manifest.version = 3;
|
manifest.version = 3;
|
||||||
manifest.files = manifest.files.filter(
|
manifest.files = manifest.files.filter(
|
||||||
(f: { name: string }) => !['icons.json', 'transfers.json'].includes(f.name),
|
(f: { name: string }) => !['icons.json', 'transfers.json', 'schedules.json'].includes(f.name),
|
||||||
);
|
);
|
||||||
contents['manifest.json'] = JSON.stringify(manifest);
|
contents['manifest.json'] = JSON.stringify(manifest);
|
||||||
const restored = validateBackup(await readBackupZip(await archive(contents)));
|
const restored = validateBackup(await readBackupZip(await archive(contents)));
|
||||||
@@ -116,9 +119,12 @@ test('legacy v3 ZIP remains readable without icons', async () => {
|
|||||||
test('legacy v4 ZIP remains readable without transfers', async () => {
|
test('legacy v4 ZIP remains readable without transfers', async () => {
|
||||||
const contents = packBackup(empty());
|
const contents = packBackup(empty());
|
||||||
delete contents['transfers.json'];
|
delete contents['transfers.json'];
|
||||||
|
delete contents['schedules.json'];
|
||||||
const manifest = JSON.parse(contents['manifest.json']);
|
const manifest = JSON.parse(contents['manifest.json']);
|
||||||
manifest.version = 4;
|
manifest.version = 4;
|
||||||
manifest.files = manifest.files.filter((f: { name: string }) => f.name !== 'transfers.json');
|
manifest.files = manifest.files.filter(
|
||||||
|
(f: { name: string }) => !['transfers.json', 'schedules.json'].includes(f.name),
|
||||||
|
);
|
||||||
contents['manifest.json'] = JSON.stringify(manifest);
|
contents['manifest.json'] = JSON.stringify(manifest);
|
||||||
const restored = validateBackup(await readBackupZip(await archive(contents)));
|
const restored = validateBackup(await readBackupZip(await archive(contents)));
|
||||||
assert.deepEqual(restored.icons, []);
|
assert.deepEqual(restored.icons, []);
|
||||||
@@ -126,3 +132,61 @@ test('legacy v4 ZIP remains readable without transfers', async () => {
|
|||||||
assert.equal(restored.preferences?.showNotes, false);
|
assert.equal(restored.preferences?.showNotes, false);
|
||||||
assert.deepEqual(restored.preferences?.hiddenMenus, ['asset']);
|
assert.deepEqual(restored.preferences?.hiddenMenus, ['asset']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ZIP v7 restores groups and schedules while v6 remains readable', async () => {
|
||||||
|
const id = randomUUID(),
|
||||||
|
stamp = new Date().toISOString();
|
||||||
|
const b = validateBackup({
|
||||||
|
...empty(),
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
name: '账户',
|
||||||
|
groupName: '日常',
|
||||||
|
kind: 'account',
|
||||||
|
side: 'asset',
|
||||||
|
category: 'bank',
|
||||||
|
currency: 'CNY',
|
||||||
|
notes: '',
|
||||||
|
archived: false,
|
||||||
|
hidden: false,
|
||||||
|
createdAt: stamp,
|
||||||
|
updatedAt: stamp,
|
||||||
|
revisions: [
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
amount: '-10',
|
||||||
|
date: '2026-09-01T10:00',
|
||||||
|
notes: '',
|
||||||
|
reason: 'balance',
|
||||||
|
createdAt: stamp,
|
||||||
|
updatedAt: stamp,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
schedules: [
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
name: '租金',
|
||||||
|
operation: 'expense',
|
||||||
|
sourceId: id,
|
||||||
|
targetId: null,
|
||||||
|
amount: '100',
|
||||||
|
received: '0',
|
||||||
|
nextAt: '2026-11-01T10:00',
|
||||||
|
intervalDays: 30,
|
||||||
|
enabled: true,
|
||||||
|
notes: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const contents = packBackup(b);
|
||||||
|
assert.deepEqual(validateBackup(await readBackupZip(await archive(contents))), b);
|
||||||
|
delete contents['schedules.json'];
|
||||||
|
const manifest = JSON.parse(contents['manifest.json']);
|
||||||
|
manifest.version = 6;
|
||||||
|
manifest.files = manifest.files.filter((f: { name: string }) => f.name !== 'schedules.json');
|
||||||
|
contents['manifest.json'] = JSON.stringify(manifest);
|
||||||
|
assert.equal(validateBackup(await readBackupZip(await archive(contents))).schedules, undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useId, useRef, useState } from 'react';
|
||||||
|
import { type Position, money, positionAmount } from './api';
|
||||||
|
import { t as tr } from './i18n';
|
||||||
|
import { iconUrl } from './IconLibrary';
|
||||||
|
export function AccountPicker({
|
||||||
|
name,
|
||||||
|
accounts,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
excludeId,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
accounts: Position[];
|
||||||
|
value: string;
|
||||||
|
onChange: (id: string) => void;
|
||||||
|
excludeId?: string;
|
||||||
|
}) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null),
|
||||||
|
title = useId();
|
||||||
|
const [grouped, setGrouped] = useState(true),
|
||||||
|
[query, setQuery] = useState('');
|
||||||
|
const selected = accounts.find((p) => p.id === value);
|
||||||
|
const rows = accounts.filter(
|
||||||
|
(p) =>
|
||||||
|
p.id !== excludeId &&
|
||||||
|
`${p.name} ${p.groupName || ''} ${p.currency}`.toLowerCase().includes(query.toLowerCase()),
|
||||||
|
);
|
||||||
|
const groups = grouped ? [...new Set(rows.map((p) => p.groupName || '未分组'))] : ['全部账户'];
|
||||||
|
return (
|
||||||
|
<div className="account-picker">
|
||||||
|
<input name={name} type="hidden" value={value} />
|
||||||
|
<button
|
||||||
|
className="secondary picker-trigger"
|
||||||
|
type="button"
|
||||||
|
onClick={() => dialog.current?.showModal()}
|
||||||
|
>
|
||||||
|
{selected
|
||||||
|
? `${selected.name} · ${money(positionAmount(selected), selected.currency)}`
|
||||||
|
: tr('选择账户')}
|
||||||
|
</button>
|
||||||
|
<dialog ref={dialog} className="picker-dialog" aria-labelledby={title}>
|
||||||
|
<div className="panel-title">
|
||||||
|
<h2 id={title}>{tr('选择账户')}</h2>
|
||||||
|
<button type="button" onClick={() => dialog.current?.close()} aria-label={tr('关闭')}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
placeholder={tr('搜索账户、分组或币种')}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" aria-pressed={!grouped} onClick={() => setGrouped(false)}>
|
||||||
|
{tr('全部账户')}
|
||||||
|
</button>
|
||||||
|
<button type="button" aria-pressed={grouped} onClick={() => setGrouped(true)}>
|
||||||
|
{tr('按分组显示')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<section key={group}>
|
||||||
|
<h3>{tr(group)}</h3>
|
||||||
|
{rows
|
||||||
|
.filter((p) => !grouped || (p.groupName || '未分组') === group)
|
||||||
|
.map((p) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="picker-option"
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(p.id);
|
||||||
|
dialog.current?.close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.iconId && <img src={iconUrl(p.iconId)} alt="" />}
|
||||||
|
<span>
|
||||||
|
{p.name}
|
||||||
|
<small>{money(positionAmount(p), p.currency)}</small>
|
||||||
|
</span>
|
||||||
|
{p.id === value && <span>✓</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
{!rows.length && <p>{tr('没有符合条件的账户')}</p>}
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { t as tr, useLocale, LanguagePicker } from './i18n';
|
|||||||
import { useState, useEffect, useRef, type FormEvent, type ReactNode } from 'react';
|
import { useState, useEffect, useRef, type FormEvent, type ReactNode } from 'react';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
CalendarDays,
|
||||||
Wallet,
|
Wallet,
|
||||||
Landmark,
|
Landmark,
|
||||||
HandCoins,
|
HandCoins,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
ApiError,
|
ApiError,
|
||||||
currencies,
|
currencies,
|
||||||
money,
|
money,
|
||||||
|
positionAmount,
|
||||||
today,
|
today,
|
||||||
nowMinute,
|
nowMinute,
|
||||||
displayTime,
|
displayTime,
|
||||||
@@ -35,6 +37,8 @@ import {
|
|||||||
type PageResult,
|
type PageResult,
|
||||||
} from './api';
|
} from './api';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
import { Calendar } from './Calendar';
|
||||||
|
import { SchedulePanel } from './SchedulePanel';
|
||||||
import { IconPicker } from './IconPicker';
|
import { IconPicker } from './IconPicker';
|
||||||
import { TransferForm } from './TransferForm';
|
import { TransferForm } from './TransferForm';
|
||||||
import { DebtPaymentForm, operationLabel, type DebtOperation } from './DebtPaymentForm';
|
import { DebtPaymentForm, operationLabel, type DebtOperation } from './DebtPaymentForm';
|
||||||
@@ -46,6 +50,7 @@ const labels: Record<string, string> = {
|
|||||||
asset: '独立资产',
|
asset: '独立资产',
|
||||||
debt: '债务',
|
debt: '债务',
|
||||||
history: '变化记录',
|
history: '变化记录',
|
||||||
|
calendar: '收支日历',
|
||||||
settings: '设置与备份',
|
settings: '设置与备份',
|
||||||
};
|
};
|
||||||
const categories: Record<string, [string, string][]> = {
|
const categories: Record<string, [string, string][]> = {
|
||||||
@@ -154,7 +159,7 @@ function Chart({ rows, base }: { rows: Total[]; base: string }) {
|
|||||||
<>
|
<>
|
||||||
<div className="legend">
|
<div className="legend">
|
||||||
<span className="green">{tr('总资产')}</span>
|
<span className="green">{tr('总资产')}</span>
|
||||||
<span className="orange">{tr('总负债')}</span>
|
<span className="danger-text">{tr('总负债')}</span>
|
||||||
<span className="blue">{tr('净资产')}</span>
|
<span className="blue">{tr('净资产')}</span>
|
||||||
<small>{base}</small>
|
<small>{base}</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,7 +181,7 @@ function Chart({ rows, base }: { rows: Total[]; base: string }) {
|
|||||||
)
|
)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={['#199b76', '#dc9463', '#457bca'][i]}
|
stroke={['#199b76', '#c73542', '#457bca'][i]}
|
||||||
strokeWidth="3"
|
strokeWidth="3"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -222,6 +227,9 @@ export default function App() {
|
|||||||
[loading, setLoading] = useState(false),
|
[loading, setLoading] = useState(false),
|
||||||
[error, setError] = useState(''),
|
[error, setError] = useState(''),
|
||||||
[success, setSuccess] = useState('');
|
[success, setSuccess] = useState('');
|
||||||
|
const [calendarRefresh, setCalendarRefresh] = useState(0);
|
||||||
|
const [quickMode, setQuickMode] = useState(false),
|
||||||
|
[accountGroup, setAccountGroup] = useState('');
|
||||||
const [fxStatus, setFxStatus] = useState<{
|
const [fxStatus, setFxStatus] = useState<{
|
||||||
state: string;
|
state: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -255,6 +263,8 @@ export default function App() {
|
|||||||
userRef.current = user;
|
userRef.current = user;
|
||||||
const activityAt = useRef(0);
|
const activityAt = useRef(0);
|
||||||
function clearAccount() {
|
function clearAccount() {
|
||||||
|
setQuickMode(false);
|
||||||
|
setAccountGroup('');
|
||||||
setClearStep(0);
|
setClearStep(0);
|
||||||
setClearConfirmation(0);
|
setClearConfirmation(0);
|
||||||
activityAt.current = 0;
|
activityAt.current = 0;
|
||||||
@@ -331,6 +341,16 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
setOverview({ ...o, trend: t.items, recent: recent.items });
|
setOverview({ ...o, trend: t.items, recent: recent.items });
|
||||||
} else if (['account', 'asset', 'debt'].includes(page)) {
|
} else if (['account', 'asset', 'debt'].includes(page)) {
|
||||||
|
if (page === 'account' && !rangeOnly) {
|
||||||
|
const result = await api<{
|
||||||
|
executed: number;
|
||||||
|
errors: { message: string }[];
|
||||||
|
hasMore: boolean;
|
||||||
|
}>('/schedules/run', 'POST', {});
|
||||||
|
if (!active()) return;
|
||||||
|
if (result.errors.length) setError(result.errors[0].message);
|
||||||
|
if (result.executed) setSuccess(tr('到期计划已记账') + ` (${result.executed})`);
|
||||||
|
}
|
||||||
const [p, h] = await Promise.all([
|
const [p, h] = await Promise.all([
|
||||||
api<Position[]>('/positions?kind=' + page),
|
api<Position[]>('/positions?kind=' + page),
|
||||||
selected
|
selected
|
||||||
@@ -359,6 +379,8 @@ export default function App() {
|
|||||||
setHistoryCursor(h.nextCursor);
|
setHistoryCursor(h.nextCursor);
|
||||||
setTransfers(t.items);
|
setTransfers(t.items);
|
||||||
setTransferCursor(t.nextCursor);
|
setTransferCursor(t.nextCursor);
|
||||||
|
} else if (page === 'calendar') {
|
||||||
|
setCalendarRefresh((value) => value + 1);
|
||||||
} else if (page === 'settings') {
|
} else if (page === 'settings') {
|
||||||
const settings = await api<User & { rates: Rate[]; fxStatus: typeof fxStatus }>(
|
const settings = await api<User & { rates: Rate[]; fxStatus: typeof fxStatus }>(
|
||||||
'/settings' + (settingsSection === 'rates' ? '?rates=true' : ''),
|
'/settings' + (settingsSection === 'rates' ? '?rates=true' : ''),
|
||||||
@@ -725,6 +747,7 @@ export default function App() {
|
|||||||
['asset', Landmark],
|
['asset', Landmark],
|
||||||
['debt', HandCoins],
|
['debt', HandCoins],
|
||||||
['history', HistoryIcon],
|
['history', HistoryIcon],
|
||||||
|
['calendar', CalendarDays],
|
||||||
['settings', Settings],
|
['settings', Settings],
|
||||||
] as const;
|
] as const;
|
||||||
const visibleNav = nav.filter(([key]) => !(user.hiddenMenus || []).includes(key));
|
const visibleNav = nav.filter(([key]) => !(user.hiddenMenus || []).includes(key));
|
||||||
@@ -921,7 +944,13 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
<ArrowUpRight size={18} />
|
<ArrowUpRight size={18} />
|
||||||
</div>
|
</div>
|
||||||
<strong>{money(value, user.baseCurrency)}</strong>
|
<strong
|
||||||
|
className={
|
||||||
|
cls === 'debt' || value.startsWith('-') ? 'danger-text' : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{money(value, user.baseCurrency)}
|
||||||
|
</strong>
|
||||||
<small>
|
<small>
|
||||||
{title === tr('净资产')
|
{title === tr('净资产')
|
||||||
? tr('总资产 − 总负债')
|
? tr('总资产 − 总负债')
|
||||||
@@ -1055,18 +1084,26 @@ export default function App() {
|
|||||||
<span className="badge">
|
<span className="badge">
|
||||||
{categoryLabel(p)} ·{' '}
|
{categoryLabel(p)} ·{' '}
|
||||||
{p.kind === 'debt'
|
{p.kind === 'debt'
|
||||||
? tr(p.side === 'asset' ? '借出(应收)' : '借入(应付)')
|
? tr(p.side === 'asset' ? '借款(待收)' : '欠款(待还)')
|
||||||
: p.side === 'asset'
|
: p.side === 'asset'
|
||||||
? tr('资产')
|
? tr('资产')
|
||||||
: tr('负债')}
|
: tr('负债')}
|
||||||
{p.archived ? tr(' · 已归档') : ''}
|
{p.archived ? tr(' · 已归档') : ''}
|
||||||
</span>
|
</span>
|
||||||
<h2>{money(p.amount, p.currency)}</h2>
|
<h2
|
||||||
|
className={
|
||||||
|
p.side === 'liability' || p.amount.startsWith('-')
|
||||||
|
? 'danger-text'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{money(positionAmount(p), p.currency)}
|
||||||
|
</h2>
|
||||||
{p.hidden && <span className="badge">{tr('隐藏项目')}</span>}
|
{p.hidden && <span className="badge">{tr('隐藏项目')}</span>}
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{tr('本位币:')}
|
{tr('本位币:')}
|
||||||
{p.converted !== null
|
{p.converted !== null
|
||||||
? money(p.converted || '0', user.baseCurrency)
|
? money(positionAmount(p, p.converted || '0'), user.baseCurrency)
|
||||||
: tr('缺少汇率')}
|
: tr('缺少汇率')}
|
||||||
</p>
|
</p>
|
||||||
<p className="notes-display">{p.notes || tr('暂无备注')}</p>
|
<p className="notes-display">{p.notes || tr('暂无备注')}</p>
|
||||||
@@ -1085,7 +1122,7 @@ export default function App() {
|
|||||||
className="secondary"
|
className="secondary"
|
||||||
onClick={() => setModal({ kind: 'debt-payment', p })}
|
onClick={() => setModal({ kind: 'debt-payment', p })}
|
||||||
>
|
>
|
||||||
{tr('债务收款 / 还款')}
|
{tr('收款 / 还款')}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1103,18 +1140,6 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
{operationLabel(p.side === 'asset' ? 'collect' : 'repay')}
|
{operationLabel(p.side === 'asset' ? 'collect' : 'repay')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
className="secondary"
|
|
||||||
onClick={() =>
|
|
||||||
setModal({
|
|
||||||
kind: 'debt-payment',
|
|
||||||
p,
|
|
||||||
operation: p.side === 'asset' ? 'lend' : 'borrow',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{operationLabel(p.side === 'asset' ? 'lend' : 'borrow')}
|
|
||||||
</button>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button className="secondary" onClick={() => setModal({ kind: 'edit', p })}>
|
<button className="secondary" onClick={() => setModal({ kind: 'edit', p })}>
|
||||||
@@ -1129,6 +1154,7 @@ export default function App() {
|
|||||||
api('/positions/' + p.id, 'PATCH', {
|
api('/positions/' + p.id, 'PATCH', {
|
||||||
name: p.name,
|
name: p.name,
|
||||||
category: p.category,
|
category: p.category,
|
||||||
|
groupName: p.groupName,
|
||||||
notes: p.notes,
|
notes: p.notes,
|
||||||
archived: !p.archived,
|
archived: !p.archived,
|
||||||
hidden: p.hidden,
|
hidden: p.hidden,
|
||||||
@@ -1196,15 +1222,63 @@ export default function App() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
|
{page === 'account' && (
|
||||||
|
<>
|
||||||
|
<div className="actions account-tools">
|
||||||
|
<button
|
||||||
|
className={quickMode ? 'primary' : 'secondary'}
|
||||||
|
aria-pressed={quickMode}
|
||||||
|
onClick={() => setQuickMode(!quickMode)}
|
||||||
|
>
|
||||||
|
{tr(quickMode ? '退出快速记账' : '快速记账')}
|
||||||
|
</button>
|
||||||
|
<select
|
||||||
|
aria-label={tr('账户分组')}
|
||||||
|
value={accountGroup}
|
||||||
|
onChange={(e) => setAccountGroup(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{tr('全部账户')}</option>
|
||||||
|
{[
|
||||||
|
...new Set(
|
||||||
|
positions
|
||||||
|
.filter((p) => p.kind === 'account')
|
||||||
|
.map((p) => p.groupName || '未分组'),
|
||||||
|
),
|
||||||
|
].map((g) => (
|
||||||
|
<option key={g} value={g}>
|
||||||
|
{tr(g)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{quickMode && <p className="muted">{tr('点击账户卡片,直接填写当前余额。')}</p>}
|
||||||
|
<SchedulePanel
|
||||||
|
accounts={positions.filter(
|
||||||
|
(p) => p.kind === 'account' && p.side === 'asset' && !p.archived,
|
||||||
|
)}
|
||||||
|
refresh={() => load(false, true)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{positions.some((p) => p.kind === page) ? (
|
{positions.some((p) => p.kind === page) ? (
|
||||||
<div className="position-grid">
|
<div className="position-grid">
|
||||||
{positions
|
{positions
|
||||||
.filter((p) => p.kind === page)
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
p.kind === page &&
|
||||||
|
(page !== 'account' ||
|
||||||
|
!accountGroup ||
|
||||||
|
(p.groupName || '未分组') === accountGroup),
|
||||||
|
)
|
||||||
.map((p) => (
|
.map((p) => (
|
||||||
<button
|
<button
|
||||||
className="position-card"
|
className="position-card"
|
||||||
key={p.id}
|
key={p.id}
|
||||||
onClick={() => setSelected(p.id)}
|
onClick={() =>
|
||||||
|
page === 'account' && quickMode && !p.archived
|
||||||
|
? setModal({ kind: 'revision', p })
|
||||||
|
: setSelected(p.id)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<span className="position-icon">
|
<span className="position-icon">
|
||||||
@@ -1221,21 +1295,30 @@ export default function App() {
|
|||||||
<ArrowUpRight size={18} />
|
<ArrowUpRight size={18} />
|
||||||
</div>
|
</div>
|
||||||
<h3>{p.name}</h3>
|
<h3>{p.name}</h3>
|
||||||
|
{p.groupName && <span className="badge">{p.groupName}</span>}
|
||||||
<p>
|
<p>
|
||||||
{categoryLabel(p)} ·{' '}
|
{categoryLabel(p)} ·{' '}
|
||||||
{p.kind === 'debt'
|
{p.kind === 'debt'
|
||||||
? tr(p.side === 'asset' ? '借出(应收)' : '借入(应付)')
|
? tr(p.side === 'asset' ? '借款(待收)' : '欠款(待还)')
|
||||||
: p.side === 'asset'
|
: p.side === 'asset'
|
||||||
? tr('资产')
|
? tr('资产')
|
||||||
: tr('负债')}
|
: tr('负债')}
|
||||||
{p.archived ? tr(' · 已归档') : ''}
|
{p.archived ? tr(' · 已归档') : ''}
|
||||||
</p>
|
</p>
|
||||||
<strong>{money(p.amount, p.currency)}</strong>
|
<strong
|
||||||
|
className={
|
||||||
|
p.side === 'liability' || p.amount.startsWith('-')
|
||||||
|
? 'danger-text'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{money(positionAmount(p), p.currency)}
|
||||||
|
</strong>
|
||||||
<small>
|
<small>
|
||||||
{tr('折合')}{' '}
|
{tr('折合')}{' '}
|
||||||
{p.converted === null
|
{p.converted === null
|
||||||
? tr('缺少汇率')
|
? tr('缺少汇率')
|
||||||
: money(p.converted || '0', user.baseCurrency)}
|
: money(positionAmount(p, p.converted || '0'), user.baseCurrency)}
|
||||||
</small>
|
</small>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -1250,92 +1333,97 @@ export default function App() {
|
|||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
{page === 'history' && (
|
{page === 'history' && (
|
||||||
<section className="panel">
|
<>
|
||||||
<div className="actions">
|
<section className="panel">
|
||||||
<label>
|
<div className="actions">
|
||||||
{tr('开始')}
|
<label>
|
||||||
<input
|
{tr('开始')}
|
||||||
type="date"
|
<input
|
||||||
value={rangeFrom}
|
type="date"
|
||||||
max={rangeTo}
|
value={rangeFrom}
|
||||||
onChange={(e) => setRangeFrom(e.target.value)}
|
max={rangeTo}
|
||||||
/>
|
onChange={(e) => setRangeFrom(e.target.value)}
|
||||||
</label>
|
/>
|
||||||
<label>
|
</label>
|
||||||
{tr('结束')}
|
<label>
|
||||||
<input
|
{tr('结束')}
|
||||||
type="date"
|
<input
|
||||||
value={rangeTo}
|
type="date"
|
||||||
min={rangeFrom}
|
value={rangeTo}
|
||||||
max={today()}
|
min={rangeFrom}
|
||||||
onChange={(e) => setRangeTo(e.target.value)}
|
max={today()}
|
||||||
/>
|
onChange={(e) => setRangeTo(e.target.value)}
|
||||||
</label>
|
/>
|
||||||
</div>
|
</label>
|
||||||
<h2>{tr('资金往来')}</h2>
|
|
||||||
{transfers.length ? (
|
|
||||||
<div className="transfer-list">
|
|
||||||
{transfers.map((t) => (
|
|
||||||
<article key={t.id}>
|
|
||||||
<div>
|
|
||||||
<strong>
|
|
||||||
{operationLabel(t.operation || 'transfer')} ·
|
|
||||||
<button
|
|
||||||
className="text"
|
|
||||||
onClick={() => {
|
|
||||||
setSelected(t.sourceId);
|
|
||||||
setPage(t.source.kind);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t.source.name}
|
|
||||||
</button>
|
|
||||||
{' ↔ '}
|
|
||||||
<button
|
|
||||||
className="text"
|
|
||||||
onClick={() => {
|
|
||||||
setSelected(t.targetId);
|
|
||||||
setPage(t.target.kind);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t.target.name}
|
|
||||||
</button>
|
|
||||||
</strong>
|
|
||||||
<small>{displayTime(t.date)}</small>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
{money(t.amount, t.sourceCurrency)} → {money(t.received, t.targetCurrency)}{' '}
|
|
||||||
{tr('· 手续费')}
|
|
||||||
{money(t.fee, t.sourceCurrency)}
|
|
||||||
</p>
|
|
||||||
{t.notes && <p className="muted notes-display">{t.notes}</p>}
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<h2>{tr('资金往来')}</h2>
|
||||||
<p className="muted">{tr('暂无账户间转账记录。')}</p>
|
{transfers.length ? (
|
||||||
)}
|
<div className="transfer-list">
|
||||||
{transferCursor && (
|
{transfers.map((t) => (
|
||||||
<button disabled={loading} onClick={() => void more('transfers')}>
|
<article key={t.id}>
|
||||||
{tr('加载更早转账')}
|
<div>
|
||||||
</button>
|
<strong>
|
||||||
)}
|
{operationLabel(t.operation || 'transfer')} ·
|
||||||
<h2>{tr('全部金额变化')}</h2>
|
<button
|
||||||
<p className="muted">
|
className="text"
|
||||||
{tr('原币绝对余额记录;负债减少即还款。选择项目可查看详情并更正。')}
|
onClick={() => {
|
||||||
</p>
|
setSelected(t.sourceId);
|
||||||
<HistoryTable
|
setPage(t.source.kind);
|
||||||
rows={historyRows}
|
}}
|
||||||
open={(id) => {
|
>
|
||||||
setSelected(id);
|
{t.source.name}
|
||||||
setPage(historyRows.find((h) => h.positionId === id)?.kind || 'account');
|
</button>
|
||||||
}}
|
{' ↔ '}
|
||||||
/>
|
<button
|
||||||
{historyCursor && (
|
className="text"
|
||||||
<button disabled={loading} onClick={() => void more('history')}>
|
onClick={() => {
|
||||||
{tr('加载更早记录')}
|
setSelected(t.targetId);
|
||||||
</button>
|
setPage(t.target.kind);
|
||||||
)}
|
}}
|
||||||
</section>
|
>
|
||||||
|
{t.target.name}
|
||||||
|
</button>
|
||||||
|
</strong>
|
||||||
|
<small>{displayTime(t.date)}</small>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
{money(t.amount, t.sourceCurrency)} →{' '}
|
||||||
|
{money(t.received, t.targetCurrency)} {tr('· 手续费')}
|
||||||
|
{money(t.fee, t.sourceCurrency)}
|
||||||
|
</p>
|
||||||
|
{t.notes && <p className="muted notes-display">{t.notes}</p>}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="muted">{tr('暂无账户间转账记录。')}</p>
|
||||||
|
)}
|
||||||
|
{transferCursor && (
|
||||||
|
<button disabled={loading} onClick={() => void more('transfers')}>
|
||||||
|
{tr('加载更早转账')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<h2>{tr('全部金额变化')}</h2>
|
||||||
|
<p className="muted">
|
||||||
|
{tr('原币绝对余额记录;负债减少即还款。选择项目可查看详情并更正。')}
|
||||||
|
</p>
|
||||||
|
<HistoryTable
|
||||||
|
rows={historyRows}
|
||||||
|
open={(id) => {
|
||||||
|
setSelected(id);
|
||||||
|
setPage(historyRows.find((h) => h.positionId === id)?.kind || 'account');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{historyCursor && (
|
||||||
|
<button disabled={loading} onClick={() => void more('history')}>
|
||||||
|
{tr('加载更早记录')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{page === 'calendar' && (
|
||||||
|
<Calendar revealed={!!user.revealed} refreshVersion={calendarRefresh} />
|
||||||
)}
|
)}
|
||||||
{page === 'settings' && (
|
{page === 'settings' && (
|
||||||
<>
|
<>
|
||||||
@@ -1781,7 +1869,9 @@ export default function App() {
|
|||||||
<Modal
|
<Modal
|
||||||
title={
|
title={
|
||||||
modal.kind === 'debt-payment'
|
modal.kind === 'debt-payment'
|
||||||
? tr('债务收款 / 还款')
|
? modal.p?.kind === 'debt'
|
||||||
|
? operationLabel(modal.p.side === 'asset' ? 'collect' : 'repay')
|
||||||
|
: tr('收款 / 还款')
|
||||||
: modal.kind === 'transfer'
|
: modal.kind === 'transfer'
|
||||||
? tr('账户间转账')
|
? tr('账户间转账')
|
||||||
: modal.kind === 'reveal'
|
: modal.kind === 'reveal'
|
||||||
@@ -1923,7 +2013,25 @@ export default function App() {
|
|||||||
{tr('隐藏此项目(密码验证后可查看和编辑)')}
|
{tr('隐藏此项目(密码验证后可查看和编辑)')}
|
||||||
</label>
|
</label>
|
||||||
{(modal.kind === 'account' || modal.p?.kind === 'account') && (
|
{(modal.kind === 'account' || modal.p?.kind === 'account') && (
|
||||||
<IconPicker key={modal.p?.id || 'new-account'} initialId={modal.p?.iconId} />
|
<>
|
||||||
|
<IconPicker key={modal.p?.id || 'new-account'} initialId={modal.p?.iconId} />
|
||||||
|
<Field label={tr('账户分组')}>
|
||||||
|
<input
|
||||||
|
name="groupName"
|
||||||
|
maxLength={60}
|
||||||
|
defaultValue={modal.p?.groupName || ''}
|
||||||
|
list="account-groups"
|
||||||
|
placeholder={tr('例如:日常、消费、理财')}
|
||||||
|
/>
|
||||||
|
<datalist id="account-groups">
|
||||||
|
{[...new Set(positions.map((p) => p.groupName).filter(Boolean))].map(
|
||||||
|
(g) => (
|
||||||
|
<option key={g} value={g} />
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</datalist>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Field label={tr('名称')}>
|
<Field label={tr('名称')}>
|
||||||
<input
|
<input
|
||||||
@@ -1957,8 +2065,8 @@ export default function App() {
|
|||||||
<>
|
<>
|
||||||
<Field label={tr('债务方向')}>
|
<Field label={tr('债务方向')}>
|
||||||
<select name="side" defaultValue="liability">
|
<select name="side" defaultValue="liability">
|
||||||
<option value="liability">{tr('借入(应付)')}</option>
|
<option value="liability">{tr('欠款(待还)')}</option>
|
||||||
<option value="asset">{tr('借出(应收)')}</option>
|
<option value="asset">{tr('借款(待收)')}</option>
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
@@ -1979,13 +2087,26 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
{['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && (
|
{['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && (
|
||||||
<Field
|
<Field
|
||||||
label={tr('变更后的绝对金额') + (modal.p ? '(' + modal.p.currency + ')' : '')}
|
label={
|
||||||
|
tr(
|
||||||
|
modal.p?.kind === 'debt'
|
||||||
|
? '剩余本金'
|
||||||
|
: modal.p?.kind === 'asset'
|
||||||
|
? '当前估值'
|
||||||
|
: '当前余额',
|
||||||
|
) + (modal.p ? '(' + modal.p.currency + ')' : '')
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
name="amount"
|
name="amount"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
required
|
required
|
||||||
pattern="(0|[1-9][0-9]{0,15})(\.[0-9]{1,8})?"
|
pattern={
|
||||||
|
(modal.p?.side === 'asset' && modal.p?.kind === 'account') ||
|
||||||
|
modal.kind === 'account'
|
||||||
|
? '-?(0|[1-9][0-9]{0,15})(\\.[0-9]{1,8})?'
|
||||||
|
: '(0|[1-9][0-9]{0,15})(\\.[0-9]{1,8})?'
|
||||||
|
}
|
||||||
defaultValue={
|
defaultValue={
|
||||||
modal.h?.after || modal.p?.amount || (modal.kind === 'debt' ? '0' : '')
|
modal.h?.after || modal.p?.amount || (modal.kind === 'debt' ? '0' : '')
|
||||||
}
|
}
|
||||||
@@ -1994,7 +2115,7 @@ export default function App() {
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
{['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && (
|
{['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && (
|
||||||
<Field label={tr('业务时间(北京时间,精确到分钟)')}>
|
<Field label={tr('记录时间(UTC+8)')}>
|
||||||
<input
|
<input
|
||||||
type={'datetime-local'}
|
type={'datetime-local'}
|
||||||
name="date"
|
name="date"
|
||||||
@@ -2129,6 +2250,7 @@ function HistoryTable({
|
|||||||
(
|
(
|
||||||
{
|
{
|
||||||
initial: tr('初始记录'),
|
initial: tr('初始记录'),
|
||||||
|
scheduled_expense: tr('计划支出'),
|
||||||
balance: tr('余额更新'),
|
balance: tr('余额更新'),
|
||||||
valuation: tr('估值更新'),
|
valuation: tr('估值更新'),
|
||||||
repayment: tr('还款'),
|
repayment: tr('还款'),
|
||||||
@@ -2144,9 +2266,44 @@ function HistoryTable({
|
|||||||
}
|
}
|
||||||
</small>
|
</small>
|
||||||
</td>
|
</td>
|
||||||
<td>{money(h.before, h.currency)}</td>
|
<td
|
||||||
<td>{money(h.after, h.currency)}</td>
|
className={h.kind === 'debt' && h.side === 'liability' ? 'danger-text' : undefined}
|
||||||
<td>{money(h.delta, h.currency)}</td>
|
>
|
||||||
|
{money(
|
||||||
|
h.kind === 'debt' && h.side === 'liability' && Number(h.before) !== 0
|
||||||
|
? '-' + h.before
|
||||||
|
: h.before,
|
||||||
|
h.currency,
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={h.kind === 'debt' && h.side === 'liability' ? 'danger-text' : undefined}
|
||||||
|
>
|
||||||
|
{money(
|
||||||
|
h.kind === 'debt' && h.side === 'liability' && Number(h.after) !== 0
|
||||||
|
? '-' + h.after
|
||||||
|
: h.after,
|
||||||
|
h.currency,
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={
|
||||||
|
(h.kind === 'debt' && h.side === 'liability') || h.delta.startsWith('-')
|
||||||
|
? 'danger-text'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{money(
|
||||||
|
h.kind === 'debt' && h.side === 'liability'
|
||||||
|
? h.delta.startsWith('-')
|
||||||
|
? h.delta.slice(1)
|
||||||
|
: Number(h.delta) !== 0
|
||||||
|
? '-' + h.delta
|
||||||
|
: h.delta
|
||||||
|
: h.delta,
|
||||||
|
h.currency,
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="notes-cell">{h.notes || '—'}</td>
|
<td className="notes-cell">{h.notes || '—'}</td>
|
||||||
{correct && (
|
{correct && (
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api, money, today } from './api';
|
||||||
|
import { t as tr } from './i18n';
|
||||||
|
type Day = { date: string; income: string; expense: string; complete: boolean };
|
||||||
|
type Entry = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
direction: string;
|
||||||
|
amount: string;
|
||||||
|
currency: string;
|
||||||
|
converted: string | null;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
export function Calendar({
|
||||||
|
revealed,
|
||||||
|
refreshVersion,
|
||||||
|
}: {
|
||||||
|
revealed: boolean;
|
||||||
|
refreshVersion: number;
|
||||||
|
}) {
|
||||||
|
const [month, setMonth] = useState(today().slice(0, 7)),
|
||||||
|
[selected, setSelected] = useState(today());
|
||||||
|
const [days, setDays] = useState<Day[]>([]),
|
||||||
|
[rows, setRows] = useState<Entry[]>([]),
|
||||||
|
[base, setBase] = useState('CNY'),
|
||||||
|
[error, setError] = useState(''),
|
||||||
|
[loading, setLoading] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setDays([]);
|
||||||
|
setError('');
|
||||||
|
void api<{ items: Day[]; baseCurrency: string }>('/calendar?month=' + month)
|
||||||
|
.then((v) => {
|
||||||
|
if (active) {
|
||||||
|
setDays(v.items);
|
||||||
|
setBase(v.baseCurrency);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (active) setError(e.message);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [month, revealed, refreshVersion]);
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setRows([]);
|
||||||
|
setLoading(true);
|
||||||
|
void api<{ items: Entry[] }>('/calendar/day?date=' + selected)
|
||||||
|
.then((v) => {
|
||||||
|
if (active) setRows(v.items);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (active) setError(e.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (active) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [selected, revealed, refreshVersion]);
|
||||||
|
const offset = (new Date(month + '-01T00:00:00Z').getUTCDay() + 6) % 7;
|
||||||
|
const day = days.find((v) => v.date === selected);
|
||||||
|
return (
|
||||||
|
<section className="panel calendar-panel">
|
||||||
|
<div className="panel-title">
|
||||||
|
<h2>{tr('收支日历')}</h2>
|
||||||
|
<input
|
||||||
|
aria-label={tr('月份')}
|
||||||
|
type="month"
|
||||||
|
min="1900-01"
|
||||||
|
max={today().slice(0, 7)}
|
||||||
|
value={month}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value) {
|
||||||
|
setMonth(e.target.value);
|
||||||
|
setSelected(
|
||||||
|
e.target.value === today().slice(0, 7) ? today() : e.target.value + '-01',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="muted">
|
||||||
|
{tr('按账户余额变动估算;转账、借贷本金、初始余额和估值不计入。负债账户按相反方向计算。')}
|
||||||
|
</p>
|
||||||
|
<div className="calendar-grid">
|
||||||
|
{['一', '二', '三', '四', '五', '六', '日'].map((v) => (
|
||||||
|
<span key={v} className="weekday">
|
||||||
|
{tr(v)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{Array.from({ length: offset }, (_, i) => (
|
||||||
|
<span key={'blank-' + i} />
|
||||||
|
))}
|
||||||
|
{days.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d.date}
|
||||||
|
disabled={d.date > today()}
|
||||||
|
aria-label={d.date}
|
||||||
|
aria-pressed={d.date === selected}
|
||||||
|
className={'calendar-day ' + (d.date === selected ? 'selected' : '')}
|
||||||
|
onClick={() => setSelected(d.date)}
|
||||||
|
>
|
||||||
|
<strong>{Number(d.date.slice(-2))}</strong>
|
||||||
|
{Number(d.income) > 0 && (
|
||||||
|
<small className="income-text">+{money(d.income, base)}</small>
|
||||||
|
)}
|
||||||
|
{Number(d.expense) > 0 && (
|
||||||
|
<small className="danger-text">−{money(d.expense, base)}</small>
|
||||||
|
)}
|
||||||
|
{!d.complete && <small>{tr('缺少汇率')}</small>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="calendar-detail">
|
||||||
|
<div className="panel-title">
|
||||||
|
<h3>
|
||||||
|
{selected} · {tr('当日收支')}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
className="text"
|
||||||
|
onClick={() => {
|
||||||
|
setMonth(today().slice(0, 7));
|
||||||
|
setSelected(today());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tr('今天')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{day && (
|
||||||
|
<p>
|
||||||
|
{tr('收入')}:{day.complete ? money(day.income, base) : tr('缺少汇率')} ·{' '}
|
||||||
|
<span className="danger-text">
|
||||||
|
{tr('支出')}:{day.complete ? money(day.expense, base) : tr('缺少汇率')}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{loading ? (
|
||||||
|
<p>{tr('正在刷新数据…')}</p>
|
||||||
|
) : rows.length ? (
|
||||||
|
rows.map((v) => (
|
||||||
|
<article className="schedule-row" key={v.id}>
|
||||||
|
<span>
|
||||||
|
{v.name}
|
||||||
|
<small className="notes-display">{v.notes}</small>
|
||||||
|
</span>
|
||||||
|
<strong className={v.direction === 'expense' ? 'danger-text' : 'income-text'}>
|
||||||
|
{v.direction === 'expense' ? '−' : '+'}
|
||||||
|
{money(v.amount, v.currency)}
|
||||||
|
</strong>
|
||||||
|
{v.converted === null && <small>{tr('缺少汇率')}</small>}
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p>{tr('当天没有收支记录')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="notice error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { AccountPicker } from './AccountPicker';
|
||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { type Position, nowMinute, money, requestUuid } from './api';
|
import { type Position, nowMinute, money, requestUuid, positionAmount } from './api';
|
||||||
import { t as tr } from './i18n';
|
import { t as tr } from './i18n';
|
||||||
export type DebtOperation = 'borrow' | 'lend' | 'collect' | 'repay';
|
export type DebtOperation = 'borrow' | 'lend' | 'collect' | 'repay';
|
||||||
export const operationLabel = (operation: string) =>
|
export const operationLabel = (operation: string) =>
|
||||||
@@ -8,8 +9,8 @@ export const operationLabel = (operation: string) =>
|
|||||||
{
|
{
|
||||||
borrow: '借入到账',
|
borrow: '借入到账',
|
||||||
lend: '借出付款',
|
lend: '借出付款',
|
||||||
collect: '收回借款',
|
collect: '收款',
|
||||||
repay: '偿还债务',
|
repay: '还款',
|
||||||
transfer: '账户转账',
|
transfer: '账户转账',
|
||||||
} as Record<string, string>
|
} as Record<string, string>
|
||||||
)[operation] || operation,
|
)[operation] || operation,
|
||||||
@@ -63,8 +64,8 @@ export function DebtPaymentForm({
|
|||||||
>
|
>
|
||||||
{debts.map((p) => (
|
{debts.map((p) => (
|
||||||
<option key={p.id} value={p.id}>
|
<option key={p.id} value={p.id}>
|
||||||
{p.name} · {tr(p.side === 'asset' ? '借出(应收)' : '借入(应付)')} ·{' '}
|
{p.name} · {tr(p.side === 'asset' ? '借款(待收)' : '欠款(待还)')} ·{' '}
|
||||||
{money(p.amount, p.currency)}
|
{money(positionAmount(p), p.currency)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -85,17 +86,12 @@ export function DebtPaymentForm({
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{tr(operation === 'borrow' || operation === 'collect' ? '收款账户' : '付款账户')}
|
{tr(operation === 'borrow' || operation === 'collect' ? '收款账户' : '付款账户')}
|
||||||
<select
|
<AccountPicker
|
||||||
name="sourceId"
|
name="sourceId"
|
||||||
|
accounts={accounts}
|
||||||
value={accountId}
|
value={accountId}
|
||||||
onChange={(event) => setAccount(event.target.value)}
|
onChange={setAccount}
|
||||||
>
|
/>
|
||||||
{accounts.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
{p.name} · {money(p.amount, p.currency)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{tr('账户与债务同时记账并自动建立关联;这里填写本次金额,不是操作后的余额。')}
|
{tr('账户与债务同时记账并自动建立关联;这里填写本次金额,不是操作后的余额。')}
|
||||||
@@ -139,7 +135,7 @@ export function DebtPaymentForm({
|
|||||||
{tr('手续费只影响账户余额:付款时额外扣除,收款时从到账金额扣除;负数表示优惠。')}
|
{tr('手续费只影响账户余额:付款时额外扣除,收款时从到账金额扣除;负数表示优惠。')}
|
||||||
</p>
|
</p>
|
||||||
<label>
|
<label>
|
||||||
{tr('业务时间(北京时间,精确到分钟)')}
|
{tr('记录时间(UTC+8)')}
|
||||||
<input
|
<input
|
||||||
name="date"
|
name="date"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api, money, nowMinute, type Position } from './api';
|
||||||
|
import { AccountPicker } from './AccountPicker';
|
||||||
|
import { t as tr } from './i18n';
|
||||||
|
type Plan = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
operation: string;
|
||||||
|
amount: string;
|
||||||
|
nextAt: string;
|
||||||
|
intervalDays: number;
|
||||||
|
enabled: boolean;
|
||||||
|
completed: boolean;
|
||||||
|
sourceId: string;
|
||||||
|
};
|
||||||
|
export function SchedulePanel({
|
||||||
|
accounts,
|
||||||
|
refresh,
|
||||||
|
}: {
|
||||||
|
accounts: Position[];
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [rows, setRows] = useState<Plan[]>([]),
|
||||||
|
[open, setOpen] = useState(false),
|
||||||
|
[busy, setBusy] = useState(false),
|
||||||
|
[error, setError] = useState(''),
|
||||||
|
[status, setStatus] = useState('');
|
||||||
|
const [operation, setOperation] = useState('expense'),
|
||||||
|
[source, setSource] = useState(''),
|
||||||
|
[target, setTarget] = useState(''),
|
||||||
|
[amount, setAmount] = useState('');
|
||||||
|
const sourceId = accounts.some((p) => p.id === source) ? source : accounts[0]?.id || '';
|
||||||
|
const sourceAccount = accounts.find((p) => p.id === sourceId),
|
||||||
|
targetAccount = accounts.find((p) => p.id === target);
|
||||||
|
const same = sourceAccount?.currency === targetAccount?.currency;
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
void api<Plan[]>('/schedules')
|
||||||
|
.then((v) => {
|
||||||
|
if (active) setRows(v);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (active) setError(e.message);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [accounts]);
|
||||||
|
async function act(work: () => Promise<unknown>) {
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
setStatus('');
|
||||||
|
try {
|
||||||
|
await work();
|
||||||
|
await refresh();
|
||||||
|
setRows(await api<Plan[]>('/schedules'));
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<details className="schedule-panel">
|
||||||
|
<summary>
|
||||||
|
{tr('定时计划')} ({rows.length})
|
||||||
|
</summary>
|
||||||
|
<p className="muted">
|
||||||
|
{tr('打开账户页时处理到期计划。每次最多执行20项,离线期间的计划会逐次补记。')}
|
||||||
|
</p>
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" disabled={busy || !accounts.length} onClick={() => setOpen(!open)}>
|
||||||
|
{tr(open ? '取消' : '添加计划')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
void act(async () => {
|
||||||
|
const result = await api<{
|
||||||
|
executed: number;
|
||||||
|
errors: { message: string }[];
|
||||||
|
hasMore: boolean;
|
||||||
|
}>('/schedules/run', 'POST', {});
|
||||||
|
setStatus(
|
||||||
|
tr('已执行') +
|
||||||
|
` ${result.executed}` +
|
||||||
|
(result.hasMore ? ' · ' + tr('仍有到期计划,请再次执行') : ''),
|
||||||
|
);
|
||||||
|
if (result.errors.length) setError(result.errors[0].message);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tr('执行到期计划')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const data = Object.fromEntries(new FormData(e.currentTarget));
|
||||||
|
void act(async () => {
|
||||||
|
await api('/schedules', 'POST', {
|
||||||
|
...data,
|
||||||
|
operation,
|
||||||
|
sourceId,
|
||||||
|
targetId: operation === 'transfer' ? target : null,
|
||||||
|
received: operation === 'transfer' ? (same ? amount : data.received) : '0',
|
||||||
|
intervalDays: Number(data.intervalDays),
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
{tr('计划名称')}
|
||||||
|
<input name="name" required maxLength={100} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{tr('操作')}
|
||||||
|
<select value={operation} onChange={(e) => setOperation(e.target.value)}>
|
||||||
|
<option value="expense">{tr('支出')}</option>
|
||||||
|
<option value="transfer">{tr('转账')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{tr('付款账户')}
|
||||||
|
<AccountPicker
|
||||||
|
name="sourceId"
|
||||||
|
accounts={accounts}
|
||||||
|
value={sourceId}
|
||||||
|
onChange={(id) => {
|
||||||
|
setSource(id);
|
||||||
|
if (target === id) setTarget('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{operation === 'transfer' && (
|
||||||
|
<label>
|
||||||
|
{tr('转入账户')}
|
||||||
|
<AccountPicker
|
||||||
|
name="targetId"
|
||||||
|
accounts={accounts}
|
||||||
|
value={target}
|
||||||
|
onChange={setTarget}
|
||||||
|
excludeId={sourceId}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label>
|
||||||
|
{tr('金额')} ({sourceAccount?.currency})
|
||||||
|
<input
|
||||||
|
name="amount"
|
||||||
|
required
|
||||||
|
inputMode="decimal"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
pattern="(0|[1-9][0-9]{0,15})([.][0-9]{1,8})?"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{operation === 'transfer' && !same && (
|
||||||
|
<label>
|
||||||
|
{tr('实际到账')} ({targetAccount?.currency})
|
||||||
|
<input
|
||||||
|
name="received"
|
||||||
|
required
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="(0|[1-9][0-9]{0,15})([.][0-9]{1,8})?"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label>
|
||||||
|
{tr('首次执行时间')}
|
||||||
|
<input name="nextAt" type="datetime-local" required defaultValue={nowMinute()} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{tr('重复间隔(天,0为仅一次)')}
|
||||||
|
<input
|
||||||
|
name="intervalDays"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="3650"
|
||||||
|
required
|
||||||
|
defaultValue="30"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{tr('备注')}
|
||||||
|
<textarea name="notes" maxLength={1800} />
|
||||||
|
</label>
|
||||||
|
<button className="primary" disabled={busy || (operation === 'transfer' && !target)}>
|
||||||
|
{tr('保存计划')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{rows.map((p) => (
|
||||||
|
<div className="schedule-row" key={p.id}>
|
||||||
|
<span>
|
||||||
|
{p.name} ·{' '}
|
||||||
|
<strong className={p.operation === 'expense' ? 'danger-text' : undefined}>
|
||||||
|
{money(p.amount, accounts.find((a) => a.id === p.sourceId)?.currency || '')}
|
||||||
|
</strong>
|
||||||
|
<small>
|
||||||
|
{p.nextAt.replace('T', ' ')} ·{' '}
|
||||||
|
{p.intervalDays ? tr('每') + ` ${p.intervalDays} ` + tr('天') : tr('仅一次')} ·{' '}
|
||||||
|
{tr(p.completed ? '已完成' : p.enabled ? '启用' : '已暂停')}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
disabled={busy || p.completed}
|
||||||
|
onClick={() =>
|
||||||
|
void act(() => api('/schedules/' + p.id, 'PATCH', { enabled: !p.enabled }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tr(p.enabled ? '暂停' : '启用')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void act(() => api('/schedules/' + p.id, 'DELETE'))}
|
||||||
|
>
|
||||||
|
{tr('删除')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="notice error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{status && <p role="status">{status}</p>}
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AccountPicker } from './AccountPicker';
|
||||||
import { t as tr } from './i18n';
|
import { t as tr } from './i18n';
|
||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { nowMinute, money, requestUuid, type Position } from './api';
|
import { nowMinute, money, requestUuid, type Position } from './api';
|
||||||
@@ -31,29 +32,29 @@ export function TransferForm({
|
|||||||
<form onSubmit={save} className="transfer-form">
|
<form onSubmit={save} className="transfer-form">
|
||||||
<input type="hidden" name="requestId" value={requestId} />
|
<input type="hidden" name="requestId" value={requestId} />
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{tr(
|
{tr('转账会同时更新双方余额。余额不足时允许透支;手续费为负数时抵扣转出金额。')}
|
||||||
'双方余额同时更新。正数手续费额外扣除,负数表示优惠抵扣(不超过转出金额);转账时间不能早于双方最新余额记录。',
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
<label>
|
<label>
|
||||||
{tr('转出账户')}
|
{tr('转出账户')}
|
||||||
<select name="sourceId" value={sourceId} onChange={(e) => setSource(e.target.value)}>
|
<AccountPicker
|
||||||
{accounts.map((p) => (
|
name="sourceId"
|
||||||
<option key={p.id} value={p.id}>
|
accounts={accounts}
|
||||||
{p.name} · {money(p.amount, p.currency)}
|
value={sourceId}
|
||||||
</option>
|
onChange={(id) => {
|
||||||
))}
|
setSource(id);
|
||||||
</select>
|
if (targetId === id) setTarget(accounts.find((p) => p.id !== id)?.id || '');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{tr('转入账户')}
|
{tr('转入账户')}
|
||||||
<select name="targetId" value={targetId} onChange={(e) => setTarget(e.target.value)}>
|
<AccountPicker
|
||||||
{accounts.map((p) => (
|
name="targetId"
|
||||||
<option key={p.id} value={p.id} disabled={p.id === sourceId}>
|
accounts={accounts}
|
||||||
{p.name} · {p.currency}
|
value={targetId}
|
||||||
</option>
|
onChange={setTarget}
|
||||||
))}
|
excludeId={sourceId}
|
||||||
</select>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="form-columns">
|
<div className="form-columns">
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export type History = {
|
|||||||
positionId: string;
|
positionId: string;
|
||||||
name: string;
|
name: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
|
side: string;
|
||||||
before: string;
|
before: string;
|
||||||
after: string;
|
after: string;
|
||||||
delta: string;
|
delta: string;
|
||||||
@@ -44,6 +45,7 @@ export type History = {
|
|||||||
reason: string;
|
reason: string;
|
||||||
};
|
};
|
||||||
export type Position = {
|
export type Position = {
|
||||||
|
groupName?: string;
|
||||||
iconId?: string | null;
|
iconId?: string | null;
|
||||||
id: string;
|
id: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
@@ -104,7 +106,11 @@ export const nowMinute = () => new Date(Date.now() + 8 * 3600000).toISOString().
|
|||||||
export const displayTime = (s: string) => s.replace('T', ' ');
|
export const displayTime = (s: string) => s.replace('T', ' ');
|
||||||
export function money(v: string, currency: string) {
|
export function money(v: string, currency: string) {
|
||||||
const [whole, frac] = v.split('.');
|
const [whole, frac] = v.split('.');
|
||||||
return `${currency} ${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}${frac !== undefined ? '.' + frac : ''}`;
|
const symbol =
|
||||||
|
(
|
||||||
|
{ CNY: '¥', USD: 'US$', HKD: 'HK$', EUR: '€', GBP: '£', JPY: 'JP¥' } as Record<string, string>
|
||||||
|
)[currency] || currency;
|
||||||
|
return `${symbol} ${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}${frac !== undefined ? '.' + frac : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Transfer = {
|
export type Transfer = {
|
||||||
@@ -133,3 +139,9 @@ export function requestUuid() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PageResult<T> = { items: T[]; nextCursor: string | null; revealed: boolean };
|
export type PageResult<T> = { items: T[]; nextCursor: string | null; revealed: boolean };
|
||||||
|
|
||||||
|
export function positionAmount(p: Position, value = p.amount) {
|
||||||
|
return p.kind === 'debt' && p.side === 'liability' && !/^[-]?0(?:\.0+)?$/.test(value)
|
||||||
|
? '-' + value.replace(/^-/, '')
|
||||||
|
: value;
|
||||||
|
}
|
||||||
@@ -234,7 +234,7 @@
|
|||||||
"变化额": "Change",
|
"变化额": "Change",
|
||||||
"操作": "Actions",
|
"操作": "Actions",
|
||||||
"初始记录": "Initial record",
|
"初始记录": "Initial record",
|
||||||
"还款": "Repayment",
|
"还款": "Repay",
|
||||||
"更正": "Correction",
|
"更正": "Correction",
|
||||||
"转账转出": "Transfer out",
|
"转账转出": "Transfer out",
|
||||||
"转账到账": "Transfer in",
|
"转账到账": "Transfer in",
|
||||||
@@ -356,5 +356,75 @@
|
|||||||
"转账历史与双方金额不一致": "Paired payment history does not match its balances",
|
"转账历史与双方金额不一致": "Paired payment history does not match its balances",
|
||||||
"缺少配对转账记录": "Missing paired payment records",
|
"缺少配对转账记录": "Missing paired payment records",
|
||||||
"需要先下载备份": "Download a backup first",
|
"需要先下载备份": "Download a backup first",
|
||||||
"资金往来": "Money movements"
|
"资金往来": "Money movements",
|
||||||
|
"选择账户": "Choose an account",
|
||||||
|
"搜索账户、分组或币种": "Search accounts, groups or currencies",
|
||||||
|
"全部账户": "All accounts",
|
||||||
|
"按分组显示": "Group accounts",
|
||||||
|
"未分组": "Ungrouped",
|
||||||
|
"没有符合条件的账户": "No matching accounts",
|
||||||
|
"账户分组": "Account group",
|
||||||
|
"例如:日常、消费、理财": "For example: Daily, Spending, Investments",
|
||||||
|
"快速记账": "Quick balance entry",
|
||||||
|
"退出快速记账": "Exit quick entry",
|
||||||
|
"点击账户卡片,直接填写当前余额。": "Select an account to enter its current balance.",
|
||||||
|
"定时计划": "Scheduled payments",
|
||||||
|
"打开账户页时处理到期计划。每次最多执行20项,离线期间的计划会逐次补记。": "Due plans run when you open Accounts. Up to 20 payments are processed per batch; missed payments are posted in sequence.",
|
||||||
|
"添加计划": "Add a plan",
|
||||||
|
"执行到期计划": "Run due plans",
|
||||||
|
"计划名称": "Plan name",
|
||||||
|
"支出": "Expense",
|
||||||
|
"转账": "Transfer",
|
||||||
|
"金额": "Amount",
|
||||||
|
"实际到账": "Amount received",
|
||||||
|
"首次执行时间": "First payment time",
|
||||||
|
"重复间隔(天,0为仅一次)": "Repeat every (days, 0 for once)",
|
||||||
|
"保存计划": "Save plan",
|
||||||
|
"每": "Every",
|
||||||
|
"天": "days",
|
||||||
|
"仅一次": "Once",
|
||||||
|
"启用": "Enable",
|
||||||
|
"已暂停": "Paused",
|
||||||
|
"暂停": "Pause",
|
||||||
|
"删除": "Delete",
|
||||||
|
"已执行": "Processed",
|
||||||
|
"仍有到期计划,请再次执行": "More payments are due. Run again.",
|
||||||
|
"到期计划已记账": "Due payments recorded",
|
||||||
|
"计划支出": "Scheduled expense",
|
||||||
|
"剩余本金": "Outstanding principal",
|
||||||
|
"当前估值": "Current value",
|
||||||
|
"当前余额": "Current balance",
|
||||||
|
"收款": "Collect payment",
|
||||||
|
"收款 / 还款": "Collect / repay",
|
||||||
|
"记录时间(UTC+8)": "Record time (UTC+8)",
|
||||||
|
"欠款(待还)": "Payable",
|
||||||
|
"借款(待收)": "Receivable",
|
||||||
|
"转账会同时更新双方余额。余额不足时允许透支;手续费为负数时抵扣转出金额。": "Both balances update together. Overdrafts are allowed; a negative fee reduces the payment.",
|
||||||
|
"仅资产账户支持负余额": "Only asset accounts allow negative balances.",
|
||||||
|
"计划账户关联无效": "Invalid plan accounts.",
|
||||||
|
"计划时间无效": "Invalid scheduled time.",
|
||||||
|
"金额必须大于零": "Amount must be greater than zero.",
|
||||||
|
"请选择启用的资产账户": "Choose active asset accounts.",
|
||||||
|
"支出计划无需转入账户": "An expense plan does not need a receiving account.",
|
||||||
|
"计划不存在": "Plan not found.",
|
||||||
|
"计划账户已归档或不可用": "The account is archived or unavailable.",
|
||||||
|
"计划未执行,请刷新或检查账户状态后重试": "Payment was not processed. Refresh or check the account before retrying.",
|
||||||
|
"转账计划须选择不同账户并填写到账金额": "Choose different accounts and enter the received amount.",
|
||||||
|
"金额最多 16 位整数和 8 位小数": "Use at most 16 integer digits and 8 decimal places.",
|
||||||
|
"收支日历": "Cash flow calendar",
|
||||||
|
"月份": "Month",
|
||||||
|
"按账户余额变动估算;转账、借贷本金、初始余额和估值不计入。负债账户按相反方向计算。": "Estimated from account balance changes. Transfers, loan principal, opening balances and valuations are excluded. Liability account changes are reversed.",
|
||||||
|
"当日收支": "Daily cash flow",
|
||||||
|
"今天": "Today",
|
||||||
|
"当天没有收支记录": "No cash flow recorded on this day",
|
||||||
|
"收入": "Income",
|
||||||
|
"一": "Mon",
|
||||||
|
"二": "Tue",
|
||||||
|
"三": "Wed",
|
||||||
|
"四": "Thu",
|
||||||
|
"五": "Fri",
|
||||||
|
"六": "Sat",
|
||||||
|
"日": "Sun",
|
||||||
|
"已完成": "Completed",
|
||||||
|
"一次性计划已完成,请新建计划": "This one-time plan is complete. Create a new plan."
|
||||||
}
|
}
|
||||||
@@ -356,5 +356,75 @@
|
|||||||
"转账历史与双方金额不一致": "轉賬歷史與雙方金額不一致",
|
"转账历史与双方金额不一致": "轉賬歷史與雙方金額不一致",
|
||||||
"缺少配对转账记录": "缺少配對轉賬記錄",
|
"缺少配对转账记录": "缺少配對轉賬記錄",
|
||||||
"需要先下载备份": "需要先下載備份",
|
"需要先下载备份": "需要先下載備份",
|
||||||
"资金往来": "資金往來"
|
"资金往来": "資金往來",
|
||||||
|
"选择账户": "選擇賬戶",
|
||||||
|
"搜索账户、分组或币种": "搜索賬戶、分組或幣種",
|
||||||
|
"全部账户": "全部賬戶",
|
||||||
|
"按分组显示": "按分組顯示",
|
||||||
|
"未分组": "未分組",
|
||||||
|
"没有符合条件的账户": "沒有符合條件的賬戶",
|
||||||
|
"账户分组": "賬戶分組",
|
||||||
|
"例如:日常、消费、理财": "例如:日常、消費、理財",
|
||||||
|
"快速记账": "快速記賬",
|
||||||
|
"退出快速记账": "退出快速記賬",
|
||||||
|
"点击账户卡片,直接填写当前余额。": "點擊賬戶卡片,直接填寫當前餘額。",
|
||||||
|
"定时计划": "定時計劃",
|
||||||
|
"打开账户页时处理到期计划。每次最多执行20项,离线期间的计划会逐次补记。": "打開賬戶頁時處理到期計劃。每次最多執行20項,離線期間的計劃會逐次補記。",
|
||||||
|
"添加计划": "添加計劃",
|
||||||
|
"执行到期计划": "執行到期計劃",
|
||||||
|
"计划名称": "計劃名稱",
|
||||||
|
"支出": "支出",
|
||||||
|
"转账": "轉賬",
|
||||||
|
"金额": "金額",
|
||||||
|
"实际到账": "實際到賬",
|
||||||
|
"首次执行时间": "首次執行時間",
|
||||||
|
"重复间隔(天,0为仅一次)": "重複間隔(天,0為僅一次)",
|
||||||
|
"保存计划": "保存計劃",
|
||||||
|
"每": "每",
|
||||||
|
"天": "天",
|
||||||
|
"仅一次": "僅一次",
|
||||||
|
"启用": "啟用",
|
||||||
|
"已暂停": "已暫停",
|
||||||
|
"暂停": "暫停",
|
||||||
|
"删除": "刪除",
|
||||||
|
"已执行": "已執行",
|
||||||
|
"仍有到期计划,请再次执行": "仍有到期計劃,請再次執行",
|
||||||
|
"到期计划已记账": "到期計劃已記賬",
|
||||||
|
"计划支出": "計劃支出",
|
||||||
|
"剩余本金": "剩餘本金",
|
||||||
|
"当前估值": "當前估值",
|
||||||
|
"当前余额": "當前餘額",
|
||||||
|
"收款": "收款",
|
||||||
|
"收款 / 还款": "收款 / 還款",
|
||||||
|
"记录时间(UTC+8)": "記錄時間(UTC+8)",
|
||||||
|
"欠款(待还)": "欠款(待還)",
|
||||||
|
"借款(待收)": "借款(待收)",
|
||||||
|
"转账会同时更新双方余额。余额不足时允许透支;手续费为负数时抵扣转出金额。": "轉賬會同時更新雙方餘額。餘額不足時允許透支;手續費為負數時抵扣轉出金額。",
|
||||||
|
"仅资产账户支持负余额": "僅資產賬戶支持負餘額",
|
||||||
|
"计划账户关联无效": "計劃賬戶關聯無效",
|
||||||
|
"计划时间无效": "計劃時間無效",
|
||||||
|
"金额必须大于零": "金額必須大於零",
|
||||||
|
"请选择启用的资产账户": "請選擇啟用的資產賬戶",
|
||||||
|
"支出计划无需转入账户": "支出計劃無需轉入賬戶",
|
||||||
|
"计划不存在": "計劃不存在",
|
||||||
|
"计划账户已归档或不可用": "計劃賬戶已歸檔或不可用",
|
||||||
|
"计划未执行,请刷新或检查账户状态后重试": "計劃未執行,請刷新或檢查賬戶狀態後重試",
|
||||||
|
"转账计划须选择不同账户并填写到账金额": "轉賬計劃須選擇不同賬戶並填寫到賬金額",
|
||||||
|
"金额最多 16 位整数和 8 位小数": "金額最多 16 位整數和 8 位小數",
|
||||||
|
"收支日历": "收支日曆",
|
||||||
|
"月份": "月份",
|
||||||
|
"按账户余额变动估算;转账、借贷本金、初始余额和估值不计入。负债账户按相反方向计算。": "按賬戶餘額變動估算;轉賬、借貸本金、初始餘額和估值不計入。負債賬戶按相反方向計算。",
|
||||||
|
"当日收支": "當日收支",
|
||||||
|
"今天": "今天",
|
||||||
|
"当天没有收支记录": "當天沒有收支記錄",
|
||||||
|
"收入": "收入",
|
||||||
|
"一": "一",
|
||||||
|
"二": "二",
|
||||||
|
"三": "三",
|
||||||
|
"四": "四",
|
||||||
|
"五": "五",
|
||||||
|
"六": "六",
|
||||||
|
"日": "日",
|
||||||
|
"已完成": "已完成",
|
||||||
|
"一次性计划已完成,请新建计划": "一次性計劃已完成,請新建計劃"
|
||||||
}
|
}
|
||||||
@@ -1203,3 +1203,135 @@ footer {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-tools {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.picker-trigger {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.picker-dialog {
|
||||||
|
width: min(480px, calc(100vw - 32px));
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #dce1e5;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 24px;
|
||||||
|
color: #1d2939;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.picker-dialog::backdrop {
|
||||||
|
background: #10203080;
|
||||||
|
}
|
||||||
|
.picker-dialog input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.picker-dialog .actions {
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.picker-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.picker-option img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.picker-option small {
|
||||||
|
display: block;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
.schedule-panel {
|
||||||
|
margin: 20px 0;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #dce1e5;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.schedule-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-top: 1px solid #edf0f2;
|
||||||
|
}
|
||||||
|
.schedule-row > span {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
.schedule-row small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.schedule-panel form {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-text {
|
||||||
|
color: #c73542 !important;
|
||||||
|
}
|
||||||
|
.income-text {
|
||||||
|
color: #1b7855;
|
||||||
|
}
|
||||||
|
.calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.weekday {
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px;
|
||||||
|
color: #67727e;
|
||||||
|
}
|
||||||
|
.calendar-day {
|
||||||
|
min-height: 88px;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.calendar-day.selected {
|
||||||
|
outline: 2px solid #255b4e;
|
||||||
|
background: #edf7f1;
|
||||||
|
}
|
||||||
|
.calendar-day small {
|
||||||
|
font-size: 11px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.calendar-detail {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #e0e5e9;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.calendar-grid {
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.calendar-day {
|
||||||
|
padding: 5px;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
.calendar-day small {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-panel > .panel-title {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.calendar-panel input[type='month'] {
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
flex: 0 1 210px;
|
||||||
|
}
|
||||||
|
.calendar-panel h2 {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -52,3 +52,7 @@
|
|||||||
- Swagger UI 在前端代理地址真实加载,OpenAPI JSON 包含实际转账和设置字段及会话认证说明。README 已记录文档及 JSON 地址。
|
- Swagger UI 在前端代理地址真实加载,OpenAPI JSON 包含实际转账和设置字段及会话认证说明。README 已记录文档及 JSON 地址。
|
||||||
- 390×844 手机设置界面检查:页面内容宽度 375,无横向溢出,开关、下拉菜单与底部导航可用;未验证真实手机硬件和外网隧道端到端链路。
|
- 390×844 手机设置界面检查:页面内容宽度 375,无横向溢出,开关、下拉菜单与底部导航可用;未验证真实手机硬件和外网隧道端到端链路。
|
||||||
- 精确清理本次创建的临时验收账号及其财务数据;共享预置图标及实际用户保留。用户原有 Vite 配置修改保留,未纳入本次功能提交。
|
- 精确清理本次创建的临时验收账号及其财务数据;共享预置图标及实际用户保留。用户原有 Vite 配置修改保留,未纳入本次功能提交。
|
||||||
|
|
||||||
|
## 2026-10-02 账户分组、计划与日历
|
||||||
|
|
||||||
|
本轮 28 项单元测试、类型检查和生产构建通过。浏览器模拟数据验证与实际数据库未验证部分详见 [本轮验收](update-2026-10-02.md)。连接数据库发生 PROTOCOL_CONNECTION_LOST,迁移、图库初始化及真实 MySQL 并发集成仍需在可连接环境执行;前文并发余额不足的旧断言已随允许透支需求调整。
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
# WorthPath 首版设计
|
# WorthPath 首版设计
|
||||||
|
|
||||||
2026-10-02 更新:查询、历史分页、趋势重放、借入借出联动及三语言的当前实现和实测见 [性能与联动验收](performance.md)。以下首版设计中的历史 ZIP v3/v4 说明保留作格式沿革;当前导出为 v6,兼容读取 v3/v4/v5/v6 ZIP 和旧 JSON。
|
2026-10-02 更新:查询、历史分页、趋势重放、借入借出联动及三语言的当前实现和实测见 [性能与联动验收](performance.md)。以下首版设计中的历史 ZIP v3/v4 说明保留作格式沿革;当前导出为 v7,兼容读取 v3/v4/v5/v6/v7 ZIP 和旧 JSON。
|
||||||
|
|
||||||
|
本轮账户分组、定时计划、收支日历、迁移步骤与验证边界见 [更新说明](update-2026-10-02.md)。
|
||||||
|
|
||||||
NestJS API、React/Vite 前端和 MySQL;同源 /api,开发环境由 Vite 代理。
|
NestJS API、React/Vite 前端和 MySQL;同源 /api,开发环境由 Vite 代理。
|
||||||
|
|
||||||
用户拥有持有项目 Position(account/asset/debt)、汇率及登录会话。账户独立保留 asset/liability 属性;信用卡与贷款固定为负债。债务可链接到多个同用户项目,链接不参与求和。每个金额均为非负原币余额,负债按 side 减去。
|
用户拥有持有项目 Position(account/asset/debt)、汇率及登录会话。账户独立保留 asset/liability 属性;信用卡与贷款固定为负债。债务可链接到多个同用户项目,链接不参与求和。资产账户允许负的原币余额;独立资产及债务本金仍非负,负债按 side 减去。
|
||||||
|
|
||||||
Revision 保存按业务日期生效的绝对金额,每次金额更新新增记录,sequence 在数据库中单调分配以确定同日顺序;按业务日期和顺序读取,日趋势使用当日最后余额。错误记录须明确调用更正接口。历史前值和变化额按业务日期重新计算,避免更正早期记录后余额与趋势不一致。未来日期不允许。初始余额也写入历史。金额 DECIMAL(24,8),汇率 DECIMAL(24,12),API 使用十进制字符串;计算使用 Decimal,前端仅图形坐标使用 Number。
|
Revision 保存按业务日期生效的绝对金额,每次金额更新新增记录,sequence 在数据库中单调分配以确定同日顺序;按业务日期和顺序读取,日趋势使用当日最后余额。错误记录须明确调用更正接口。历史前值和变化额按业务日期重新计算,避免更正早期记录后余额与趋势不一致。未来日期不允许。初始余额也写入历史。金额 DECIMAL(24,8),汇率 DECIMAL(24,12),API 使用十进制字符串;计算使用 Decimal,前端仅图形坐标使用 Number。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# 2026-10-02 更新验收与使用
|
||||||
|
|
||||||
|
## 本次范围
|
||||||
|
|
||||||
|
按主目录 `update.md` 的要求完成 13 项代码更新,包括执行期间追加的红色金额和收支日历;完成时间为 2026-10-02 17:37(Asia/Hong_Kong)。
|
||||||
|
|
||||||
|
- 资产账户转出余额不足时允许透支。原币、8 位小数和双方事务不变;16 位整数范围改为检查金额绝对值。还款/收款超过剩余债务仍拒绝。
|
||||||
|
- 欠款在债务列表、详情、选择器和历史中显示负数,内部仍保存正的剩余本金并通过 side 参与净资产计算;债务详情只显示对应方向的“还款”或“收款”。
|
||||||
|
- 欠债、负债、负余额和支出金额使用红色。金额显示采用 ¥、HK$、US$、€、£、JP¥ 等符号;选择币种时仍保留代码。
|
||||||
|
- 新增/编辑账户可输入自定义分组。账户页按分组筛选;转账和借贷的账户选择使用可搜索弹窗,支持全部账户与分组视图。
|
||||||
|
- 账户页“快速记账”开启后,点击未归档账户直接编辑当前余额。保存新增历史,退出该模式恢复账户详情导航。
|
||||||
|
- 净资产轨迹裁掉第一条可见余额之前的日期;已有记录的零余额日期保留,空空间返回空轨迹。
|
||||||
|
- 新增定时支出/转账,支持一次性或每 N 天重复、暂停、删除。打开账户页或点击“执行到期计划”按需处理,每批最多 20 项;较长欠执行期可分批补记。一次性计划完成后不能重新启用。
|
||||||
|
- 新增独立“收支日历”入口:上方月历展示每日收入/支出,下方展示所选日期明细,默认今天。支持历史月份、未来日期不可选。
|
||||||
|
- 调整余额、债务方向与记录时间的文案,新增文案有 English 和繁体翻译。
|
||||||
|
- 内置图库从 20 扩充至 31:汇丰香港、恒生、渣打香港、东亚、星展香港、花旗香港、中银香港,以及 Binance、Gate、Bybit、Bitget。图标来自公开品牌站点,记录来源与下载摘要;PNG 已入库文件,页面无需访问品牌网站。
|
||||||
|
|
||||||
|
## 计划与收支的统计口径
|
||||||
|
|
||||||
|
计划只记账,不调用银行或交易所,不执行真实资金划转。一次执行写入当前业务分钟,备注保留原到期时间;不向既有余额历史中插入过去的支出,避免覆盖当前余额。重复计划按原到期时间加固定天数推进,尚未补完时下一批继续;每月不是固定天数,请按需要选择间隔。
|
||||||
|
|
||||||
|
每项计划在 Serializable 事务内锁定计划行,然后按固定顺序锁定账户;余额记录、转账记录和计划推进同事务提交。确定的“计划 ID + 到期时间”执行标识及完成状态用于防重复执行。失败不推进计划,保留错误提示供重试。隐藏账户未解锁时不显示/执行有关计划;归档账户计划执行失败并保留。所有资源按会话用户隔离。
|
||||||
|
|
||||||
|
现有数据保存的是余额快照,日历因此采用“账户余额变动估算”口径,不能视为完整交易流水:资产账户增加为收入、减少为支出,负债账户相反。只统计 balance/correction/scheduled_expense;初始余额(包括被更正的第一条记录)、估值、账户内部转账和借贷本金配对变化不计入。手动更新欠款减少也会被估算为收入,页面已注明负债账户反向计算;精确的经营收入/消费统计需要后续显式收支记录模型。
|
||||||
|
|
||||||
|
按变动发生日的可用历史汇率换算,保留十进制精度后汇总至两位小数;缺少汇率则该日标记不完整,不把外币当本位币。月查询只重放该月历史并为每个账户读取月前最后余额;点击日期只读取该日记录,避免为每次点击重复传整月明细。
|
||||||
|
|
||||||
|
## 数据与备份
|
||||||
|
|
||||||
|
迁移 `20261002000400_groups_schedules` 增加 Position.groupName 与 Schedule,不更改既有迁移。备份 ZIP manifest 升为 v7,新增 schedules.json,账户分组与计划余额/进度一并导出。旧 v3/v4/v5/v6 ZIP 和 JSON 仍支持;恢复时重映射计划账户 ID,一次性已完成计划不会再次扣款。备份后清空同时删除当前用户计划,财务数据/计划变化会使旧清空确认凭据失效。
|
||||||
|
|
||||||
|
应用本工作树更新后,在配置好数据库的环境执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm db:generate
|
||||||
|
pnpm db:migrate
|
||||||
|
pnpm build
|
||||||
|
pnpm --filter @worthpath/api icons:seed
|
||||||
|
pnpm test
|
||||||
|
pnpm test:integration
|
||||||
|
```
|
||||||
|
|
||||||
|
初始化图库仅按既有的固定内置图标 ID 追加/更新,不覆盖上传图标。
|
||||||
|
|
||||||
|
## 已验证与边界
|
||||||
|
|
||||||
|
- 前后端 `pnpm typecheck` 与生产 `pnpm build` 通过。
|
||||||
|
- `pnpm test` 28 项通过:透支/负转入余额、债务超额还款拒绝、请求去重、金额范围、债务符号/币种、轨迹裁剪、计划完成/重复/失败推进、日历口径/小数汇总/缺汇率、ZIP v7 分组计划往返及旧版兼容。
|
||||||
|
- 浏览器使用明确的模拟数据检查分组弹窗切换、转账选择和月历默认今天/日期切换/上下布局及红色支出,未操作真实用户财务数据。
|
||||||
|
- 新增 `test/update-integration.test.ts`,覆盖真实 MySQL 中计划并发执行一次、透支、补记、分组恢复、隐藏账户与日历隔离,纳入 `pnpm test:integration`。
|
||||||
|
- 本环境尝试连接现有数据库时发生 `PROTOCOL_CONNECTION_LOST`,因此本轮未应用数据库迁移、未向实际数据库导入新图标、未运行真实数据库集成测试。上述命令需在数据库可连接后完成;历史验收文档的旧测试结果不代表本轮已通过。
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
要求:
|
||||||
|
|
||||||
|
> 完成重要任务自行git commit,不需要push。
|
||||||
|
> 完成一项任务后添加为这项目任务添加删除线,并写上完成时间
|
||||||
|
> 这份文档可能随时更新,在完成所有任务之后应判断文档是否加入新的更新要求
|
||||||
|
> 同时需要继续更新docs文件夹或里面的文档
|
||||||
|
|
||||||
|
更新要求:
|
||||||
|
|
||||||
|
- ~~转出账户余额不足时直接变成负的而不是拒绝转账~~ — 已完成代码更新:2026-10-02 17:37(香港时间)
|
||||||
|
- ~~在债务中欠债显示负数~~ — 已完成代码更新:2026-10-02 17:37(香港时间)
|
||||||
|
- ~~在债务中改成显示(还款和收款),并且同时只显示一个~~ — 已完成代码更新:2026-10-02 17:37(香港时间)
|
||||||
|
- ~~在账号中可以自定义分组(比如设置日常账号组,消费账号组,理财账号组)~~ — 已完成代码更新:2026-10-02 17:37(香港时间)
|
||||||
|
- ~~在选择账号时(如还款,收款,转账)用弹窗选择,并在弹窗中设置显示显示全部账号还是分组显示~~ — 已完成代码更新:2026-10-02 17:37(香港时间)
|
||||||
|
- ~~优化所有文字描述~~ — 已完成代码更新:2026-10-02 17:37(香港时间)
|
||||||
|
- ~~CNY等在部分地方可以用图标替代~~ — 已完成代码更新:2026-10-02 17:37(香港时间)
|
||||||
|
- ~~优化净资产轨迹,一开始数据没这么多,不显示没有数据日期~~ — 已完成代码更新:2026-10-02 17:37(香港时间)
|
||||||
|
- ~~可以快速开启记账模式,在账号页点击即可设置金额~~ — 已完成代码更新:2026-10-02 17:37(香港时间)
|
||||||
|
- ~~添加定时计划(有想办法优化性能,可以在用户需要的时候在执行,不一定要真实的定时执行),可以设置定时支出,转账。~~ — 已完成代码更新:2026-10-02 17:37(香港时间)
|
||||||
|
- ~~丰富图标库,添加香港的主要部分银行,热门虚拟货币交易所的图标(如币安、gate、bybit、bitget)等等~~ — 已完成代码更新:2026-10-02 17:37(香港时间)
|
||||||
|
- ~~欠债、负债、支出显示红色~~ — 已完成代码更新:2026-10-02 17:37(香港时间)
|
||||||
|
- ~~添加日历功能:显示一个月每天的总收入或支出,点击看查看某一天的收入或支出(默认今天)两个应在同一个页面上下显示~~ — 已完成代码更新:2026-10-02 17:37(香港时间)
|
||||||
|
-
|
||||||
|
|
||||||
|
验证与部署边界见 [更新说明](docs/update-2026-10-02.md)。
|
||||||