From 86c6daf69583e907dd630b97c787af17390a9dac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E7=85=9C?= <1871263099@qq.com> Date: Fri, 2 Oct 2026 17:42:57 +0800 Subject: [PATCH] feat: add account groups, scheduled payments and cash flow calendar --- README.md | 8 + apps/api/assets/icons/21.png | Bin 0 -> 4037 bytes apps/api/assets/icons/22.png | Bin 0 -> 2201 bytes apps/api/assets/icons/23.png | Bin 0 -> 6469 bytes apps/api/assets/icons/24.png | Bin 0 -> 580 bytes apps/api/assets/icons/25.png | Bin 0 -> 4230 bytes apps/api/assets/icons/26.png | Bin 0 -> 1303 bytes apps/api/assets/icons/27.png | Bin 0 -> 702 bytes apps/api/assets/icons/28.png | Bin 0 -> 645 bytes apps/api/assets/icons/29.png | Bin 0 -> 2022 bytes apps/api/assets/icons/30.png | Bin 0 -> 1284 bytes apps/api/assets/icons/31.png | Bin 0 -> 511 bytes apps/api/assets/icons/sources.json | 66 +++ apps/api/package.json | 4 +- .../migration.sql | 12 + apps/api/prisma/schema.prisma | 24 ++ apps/api/scripts/expand-icons.py | 35 ++ apps/api/src/backup.ts | 61 +++ apps/api/src/calculation.ts | 6 + apps/api/src/calendar.ts | 161 +++++++ apps/api/src/main.ts | 4 + apps/api/src/openapi.ts | 3 + apps/api/src/portfolio.ts | 4 + apps/api/src/queries.ts | 12 +- apps/api/src/schedules.ts | 266 ++++++++++++ apps/api/src/transfers.ts | 227 +++++----- apps/api/src/validation.ts | 15 +- apps/api/src/zip.ts | 12 +- apps/api/test/transfers.test.ts | 23 +- apps/api/test/update-integration.test.ts | 157 +++++++ apps/api/test/update.test.ts | 345 +++++++++++++++ apps/api/test/zip.test.ts | 68 ++- apps/web/src/AccountPicker.tsx | 90 ++++ apps/web/src/App.tsx | 395 ++++++++++++------ apps/web/src/Calendar.tsx | 168 ++++++++ apps/web/src/DebtPaymentForm.tsx | 26 +- apps/web/src/SchedulePanel.tsx | 232 ++++++++++ apps/web/src/TransferForm.tsx | 35 +- apps/web/src/api.ts | 14 +- apps/web/src/locales/en.json | 74 +++- apps/web/src/locales/zh-Hant.json | 72 +++- apps/web/src/style.css | 132 ++++++ docs/acceptance.md | 4 + docs/architecture.md | 6 +- docs/update-2026-10-02.md | 52 +++ update.md | 25 ++ 46 files changed, 2548 insertions(+), 290 deletions(-) create mode 100644 apps/api/assets/icons/21.png create mode 100644 apps/api/assets/icons/22.png create mode 100644 apps/api/assets/icons/23.png create mode 100644 apps/api/assets/icons/24.png create mode 100644 apps/api/assets/icons/25.png create mode 100644 apps/api/assets/icons/26.png create mode 100644 apps/api/assets/icons/27.png create mode 100644 apps/api/assets/icons/28.png create mode 100644 apps/api/assets/icons/29.png create mode 100644 apps/api/assets/icons/30.png create mode 100644 apps/api/assets/icons/31.png create mode 100644 apps/api/prisma/migrations/20261002000400_groups_schedules/migration.sql create mode 100644 apps/api/scripts/expand-icons.py create mode 100644 apps/api/src/calendar.ts create mode 100644 apps/api/src/schedules.ts create mode 100644 apps/api/test/update-integration.test.ts create mode 100644 apps/api/test/update.test.ts create mode 100644 apps/web/src/AccountPicker.tsx create mode 100644 apps/web/src/Calendar.tsx create mode 100644 apps/web/src/SchedulePanel.tsx create mode 100644 docs/update-2026-10-02.md create mode 100644 update.md diff --git a/README.md b/README.md index 6c30f55..84636e4 100644 --- a/README.md +++ b/README.md @@ -76,3 +76,11 @@ Invoke-RestMethod http://localhost:5173/api/openapi.json ``` 接口定义由实际控制器路由和表单校验模式生成。文档本身不包含用户财务数据;除注册、登录及健康检查外,业务接口需登录会话 Cookie。建议先在同一站点登录,再通过前端代理地址使用文档;写入接口仍受来源校验和数据隔离约束。部署时将上述地址中的主机替换为自己的站点,并将 `/api` 代理到后端。实现使用 [NestJS Swagger](https://docs.nestjs.com/openapi/introduction)。 + +## 账户分组、定时计划与收支日历 + +账户可自定义分组,并通过弹窗选择全部或分组账户;快速记账模式点击账户直接录入当前余额。资产账户允许透支,欠债显示负数,负债/支出显示红色。 + +“定时计划”支持支出及转账,打开账户页时按需执行,每批最多 20 项;仅在本应用内记账。新增“收支日历”默认今天,月历与当日明细在同页上下展示,按账户余额变化估算,排除内部转账、借贷本金及初始余额。 + +本次需要新增数据库迁移并运行图库初始化,备份导出升级至 ZIP v7。操作步骤、31 个内置图标与验证边界见 [更新说明](docs/update-2026-10-02.md)。 diff --git a/apps/api/assets/icons/21.png b/apps/api/assets/icons/21.png new file mode 100644 index 0000000000000000000000000000000000000000..316088e6c446a353c5aae88158dead20077efac7 GIT binary patch literal 4037 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 LYokU3RNQ|7NWhQb literal 0 HcmV?d00001 diff --git a/apps/api/assets/icons/22.png b/apps/api/assets/icons/22.png new file mode 100644 index 0000000000000000000000000000000000000000..8d4a50d648a5dd7caf77273b3985b475febceb4b GIT binary patch literal 2201 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 literal 0 HcmV?d00001 diff --git a/apps/api/assets/icons/24.png b/apps/api/assets/icons/24.png new file mode 100644 index 0000000000000000000000000000000000000000..1e282d141693240464f4e08119426704c4b4f2bc GIT binary patch literal 580 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 literal 0 HcmV?d00001 diff --git a/apps/api/assets/icons/26.png b/apps/api/assets/icons/26.png new file mode 100644 index 0000000000000000000000000000000000000000..0f29f4669c6be72a17194d659bc81f86e873a4a8 GIT binary patch literal 1303 zcmV+y1?c*TP)TYYgI?l|xCTL}15`8FUL};0Tu!Yd-rs9t>s((dNKl%|x5TsEH zefWVI*@tOL1(|_}W>}G#*>2A6sO#vi?z*}boxv+IYZQq5N+ARQky+knp)a&j10!mq{h zca|W#J7or>8iRPx1a?JcuzidLZq32m;Se>FEPyQlUao3s-g4Oj!jw;q)jn#7mq?DZ zP+G#Oat0qCI_d7c;ds-@ykYgCSXt1WIem0(x+@y3%)-4Ht}{nMy&+2>>;v^&RkC^g zWeGSM8~w1MUFMQQT-aU42Ct@#`Mf5XJexO&E~s>kvv3$Qb)E87o@#w(={es6iNxUR zEFJ;Pla{sUmO#}Qrt*u|)1Z=#JPsGr3 zDaV2tSoQp!P1(VN-vJ08d=Gf%l^~I5NJx5s(j*Yq6BM97IuQAyl1*EZ$*pHAs_SDX zBIS!B@T-fg|5@n5pS!OqR;M(w+Z4S%H=W=IUD8%U0Tu^rv0+XQGV zXr3_V_w-}~Oor4{?gd{T2U3*`ZWrw1QZ5K&Hyr{B0uqUa>5$$aSsT7fb}zvF`LPDZ zgB(5p{$oYf`Bc&g*Cl$#F9_|eRf>+j0x*=oI0-I^CIM{(e)oNJ6nK$y2nfnLmU&+{ z8l5|W(h`uhlEUq^%vv`3en*?N6)f>ENsf6MQqq`UzZ004;HWn{0**128nS)p(Bj!8 z5logbt4Wkwp|Pi@r_q>VrPHg<m3({$vj>YD+JCGHOD0Lq@wyysm^ebu@ssI<(-a=yt z*<*q5L2Jv3{=xG&bZBKhg%^h{3F*TCmhdjc!vGkZuMc=C2+)eF3^veHwbbUB$kX3=nyiJ?c)Gu6kYH>sZ{o-M56R9NJ~hL2h6fWqG5w%^Lv0aw@I1> z;z%qOuL}ZoxhDLr5~O;^u>i+m7E%_V2WWacJ}*sTWXRRhg3Yi9!dizJ>JtF0%Vsij zmPR681%w48#{w7&&?i~glw>%izd63|TZ1Cd&EH5uDbU@&BuNlE0X$@A9t1R1LK*2S zF60|!M4odH8Hz+9)opES+a>=+K~D+VAZZ_n4oPj2euVU<6&4>jym~)r1YwtCcLO{C zl=s~xnOuIvTKk%0Hw$V3&?)E>09y-!l@hR1vfBaMbtye^4cGAB$Db_UMqY^MQ!@Yn N002ovPDHLkV1gO;V3q&? literal 0 HcmV?d00001 diff --git a/apps/api/assets/icons/27.png b/apps/api/assets/icons/27.png new file mode 100644 index 0000000000000000000000000000000000000000..d1debc4b36c42cce0c2d224f52bc742d845c31b5 GIT binary patch literal 702 zcmV;v0zv(WP)OZhn|zPi2J8}8)W<3xlNP|)ykk>jWA(TruNrECJ%ge z;^3Bm7w9!tfRLTCz%2m)s}A_+ib;l7 z&()y65DVnM5m)g8y>IS}hk)N`i)<5oGT62YD7x$3k~Ke&qr7sMY?`zek#tEyi^*HZ z;>P(7s0ti9i6A_HBz=&v^$2T1G!#|4kfIXl9)FqZ+#_%;!=D_LFGeLg-kn?cZx9Xo z3voJBD^~fG2yV1R<|G{SXCp;j5{c2mhND8wiT46MvA2N;!ZhLfs8&{^7MQ?v_`3APH`%a~UFE9gyB2HFJ)L+vc>}EQyVI7%LyOQYA(u(}ZoF-|SOgJ) kP(&Z5>2S{+>LUC80hWUgi=op~g8%>k07*qoM6N<$g2F&cOaK4? literal 0 HcmV?d00001 diff --git a/apps/api/assets/icons/28.png b/apps/api/assets/icons/28.png new file mode 100644 index 0000000000000000000000000000000000000000..f178c23ba72cdba52a208dd7f7b15a723bd94f76 GIT binary patch literal 645 zcmV;00($+4P)D8-UP z4j!7dy(}0cZ)CP4W;Ha#gUPi1An<1Ah4-I*vyXW&gg+^SVYt}s;t?~&B61lMga~^g zw970T?RNVefNst|XgKGtiO?1i1(-raBw@y#BuU=)o^fyBT=)dwooN-Xs5TL5IOkS# zJD_h~#lsLp66;Rr4irSV24i@SH>#b&FudhG-ohZ>(@plCR$vrnJk%6ggi(m(200gY z2Tvnocs49vVawt({s%B=7{)11YT>Jwe&Awu03P%D(>bJ_Tx~hs0o-5sj(e3(-`@Q) zgS1mPdnv>)PI1`7IIduM`9a^_+a5vU*{=F@(pWiPsb7# zkI-;p08td7TCMi&?d^K*&!ti+=kF~r$kNhXl*{F8+-kMP4j>w#;nbuM!#E7%FpR@6 z4#PMM<1mcFFivq&3xC+3$=QEXN>4O^S3BpkAI^FTF^ogbg)*!X)N;0mR)NiCGgpmj zwb?^`zgLRt*Lg~srl{4n#u65d&~Rb^mS7l%VH}2W7^ffr54(6KqJ0>J2zxAoI*cMh zb!L|5FpBqhp(S8pZZ7;vgn1Z31ntAa_67ZjyUg;dE-(VK+|IPCaol_0AHxiOHA#|> zI?CN5f*`1PugxCQnNA`&U>0rm$iyqdu0e&_+4<|tSRs-H5nTDVGV>7Ov$orKkBy_F f13fWw?KVFFxOL9e!bQi{00000NkvXXu0mjfJ3uPp literal 0 HcmV?d00001 diff --git a/apps/api/assets/icons/29.png b/apps/api/assets/icons/29.png new file mode 100644 index 0000000000000000000000000000000000000000..1faffb3f54d42e25a39cb5247027f7d53ff16dd4 GIT binary patch literal 2022 zcmV zO>9(E7>3_7%*1G*%oubb5X;WQ1?{2>+*sm*B~1+M3`2AUiI%QRTu>GyuCS2k3eq<0 zY)o*cF^29maY0>>uw#sLp+aU_Oe2x@m~VdUOgp8wbI(2Z{Jc+6rqE&TP3Gx)&iT&I zC}d4t4+9uS0mq>s-^*9I{YcDF1JGzbYWxadsrgk=^6#0UGI`ay7GX|9V}hdOP7Ml+ z*uXq0YSCSR>bi^qK7qz$J7I`>NIG2suxKio(hEd!e53(|Ic(s1T9MKTw4x<(rkOaN zCsIYt*%e4S#tO5|&M^r^q?3cBQDLCeQMgBWfCWkt_IqdMq)5s#1#_Ca+%*;A9y(9&d$q`fcV6-y|q(e!;FNgm1*BO~qiK7~Ll z`ZAlXh|#Dv`@I7-VXYd^OcwV=JVWCWDrzRVK&?8D^m{lZL4*c0M*G!?`Zr+;cPS6) zqTDVVV|Rh-T88ReYC*O#-mlQ8`ZQg4LldaD{0N86pTWN6->qCv4-Mh3*#-Rj*4qfM zDLaOWl66OY5=fR~P@YuiaOFX}PdwQ_fdcF}+fJVK!$+I)tRFstfayu0?gWyVb;>g! z>v71lErm7{sG%Q*(tI&_GN*yEs%$2ZtYM}+1F;@Y@^n*fJJS}ZfO7}{CXZDnHU-lD zuPmfImQgs}dT5Qlr2peBlqV-Njum`g=R zg2I~x->T1)$0^DR4Xxn`l*c{xzOgVTD36v4J_Ab}`PxMVI6 z-Hh9p>=KB!OPJ^EKs(gp34QfDJfQ&mSk3v?ir~^_tOT zsFIvA&0>SKiL-bfz^P`L^nog$O*P*P8^_B%o?S5ZM(kUDgx4&s#a=&r6i;s4^!_;D zOq#H@i8HW)ywS4w-c)MHIft$D4v)ghy;tp3=DiZ=+1Lp@y*LFM_$*Pu^7M{Zb)eM? zQCz16<^|mE?_2Unl zZ-@yv#CK#@hE0j&w^)9gO-dvcWcgsZtnndGNKmsN5OCsk45-5>B5;-Z5K#BGy(M!Ml zggkIoAb}F?O(GlX(6?P1jzfvu+sxpVKmsLlS0XP25-5?I5_uz#K#AOw$SZ*aO5~PA z-U%d7B6lS6>O*(8GW69o9Qg8@mj^kN9^7wkqgJbC)+p`9lN-12&wHngzpmE;Zt@6s*N^EZxI zJDNpAGIgrPv#}Fc`E(iwKc7a{$mt`d2|}RGc{@H*Tz-W8x9y}*7UvOp--P*0(eh`* zJ{x<-_-j(2NaT5XajLl`$utxw5_wiHWHZp9K$a;6B#%IWEK&?i9)SW`q8Ok&0tK=_ zF;IDessp)7$?}|+O?d<+P==HOmq(yL8BvNr9)SX7Kq(S=1PWxBQbh6y6v!Z@$m9_y zkReJD$|F!91C%0_N1#9{DMc)gK!H+Fid-In0u2b|X+ePofbwiYf%XXH>4gIA0m`!- z3X~Aa(;o`tpgezn@R4()t(~Aij>)q&akjb9&=)9>WAfCm+S%uC3JSCXlqb1BwS>=& z201d9$&-kh2-HaS+&GZKnB>S@Hd~2kFoCwnWAgZx5c@nmX#7@?Kk~j07oa=?u_Ot! zgvg;h=>%Ft#8948EEP~uwOB`DlqVH6ai>)W()bpUvH$i>c9_$kS}j+nka!0XA*Z_9 zIFm4#(N;e;FfR}4|9@!cI)E}FkVlRNw#8bA@<{VlR>yj$GBmCua43%+m~}x zRHM6nBcum$qf4Ic3DlvX?*bU_`!f29-Kx)ib}9eFAMwBVe*U)aN9u zKjKJ@Z>Bl_nS%5J^@=p!UJk;hEpTzn3)D?r%S3Dy3hz>;+^MxUP25368Q;Q{jX>Lz zNs5poP&nQeN}4*R6pSK$7)ccoMs^#^hS9}A`!-4W56aY!?AtLdQ~&?~07*qoM6N<$ Eg0|AY5&!@I literal 0 HcmV?d00001 diff --git a/apps/api/assets/icons/30.png b/apps/api/assets/icons/30.png new file mode 100644 index 0000000000000000000000000000000000000000..746b14414b36c1a8521b56c5cf46edb6a59de64f GIT binary patch literal 1284 zcmV+f1^fDmP)S!ts)lB6}6!c&P1x7EuBosxxC{scamC%H})Jq?} zl+lM`NJ5Ygf*ECLVU{HniFE5FN0JGdk9*FJb^EV7YtG!EM;F|gbM{&5|6gnEwb$M| zODS<7rOO|(ENc($yLvF{WE&bMlExMTYk*sT>-28&H}D&97&vC{8yY7J$3WW=+L((6 zp4r0!cn=r|t$;g4dCsNP2=i`4y*!}>3LEq^Tc}{WX3P4F@oqlt=oQW{pkyt}rk@$S@xX;Nd=K}xU41K}HpPN-DgyHXf z;rsdMKqGuusxeQm&%Zxkq5||K>X~fnTh#Su*IOF0*oY2%A3iMC{G8F)IV~UG$#!Ue zuP(nr=LesnN!ez49}u<&dOSMtl~Q62=^8baPZa4tmPz0#Ew$_QTS#VPw@ zCvhE)MfkhgsBbclS5o&Zu*bLEO4P9ykj@_2c=QO<@ehg<&wvfS1KcgZyMTL?1xxh= z@BnbTqPK`fb!syCPMglfz^~dDkge6my}~RsW?b`@-^V&Wt&!K(@s0w2hNg5VfIk=< zI2GEri`!fUye7<=IE-uFd0<9S74rI8V7FRv5h~xevwJ`+-*lIH|(<6d06|uk-3{rZv`AYx&fa?x1W(gjE;z{&-B!BS`t*Wnol%dXEf; zRIyuuD@5Rj4wu&0-ih%k>1>tFkT80LIVz5J0J;6~mYdIBQ0y4JQ--A)%r@X_;7#Dp za4Yf-cSn=-mUOnsrWD3bVYWG(GwX0Kh&Lk3#`6O43a+N!ZRpKwOq8v}xbCFv$Ew(K z?gh~!y9Wm4G0oww1?D|&LYyo7qSSidCEGD~(6QqtmsRhECLOM5CU%0cqqd#@>_lS~ u9>oqGcHsB|ct6|==UvI#Ui#JSLdw5xLcn5p6IVI_0000pLXmVa06^a#ve(g$GI7J1m64LChUDI6Yq8| zvIL~kufVz|F$M3DFz;?krC*W+y0+Q3=?qCs!MnCu?%HNGA~6Lwa~npm2D;yZ3xOKo z&4&Yb_D>klvkL+irPA7ab`5L+W2yAfL?BS@(Nufz1^ej3m+;2B@SpgW7|m$4phN(U zBe5-&eoBB(Bn1B)qyJt4VeX{TC%{<@Zi>+B4pZ5iw$i8Iw%QwKyqRkkxOL%^3^*^E z7P<1^OTYv05^6(72ya(h4z(e$CzZbZvjnjSq`Gfu{4F_eBTA@tGx3eP9M1UAd0^gs z=-Ot5NQl@Ez*?Zyf)YK4(Y&227^4A>V|q@)Nx@|cBuY>UH_ literal 0 HcmV?d00001 diff --git a/apps/api/assets/icons/sources.json b/apps/api/assets/icons/sources.json index 5afadf0..7281fc3 100644 --- a/apps/api/assets/icons/sources.json +++ b/apps/api/assets/icons/sources.json @@ -118,5 +118,71 @@ "source": "https://jr.jd.com/logo.png", "file": "20.png", "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" } ] diff --git a/apps/api/package.json b/apps/api/package.json index 9ade81d..fc367d8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,11 +5,11 @@ "dev": "node scripts/dev.cjs", "build": "tsc", "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:migrate": "node scripts/database.cjs deploy", "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", "icons:seed": "node scripts/seed-icons.cjs" }, diff --git a/apps/api/prisma/migrations/20261002000400_groups_schedules/migration.sql b/apps/api/prisma/migrations/20261002000400_groups_schedules/migration.sql new file mode 100644 index 0000000..c88538f --- /dev/null +++ b/apps/api/prisma/migrations/20261002000400_groups_schedules/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 37d564c..1739c55 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -30,6 +30,7 @@ model User { sessions Session[] icons Icon[] transfers Transfer[] + schedules Schedule[] } /// 登录会话与隐藏项目查看授权 model Session { @@ -67,6 +68,8 @@ model Position { name String @db.VarChar(100) /// 项目分类代码 category String @db.VarChar(40) + /// 自定义账户分组;空字符串表示未分组 + groupName String @default("") @db.VarChar(60) /// 原始币种代码 currency String @db.Char(3) /// 用户备注,原文保留 @@ -210,3 +213,24 @@ model Transfer { @@unique([userId,importedFromId]) @@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]) +} diff --git a/apps/api/scripts/expand-icons.py b/apps/api/scripts/expand-icons.py new file mode 100644 index 0000000..498bffc --- /dev/null +++ b/apps/api/scripts/expand-icons.py @@ -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']*>', 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) diff --git a/apps/api/src/backup.ts b/apps/api/src/backup.ts index 9491119..93f09e3 100644 --- a/apps/api/src/backup.ts +++ b/apps/api/src/backup.ts @@ -1,3 +1,4 @@ +import { scheduleInput } from './schedules'; import { movementDeltas } from './movement'; import { pairedReasons } from './validation'; import { @@ -109,6 +110,16 @@ const backupSchema = z }), ) .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), links: z.array(z.object({ sourceId: z.string().uuid(), targetId: z.string().uuid() }).strict()), rates: z.array(rateInput.safeExtend({ source: z.enum(['manual', 'frankfurter']) })), @@ -132,6 +143,7 @@ export function validateBackup(raw: unknown) { positionInput.parse({ name: p.name, category: p.category, + groupName: p.groupName, kind: p.kind, side: p.side, currency: p.currency, @@ -150,6 +162,31 @@ export function validateBackup(raw: unknown) { } 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(); + 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(), usedRevisions = new Set(); for (const t of b.transfers || []) { @@ -288,6 +325,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { kind: p.kind, side: p.side, category: p.category, + groupName: p.groupName, currency: p.currency, notes: p.notes, archived: p.archived, @@ -317,6 +355,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { }, }); const transfers = await client.transfer.findMany({ where: { userId } }); + const schedules = await client.schedule.findMany({ where: { userId } }); return backupSchema.parse({ format: 'worthpath', version: 2, @@ -349,6 +388,12 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { date: businessTime(effectiveDate), createdAt: t.createdAt.toISOString(), })), + schedules: schedules.map(({ userId, ...v }) => ({ + ...v, + amount: v.amount.toString(), + received: v.received.toString(), + nextAt: businessTime(v.nextAt), + })), positions, links: ps.flatMap((p) => p.outgoing.map((l) => ({ sourceId: l.sourceId, targetId: l.targetId })), @@ -467,6 +512,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { throw new BadRequestException('请先下载当前账号备份,再进入下一步(10 分钟内有效)'); if (this.fingerprint(await this.data(r.userId, tx)) !== s.backupDigest) throw new ConflictException('数据已变化,请重新下载备份'); + await tx.schedule.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.exchangeRate.deleteMany({ where: { userId: r.userId } }); @@ -490,6 +536,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { rates: b.rates.length, icons: (b.icons || []).length, transfers: (b.transfers || []).length, + schedules: (b.schedules || []).length, baseCurrency: b.baseCurrency, currentBaseCurrency: existing.baseCurrency, message: @@ -567,6 +614,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { kind: p.kind, side: p.side, category: p.category, + groupName: p.groupName, currency: p.currency, notes: p.notes, archived: p.archived, @@ -625,6 +673,19 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { 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) await tx.positionLink.create({ data: { sourceId: mapping.get(l.sourceId)!, targetId: mapping.get(l.targetId)! }, diff --git a/apps/api/src/calculation.ts b/apps/api/src/calculation.ts index 75d408d..8b2f4e2 100644 --- a/apps/api/src/calculation.ts +++ b/apps/api/src/calculation.ts @@ -40,6 +40,7 @@ export function history(p: Holding) { positionId: p.id, name: p.name, kind: p.kind, + side: p.side, currency: p.currency, date: businessDay(r.effectiveDate), time: businessTime(r.effectiveDate), @@ -153,6 +154,11 @@ export function trend( to: string, 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) => ({ p, rows: [...p.revisions].sort(compareRevisions), diff --git a/apps/api/src/calendar.ts b/apps/api/src/calendar.ts new file mode 100644 index 0000000..6a5a82b --- /dev/null +++ b/apps/api/src/calendar.ts @@ -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 }, + ); + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 7340a3a..3b634dc 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -7,6 +7,8 @@ import cookieParser from 'cookie-parser'; import helmet from 'helmet'; import { json } from 'express'; import { AuthController, AuthGuard, AuthService } from './auth'; +import { CalendarController } from './calendar'; +import { SchedulesController } from './schedules'; import { TransfersController } from './transfers'; import { PortfolioController } from './portfolio'; import { BackupController } from './backup'; @@ -53,6 +55,8 @@ class SafeErrors implements ExceptionFilter { ], controllers: [ TransfersController, + SchedulesController, + CalendarController, IconsController, AuthController, PortfolioController, diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index a1ed3b7..4686960 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -1,5 +1,6 @@ import { INestApplication } from '@nestjs/common'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { scheduleInput } from './schedules'; import { z } from 'zod'; import { credentials, @@ -35,6 +36,8 @@ export function setupOpenApi(app: INestApplication) { .object({ targetIds: z.array(z.string().uuid()).max(20) }) .strict(), 'POST /api/transfers': transferInput, + 'POST /api/schedules': scheduleInput, + 'PATCH /api/schedules/{id}': z.object({ enabled: z.boolean() }).strict(), 'PATCH /api/settings': z .object({ baseCurrency: currency.optional(), diff --git a/apps/api/src/portfolio.ts b/apps/api/src/portfolio.ts index 2df51c8..b935ba6 100644 --- a/apps/api/src/portfolio.ts +++ b/apps/api/src/portfolio.ts @@ -156,6 +156,8 @@ export class PortfolioController { }); if (!p) throw new NotFoundException('项目不存在'); 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({ where: { positionId: p.id, reason: { in: pairedReasons } }, orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }], @@ -198,6 +200,8 @@ export class PortfolioController { }); if (!p) throw new NotFoundException('项目不存在'); 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 } }); if (!original) throw new NotFoundException('历史记录不存在'); const transfer = await tx.revision.findFirst({ diff --git a/apps/api/src/queries.ts b/apps/api/src/queries.ts index 8abea6e..f42ab8b 100644 --- a/apps/api/src/queries.ts +++ b/apps/api/src/queries.ts @@ -131,7 +131,7 @@ export async function historyPage(db: Reader, userId: string, revealed: boolean, ...(revealed ? {} : { hidden: false }), ...(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 // 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, }) .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, name: r.name, kind: r.kind, - side: '', + side: r.side, currency: r.currency, revisions: [r], })[0], diff --git a/apps/api/src/schedules.ts b/apps/api/src/schedules.ts new file mode 100644 index 0000000..98b9108 --- /dev/null +++ b/apps/api/src/schedules.ts @@ -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), + }; + } +} diff --git a/apps/api/src/transfers.ts b/apps/api/src/transfers.ts index bcee4a0..c146991 100644 --- a/apps/api/src/transfers.ts +++ b/apps/api/src/transfers.ts @@ -44,116 +44,121 @@ export class TransfersController { }; } @Post() async create(@Req() r: UserRequest, @Body() body: unknown) { - const v = transferInput.parse(body), - when = toBusinessDate(v.date); - 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 }; - }); + const v = transferInput.parse(body); + return this.db.serial((tx) => executeMovement(tx, r, v)); } } +export async function executeMovement( + tx: Prisma.TransactionClient, + r: Pick, + v: ReturnType, +) { + 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 }; +} diff --git a/apps/api/src/validation.ts b/apps/api/src/validation.ts index 772b95f..4144d94 100644 --- a/apps/api/src/validation.ts +++ b/apps/api/src/validation.ts @@ -51,14 +51,18 @@ export const rateValue = z .regex(/^(0|[1-9]\d{0,11})(\.\d{1,12})?$/) .refine((s) => /[1-9]/.test(s), '汇率必须大于零'); 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 .object({ - amount, + amount: signedAmount, date: businessDate, notes, reason: z .enum([ 'initial', + 'scheduled_expense', 'balance', 'valuation', 'repayment', @@ -75,6 +79,7 @@ export const revisionInput = z .strict(); export const positionMeta = z .object({ + groupName: z.string().trim().max(60).optional(), iconId: z.string().uuid().nullable().optional(), name: z.string().trim().min(1).max(100), category: z.string().trim().min(1).max(40), @@ -88,11 +93,13 @@ export const positionInput = positionMeta kind: z.enum(['account', 'asset', 'debt']), side: z.enum(['asset', 'liability']), currency, - amount, + amount: signedAmount, date: businessDate, }) .strict() .superRefine((p, c) => { + if (p.amount.startsWith('-') && (p.kind !== 'account' || p.side !== 'asset')) + c.addIssue({ code: 'custom', message: '仅资产账户支持负余额' }); if ( (p.kind === 'asset' && p.side !== 'asset') || (p.kind === 'account' && @@ -121,10 +128,10 @@ export const credentials = z }) .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 .array(menuKey) - .max(5) + .max(6) .refine((v) => new Set(v).size === v.length, '菜单不可重复'); export const transferInput = z .object({ diff --git a/apps/api/src/zip.ts b/apps/api/src/zip.ts index e333bdf..88c2475 100644 --- a/apps/api/src/zip.ts +++ b/apps/api/src/zip.ts @@ -17,6 +17,7 @@ const files = [ 'rates.json', 'icons.json', 'transfers.json', + 'schedules.json', ] as const; const sha = (s: Buffer | string) => createHash('sha256').update(s).digest('hex'); export function packBackup(b: Backup) { @@ -34,6 +35,7 @@ export function packBackup(b: Backup) { 'rates.json': b.rates, 'icons.json': b.icons || [], 'transfers.json': b.transfers || [], + 'schedules.json': b.schedules || [], }; const contents = Object.fromEntries( files.map((name) => [name, JSON.stringify(data[name], null, 2)]), @@ -41,7 +43,7 @@ export function packBackup(b: Backup) { contents['manifest.json'] = JSON.stringify( { format: 'worthpath', - version: 6, + version: 7, exportedAt: b.exportedAt, files: files.map((name) => ({ name, sha256: sha(contents[name]) })), }, @@ -116,13 +118,13 @@ export async function readBackupZip(input: string | Buffer): Promise { const manifest = z .object({ 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(), files: z .array( 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), }) .strict() @@ -130,7 +132,8 @@ export async function readBackupZip(input: string | Buffer): Promise { const expected = files.filter( (f) => (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 ( contents.size !== expected.length + 1 || @@ -181,6 +184,7 @@ export async function readBackupZip(input: string | Buffer): Promise { links: parse('links.json'), rates: parse('rates.json'), ...(manifest.version >= 5 ? { transfers: parse('transfers.json') } : {}), + ...(manifest.version >= 7 ? { schedules: parse('schedules.json') } : {}), ...(manifest.version >= 4 ? { icons: parse('icons.json') } : {}), }; } catch { diff --git a/apps/api/test/transfers.test.ts b/apps/api/test/transfers.test.ts index 0e9e483..dc89a6e 100644 --- a/apps/api/test/transfers.test.ts +++ b/apps/api/test/transfers.test.ts @@ -85,8 +85,13 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba 400, ); 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, ); 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( race.filter((r) => r.status === 201).length, - 1, + 2, 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( ( 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); 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.deepEqual(backup.preferences.hiddenMenus, ['asset', 'debt']); const broken = structuredClone(backup); @@ -227,7 +236,7 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba 201, ); 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. assert.equal((await call('/settings', b.cookie)).data.showNotes, true); const c = await account(); @@ -254,7 +263,7 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba hidden: true, }); const visibleTransfers = (await call('/transfers', a.cookie)).data.items; - assert.equal(visibleTransfers.length, 2); + assert.equal(visibleTransfers.length, 3); assert.ok( visibleTransfers.every((t: any) => t.sourceId !== sourceId && t.targetId !== sourceId), ); diff --git a/apps/api/test/update-integration.test.ts b/apps/api/test/update-integration.test.ts new file mode 100644 index 0000000..2be4829 --- /dev/null +++ b/apps/api/test/update-integration.test.ts @@ -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(); + } +}); diff --git a/apps/api/test/update.test.ts b/apps/api/test/update.test.ts new file mode 100644 index 0000000..7f66461 --- /dev/null +++ b/apps/api/test/update.test.ts @@ -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); +}); diff --git a/apps/api/test/zip.test.ts b/apps/api/test/zip.test.ts index 595dbae..6b844d2 100644 --- a/apps/api/test/zip.test.ts +++ b/apps/api/test/zip.test.ts @@ -14,6 +14,7 @@ const empty = () => currencies: ['CNY'], icons: [], transfers: [], + schedules: [], positions: [], rates: [], links: [], @@ -43,6 +44,7 @@ test('ZIP contains separate JSON files and restores settings without authenticat 'links.json', 'manifest.json', 'rates.json', + 'schedules.json', 'settings.json', 'transfers.json', ]); @@ -102,10 +104,11 @@ test('legacy v3 ZIP remains readable without icons', async () => { const contents = packBackup(empty()); delete contents['icons.json']; delete contents['transfers.json']; + delete contents['schedules.json']; const manifest = JSON.parse(contents['manifest.json']); manifest.version = 3; 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); 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 () => { const contents = packBackup(empty()); delete contents['transfers.json']; + delete contents['schedules.json']; const manifest = JSON.parse(contents['manifest.json']); 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); const restored = validateBackup(await readBackupZip(await archive(contents))); assert.deepEqual(restored.icons, []); @@ -126,3 +132,61 @@ test('legacy v4 ZIP remains readable without transfers', async () => { assert.equal(restored.preferences?.showNotes, false); 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); +}); diff --git a/apps/web/src/AccountPicker.tsx b/apps/web/src/AccountPicker.tsx new file mode 100644 index 0000000..90231c6 --- /dev/null +++ b/apps/web/src/AccountPicker.tsx @@ -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(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 ( +
+ + + +
+

