From 6dd6fffb93582cfa7aaa3dc4fd098bbc4444e550 Mon Sep 17 00:00:00 2001
From: gin-melodic <4485145+gin-melodic@users.noreply.github.com>
Date: Fri, 16 Jan 2026 13:44:33 +0800
Subject: [PATCH 01/14] feat: add protobuf support and update auth flow
- Add @bufbuild/protobuf, protobufjs, decimal.js, long, and ts-proto
dependencies for protobuf functionality
- Add @types/decimal.js as dev dependency
- Introduce 'proto' script in package.json for proto generation
- Replace direct API calls with useRegister hook in register page
- Update useLogin hook usage in login page component
- Modify login mutation parameters to use correct field names
- Update mock user login to use UserLevelType enum values
- Remove manual loading state in favor of mutation pending state
- Adjust routing after registration to dashboard
---
package-lock.json | 221 +-
package.json | 9 +-
scripts/generate-proto.js | 55 +
src/app/register/page.tsx | 38 +-
src/components/auth/LoginPage.tsx | 20 +-
src/components/features/Accounts.tsx | 82 +-
src/components/features/AddAccountModal.tsx | 54 +-
src/components/features/BalanceTrendChart.tsx | 16 +-
src/components/features/Dashboard.tsx | 18 +-
src/components/features/EditAccountModal.tsx | 29 +-
src/components/features/Transactions.tsx | 94 +-
.../features/settings/CurrencySettings.tsx | 9 +-
.../features/settings/DataExportSettings.tsx | 14 +-
.../features/settings/MainSettings.tsx | 8 +-
.../features/settings/Subscription.tsx | 5 +-
.../features/settings/ThemeSettings.tsx | 5 +-
src/context/GlobalContext.tsx | 11 +-
src/lib/api.ts | 4 +-
src/lib/crypto/browser-crypto.ts | 113 +
src/lib/crypto/encoding.ts | 21 +
src/lib/data.ts | 13 +-
src/lib/hooks/useAccounts.test.tsx | 69 +-
src/lib/hooks/useAccounts.ts | 47 +-
src/lib/hooks/useAuth.test.tsx | 213 +-
src/lib/hooks/useAuth.ts | 43 +-
src/lib/hooks/useTransactions.test.tsx | 52 +-
src/lib/hooks/useTransactions.ts | 55 +-
src/lib/hooks/useWebSocket.ts | 2 +-
src/lib/network/secure-client.ts | 378 ++++
src/lib/proto/account/v1/account.ts | 1810 +++++++++++++++++
src/lib/proto/auth/v1/auth.ts | 1407 +++++++++++++
src/lib/proto/base/base.ts | 862 ++++++++
src/lib/proto/config/v1/config.ts | 907 +++++++++
src/lib/proto/dashboard/v1/dashboard.ts | 870 ++++++++
src/lib/proto/data/v1/data.ts | 780 +++++++
src/lib/proto/google/protobuf/struct.ts | 591 ++++++
src/lib/proto/google/protobuf/timestamp.ts | 219 ++
src/lib/proto/health/v1/health.ts | 168 ++
src/lib/proto/hello/v1/hello.ts | 171 ++
src/lib/proto/task/v1/task.ts | 1181 +++++++++++
src/lib/proto/transaction/v1/transaction.ts | 1433 +++++++++++++
src/lib/proto/user/v1/user.ts | 836 ++++++++
src/lib/services/accountService.ts | 118 +-
src/lib/services/authService.ts | 15 +-
src/lib/services/dashboardService.ts | 17 +-
src/lib/services/index.ts | 3 +
src/lib/services/secureAuthService.ts | 179 ++
src/lib/services/taskService.ts | 26 +-
src/lib/services/transactionService.ts | 92 +-
src/lib/types.test.ts | 74 +-
src/lib/types.ts | 244 +--
src/lib/utils/constant.ts | 1 +
src/lib/utils/money.ts | 134 ++
src/locales/en/accounts.json | 4 +-
src/locales/en/transactions.json | 11 +-
src/locales/ja/accounts.json | 4 +-
src/locales/ja/transactions.json | 11 +-
src/locales/zh-CN/accounts.json | 4 +-
src/locales/zh-CN/transactions.json | 11 +-
src/locales/zh-TW/accounts.json | 4 +-
src/locales/zh-TW/transactions.json | 11 +-
61 files changed, 13213 insertions(+), 683 deletions(-)
create mode 100644 scripts/generate-proto.js
create mode 100644 src/lib/crypto/browser-crypto.ts
create mode 100644 src/lib/crypto/encoding.ts
create mode 100644 src/lib/network/secure-client.ts
create mode 100644 src/lib/proto/account/v1/account.ts
create mode 100644 src/lib/proto/auth/v1/auth.ts
create mode 100644 src/lib/proto/base/base.ts
create mode 100644 src/lib/proto/config/v1/config.ts
create mode 100644 src/lib/proto/dashboard/v1/dashboard.ts
create mode 100644 src/lib/proto/data/v1/data.ts
create mode 100644 src/lib/proto/google/protobuf/struct.ts
create mode 100644 src/lib/proto/google/protobuf/timestamp.ts
create mode 100644 src/lib/proto/health/v1/health.ts
create mode 100644 src/lib/proto/hello/v1/hello.ts
create mode 100644 src/lib/proto/task/v1/task.ts
create mode 100644 src/lib/proto/transaction/v1/transaction.ts
create mode 100644 src/lib/proto/user/v1/user.ts
create mode 100644 src/lib/services/secureAuthService.ts
create mode 100644 src/lib/utils/constant.ts
create mode 100644 src/lib/utils/money.ts
diff --git a/package-lock.json b/package-lock.json
index 3cb88d9..c83905c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "gaap-web",
"version": "0.1.0",
"dependencies": {
+ "@bufbuild/protobuf": "^2.10.2",
"@marsidev/react-turnstile": "^1.4.0",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -23,12 +24,15 @@
"@tanstack/react-query": "^5.90.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "decimal.js": "^10.6.0",
"i18next": "^25.6.3",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-resources-to-backend": "^1.2.1",
+ "long": "^5.3.2",
"lucide-react": "^0.554.0",
"next": "^16.1.0",
"next-themes": "^0.4.6",
+ "protobufjs": "^8.0.0",
"qrcode.react": "^4.2.0",
"react": "19.2.0",
"react-dom": "19.2.0",
@@ -44,6 +48,7 @@
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
+ "@types/decimal.js": "^0.0.32",
"@types/node": "^20.19.25",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -53,6 +58,7 @@
"jsdom": "^27.3.0",
"nodemon": "^3.1.9",
"tailwindcss": "^4",
+ "ts-proto": "^2.10.1",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
"vitest": "^4.0.16"
@@ -171,7 +177,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -432,6 +437,12 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bufbuild/protobuf": {
+ "version": "2.10.2",
+ "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.2.tgz",
+ "integrity": "sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==",
+ "license": "(Apache-2.0 AND BSD-3-Clause)"
+ },
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@@ -520,7 +531,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18"
},
@@ -564,7 +574,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -1995,6 +2004,70 @@
"node": ">=12.4.0"
}
},
+ "node_modules/@protobufjs/aspromise": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/base64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/codegen": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
+ "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/eventemitter": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
+ "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/fetch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
+ "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.1",
+ "@protobufjs/inquire": "^1.1.0"
+ }
+ },
+ "node_modules/@protobufjs/float": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
+ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/inquire": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
+ "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/path": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
+ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/pool": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
+ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/utf8": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
+ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -4422,7 +4495,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz",
"integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@tanstack/query-core": "5.90.12"
},
@@ -4674,6 +4746,13 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
+ "node_modules/@types/decimal.js": {
+ "version": "0.0.32",
+ "resolved": "https://registry.npmjs.org/@types/decimal.js/-/decimal.js-0.0.32.tgz",
+ "integrity": "sha512-qiZoeFWRa6SaedYkSV8VrGV8xDGV3C6usFlUKOOl/fpvVdKVx+eHm+yHjJbGIuHgNsoe24wUddwLJGVBZFM5Ow==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -4704,9 +4783,7 @@
"version": "20.19.25",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz",
"integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
- "dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -4717,7 +4794,6 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -4728,7 +4804,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -4785,7 +4860,6 @@
"integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.48.0",
"@typescript-eslint/types": "8.48.0",
@@ -5416,7 +5490,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5830,7 +5903,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@@ -5924,6 +5996,19 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/case-anything": {
+ "version": "2.1.13",
+ "resolved": "https://registry.npmjs.org/case-anything/-/case-anything-2.1.13.tgz",
+ "integrity": "sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.13"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mesqueeb"
+ }
+ },
"node_modules/chai": {
"version": "6.2.1",
"resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.1.tgz",
@@ -6318,9 +6403,8 @@
},
"node_modules/decimal.js": {
"version": "10.6.0",
- "resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
- "dev": true,
"license": "MIT"
},
"node_modules/decimal.js-light": {
@@ -6417,6 +6501,29 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/dprint-node": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/dprint-node/-/dprint-node-1.0.8.tgz",
+ "integrity": "sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^1.0.3"
+ }
+ },
+ "node_modules/dprint-node/node_modules/detect-libc": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
+ "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "detect-libc": "bin/detect-libc.js"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -6736,7 +6843,6 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -6922,7 +7028,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -7723,7 +7828,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@@ -8351,7 +8455,6 @@
"integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.28",
"@asamuzakjp/dom-selector": "^6.7.6",
@@ -8770,6 +8873,12 @@
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"license": "MIT"
},
+ "node_modules/long": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+ "license": "Apache-2.0"
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -9484,6 +9593,30 @@
"react-is": "^16.13.1"
}
},
+ "node_modules/protobufjs": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.0.0.tgz",
+ "integrity": "sha512-jx6+sE9h/UryaCZhsJWbJtTEy47yXoGNYI4z8ZaRncM0zBKeRqjO2JEcOUYwrYGb1WLhXM1FfMzW3annvFv0rw==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.2",
+ "@protobufjs/base64": "^1.1.2",
+ "@protobufjs/codegen": "^2.0.4",
+ "@protobufjs/eventemitter": "^1.1.0",
+ "@protobufjs/fetch": "^1.1.0",
+ "@protobufjs/float": "^1.0.2",
+ "@protobufjs/inquire": "^1.1.0",
+ "@protobufjs/path": "^1.1.2",
+ "@protobufjs/pool": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.0",
+ "@types/node": ">=13.7.0",
+ "long": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
@@ -9535,7 +9668,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -9545,7 +9677,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -9584,15 +9715,13 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -9752,8 +9881,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -10540,8 +10668,7 @@
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
@@ -10630,7 +10757,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -10730,6 +10856,42 @@
"typescript": ">=4.8.4"
}
},
+ "node_modules/ts-poet": {
+ "version": "6.12.0",
+ "resolved": "https://registry.npmjs.org/ts-poet/-/ts-poet-6.12.0.tgz",
+ "integrity": "sha512-xo+iRNMWqyvXpFTaOAvLPA5QAWO6TZrSUs5s4Odaya3epqofBu/fMLHEWl8jPmjhA0s9sgj9sNvF1BmaQlmQkA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dprint-node": "^1.0.8"
+ }
+ },
+ "node_modules/ts-proto": {
+ "version": "2.10.1",
+ "resolved": "https://registry.npmjs.org/ts-proto/-/ts-proto-2.10.1.tgz",
+ "integrity": "sha512-4sOE1hCs0uobJgdRCtcEwdbc8MAyKP+LJqUIKxZIiKac0rPBlVKsRGEGo2oQ1MnKA2Wwk0KuGP2POkiCwPtebw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@bufbuild/protobuf": "^2.10.2",
+ "case-anything": "^2.1.13",
+ "ts-poet": "^6.12.0",
+ "ts-proto-descriptors": "2.1.0"
+ },
+ "bin": {
+ "protoc-gen-ts_proto": "protoc-gen-ts_proto"
+ }
+ },
+ "node_modules/ts-proto-descriptors": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/ts-proto-descriptors/-/ts-proto-descriptors-2.1.0.tgz",
+ "integrity": "sha512-S5EZYEQ6L9KLFfjSRpZWDIXDV/W7tAj8uW7pLsihIxyr62EAVSiKuVPwE8iWnr849Bqa53enex1jhDUcpgquzA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@bufbuild/protobuf": "^2.0.0"
+ }
+ },
"node_modules/tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@@ -10868,7 +11030,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -10931,7 +11092,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {
@@ -11089,7 +11249,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -11183,7 +11342,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -11545,7 +11703,6 @@
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"dev": true,
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/package.json b/package.json
index 9e728a9..2e26c0b 100644
--- a/package.json
+++ b/package.json
@@ -9,9 +9,11 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
- "test": "vitest"
+ "test": "vitest",
+ "proto": "node scripts/generate-proto.js"
},
"dependencies": {
+ "@bufbuild/protobuf": "^2.10.2",
"@marsidev/react-turnstile": "^1.4.0",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -27,12 +29,15 @@
"@tanstack/react-query": "^5.90.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "decimal.js": "^10.6.0",
"i18next": "^25.6.3",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-resources-to-backend": "^1.2.1",
+ "long": "^5.3.2",
"lucide-react": "^0.554.0",
"next": "^16.1.0",
"next-themes": "^0.4.6",
+ "protobufjs": "^8.0.0",
"qrcode.react": "^4.2.0",
"react": "19.2.0",
"react-dom": "19.2.0",
@@ -48,6 +53,7 @@
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
+ "@types/decimal.js": "^0.0.32",
"@types/node": "^20.19.25",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -57,6 +63,7 @@
"jsdom": "^27.3.0",
"nodemon": "^3.1.9",
"tailwindcss": "^4",
+ "ts-proto": "^2.10.1",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
"vitest": "^4.0.16"
diff --git a/scripts/generate-proto.js b/scripts/generate-proto.js
new file mode 100644
index 0000000..16122b8
--- /dev/null
+++ b/scripts/generate-proto.js
@@ -0,0 +1,55 @@
+/* eslint-disable @typescript-eslint/no-require-imports */
+const { execSync } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+const projectRoot = path.resolve(__dirname, '..');
+const protoRoot = path.resolve(projectRoot, '../gaap-api/manifest/protobuf');
+const outDir = path.resolve(projectRoot, 'src/lib/proto');
+const pluginPath = path.resolve(projectRoot, 'node_modules/.bin/protoc-gen-ts_proto' + (process.platform === 'win32' ? '.cmd' : ''));
+
+function getFiles(dir, extension, files = []) {
+ if (!fs.existsSync(dir)) return files;
+ const list = fs.readdirSync(dir);
+ for (const file of list) {
+ const name = path.join(dir, file);
+ if (fs.statSync(name).isDirectory()) {
+ getFiles(name, extension, files);
+ } else if (name.endsWith(extension)) {
+ // Use relative path from protoRoot for protoc, ensuring forward slashes
+ const relativePath = path.relative(protoRoot, name).split(path.sep).join('/');
+ files.push(relativePath);
+ }
+ }
+ return files;
+}
+
+if (!fs.existsSync(outDir)) {
+ fs.mkdirSync(outDir, { recursive: true });
+}
+
+console.log(`Searching for .proto files in ${protoRoot}...`);
+const protoFiles = getFiles(protoRoot, '.proto');
+
+if (protoFiles.length === 0) {
+ console.error('No .proto files found in', protoRoot);
+ process.exit(1);
+}
+
+const command = [
+ 'protoc',
+ `--plugin=protoc-gen-ts_proto="${pluginPath}"`,
+ `--ts_proto_out="${outDir}"`,
+ `--proto_path="${protoRoot}"`,
+ ...protoFiles.map(f => `"${f}"`),
+ '--ts_proto_opt=esModuleInterop=true,forceLong=string'
+].join(' ');
+
+console.log(`Generating ${protoFiles.length} protos...`);
+try {
+ execSync(command, { cwd: protoRoot, stdio: 'inherit' });
+ console.log('Successfully generated protos.');
+} catch {
+ console.error('Error generating protos.');
+ process.exit(1);
+}
diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx
index 253fbad..7aecb20 100644
--- a/src/app/register/page.tsx
+++ b/src/app/register/page.tsx
@@ -9,18 +9,19 @@ import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
-import apiRequest, { ApiError } from '@/lib/api';
import { sha256 } from '@/lib/utils';
+import { useRegister } from '@/lib/hooks';
+
export default function RegisterPage() {
const { t } = useTranslation(['auth', 'common', 'settings']);
const router = useRouter();
+ const registerMutation = useRegister();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [nickname, setNickname] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
- const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -32,35 +33,28 @@ export default function RegisterPage() {
toast.error(t('auth:password_mismatch'));
return;
}
- setLoading(true);
try {
const hashedPassword = await sha256(password);
- await apiRequest('/api/auth/register', {
- method: 'POST',
- body: JSON.stringify({
- email,
- password: hashedPassword,
- nickname,
- cf_turnstile_response: turnstileToken
- })
+ await registerMutation.mutateAsync({
+ email,
+ password: hashedPassword,
+ nickname,
+ cfTurnstileResponse: turnstileToken
});
- toast.success(t('auth:register_success'));
- router.push('/login');
+ // useRegister hook handles toast and tokens
+ router.push('/dashboard'); // Auto-login often redirects to dashboard, but let's see. Hook says "请登录" (Please login) but secureAuthService returns tokens.
+ // If secureAuthService auto-logs in, we should go to dashboard.
+ // Wait, useRegister hook toast says "注册成功,请登录" in original, but I updated it to just "注册成功".
+ // secureAuthService.register keeps tokens. So we can go to dashboard.
} catch (err: unknown) {
- if (err instanceof ApiError) {
- toast.error(err.message);
- } else if (err instanceof Error) {
- toast.error(err.message);
- } else {
- toast.error(t('auth:unknown_error'));
- }
- } finally {
- setLoading(false);
+ // Error handled by hook
}
};
+ const loading = registerMutation.isPending;
+
return (
diff --git a/src/components/auth/LoginPage.tsx b/src/components/auth/LoginPage.tsx
index d62786b..1550eb2 100644
--- a/src/components/auth/LoginPage.tsx
+++ b/src/components/auth/LoginPage.tsx
@@ -3,7 +3,7 @@
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useGlobal } from '@/context/GlobalContext';
-import { useLogin } from '@/lib/hooks';
+import { useLogin, UserLevelType } from '@/lib/hooks';
import { useTranslation } from 'react-i18next';
import { Turnstile } from '@marsidev/react-turnstile';
import {
@@ -72,20 +72,20 @@ const LoginPage = () => {
const data = await loginMutation.mutateAsync({
email,
password: hashedPassword,
- code: step === 2 ? code : undefined,
- cf_turnstile_response: turnstileToken
+ code: step === 2 ? code : '',
+ cfTurnstileResponse: turnstileToken
});
// Login success
- if (!data || !data.user) {
+ if (!data || !data.auth || !data.auth.user) {
throw new Error('Invalid response format');
}
contextLogin({
- email: data.user.email,
- nickname: data.user.nickname,
- avatar: data.user.avatar,
- plan: data.user.plan
+ email: data.auth.user.email,
+ nickname: data.auth.user.nickname,
+ avatar: data.auth.user.avatar,
+ plan: data.auth.user.plan
});
toast.success(t('auth:login_success'), { duration: 4000 });
@@ -280,7 +280,7 @@ const LoginPage = () => {
variant="outline"
type="button"
onClick={() => {
- contextLogin({ email: 'github_user@example.com', nickname: 'GitHub User', plan: 'PRO' });
+ contextLogin({ email: 'github_user@example.com', nickname: 'GitHub User', plan: UserLevelType.USER_LEVEL_TYPE_PRO });
router.push('/dashboard');
}}
className="flex items-center justify-center gap-2 py-6 rounded-xl hover:bg-slate-50"
@@ -292,7 +292,7 @@ const LoginPage = () => {
variant="outline"
type="button"
onClick={() => {
- contextLogin({ email: 'wechat_user@example.com', nickname: t('auth:default_wechat_username'), plan: 'FREE' });
+ contextLogin({ email: 'wechat_user@example.com', nickname: t('auth:default_wechat_username'), plan: UserLevelType.USER_LEVEL_TYPE_FREE });
router.push('/dashboard');
}}
className="flex items-center justify-center gap-2 py-6 rounded-xl hover:bg-slate-50"
diff --git a/src/components/features/Accounts.tsx b/src/components/features/Accounts.tsx
index e30209e..5bdd166 100644
--- a/src/components/features/Accounts.tsx
+++ b/src/components/features/Accounts.tsx
@@ -1,9 +1,10 @@
'use client';
import React, { useState, useMemo } from 'react';
-import { useAllAccountsSuspense, Account } from '@/lib/hooks';
+import { useAllAccountsSuspense, Account, AccountType, Money } from '@/lib/hooks';
import { useTranslation } from 'react-i18next';
import { ACCOUNT_TYPES, EXCHANGE_RATES } from '@/lib/data';
+import { MoneyHelper } from '@/lib/utils/money';
import {
Plus,
Wallet,
@@ -23,6 +24,7 @@ import EditAccountModal from './EditAccountModal';
const Accounts = () => {
const { t } = useTranslation(['accounts', 'common']);
const { accounts } = useAllAccountsSuspense();
+ // We use string IDs for tabs, but map them to Enums for filtering
const [activeTab, setActiveTab] = useState('ALL');
const [page, setPage] = useState(1);
const [searchQuery, setSearchQuery] = useState('');
@@ -30,17 +32,39 @@ const Accounts = () => {
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const itemsPerPage = 10;
- const formatCurrency = (amount: number, currency = 'CNY') => {
+ const formatCurrency = (amount: number | Money, currency = 'CNY') => {
+ let val = 0;
+ if (typeof amount === 'number') {
+ val = amount;
+ } else {
+ // Assume Money proto or undefined
+ val = MoneyHelper.from(amount).toNumber();
+ }
+
try {
- return new Intl.NumberFormat('zh-CN', { style: 'currency', currency }).format(amount);
+ return new Intl.NumberFormat('zh-CN', { style: 'currency', currency }).format(val);
} catch {
- return `${currency} ${amount.toFixed(2)}`;
+ return `${currency} ${val.toFixed(2)}`;
}
};
+ const tabToEnum: Record
= useMemo(() => ({
+ 'ASSET': AccountType.ACCOUNT_TYPE_ASSET,
+ 'LIABILITY': AccountType.ACCOUNT_TYPE_LIABILITY,
+ 'INCOME': AccountType.ACCOUNT_TYPE_INCOME,
+ 'EXPENSE': AccountType.ACCOUNT_TYPE_EXPENSE
+ }), []);
+
const topLevelAccounts = useMemo(() => {
let filtered = accounts.filter(a => !a.parentId);
- if (activeTab !== 'ALL') filtered = filtered.filter(a => a.type === activeTab);
+
+ if (activeTab !== 'ALL') {
+ const targetType = tabToEnum[activeTab];
+ if (targetType !== undefined) {
+ filtered = filtered.filter(a => a.type === targetType);
+ }
+ }
+
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(parent => {
@@ -49,14 +73,15 @@ const Accounts = () => {
return children.some(child => child.name.toLowerCase().includes(query));
});
}
- // Sort by created_at in descending order (newest first)
+ // Sort by createdAt in descending order (newest first)
filtered.sort((a, b) => {
- const dateA = a.created_at ? new Date(a.created_at).getTime() : 0;
- const dateB = b.created_at ? new Date(b.created_at).getTime() : 0;
+ // Ensure date handling is safe
+ const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
+ const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return dateB - dateA;
});
return filtered;
- }, [accounts, activeTab, searchQuery]);
+ }, [accounts, activeTab, searchQuery, tabToEnum]);
const totalPages = Math.ceil(topLevelAccounts.length / itemsPerPage);
@@ -74,14 +99,19 @@ const Accounts = () => {
];
const AccountRow = ({ account, isChild = false, groupBalance, hasChildren = false }: { account: Account, isChild?: boolean, groupBalance?: number, hasChildren?: boolean }) => {
- const TypeIcon = ACCOUNT_TYPES[account.type].icon;
- const typeMeta = ACCOUNT_TYPES[account.type];
+ const typeMeta = ACCOUNT_TYPES[account.type] || ACCOUNT_TYPES[AccountType.ACCOUNT_TYPE_ASSET];
+ const TypeIcon = typeMeta.icon;
const handleClick = () => {
setEditAccount(account);
setIsEditModalOpen(true);
};
+ // Safe access to currency
+ const currency = account.balance?.currencyCode || 'CNY';
+ // Safe access to balance value
+ const balanceVal = account.balance; // Money object
+
if (account.isGroup) {
const itemBg = hasChildren ? 'bg-[var(--bg-card)]' : 'bg-[var(--bg-main)]';
return (
@@ -124,17 +154,17 @@ const Accounts = () => {
{account.name}
- {account.currency !== 'CNY' && (
- {account.currency}
+ {currency !== 'CNY' && (
+ {currency}
)}
- {!isChild &&
{t('common:' + account.type.toLowerCase())}
}
+ {!isChild &&
{t('common:' + typeMeta.label.toLowerCase())}
}
-
{formatCurrency(account.balance, account.currency)}
- {account.currency !== 'CNY' && (
-
≈ {formatCurrency(account.balance * (EXCHANGE_RATES[account.currency] || 1), 'CNY')}
+
{formatCurrency(MoneyHelper.from(balanceVal).toNumber(), currency)}
+ {currency !== 'CNY' && (
+
≈ {formatCurrency(MoneyHelper.from(balanceVal).toNumber() * (EXCHANGE_RATES[currency] || 1), 'CNY')}
)}
@@ -144,8 +174,10 @@ const Accounts = () => {
const renderAccountCard = (parentAccount: Account) => {
const children = accounts.filter(a => a.parentId === parentAccount.id);
const groupBalance = children.reduce((sum, child) => {
- const rate = EXCHANGE_RATES[child.currency] || 1;
- return sum + (child.balance * rate);
+ const childIso = child.balance?.currencyCode || 'CNY';
+ const rate = EXCHANGE_RATES[childIso] || 1;
+ const balVal = MoneyHelper.from(child.balance).toNumber();
+ return sum + (balVal * rate);
}, 0);
return (
@@ -222,11 +254,13 @@ const Accounts = () => {
setIsAddModalOpen(false)} />
- setIsEditModalOpen(false)}
- account={editAccount}
- />
+ {editAccount && (
+ { setIsEditModalOpen(false); setEditAccount(null); }}
+ account={editAccount}
+ />
+ )}
);
};
diff --git a/src/components/features/AddAccountModal.tsx b/src/components/features/AddAccountModal.tsx
index 279af99..e778a2e 100644
--- a/src/components/features/AddAccountModal.tsx
+++ b/src/components/features/AddAccountModal.tsx
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useGlobal } from '@/context/GlobalContext';
-import { useCreateAccount, useAllAccounts, AccountType } from '@/lib/hooks';
+import { useCreateAccount, useAllAccounts, AccountType, UserLevelType } from '@/lib/hooks';
import { ACCOUNT_TYPES } from '@/lib/data';
import {
Dialog,
@@ -41,7 +41,7 @@ const AddAccountModal = ({ isOpen, onClose }: AddAccountModalProps) => {
const { accounts } = useAllAccounts();
const createAccount = useCreateAccount();
- const [type, setType] = useState(AccountType.ASSET);
+ const [type, setType] = useState(AccountType.ACCOUNT_TYPE_ASSET);
const [isGroup, setIsGroup] = useState(false);
const [name, setName] = useState('');
const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
@@ -113,10 +113,11 @@ const AddAccountModal = ({ isOpen, onClose }: AddAccountModalProps) => {
const handleSubmit = async () => {
if (!name) return;
+ const finalDate = date || new Date().toISOString().split('T')[0];
// FREE user ASSET limit check
- if (type === AccountType.ASSET && user.plan === 'FREE') {
- const currentAssetCount = accounts.filter(a => a.type === AccountType.ASSET && !a.parentId).length;
+ if (type === AccountType.ACCOUNT_TYPE_ASSET && user.plan === UserLevelType.USER_LEVEL_TYPE_FREE) {
+ const currentAssetCount = accounts.filter(a => a.type === AccountType.ACCOUNT_TYPE_ASSET && !a.parentId).length;
if (currentAssetCount >= 5) {
toast.error(t('accounts:free_asset_limit_reached'));
return;
@@ -124,29 +125,32 @@ const AddAccountModal = ({ isOpen, onClose }: AddAccountModalProps) => {
}
try {
- if (isGroup && (type === AccountType.ASSET || type === AccountType.LIABILITY)) {
+ if (isGroup && (type === AccountType.ACCOUNT_TYPE_ASSET || type === AccountType.ACCOUNT_TYPE_LIABILITY)) {
// Create parent account first
- const parent = await createAccount.mutateAsync({
+ const res = await createAccount.mutateAsync({
name,
type,
isGroup: true,
balance: 0,
currency: 'CNY',
- ...(date ? { date } : {}),
+ date: finalDate,
...(number ? { number } : {}),
...(remarks ? { remarks } : {}),
});
- // Create children with parentId
- for (const child of children) {
- await createAccount.mutateAsync({
- parentId: parent.id,
- name: child.name || `${name} ${child.currency}`,
- type,
- balance: parseFloat(child.balance) || 0,
- currency: child.currency,
- isGroup: false,
- });
+ if (res.account?.id) {
+ // Create children with parentId
+ for (const child of children) {
+ await createAccount.mutateAsync({
+ parentId: res.account.id,
+ name: child.name || `${name} ${child.currency}`,
+ type,
+ balance: parseFloat(child.balance) || 0,
+ currency: child.currency,
+ isGroup: false,
+ date: finalDate
+ });
+ }
}
} else {
// Simple Account
@@ -155,7 +159,7 @@ const AddAccountModal = ({ isOpen, onClose }: AddAccountModalProps) => {
type,
balance: parseFloat(balance) || 0,
currency,
- ...(date ? { date } : {}),
+ date: finalDate,
...(number ? { number } : {}),
...(remarks ? { remarks } : {}),
isGroup: false,
@@ -171,7 +175,7 @@ const AddAccountModal = ({ isOpen, onClose }: AddAccountModalProps) => {
}
};
- const isPro = user.plan === 'PRO';
+ const isPro = user.plan === UserLevelType.USER_LEVEL_TYPE_PRO;
const isPending = createAccount.isPending;
return (
@@ -188,12 +192,13 @@ const AddAccountModal = ({ isOpen, onClose }: AddAccountModalProps) => {
{
- setType(key as AccountType);
- if (key === AccountType.INCOME || key === AccountType.EXPENSE) setIsGroup(false);
+ const typeVal = Number(key) as AccountType;
+ setType(typeVal);
+ if (typeVal === AccountType.ACCOUNT_TYPE_INCOME || typeVal === AccountType.ACCOUNT_TYPE_EXPENSE) setIsGroup(false);
}}
className={`
cursor-pointer rounded-xl border-2 p-4 flex flex-col items-center gap-2 transition-all
- ${type === key ? `border-[var(--primary)] bg-[var(--primary)]/5 ${value.color}` : 'border-transparent bg-[var(--bg-main)] text-[var(--text-muted)] hover:bg-[var(--bg-main)]/80'}
+ ${type === Number(key) ? `border-[var(--primary)] bg-[var(--primary)]/5 ${value.color}` : 'border-transparent bg-[var(--bg-main)] text-[var(--text-muted)] hover:bg-[var(--bg-main)]/80'}
`}
>
@@ -203,7 +208,7 @@ const AddAccountModal = ({ isOpen, onClose }: AddAccountModalProps) => {
{/* Group Toggle - PRO only */}
- {(type === AccountType.ASSET || type === AccountType.LIABILITY) && (
+ {(type === AccountType.ACCOUNT_TYPE_ASSET || type === AccountType.ACCOUNT_TYPE_LIABILITY) && (
{
setBalance(e.target.value)} />
+ {parseFloat(balance) !== 0 && (type === AccountType.ACCOUNT_TYPE_ASSET || type === AccountType.ACCOUNT_TYPE_LIABILITY) && (
+
{t('accounts:initial_balance_info')}
+ )}
)}
diff --git a/src/components/features/BalanceTrendChart.tsx b/src/components/features/BalanceTrendChart.tsx
index 05fe0a6..c9fe47f 100644
--- a/src/components/features/BalanceTrendChart.tsx
+++ b/src/components/features/BalanceTrendChart.tsx
@@ -20,9 +20,12 @@ import {
DropdownMenuContent,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
-import { useAllAccounts, useBalanceTrend, useProfile } from '@/lib/hooks';
+import { AccountType, useAllAccounts, useBalanceTrend, useProfile } from '@/lib/hooks';
import { EXCHANGE_RATES } from '@/lib/data';
import { ChevronDown, Loader2 } from 'lucide-react';
+import { MoneyHelper } from '@/lib/utils/money';
+import { DailyBalance } from '@/lib/types';
+import { DEFAULT_CURRENCY_CODE } from '@/lib/utils/constant';
const COLORS = [
'var(--primary)',
@@ -45,7 +48,7 @@ const BalanceTrendChart = () => {
// Filter valid asset accounts for the dropdown
const assetAccounts = useMemo(() => {
- return accounts.filter(acc => acc.type === 'ASSET' && !acc.isGroup);
+ return accounts.filter(acc => acc.type === AccountType.ACCOUNT_TYPE_ASSET && !acc.isGroup);
}, [accounts]);
const toggleAccount = (id: string) => {
@@ -78,18 +81,19 @@ const BalanceTrendChart = () => {
const baseRate = EXCHANGE_RATES[mainCurrency] || 1;
- return trendData.data.map(d => {
+ return trendData.data.map((d: DailyBalance) => {
let allTotal = 0;
const convertedBalances: Record = {};
Object.entries(d.balances).forEach(([id, balance]) => {
const acc = accounts.find(a => a.id === id);
- const accRate = acc ? (EXCHANGE_RATES[acc.currency] || 1) : 1;
- const converted = balance * (accRate / baseRate);
+ const accRate = acc ? (EXCHANGE_RATES[acc.balance?.currencyCode || DEFAULT_CURRENCY_CODE] || 1) : 1;
+ const amount = MoneyHelper.from(balance).toNumber();
+ const converted = amount * (accRate / baseRate);
convertedBalances[id] = converted;
- if (acc && acc.type === 'ASSET') {
+ if (acc && acc.type === AccountType.ACCOUNT_TYPE_ASSET) {
allTotal += converted;
}
});
diff --git a/src/components/features/Dashboard.tsx b/src/components/features/Dashboard.tsx
index 8763066..dc30c24 100644
--- a/src/components/features/Dashboard.tsx
+++ b/src/components/features/Dashboard.tsx
@@ -8,6 +8,8 @@ import { TransactionType } from '@/lib/types';
import { TrendingUp, TrendingDown } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import BalanceTrendChart from './BalanceTrendChart';
+import { DEFAULT_CURRENCY_CODE } from '@/lib/utils/constant';
+import { MoneyHelper } from '@/lib/utils/money';
const Dashboard = () => {
const { t } = useTranslation(['dashboard', 'common']);
@@ -35,11 +37,11 @@ const Dashboard = () => {
accounts.forEach(acc => {
if (acc.isGroup) return;
- const accRate = EXCHANGE_RATES[acc.currency] || 1;
- const convertedBalance = acc.balance * (accRate / baseRate);
+ const accRate = EXCHANGE_RATES[acc.balance?.currencyCode || DEFAULT_CURRENCY_CODE] || 1;
+ const convertedBalance = MoneyHelper.from(acc.balance).toNumber() * (accRate / baseRate);
- if (acc.type === AccountType.ASSET) assets += convertedBalance;
- if (acc.type === AccountType.LIABILITY) liabilities += convertedBalance;
+ if (acc.type === AccountType.ACCOUNT_TYPE_ASSET) assets += convertedBalance;
+ if (acc.type === AccountType.ACCOUNT_TYPE_LIABILITY) liabilities += convertedBalance;
});
return { assets, liabilities, netWorth: assets - liabilities };
}, [accounts, mainCurrency]);
@@ -61,11 +63,11 @@ const Dashboard = () => {
// Note: we're using string comparison which is safe for ISO format
if (!tx.date.startsWith(currentMonth)) return;
- const txRate = EXCHANGE_RATES[tx.currency] || 1;
- const amount = tx.amount * (txRate / baseRate);
+ const txRate = EXCHANGE_RATES[tx.amount?.currencyCode || DEFAULT_CURRENCY_CODE] || 1;
+ const amount = MoneyHelper.from(tx.amount).toNumber() * (txRate / baseRate);
- if (tx.type === TransactionType.INCOME) income += amount;
- if (tx.type === TransactionType.EXPENSE) expense += amount;
+ if (tx.type === TransactionType.TRANSACTION_TYPE_INCOME) income += amount;
+ if (tx.type === TransactionType.TRANSACTION_TYPE_EXPENSE) expense += amount;
});
return { income, expense };
diff --git a/src/components/features/EditAccountModal.tsx b/src/components/features/EditAccountModal.tsx
index 728d66d..fdc58f5 100644
--- a/src/components/features/EditAccountModal.tsx
+++ b/src/components/features/EditAccountModal.tsx
@@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next';
import { useGlobal } from '@/context/GlobalContext';
import { useUpdateAccount, useDeleteAccount, useCreateAccount, useAllAccounts, AccountType, Account } from '@/lib/hooks';
import { accountService } from '@/lib/services';
+import { ACCOUNT_TYPES } from '@/lib/data';
+import { MoneyHelper } from '@/lib/utils/money';
import {
Dialog,
DialogContent,
@@ -50,8 +52,8 @@ const EditAccountForm = ({ account, onClose }: EditAccountFormProps) => {
const [date, setDate] = useState(account.date || new Date().toISOString().split('T')[0]);
const [number, setNumber] = useState(account.number || '');
const [remarks, setRemarks] = useState(account.remarks || '');
- const [balance, setBalance] = useState(account.balance?.toString() || '0');
- const [currency, setCurrency] = useState(account.currency || 'CNY');
+ const [balance, setBalance] = useState(() => account.balance ? MoneyHelper.from(account.balance).toNumber().toString() : '0');
+ const [currency, setCurrency] = useState(account.balance?.currencyCode || 'CNY');
// Group account state
const isGroup = account.isGroup;
@@ -61,8 +63,8 @@ const EditAccountForm = ({ account, onClose }: EditAccountFormProps) => {
return accountChildren.map(c => ({
id: c.id,
name: c.name,
- currency: c.currency,
- balance: c.balance.toString(),
+ currency: c.balance?.currencyCode || 'CNY',
+ balance: c.balance ? MoneyHelper.from(c.balance).toNumber().toString() : '0',
isDefault: false,
isNew: false
}));
@@ -111,6 +113,7 @@ const EditAccountForm = ({ account, onClose }: EditAccountFormProps) => {
id: account.id,
input: {
name,
+ type: account.type,
date,
number,
remarks,
@@ -128,6 +131,7 @@ const EditAccountForm = ({ account, onClose }: EditAccountFormProps) => {
balance: parseFloat(child.balance) || 0,
currency: child.currency,
isGroup: false,
+ date
});
} else {
await updateAccountMutation.mutateAsync({
@@ -154,7 +158,7 @@ const EditAccountForm = ({ account, onClose }: EditAccountFormProps) => {
}
return accounts.filter(a =>
- a.currency === curr &&
+ a.balance?.currencyCode === curr &&
a.type === account.type && // Filter by same account type
!accountsToDeleteIds.includes(a.id) &&
!a.isGroup
@@ -206,8 +210,13 @@ const EditAccountForm = ({ account, onClose }: EditAccountFormProps) => {
// Calculate if we have any blocked currencies (no targets available)
const requiredCurrencies = Object.keys(
- [account, ...(isGroup ? children : [])].reduce((acc, curr) => {
- if (curr && curr.currency) acc[curr.currency] = true;
+ [
+ isGroup ? null : account,
+ ...(isGroup ? children : [])
+ ].reduce((acc, curr) => {
+ // Fix: Account uses balance.currencyCode, ChildAccount uses currency
+ const currencyCode = (curr as Account)?.balance?.currencyCode || (curr as ChildAccount)?.currency;
+ if (curr && currencyCode) acc[currencyCode] = true;
return acc;
}, {} as Record)
);
@@ -252,7 +261,7 @@ const EditAccountForm = ({ account, onClose }: EditAccountFormProps) => {
type="number"
value={balance}
onChange={e => setBalance(e.target.value)}
- disabled={account?.type === 'EXPENSE' || account?.type === 'INCOME'}
+ disabled={account?.type === AccountType.ACCOUNT_TYPE_EXPENSE || account?.type === AccountType.ACCOUNT_TYPE_INCOME}
/>
@@ -362,7 +371,7 @@ const EditAccountForm = ({ account, onClose }: EditAccountFormProps) => {
{t('accounts:no_available_funding_accounts', {
type: account?.type,
- defaultValue: `No available ${account?.type} funding accounts, please create one before proceeding with deletion`
+ defaultValue: `No available ${account?.type ? t(`common:${ACCOUNT_TYPES[account.type]?.label.toLowerCase()}`) : ''} funding accounts, please create one before proceeding with deletion`
})}
@@ -376,7 +385,7 @@ const EditAccountForm = ({ account, onClose }: EditAccountFormProps) => {
{targets.map(t => (
- {t.name} ({t.currency})
+ {t.name} ({t.balance?.currencyCode || ''})
))}
diff --git a/src/components/features/Transactions.tsx b/src/components/features/Transactions.tsx
index b6f9536..681415a 100644
--- a/src/components/features/Transactions.tsx
+++ b/src/components/features/Transactions.tsx
@@ -1,7 +1,9 @@
'use client';
import React, { useState } from 'react';
-import { useAllTransactionsSuspense, useAllAccountsSuspense, useCreateTransaction, useUpdateTransaction, useDeleteTransaction, useCreateAccount, TransactionType, AccountType, Account, Transaction } from '@/lib/hooks';
+import { useAllTransactionsSuspense, useAllAccountsSuspense, useCreateTransaction, useUpdateTransaction, useDeleteTransaction, useCreateAccount } from '@/lib/hooks';
+import { TransactionType, AccountType, Account, Transaction, Money } from '@/lib/types';
+import { MoneyHelper } from '@/lib/utils/money';
import { useTranslation } from 'react-i18next';
import { Plus, ArrowRightLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -57,11 +59,18 @@ const Transactions = () => {
const [newTx, setNewTx] = useState({ amount: '', note: '', from: '', to: '', date: getCurrentDateTime() });
- const formatCurrency = (amount: number, currency = 'CNY') => {
+ const formatCurrency = (amount: Money | number, currency = 'CNY') => {
+ let val = amount;
+ // Also handle Money encoded object
+ if (typeof amount === 'object' && amount !== null && ('units' in amount || 'nanos' in amount)) {
+ // Check if it has 'units' (string) or needs coercion
+ val = MoneyHelper.from(amount).toNumber();
+ }
+
try {
- return new Intl.NumberFormat('zh-CN', { style: 'currency', currency }).format(amount);
+ return new Intl.NumberFormat('zh-CN', { style: 'currency', currency }).format(val as number);
} catch {
- return `${currency} ${amount.toFixed(2)}`;
+ return `${currency} ${Number(val).toFixed(2)}`;
}
};
@@ -75,7 +84,13 @@ const Transactions = () => {
}
};
- const currentCurrency = accounts.find(a => a.id === newTx.from)?.currency || 'CNY';
+ // Helper to safely get currency from an account
+ const getAccountCurrency = (account?: Account) => {
+ // If account.balance is undefined, default to CNY
+ return account?.balance?.currencyCode || 'CNY';
+ }
+
+ const currentCurrency = getAccountCurrency(accounts.find(a => a.id === newTx.from));
const getFullAccountName = (account: Account, allAccounts: Account[]) => {
if (account.parentId) {
@@ -147,41 +162,47 @@ const Transactions = () => {
if (newTx.from === 'NEW_INCOME') {
if (!newIncomeName) return;
- const newAccount = await createAccount.mutateAsync({
+ const res = await createAccount.mutateAsync({
name: newIncomeName,
- type: AccountType.INCOME,
+ type: AccountType.ACCOUNT_TYPE_INCOME,
currency: 'CNY',
balance: 0,
isGroup: false,
+ date: getCurrentDateTime(),
});
- finalFromAccount = newAccount.id;
+ if (res.account) {
+ finalFromAccount = res.account.id;
+ }
}
if (newTx.to === 'NEW_EXPENSE') {
if (!newExpenseName) return;
const sourceAcc = accounts.find(a => a.id === finalFromAccount);
- const newAccount = await createAccount.mutateAsync({
+ const sourceCurrency = getAccountCurrency(sourceAcc);
+ const res = await createAccount.mutateAsync({
name: newExpenseName,
- type: AccountType.EXPENSE,
- currency: sourceAcc?.currency || 'CNY',
+ type: AccountType.ACCOUNT_TYPE_EXPENSE,
+ currency: sourceCurrency,
balance: 0,
isGroup: false,
+ date: getCurrentDateTime(),
});
- console.log('Created account response:', newAccount);
- finalToAccount = newAccount.id;
- console.log('finalToAccount:', finalToAccount);
+ if (res.account) {
+ finalToAccount = res.account.id;
+ }
}
- const fromAccount = finalFromAccount === newTx.from ? accounts.find(a => a.id === newTx.from) : { type: AccountType.INCOME, currency: 'CNY' };
- const toAccount = finalToAccount === newTx.to ? accounts.find(a => a.id === newTx.to) : { type: AccountType.EXPENSE };
+ const fromAccount = finalFromAccount === newTx.from ? accounts.find(a => a.id === newTx.from) : undefined;
+ const toAccount = finalToAccount === newTx.to ? accounts.find(a => a.id === newTx.to) : undefined;
+ const fromCurrency = getAccountCurrency(fromAccount);
- let type = TransactionType.TRANSFER;
- if (toAccount?.type === AccountType.EXPENSE) type = TransactionType.EXPENSE;
- if (fromAccount?.type === AccountType.INCOME) type = TransactionType.INCOME;
+ let type = TransactionType.TRANSACTION_TYPE_TRANSFER;
+ if (toAccount?.type === AccountType.ACCOUNT_TYPE_EXPENSE) type = TransactionType.TRANSACTION_TYPE_EXPENSE;
+ if (fromAccount?.type === AccountType.ACCOUNT_TYPE_INCOME) type = TransactionType.TRANSACTION_TYPE_INCOME;
let finalNote = newTx.note;
if (!finalNote) {
- const typeName = type === TransactionType.EXPENSE ? t('common:expense') : type === TransactionType.INCOME ? t('common:income') : t('transactions:transfer');
+ const typeName = type === TransactionType.TRANSACTION_TYPE_EXPENSE ? t('common:expense') : type === TransactionType.TRANSACTION_TYPE_INCOME ? t('common:income') : t('transactions:transfer');
const targetAccountName = finalToAccount === newTx.to
? (accounts.find(a => a.id === newTx.to)?.name || '')
: newExpenseName;
@@ -198,7 +219,7 @@ const Transactions = () => {
from: finalFromAccount,
to: finalToAccount,
amount: parseFloat(newTx.amount),
- currency: fromAccount?.currency || 'CNY',
+ currency: fromCurrency,
type,
note: finalNote,
date: dateToSend,
@@ -228,8 +249,10 @@ const Transactions = () => {
};
const handleEdit = (tx: Transaction) => {
+ // tx.amount is Money (proto). Construct a helper from it.
+ const amountVal = MoneyHelper.from(tx.amount).toNumber();
setNewTx({
- amount: tx.amount.toString(),
+ amount: amountVal.toString(),
note: tx.note,
from: tx.from,
to: tx.to,
@@ -334,12 +357,12 @@ const Transactions = () => {
{t('transactions:asset_source')}
- {renderAccountOptions(a => a.type === AccountType.ASSET)}
+ {renderAccountOptions(a => a.type === AccountType.ACCOUNT_TYPE_ASSET)}
{t('transactions:income_source')}
{t('transactions:new_income_account')}
- {renderAccountOptions(a => a.type === AccountType.INCOME)}
+ {renderAccountOptions(a => a.type === AccountType.ACCOUNT_TYPE_INCOME)}
@@ -356,15 +379,15 @@ const Transactions = () => {
{t('transactions:expense_destination')}
{t('transactions:new_expense_account')}
- {renderAccountOptions(a => a.type === AccountType.EXPENSE)}
+ {renderAccountOptions(a => a.type === AccountType.ACCOUNT_TYPE_EXPENSE)}
{t('transactions:asset_deposit')}
- {renderAccountOptions(a => a.type === AccountType.ASSET)}
+ {renderAccountOptions(a => a.type === AccountType.ACCOUNT_TYPE_ASSET)}
{t('transactions:liability_repayment')}
- {renderAccountOptions(a => a.type === AccountType.LIABILITY)}
+ {renderAccountOptions(a => a.type === AccountType.ACCOUNT_TYPE_LIABILITY)}
@@ -418,15 +441,26 @@ const Transactions = () => {
{transactions.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).map(tx => {
const fromAcc = accounts.find(a => a.id === tx.from);
const toAcc = accounts.find(a => a.id === tx.to);
+ const isOpeningBalance = tx.type === TransactionType.TRANSACTION_TYPE_OPENING_BALANCE;
+ const txCurrency = tx.amount?.currencyCode || 'CNY';
return (
- handleEdit(tx)}>
+ !isOpeningBalance && handleEdit(tx)}
+ >
-
{tx.note || t('transactions:unnamed_transaction')}
+
+ {tx.note || t('transactions:unnamed_transaction')}
+ {isOpeningBalance && (
+ {t('transactions:system_generated')}
+ )}
+
{formatDateForDisplay(tx.date)}
-
{formatCurrency(tx.amount, tx.currency)}
+
{formatCurrency(tx.amount || 0, txCurrency)}
{fromAcc ? getFullAccountName(fromAcc, accounts) : t('transactions:unknown_account')}
diff --git a/src/components/features/settings/CurrencySettings.tsx b/src/components/features/settings/CurrencySettings.tsx
index 1d845ff..996205a 100644
--- a/src/components/features/settings/CurrencySettings.tsx
+++ b/src/components/features/settings/CurrencySettings.tsx
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { toast } from 'sonner';
+import { UserLevelType } from '@/lib/hooks';
export const CurrencySettings = ({ onBack, onUpgrade }: { onBack: () => void; onUpgrade: () => void }) => {
const { t } = useTranslation(['settings', 'common']);
@@ -25,7 +26,7 @@ export const CurrencySettings = ({ onBack, onUpgrade }: { onBack: () => void; on
// Fetch rates for Pro users
useEffect(() => {
- if (user.plan === 'PRO' && baseCurrency) {
+ if (user.plan === UserLevelType.USER_LEVEL_TYPE_PRO && baseCurrency) {
const fetchRates = async () => {
setIsRefreshing(true);
try {
@@ -75,18 +76,18 @@ export const CurrencySettings = ({ onBack, onUpgrade }: { onBack: () => void; on
{t('settings:currency_management')}
- {user.plan === 'PRO' && (
+ {user.plan === UserLevelType.USER_LEVEL_TYPE_PRO && (
{isRefreshing ? t('settings:syncing') : t('settings:realtime_rates_active')}
)}
- {user.plan === 'PRO' && exchangeRatesLastUpdated && (
+ {user.plan === UserLevelType.USER_LEVEL_TYPE_PRO && exchangeRatesLastUpdated && (
{t('settings:last_updated', { time: new Date(exchangeRatesLastUpdated).toLocaleString() })}
)}
- {user.plan === 'FREE' && (
+ {user.plan === UserLevelType.USER_LEVEL_TYPE_FREE && (