{tr('选择账户')}

+ +
+ setQuery(e.target.value)} + /> +
+ + +
+ {groups.map((group) => ( +
+

{tr(group)}

+ {rows + .filter((p) => !grouped || (p.groupName || '未分组') === group) + .map((p) => ( + + ))} +
+ ))} + {!rows.length &&

{tr('没有符合条件的账户')}

} +
+
+ ); +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c58e6a4..531d931 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,6 +2,7 @@ import { t as tr, useLocale, LanguagePicker } from './i18n'; import { useState, useEffect, useRef, type FormEvent, type ReactNode } from 'react'; import { LayoutDashboard, + CalendarDays, Wallet, Landmark, HandCoins, @@ -23,6 +24,7 @@ import { ApiError, currencies, money, + positionAmount, today, nowMinute, displayTime, @@ -35,6 +37,8 @@ import { type PageResult, } from './api'; import './style.css'; +import { Calendar } from './Calendar'; +import { SchedulePanel } from './SchedulePanel'; import { IconPicker } from './IconPicker'; import { TransferForm } from './TransferForm'; import { DebtPaymentForm, operationLabel, type DebtOperation } from './DebtPaymentForm'; @@ -46,6 +50,7 @@ const labels: Record = { asset: '独立资产', debt: '债务', history: '变化记录', + calendar: '收支日历', settings: '设置与备份', }; const categories: Record = { @@ -154,7 +159,7 @@ function Chart({ rows, base }: { rows: Total[]; base: string }) { <>
{tr('总资产')} - {tr('总负债')} + {tr('总负债')} {tr('净资产')} {base}
@@ -176,7 +181,7 @@ function Chart({ rows, base }: { rows: Total[]; base: string }) { ) .join(' ')} fill="none" - stroke={['#199b76', '#dc9463', '#457bca'][i]} + stroke={['#199b76', '#c73542', '#457bca'][i]} strokeWidth="3" /> ))} @@ -222,6 +227,9 @@ export default function App() { [loading, setLoading] = useState(false), [error, setError] = useState(''), [success, setSuccess] = useState(''); + const [calendarRefresh, setCalendarRefresh] = useState(0); + const [quickMode, setQuickMode] = useState(false), + [accountGroup, setAccountGroup] = useState(''); const [fxStatus, setFxStatus] = useState<{ state: string; message: string; @@ -255,6 +263,8 @@ export default function App() { userRef.current = user; const activityAt = useRef(0); function clearAccount() { + setQuickMode(false); + setAccountGroup(''); setClearStep(0); setClearConfirmation(0); activityAt.current = 0; @@ -331,6 +341,16 @@ export default function App() { } setOverview({ ...o, trend: t.items, recent: recent.items }); } 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([ api('/positions?kind=' + page), selected @@ -359,6 +379,8 @@ export default function App() { setHistoryCursor(h.nextCursor); setTransfers(t.items); setTransferCursor(t.nextCursor); + } else if (page === 'calendar') { + setCalendarRefresh((value) => value + 1); } else if (page === 'settings') { const settings = await api( '/settings' + (settingsSection === 'rates' ? '?rates=true' : ''), @@ -725,6 +747,7 @@ export default function App() { ['asset', Landmark], ['debt', HandCoins], ['history', HistoryIcon], + ['calendar', CalendarDays], ['settings', Settings], ] as const; const visibleNav = nav.filter(([key]) => !(user.hiddenMenus || []).includes(key)); @@ -921,7 +944,13 @@ export default function App() { - {money(value, user.baseCurrency)} + + {money(value, user.baseCurrency)} + {title === tr('净资产') ? tr('总资产 − 总负债') @@ -1055,18 +1084,26 @@ export default function App() { {categoryLabel(p)} ·{' '} {p.kind === 'debt' - ? tr(p.side === 'asset' ? '借出(应收)' : '借入(应付)') + ? tr(p.side === 'asset' ? '借款(待收)' : '欠款(待还)') : p.side === 'asset' ? tr('资产') : tr('负债')} {p.archived ? tr(' · 已归档') : ''} -

{money(p.amount, p.currency)}

+

+ {money(positionAmount(p), p.currency)} +

{p.hidden && {tr('隐藏项目')}}

{tr('本位币:')} {p.converted !== null - ? money(p.converted || '0', user.baseCurrency) + ? money(positionAmount(p, p.converted || '0'), user.baseCurrency) : tr('缺少汇率')}

{p.notes || tr('暂无备注')}

@@ -1085,7 +1122,7 @@ export default function App() { className="secondary" onClick={() => setModal({ kind: 'debt-payment', p })} > - {tr('债务收款 / 还款')} + {tr('收款 / 还款')} )} @@ -1103,18 +1140,6 @@ export default function App() { > {operationLabel(p.side === 'asset' ? 'collect' : 'repay')} - )} + + + {quickMode &&

{tr('点击账户卡片,直接填写当前余额。')}

} + p.kind === 'account' && p.side === 'asset' && !p.archived, + )} + refresh={() => load(false, true)} + /> + + )} {positions.some((p) => p.kind === page) ? (
{positions - .filter((p) => p.kind === page) + .filter( + (p) => + p.kind === page && + (page !== 'account' || + !accountGroup || + (p.groupName || '未分组') === accountGroup), + ) .map((p) => ( ))} @@ -1250,92 +1333,97 @@ export default function App() { ))} {page === 'history' && ( -
-
- - -
-

{tr('资金往来')}

- {transfers.length ? ( -
- {transfers.map((t) => ( -
-
- - {operationLabel(t.operation || 'transfer')} · - - {' ↔ '} - - - {displayTime(t.date)} -
-

- {money(t.amount, t.sourceCurrency)} → {money(t.received, t.targetCurrency)}{' '} - {tr('· 手续费')} - {money(t.fee, t.sourceCurrency)} -

- {t.notes &&

{t.notes}

} -
- ))} + <> +
+
+ +
- ) : ( -

{tr('暂无账户间转账记录。')}

- )} - {transferCursor && ( - - )} -

{tr('全部金额变化')}

-

- {tr('原币绝对余额记录;负债减少即还款。选择项目可查看详情并更正。')} -

- { - setSelected(id); - setPage(historyRows.find((h) => h.positionId === id)?.kind || 'account'); - }} - /> - {historyCursor && ( - - )} -
+

{tr('资金往来')}

+ {transfers.length ? ( +
+ {transfers.map((t) => ( +
+
+ + {operationLabel(t.operation || 'transfer')} · + + {' ↔ '} + + + {displayTime(t.date)} +
+

+ {money(t.amount, t.sourceCurrency)} →{' '} + {money(t.received, t.targetCurrency)} {tr('· 手续费')} + {money(t.fee, t.sourceCurrency)} +

+ {t.notes &&

{t.notes}

} +
+ ))} +
+ ) : ( +

{tr('暂无账户间转账记录。')}

+ )} + {transferCursor && ( + + )} +

{tr('全部金额变化')}

+

+ {tr('原币绝对余额记录;负债减少即还款。选择项目可查看详情并更正。')} +

+ { + setSelected(id); + setPage(historyRows.find((h) => h.positionId === id)?.kind || 'account'); + }} + /> + {historyCursor && ( + + )} +
+ + )} + {page === 'calendar' && ( + )} {page === 'settings' && ( <> @@ -1781,7 +1869,9 @@ export default function App() { {(modal.kind === 'account' || modal.p?.kind === 'account') && ( - + <> + + + + + {[...new Set(positions.map((p) => p.groupName).filter(Boolean))].map( + (g) => ( + + + )}

@@ -1979,13 +2087,26 @@ export default function App() { )} {['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && ( )} {['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && ( - + - {money(h.before, h.currency)} - {money(h.after, h.currency)} - {money(h.delta, h.currency)} + + {money( + h.kind === 'debt' && h.side === 'liability' && Number(h.before) !== 0 + ? '-' + h.before + : h.before, + h.currency, + )} + + + {money( + h.kind === 'debt' && h.side === 'liability' && Number(h.after) !== 0 + ? '-' + h.after + : h.after, + h.currency, + )} + + + {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, + )} + {h.notes || '—'} {correct && ( diff --git a/apps/web/src/Calendar.tsx b/apps/web/src/Calendar.tsx new file mode 100644 index 0000000..e2e05a8 --- /dev/null +++ b/apps/web/src/Calendar.tsx @@ -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([]), + [rows, setRows] = useState([]), + [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 ( +

+
+

{tr('收支日历')}

+ { + if (e.target.value) { + setMonth(e.target.value); + setSelected( + e.target.value === today().slice(0, 7) ? today() : e.target.value + '-01', + ); + } + }} + /> +
+

+ {tr('按账户余额变动估算;转账、借贷本金、初始余额和估值不计入。负债账户按相反方向计算。')} +

+
+ {['一', '二', '三', '四', '五', '六', '日'].map((v) => ( + + {tr(v)} + + ))} + {Array.from({ length: offset }, (_, i) => ( + + ))} + {days.map((d) => ( + + ))} +
+
+
+

+ {selected} · {tr('当日收支')} +

+ +
+ {day && ( +

+ {tr('收入')}:{day.complete ? money(day.income, base) : tr('缺少汇率')} ·{' '} + + {tr('支出')}:{day.complete ? money(day.expense, base) : tr('缺少汇率')} + +

+ )} + {loading ? ( +

{tr('正在刷新数据…')}

+ ) : rows.length ? ( + rows.map((v) => ( +
+ + {v.name} + {v.notes} + + + {v.direction === 'expense' ? '−' : '+'} + {money(v.amount, v.currency)} + + {v.converted === null && {tr('缺少汇率')}} +
+ )) + ) : ( +

{tr('当天没有收支记录')}

+ )} +
+ {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/apps/web/src/DebtPaymentForm.tsx b/apps/web/src/DebtPaymentForm.tsx index b94c3a2..13370d9 100644 --- a/apps/web/src/DebtPaymentForm.tsx +++ b/apps/web/src/DebtPaymentForm.tsx @@ -1,5 +1,6 @@ +import { AccountPicker } from './AccountPicker'; 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'; export type DebtOperation = 'borrow' | 'lend' | 'collect' | 'repay'; export const operationLabel = (operation: string) => @@ -8,8 +9,8 @@ export const operationLabel = (operation: string) => { borrow: '借入到账', lend: '借出付款', - collect: '收回借款', - repay: '偿还债务', + collect: '收款', + repay: '还款', transfer: '账户转账', } as Record )[operation] || operation, @@ -63,8 +64,8 @@ export function DebtPaymentForm({ > {debts.map((p) => ( ))} @@ -85,17 +86,12 @@ export function DebtPaymentForm({

{tr('账户与债务同时记账并自动建立关联;这里填写本次金额,不是操作后的余额。')} @@ -139,7 +135,7 @@ export function DebtPaymentForm({ {tr('手续费只影响账户余额:付款时额外扣除,收款时从到账金额扣除;负数表示优惠。')}