Merge pull request 'Feature/statement parsing' (#1) from feature/statement-parsing into main
CI / secrets-scan (push) Successful in 5s
CI / sast (push) Successful in 15s
CI / vuln-scan (push) Successful in 19s
CI / test (push) Successful in 26s
CI / lint (push) Successful in 33s
CI / build-images (push) Successful in 2m6s
CI / image-scan (push) Successful in 52s
CI / push (push) Successful in 35s
CI / secrets-scan (push) Successful in 5s
CI / sast (push) Successful in 15s
CI / vuln-scan (push) Successful in 19s
CI / test (push) Successful in 26s
CI / lint (push) Successful in 33s
CI / build-images (push) Successful in 2m6s
CI / image-scan (push) Successful in 52s
CI / push (push) Successful in 35s
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
+24
-10
@@ -10,14 +10,21 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Set up pnpm via corepack
|
||||
# pnpm/action-setup@v4 was ignoring its `version` input on this
|
||||
# runner and always installing latest pnpm 10.x, which blocked the
|
||||
# install with ERR_PNPM_IGNORED_BUILDS no matter how we configured
|
||||
# onlyBuiltDependencies. Install pnpm 9.14.4 via corepack instead
|
||||
# — same mechanism the Dockerfiles use, no strict-build gate, runs
|
||||
# postinstall scripts the way pnpm has for years.
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@9.14.4 --activate
|
||||
pnpm --version
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -43,14 +50,21 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Set up pnpm via corepack
|
||||
# pnpm/action-setup@v4 was ignoring its `version` input on this
|
||||
# runner and always installing latest pnpm 10.x, which blocked the
|
||||
# install with ERR_PNPM_IGNORED_BUILDS no matter how we configured
|
||||
# onlyBuiltDependencies. Install pnpm 9.14.4 via corepack instead
|
||||
# — same mechanism the Dockerfiles use, no strict-build gate, runs
|
||||
# postinstall scripts the way pnpm has for years.
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@9.14.4 --activate
|
||||
pnpm --version
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
; pnpm settings — kept here (rather than in pnpm-workspace.yaml or
|
||||
; package.json#pnpm) because .npmrc is honored by every pnpm version we
|
||||
; run across CI (pnpm 10 via pnpm/action-setup) and Docker builds (pnpm 9
|
||||
; via corepack). The pnpm 10 migration moved onlyBuiltDependencies out of
|
||||
; package.json, but .npmrc remains supported and avoids the mismatch.
|
||||
only-built-dependencies[]=@nestjs/core
|
||||
only-built-dependencies[]=@prisma/client
|
||||
only-built-dependencies[]=@prisma/engines
|
||||
only-built-dependencies[]=msw
|
||||
only-built-dependencies[]=prisma
|
||||
only-built-dependencies[]=unrs-resolver
|
||||
@@ -21,6 +21,7 @@
|
||||
"@nestjs/core",
|
||||
"@prisma/client",
|
||||
"@prisma/engines",
|
||||
"msw",
|
||||
"prisma",
|
||||
"unrs-resolver"
|
||||
]
|
||||
|
||||
Generated
+177
@@ -35,6 +35,12 @@ importers:
|
||||
'@supabase/supabase-js':
|
||||
specifier: ^2.103.0
|
||||
version: 2.103.0
|
||||
'@types/papaparse':
|
||||
specifier: ^5.5.2
|
||||
version: 5.5.2
|
||||
'@types/pdf-parse':
|
||||
specifier: ^1.1.5
|
||||
version: 1.1.5
|
||||
class-transformer:
|
||||
specifier: ^0.5.1
|
||||
version: 0.5.1
|
||||
@@ -44,6 +50,15 @@ importers:
|
||||
dotenv:
|
||||
specifier: ^17.4.2
|
||||
version: 17.4.2
|
||||
node-ofx-parser:
|
||||
specifier: ^0.5.1
|
||||
version: 0.5.1
|
||||
papaparse:
|
||||
specifier: ^5.5.3
|
||||
version: 5.5.3
|
||||
pdf-parse:
|
||||
specifier: ^2.4.5
|
||||
version: 2.4.5
|
||||
reflect-metadata:
|
||||
specifier: ^0.2.2
|
||||
version: 0.2.2
|
||||
@@ -1393,24 +1408,48 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.80':
|
||||
resolution: {integrity: sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.100':
|
||||
resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.80':
|
||||
resolution: {integrity: sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.100':
|
||||
resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.80':
|
||||
resolution: {integrity: sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.100':
|
||||
resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.80':
|
||||
resolution: {integrity: sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.100':
|
||||
resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -1418,6 +1457,13 @@ packages:
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.80':
|
||||
resolution: {integrity: sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.100':
|
||||
resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -1425,6 +1471,13 @@ packages:
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.80':
|
||||
resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.100':
|
||||
resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -1432,6 +1485,13 @@ packages:
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.80':
|
||||
resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.100':
|
||||
resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -1439,6 +1499,13 @@ packages:
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.80':
|
||||
resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.100':
|
||||
resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -1446,6 +1513,13 @@ packages:
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.80':
|
||||
resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@0.1.100':
|
||||
resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -1458,10 +1532,20 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.80':
|
||||
resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas@0.1.100':
|
||||
resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@napi-rs/canvas@0.1.80':
|
||||
resolution: {integrity: sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||
|
||||
@@ -2106,6 +2190,12 @@ packages:
|
||||
'@types/node@24.12.2':
|
||||
resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
|
||||
|
||||
'@types/papaparse@5.5.2':
|
||||
resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==}
|
||||
|
||||
'@types/pdf-parse@1.1.5':
|
||||
resolution: {integrity: sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==}
|
||||
|
||||
'@types/qs@6.15.0':
|
||||
resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==}
|
||||
|
||||
@@ -3413,6 +3503,10 @@ packages:
|
||||
fast-uri@3.1.0:
|
||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||
|
||||
fast-xml-parser@3.21.1:
|
||||
resolution: {integrity: sha512-FTFVjYoBOZTJekiUsawGsSYV9QL0A+zDYCRj7y34IO6Jg+2IMYEtQa+bbictpdpV8dHxXywqU7C0gRDEOFtBFg==}
|
||||
hasBin: true
|
||||
|
||||
fastq@1.20.1:
|
||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||
|
||||
@@ -4572,6 +4666,10 @@ packages:
|
||||
node-int64@0.4.0:
|
||||
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
|
||||
|
||||
node-ofx-parser@0.5.1:
|
||||
resolution: {integrity: sha512-YEOf61PPoOt6SvBVMunaxItUBi4TnhODrvc/afoYG8OIN8b63kFJz2u0UcVRcSyyIHOoY/sO+Rf7sA+KgpofJw==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
node-releases@2.0.37:
|
||||
resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==}
|
||||
|
||||
@@ -4679,6 +4777,9 @@ packages:
|
||||
package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
papaparse@5.5.3:
|
||||
resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -4741,6 +4842,11 @@ packages:
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
pdf-parse@2.4.5:
|
||||
resolution: {integrity: sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==}
|
||||
engines: {node: '>=20.16.0 <21 || >=22.3.0'}
|
||||
hasBin: true
|
||||
|
||||
pdfjs-dist@5.4.296:
|
||||
resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==}
|
||||
engines: {node: '>=20.16.0 || >=22.3.0'}
|
||||
@@ -5323,6 +5429,9 @@ packages:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strnum@1.1.2:
|
||||
resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==}
|
||||
|
||||
strtok3@10.3.5:
|
||||
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -7528,36 +7637,66 @@ snapshots:
|
||||
'@napi-rs/canvas-android-arm64@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas@0.1.100':
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas-android-arm64': 0.1.100
|
||||
@@ -7573,6 +7712,19 @@ snapshots:
|
||||
'@napi-rs/canvas-win32-x64-msvc': 0.1.100
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas@0.1.80':
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas-android-arm64': 0.1.80
|
||||
'@napi-rs/canvas-darwin-arm64': 0.1.80
|
||||
'@napi-rs/canvas-darwin-x64': 0.1.80
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.80
|
||||
'@napi-rs/canvas-linux-arm64-gnu': 0.1.80
|
||||
'@napi-rs/canvas-linux-arm64-musl': 0.1.80
|
||||
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.80
|
||||
'@napi-rs/canvas-linux-x64-gnu': 0.1.80
|
||||
'@napi-rs/canvas-linux-x64-musl': 0.1.80
|
||||
'@napi-rs/canvas-win32-x64-msvc': 0.1.80
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.9.2
|
||||
@@ -8203,6 +8355,14 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
||||
'@types/papaparse@5.5.2':
|
||||
dependencies:
|
||||
'@types/node': 24.12.2
|
||||
|
||||
'@types/pdf-parse@1.1.5':
|
||||
dependencies:
|
||||
'@types/node': 24.12.2
|
||||
|
||||
'@types/qs@6.15.0': {}
|
||||
|
||||
'@types/range-parser@1.2.7': {}
|
||||
@@ -9661,6 +9821,10 @@ snapshots:
|
||||
|
||||
fast-uri@3.1.0: {}
|
||||
|
||||
fast-xml-parser@3.21.1:
|
||||
dependencies:
|
||||
strnum: 1.1.2
|
||||
|
||||
fastq@1.20.1:
|
||||
dependencies:
|
||||
reusify: 1.1.0
|
||||
@@ -10935,6 +11099,10 @@ snapshots:
|
||||
|
||||
node-int64@0.4.0: {}
|
||||
|
||||
node-ofx-parser@0.5.1:
|
||||
dependencies:
|
||||
fast-xml-parser: 3.21.1
|
||||
|
||||
node-releases@2.0.37: {}
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
@@ -11061,6 +11229,8 @@ snapshots:
|
||||
|
||||
package-json-from-dist@1.0.1: {}
|
||||
|
||||
papaparse@5.5.3: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
@@ -11110,6 +11280,11 @@ snapshots:
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
pdf-parse@2.4.5:
|
||||
dependencies:
|
||||
'@napi-rs/canvas': 0.1.80
|
||||
pdfjs-dist: 5.4.296
|
||||
|
||||
pdfjs-dist@5.4.296:
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 0.1.100
|
||||
@@ -11792,6 +11967,8 @@ snapshots:
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
strnum@1.1.2: {}
|
||||
|
||||
strtok3@10.3.5:
|
||||
dependencies:
|
||||
'@tokenizer/token': 0.3.0
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
packages:
|
||||
- "tehriehlbudget-frontend"
|
||||
- "tehriehlbudget-backend"
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- "@nestjs/core"
|
||||
- "@prisma/client"
|
||||
- "@prisma/engines"
|
||||
- "msw"
|
||||
- "prisma"
|
||||
- "unrs-resolver"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tehriehlbudget-backend",
|
||||
"version": "0.3.2",
|
||||
"version": "0.4.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -35,9 +35,14 @@
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@prisma/client": "^6.19.3",
|
||||
"@supabase/supabase-js": "^2.103.0",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/pdf-parse": "^1.1.5",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"node-ofx-parser": "^0.5.1",
|
||||
"papaparse": "^5.5.3",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
@@ -92,6 +97,7 @@
|
||||
"/node_modules/",
|
||||
"/generated/",
|
||||
".*\\.module\\.ts$",
|
||||
".*\\.d\\.ts$",
|
||||
"src/main\\.ts$"
|
||||
],
|
||||
"coverageDirectory": "coverage",
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
-- Add external_id column for statement-import dedupe via bank-provided transaction IDs (e.g. OFX FITID).
|
||||
-- Not encrypted: needs to be indexed for fast lookup, and is an opaque bank ID rather than sensitive PII.
|
||||
ALTER TABLE "transactions" ADD COLUMN "external_id" TEXT;
|
||||
|
||||
-- Speeds up duplicate detection via externalId (statement re-imports).
|
||||
CREATE INDEX "transactions_account_id_external_id_idx" ON "transactions"("account_id", "external_id");
|
||||
|
||||
-- Speeds up the windowed date+amount duplicate-detection query and the existing date-range filters.
|
||||
CREATE INDEX "transactions_account_id_date_idx" ON "transactions"("account_id", "date");
|
||||
@@ -78,6 +78,7 @@ model Transaction {
|
||||
notes String?
|
||||
date DateTime
|
||||
receiptPath String? @map("receipt_path")
|
||||
externalId String? @map("external_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -86,6 +87,8 @@ model Transaction {
|
||||
destinationAccount Account? @relation("DestinationAccountTransactions", fields: [destinationAccountId], references: [id], onDelete: Cascade)
|
||||
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([accountId, externalId])
|
||||
@@index([accountId, date])
|
||||
@@map("transactions")
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { AggregationsModule } from './aggregations/aggregations.module';
|
||||
import { AdvisorModule } from './advisor/advisor.module';
|
||||
import { ValuationsModule } from './valuations/valuations.module';
|
||||
import { ActivityLogModule } from './activity-log/activity-log.module';
|
||||
import { StatementsModule } from './statements/statements.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -28,6 +29,7 @@ import { ActivityLogModule } from './activity-log/activity-log.module';
|
||||
AdvisorModule,
|
||||
ValuationsModule,
|
||||
ActivityLogModule,
|
||||
StatementsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'reflect-metadata';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync } from 'class-validator';
|
||||
import { ParseStatementDto } from './parse-statement.dto';
|
||||
|
||||
describe('ParseStatementDto', () => {
|
||||
const baseAccountId = 'a1b2c3d4-1111-4222-9333-1234567890ab';
|
||||
|
||||
it('accepts a bare accountId', () => {
|
||||
const dto = plainToInstance(ParseStatementDto, {
|
||||
accountId: baseAccountId,
|
||||
});
|
||||
expect(validateSync(dto)).toHaveLength(0);
|
||||
expect(dto.mapping).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses mapping when sent as a JSON string (multipart form field)', () => {
|
||||
const dto = plainToInstance(ParseStatementDto, {
|
||||
accountId: baseAccountId,
|
||||
mapping: JSON.stringify({
|
||||
date: 'When',
|
||||
description: 'What',
|
||||
amount: 'How Much',
|
||||
}),
|
||||
});
|
||||
expect(validateSync(dto)).toHaveLength(0);
|
||||
expect(dto.mapping?.date).toBe('When');
|
||||
expect(dto.mapping?.description).toBe('What');
|
||||
expect(dto.mapping?.amount).toBe('How Much');
|
||||
});
|
||||
|
||||
it('drops mapping silently when the JSON string parses to a non-object (e.g. number)', () => {
|
||||
const dto = plainToInstance(ParseStatementDto, {
|
||||
accountId: baseAccountId,
|
||||
mapping: '42',
|
||||
});
|
||||
expect(validateSync(dto)).toHaveLength(0);
|
||||
expect(dto.mapping).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops mapping silently when JSON is malformed', () => {
|
||||
const dto = plainToInstance(ParseStatementDto, {
|
||||
accountId: baseAccountId,
|
||||
mapping: '{not json',
|
||||
});
|
||||
expect(validateSync(dto)).toHaveLength(0);
|
||||
expect(dto.mapping).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts mapping when already an object', () => {
|
||||
const dto = plainToInstance(ParseStatementDto, {
|
||||
accountId: baseAccountId,
|
||||
mapping: { date: 'D', description: 'X', amount: 'A' },
|
||||
});
|
||||
expect(validateSync(dto)).toHaveLength(0);
|
||||
expect(dto.mapping?.date).toBe('D');
|
||||
});
|
||||
|
||||
it('rejects a non-UUID accountId', () => {
|
||||
const dto = plainToInstance(ParseStatementDto, { accountId: 'nope' });
|
||||
expect(validateSync(dto).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { IsObject, IsOptional, IsUUID } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
export interface ColumnMappingDto {
|
||||
date?: string;
|
||||
description?: string;
|
||||
amount?: string;
|
||||
debit?: string;
|
||||
credit?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export class ParseStatementDto {
|
||||
@IsUUID()
|
||||
accountId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
@Transform(({ value }: { value: unknown }): ColumnMappingDto | undefined => {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return typeof parsed === 'object' && parsed !== null
|
||||
? (parsed as ColumnMappingDto)
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return value as ColumnMappingDto;
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
mapping?: ColumnMappingDto;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DuplicateDetectorService } from './duplicate-detector.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { ParsedRow } from './parsers/parser.interface';
|
||||
|
||||
jest.mock('@prisma/client', () => ({
|
||||
PrismaClient: class {},
|
||||
TransactionType: {
|
||||
INCOME: 'INCOME',
|
||||
EXPENSE: 'EXPENSE',
|
||||
TRANSFER: 'TRANSFER',
|
||||
},
|
||||
}));
|
||||
|
||||
const row = (over: Partial<ParsedRow> = {}): ParsedRow => ({
|
||||
sourceIndex: 0,
|
||||
date: '2026-04-10',
|
||||
amount: 42.1,
|
||||
type: 'EXPENSE',
|
||||
description: 'Coffee Shop',
|
||||
confidence: 0.95,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('DuplicateDetectorService', () => {
|
||||
let service: DuplicateDetectorService;
|
||||
const mockPrisma: any = {
|
||||
transaction: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DuplicateDetectorService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<DuplicateDetectorService>(DuplicateDetectorService);
|
||||
});
|
||||
|
||||
describe('externalId-based dedupe', () => {
|
||||
it('flags rows whose externalId matches an existing transaction as duplicate with confidence 1', async () => {
|
||||
mockPrisma.transaction.findMany
|
||||
// externalId lookup
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'existing-1',
|
||||
externalId: 'FITID-A',
|
||||
accountId: 'acc-1',
|
||||
amount: 42.1,
|
||||
date: new Date('2026-04-10T12:00:00Z'),
|
||||
description: 'Coffee',
|
||||
},
|
||||
])
|
||||
// windowed dedupe for the remaining row
|
||||
.mockResolvedValueOnce([])
|
||||
// cross-account check
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.classify('user-1', 'acc-1', [
|
||||
row({ externalId: 'FITID-A', sourceIndex: 0 }),
|
||||
row({
|
||||
externalId: 'FITID-B',
|
||||
sourceIndex: 1,
|
||||
amount: 9,
|
||||
description: 'Diff',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result.get(0)).toMatchObject({
|
||||
status: 'duplicate',
|
||||
confidence: 1,
|
||||
duplicateOf: expect.objectContaining({ id: 'existing-1' }),
|
||||
});
|
||||
expect(result.get(1)?.status).toBe('new');
|
||||
});
|
||||
|
||||
it('skips externalId lookup when no rows have one', async () => {
|
||||
mockPrisma.transaction.findMany.mockResolvedValue([]);
|
||||
await service.classify('user-1', 'acc-1', [
|
||||
row({ sourceIndex: 0 }),
|
||||
row({ sourceIndex: 1, amount: 5 }),
|
||||
]);
|
||||
// No externalId call. Windowed dedupe and cross-account transfer-pairing
|
||||
// both run, but no findMany was filtered by externalId.
|
||||
const calls = mockPrisma.transaction.findMany.mock.calls;
|
||||
const externalIdCalls = calls.filter(
|
||||
([arg]: any[]) => arg?.where?.externalId,
|
||||
);
|
||||
expect(externalIdCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('heuristic match', () => {
|
||||
it('flags as duplicate when amount + date (±1 day) + similar description match', async () => {
|
||||
mockPrisma.transaction.findMany
|
||||
// windowed dedupe call
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'existing-1',
|
||||
externalId: null,
|
||||
accountId: 'acc-1',
|
||||
amount: 42.1,
|
||||
date: new Date('2026-04-10T12:00:00Z'),
|
||||
description: 'COFFEE SHOP #1',
|
||||
},
|
||||
])
|
||||
// cross-account call (no candidates)
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.classify('user-1', 'acc-1', [
|
||||
row({
|
||||
amount: 42.1,
|
||||
date: '2026-04-10',
|
||||
description: 'Coffee Shop #1',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result.get(0)?.status).toBe('duplicate');
|
||||
expect(result.get(0)?.confidence).toBeGreaterThanOrEqual(0.9);
|
||||
});
|
||||
|
||||
it('flags as needs_review when amount close and date within ±3 days but description differs', async () => {
|
||||
mockPrisma.transaction.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'existing-2',
|
||||
externalId: null,
|
||||
accountId: 'acc-1',
|
||||
amount: 42.1,
|
||||
date: new Date('2026-04-08T12:00:00Z'),
|
||||
description: 'Some completely different thing',
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.classify('user-1', 'acc-1', [
|
||||
row({ amount: 42.1, date: '2026-04-10', description: 'Coffee Shop' }),
|
||||
]);
|
||||
|
||||
expect(result.get(0)?.status).toBe('needs_review');
|
||||
});
|
||||
|
||||
it('returns new for rows with no match in the window', async () => {
|
||||
mockPrisma.transaction.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'existing-3',
|
||||
externalId: null,
|
||||
accountId: 'acc-1',
|
||||
amount: 9999,
|
||||
date: new Date('2026-04-10T12:00:00Z'),
|
||||
description: 'Nope',
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.classify('user-1', 'acc-1', [
|
||||
row({ amount: 42.1 }),
|
||||
]);
|
||||
|
||||
expect(result.get(0)?.status).toBe('new');
|
||||
});
|
||||
});
|
||||
|
||||
describe('transfer detection', () => {
|
||||
it('flags possible_transfer when an opposite-sign matching row exists on another account', async () => {
|
||||
mockPrisma.transaction.findMany
|
||||
// same-account window (no externalId on this row → no externalId call)
|
||||
.mockResolvedValueOnce([])
|
||||
// cross-account window
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'other-acc-txn',
|
||||
accountId: 'acc-2',
|
||||
amount: 200,
|
||||
date: new Date('2026-04-10T12:00:00Z'),
|
||||
description: 'Transfer from checking',
|
||||
account: { name: 'Savings' },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.classify('user-1', 'acc-1', [
|
||||
row({
|
||||
amount: 200,
|
||||
type: 'EXPENSE',
|
||||
description: 'Transfer to savings',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result.get(0)?.status).toBe('possible_transfer');
|
||||
expect(result.get(0)?.transferCandidate).toMatchObject({
|
||||
accountId: 'acc-2',
|
||||
accountName: 'Savings',
|
||||
matchedTransactionId: 'other-acc-txn',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores cross-account candidates with a different amount', async () => {
|
||||
mockPrisma.transaction.findMany
|
||||
.mockResolvedValueOnce([]) // windowed
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'cross-mismatch',
|
||||
accountId: 'acc-2',
|
||||
amount: 199,
|
||||
date: new Date('2026-04-10T12:00:00Z'),
|
||||
description: 'Different',
|
||||
account: { name: 'Savings' },
|
||||
},
|
||||
{
|
||||
id: 'cross-match',
|
||||
accountId: 'acc-2',
|
||||
amount: 200,
|
||||
date: new Date('2026-04-10T12:00:00Z'),
|
||||
description: 'Right',
|
||||
account: { name: 'Savings' },
|
||||
},
|
||||
]);
|
||||
const result = await service.classify('user-1', 'acc-1', [
|
||||
row({ amount: 200 }),
|
||||
]);
|
||||
expect(result.get(0)?.transferCandidate?.matchedTransactionId).toBe(
|
||||
'cross-match',
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores cross-account candidates with the same accountId (defensive)', async () => {
|
||||
mockPrisma.transaction.findMany
|
||||
.mockResolvedValueOnce([]) // windowed
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'leaked-same-account',
|
||||
accountId: 'acc-1',
|
||||
amount: 200,
|
||||
date: new Date('2026-04-10T12:00:00Z'),
|
||||
description: 'Should be ignored',
|
||||
account: { name: 'Self' },
|
||||
},
|
||||
]);
|
||||
const result = await service.classify('user-1', 'acc-1', [
|
||||
row({ amount: 200 }),
|
||||
]);
|
||||
expect(result.get(0)?.status).toBe('new');
|
||||
});
|
||||
|
||||
it('does not consider duplicate rows for transfer pairing', async () => {
|
||||
// Row has externalId, so externalId call happens first and resolves to duplicate.
|
||||
// No remaining rows for the windowed or cross-account calls.
|
||||
mockPrisma.transaction.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'existing-dup',
|
||||
externalId: 'F-1',
|
||||
accountId: 'acc-1',
|
||||
amount: 200,
|
||||
date: new Date('2026-04-10T12:00:00Z'),
|
||||
description: 'x',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.classify('user-1', 'acc-1', [
|
||||
row({ externalId: 'F-1', amount: 200 }),
|
||||
]);
|
||||
expect(result.get(0)?.status).toBe('duplicate');
|
||||
expect(result.get(0)?.transferCandidate).toBeUndefined();
|
||||
expect(mockPrisma.transaction.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('returns a status for every row', () => {
|
||||
it('handles an empty input array without querying', async () => {
|
||||
const result = await service.classify('user-1', 'acc-1', []);
|
||||
expect(result.size).toBe(0);
|
||||
expect(mockPrisma.transaction.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { ParsedRow } from './parsers/parser.interface';
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const SAME_AMOUNT_EPSILON = 0.005;
|
||||
const NEAR_AMOUNT_EPSILON = 0.02;
|
||||
const STRICT_DATE_WINDOW_DAYS = 1;
|
||||
const LOOSE_DATE_WINDOW_DAYS = 3;
|
||||
const TRANSFER_WINDOW_DAYS = 3;
|
||||
const STRONG_DESCRIPTION_SIMILARITY = 0.85;
|
||||
|
||||
export type ClassificationStatus =
|
||||
| 'new'
|
||||
| 'duplicate'
|
||||
| 'needs_review'
|
||||
| 'possible_transfer';
|
||||
|
||||
export interface DuplicateMatch {
|
||||
id: string;
|
||||
date: string;
|
||||
amount: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface TransferCandidate {
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
matchedTransactionId: string;
|
||||
}
|
||||
|
||||
export interface ClassifiedRow {
|
||||
status: ClassificationStatus;
|
||||
confidence: number;
|
||||
duplicateOf?: DuplicateMatch;
|
||||
transferCandidate?: TransferCandidate;
|
||||
}
|
||||
|
||||
function parseInputDate(date: string): Date {
|
||||
const datePart = date.slice(0, 10);
|
||||
const [y, m, d] = datePart.split('-').map(Number);
|
||||
return new Date(Date.UTC(y, m - 1, d, 12, 0, 0));
|
||||
}
|
||||
|
||||
function dayDiff(a: Date, b: Date): number {
|
||||
return Math.abs(a.getTime() - b.getTime()) / MS_PER_DAY;
|
||||
}
|
||||
|
||||
function normalizeDescription(input: string): string {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.replace(/^(pos|debit|ach|credit|wire)\s+/g, '')
|
||||
.replace(/[#*]?\d{3,}\b/g, '')
|
||||
.replace(/[^a-z0-9 ]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function jaroWinkler(a: string, b: string): number {
|
||||
if (a === b) return 1;
|
||||
if (!a.length || !b.length) return 0;
|
||||
const matchWindow = Math.max(
|
||||
0,
|
||||
Math.floor(Math.max(a.length, b.length) / 2) - 1,
|
||||
);
|
||||
const aMatches = new Array(a.length).fill(false);
|
||||
const bMatches = new Array(b.length).fill(false);
|
||||
let matches = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const start = Math.max(0, i - matchWindow);
|
||||
const end = Math.min(i + matchWindow + 1, b.length);
|
||||
for (let j = start; j < end; j++) {
|
||||
if (bMatches[j]) continue;
|
||||
if (a[i] !== b[j]) continue;
|
||||
aMatches[i] = true;
|
||||
bMatches[j] = true;
|
||||
matches += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matches === 0) return 0;
|
||||
let transpositions = 0;
|
||||
let k = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (!aMatches[i]) continue;
|
||||
while (!bMatches[k]) k += 1;
|
||||
if (a[i] !== b[k]) transpositions += 1;
|
||||
k += 1;
|
||||
}
|
||||
transpositions /= 2;
|
||||
const jaro =
|
||||
(matches / a.length +
|
||||
matches / b.length +
|
||||
(matches - transpositions) / matches) /
|
||||
3;
|
||||
let prefix = 0;
|
||||
for (let i = 0; i < Math.min(4, a.length, b.length); i++) {
|
||||
if (a[i] === b[i]) prefix += 1;
|
||||
else break;
|
||||
}
|
||||
return jaro + prefix * 0.1 * (1 - jaro);
|
||||
}
|
||||
|
||||
interface ExistingTxn {
|
||||
id: string;
|
||||
externalId?: string | null;
|
||||
amount: number | string | { toString(): string };
|
||||
date: Date;
|
||||
description: string;
|
||||
account?: { name: string };
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
function existingToMatch(t: ExistingTxn): DuplicateMatch {
|
||||
return {
|
||||
id: t.id,
|
||||
date: t.date.toISOString().slice(0, 10),
|
||||
amount: Number(t.amount),
|
||||
description: t.description,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DuplicateDetectorService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async classify(
|
||||
userId: string,
|
||||
accountId: string,
|
||||
rows: ParsedRow[],
|
||||
): Promise<Map<number, ClassifiedRow>> {
|
||||
const result = new Map<number, ClassifiedRow>();
|
||||
if (rows.length === 0) return result;
|
||||
|
||||
// 1. externalId lookup
|
||||
const idsByRow = new Map<string, number[]>();
|
||||
for (const r of rows) {
|
||||
if (r.externalId) {
|
||||
const arr = idsByRow.get(r.externalId) ?? [];
|
||||
arr.push(r.sourceIndex);
|
||||
idsByRow.set(r.externalId, arr);
|
||||
}
|
||||
}
|
||||
if (idsByRow.size > 0) {
|
||||
const existing = ((await this.prisma.transaction.findMany({
|
||||
where: {
|
||||
userId,
|
||||
accountId,
|
||||
externalId: { in: Array.from(idsByRow.keys()) },
|
||||
},
|
||||
})) ?? []) as ExistingTxn[];
|
||||
const byExtId = new Map(existing.map((e) => [e.externalId!, e]));
|
||||
for (const [extId, indices] of idsByRow.entries()) {
|
||||
const match = byExtId.get(extId);
|
||||
if (!match) continue;
|
||||
for (const idx of indices) {
|
||||
result.set(idx, {
|
||||
status: 'duplicate',
|
||||
confidence: 1,
|
||||
duplicateOf: existingToMatch(match),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. windowed query for same account (heuristic dedupe)
|
||||
const remaining = rows.filter((r) => !result.has(r.sourceIndex));
|
||||
if (remaining.length > 0) {
|
||||
const dates = remaining.map((r) => parseInputDate(r.date));
|
||||
const minDate = new Date(
|
||||
Math.min(...dates.map((d) => d.getTime())) -
|
||||
LOOSE_DATE_WINDOW_DAYS * MS_PER_DAY,
|
||||
);
|
||||
const maxDate = new Date(
|
||||
Math.max(...dates.map((d) => d.getTime())) +
|
||||
LOOSE_DATE_WINDOW_DAYS * MS_PER_DAY,
|
||||
);
|
||||
const candidates = ((await this.prisma.transaction.findMany({
|
||||
where: {
|
||||
userId,
|
||||
accountId,
|
||||
date: { gte: minDate, lte: maxDate },
|
||||
},
|
||||
})) ?? []) as ExistingTxn[];
|
||||
|
||||
for (const row of remaining) {
|
||||
const rowDate = parseInputDate(row.date);
|
||||
const rowNormDesc = normalizeDescription(row.description);
|
||||
let bestStrong: { match: ExistingTxn; sim: number } | null = null;
|
||||
let bestNear: {
|
||||
match: ExistingTxn;
|
||||
sim: number;
|
||||
dDiff: number;
|
||||
} | null = null;
|
||||
for (const c of candidates) {
|
||||
const dDiff = dayDiff(rowDate, c.date);
|
||||
const amountDiff = Math.abs(Number(c.amount) - row.amount);
|
||||
const sim = jaroWinkler(
|
||||
rowNormDesc,
|
||||
normalizeDescription(c.description),
|
||||
);
|
||||
if (
|
||||
amountDiff <= SAME_AMOUNT_EPSILON &&
|
||||
dDiff <= STRICT_DATE_WINDOW_DAYS &&
|
||||
sim >= STRONG_DESCRIPTION_SIMILARITY &&
|
||||
(!bestStrong || sim > bestStrong.sim)
|
||||
) {
|
||||
bestStrong = { match: c, sim };
|
||||
} else if (
|
||||
amountDiff <= NEAR_AMOUNT_EPSILON &&
|
||||
dDiff <= LOOSE_DATE_WINDOW_DAYS &&
|
||||
(!bestNear ||
|
||||
dDiff < bestNear.dDiff ||
|
||||
(dDiff === bestNear.dDiff && sim > bestNear.sim))
|
||||
) {
|
||||
bestNear = { match: c, sim, dDiff };
|
||||
}
|
||||
}
|
||||
if (bestStrong) {
|
||||
result.set(row.sourceIndex, {
|
||||
status: 'duplicate',
|
||||
confidence: 0.95,
|
||||
duplicateOf: existingToMatch(bestStrong.match),
|
||||
});
|
||||
} else if (bestNear) {
|
||||
result.set(row.sourceIndex, {
|
||||
status: 'needs_review',
|
||||
confidence: 0.75,
|
||||
duplicateOf: existingToMatch(bestNear.match),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. cross-account transfer pairing for rows that didn't dedupe
|
||||
const unmatched = rows.filter((r) => !result.has(r.sourceIndex));
|
||||
if (unmatched.length > 0) {
|
||||
const dates = unmatched.map((r) => parseInputDate(r.date));
|
||||
const minDate = new Date(
|
||||
Math.min(...dates.map((d) => d.getTime())) -
|
||||
TRANSFER_WINDOW_DAYS * MS_PER_DAY,
|
||||
);
|
||||
const maxDate = new Date(
|
||||
Math.max(...dates.map((d) => d.getTime())) +
|
||||
TRANSFER_WINDOW_DAYS * MS_PER_DAY,
|
||||
);
|
||||
const crossCandidates = ((await this.prisma.transaction.findMany({
|
||||
where: {
|
||||
userId,
|
||||
accountId: { not: accountId },
|
||||
date: { gte: minDate, lte: maxDate },
|
||||
},
|
||||
include: { account: { select: { name: true } } },
|
||||
})) ?? []) as ExistingTxn[];
|
||||
|
||||
for (const row of unmatched) {
|
||||
const rowDate = parseInputDate(row.date);
|
||||
let best: ExistingTxn | null = null;
|
||||
let bestDayDiff = Infinity;
|
||||
for (const c of crossCandidates) {
|
||||
if (c.accountId === accountId) continue;
|
||||
if (Math.abs(Number(c.amount) - row.amount) > SAME_AMOUNT_EPSILON) {
|
||||
continue;
|
||||
}
|
||||
const dDiff = dayDiff(rowDate, c.date);
|
||||
if (dDiff > TRANSFER_WINDOW_DAYS) continue;
|
||||
if (dDiff < bestDayDiff) {
|
||||
best = c;
|
||||
bestDayDiff = dDiff;
|
||||
}
|
||||
}
|
||||
if (best && best.accountId && best.account?.name) {
|
||||
result.set(row.sourceIndex, {
|
||||
status: 'possible_transfer',
|
||||
confidence: 0.85,
|
||||
transferCandidate: {
|
||||
accountId: best.accountId,
|
||||
accountName: best.account.name,
|
||||
matchedTransactionId: best.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. everything else is new
|
||||
for (const r of rows) {
|
||||
if (!result.has(r.sourceIndex)) {
|
||||
result.set(r.sourceIndex, { status: 'new', confidence: r.confidence });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { guessColumnMapping, isMappingUsable } from './column-mapping';
|
||||
|
||||
describe('guessColumnMapping', () => {
|
||||
it('matches Chase-style headers (Posting Date / Description / Amount / Type)', () => {
|
||||
const m = guessColumnMapping([
|
||||
'Posting Date',
|
||||
'Description',
|
||||
'Amount',
|
||||
'Type',
|
||||
'Balance',
|
||||
]);
|
||||
expect(m.date).toBe('Posting Date');
|
||||
expect(m.description).toBe('Description');
|
||||
expect(m.amount).toBe('Amount');
|
||||
expect(m.type).toBe('Type');
|
||||
});
|
||||
|
||||
it('matches BoA-style debit/credit headers', () => {
|
||||
const m = guessColumnMapping(['Date', 'Description', 'Debit', 'Credit']);
|
||||
expect(m.date).toBe('Date');
|
||||
expect(m.debit).toBe('Debit');
|
||||
expect(m.credit).toBe('Credit');
|
||||
expect(m.amount).toBeUndefined();
|
||||
});
|
||||
|
||||
it('matches alternate column names (Transaction Date, Payee, Withdrawals, Deposits)', () => {
|
||||
const m = guessColumnMapping([
|
||||
'Trans. Date',
|
||||
'Payee',
|
||||
'Withdrawals',
|
||||
'Deposits',
|
||||
]);
|
||||
expect(m.date).toBe('Trans. Date');
|
||||
expect(m.description).toBe('Payee');
|
||||
expect(m.debit).toBe('Withdrawals');
|
||||
expect(m.credit).toBe('Deposits');
|
||||
});
|
||||
|
||||
it('does not match unknown headers', () => {
|
||||
const m = guessColumnMapping(['When', 'What', 'How Much', 'Direction']);
|
||||
// "When" and "How Much" match aliases we added; "What" matches description.
|
||||
expect(m.date).toBe('When');
|
||||
expect(m.amount).toBe('How Much');
|
||||
expect(m.description).toBe('What');
|
||||
});
|
||||
|
||||
it('returns nothing for entirely foreign headers', () => {
|
||||
const m = guessColumnMapping(['Col1', 'Col2', 'Col3']);
|
||||
expect(m.date).toBeUndefined();
|
||||
expect(m.amount).toBeUndefined();
|
||||
expect(m.description).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMappingUsable', () => {
|
||||
it('requires date, description, and either amount or both debit+credit', () => {
|
||||
expect(
|
||||
isMappingUsable({
|
||||
date: 'Date',
|
||||
description: 'Desc',
|
||||
amount: 'Amount',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isMappingUsable({
|
||||
date: 'Date',
|
||||
description: 'Desc',
|
||||
debit: 'Debit',
|
||||
credit: 'Credit',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isMappingUsable({ date: 'Date', description: 'Desc' })).toBe(false);
|
||||
expect(
|
||||
isMappingUsable({
|
||||
date: 'Date',
|
||||
description: 'Desc',
|
||||
debit: 'Debit',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(isMappingUsable({ description: 'Desc', amount: 'Amount' })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { ColumnMapping } from './parser.interface';
|
||||
|
||||
const DATE_PATTERNS = [
|
||||
/^(posting|transaction|trans\.?|trade)\s*date$/,
|
||||
/^date(?:\s|$)/,
|
||||
/^when$/,
|
||||
];
|
||||
|
||||
const DESCRIPTION_PATTERNS = [
|
||||
/^description$/,
|
||||
/^payee$/,
|
||||
/^memo$/,
|
||||
/^merchant$/,
|
||||
/^narration$/,
|
||||
/^details?$/,
|
||||
/^name$/,
|
||||
/^what$/,
|
||||
];
|
||||
|
||||
const AMOUNT_PATTERNS = [
|
||||
/^amount$/,
|
||||
/^value$/,
|
||||
/^transaction\s*amount$/,
|
||||
/^how\s*much$/,
|
||||
];
|
||||
|
||||
const DEBIT_PATTERNS = [
|
||||
/^debit$/,
|
||||
/^debit\s*amount$/,
|
||||
/^withdrawals?$/,
|
||||
/^money\s*out$/,
|
||||
];
|
||||
|
||||
const CREDIT_PATTERNS = [
|
||||
/^credit$/,
|
||||
/^credit\s*amount$/,
|
||||
/^deposits?$/,
|
||||
/^money\s*in$/,
|
||||
];
|
||||
|
||||
const TYPE_PATTERNS = [/^type$/, /^transaction\s*type$/, /^dr\/?cr$/];
|
||||
|
||||
function normalize(header: string): string {
|
||||
return header.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function matches(header: string, patterns: RegExp[]): boolean {
|
||||
const n = normalize(header);
|
||||
return patterns.some((p) => p.test(n));
|
||||
}
|
||||
|
||||
export function guessColumnMapping(headers: string[]): ColumnMapping {
|
||||
const mapping: ColumnMapping = {};
|
||||
for (const header of headers) {
|
||||
if (!mapping.date && matches(header, DATE_PATTERNS)) {
|
||||
mapping.date = header;
|
||||
} else if (!mapping.description && matches(header, DESCRIPTION_PATTERNS)) {
|
||||
mapping.description = header;
|
||||
} else if (!mapping.amount && matches(header, AMOUNT_PATTERNS)) {
|
||||
mapping.amount = header;
|
||||
} else if (!mapping.debit && matches(header, DEBIT_PATTERNS)) {
|
||||
mapping.debit = header;
|
||||
} else if (!mapping.credit && matches(header, CREDIT_PATTERNS)) {
|
||||
mapping.credit = header;
|
||||
} else if (!mapping.type && matches(header, TYPE_PATTERNS)) {
|
||||
mapping.type = header;
|
||||
}
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
export function isMappingUsable(mapping: ColumnMapping): boolean {
|
||||
if (!mapping.date || !mapping.description) return false;
|
||||
const hasAmount = !!mapping.amount || (!!mapping.debit && !!mapping.credit);
|
||||
return hasAmount;
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { CsvParser } from './csv.parser';
|
||||
|
||||
jest.mock('@prisma/client', () => ({
|
||||
AccountType: {
|
||||
CHECKING: 'CHECKING',
|
||||
SAVINGS: 'SAVINGS',
|
||||
CREDIT: 'CREDIT',
|
||||
LOAN: 'LOAN',
|
||||
STOCK: 'STOCK',
|
||||
CASH: 'CASH',
|
||||
INVESTMENT: 'INVESTMENT',
|
||||
RETIREMENT: 'RETIREMENT',
|
||||
},
|
||||
}));
|
||||
|
||||
const fixturesDir = path.join(__dirname, '../../../test/fixtures/statements');
|
||||
const loadFixture = (name: string) =>
|
||||
fs.readFileSync(path.join(fixturesDir, name));
|
||||
|
||||
describe('CsvParser', () => {
|
||||
const parser = new CsvParser();
|
||||
const checkingAccount = { type: AccountType.CHECKING };
|
||||
const creditAccount = { type: AccountType.CREDIT };
|
||||
|
||||
describe('canParse', () => {
|
||||
it('accepts text/csv mime', () => {
|
||||
expect(
|
||||
parser.canParse({ buffer: Buffer.from(''), mimetype: 'text/csv' }),
|
||||
).toBe(true);
|
||||
});
|
||||
it('accepts .csv extension', () => {
|
||||
expect(
|
||||
parser.canParse({
|
||||
buffer: Buffer.from(''),
|
||||
originalname: 'export.csv',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
it('rejects pdf mime', () => {
|
||||
expect(
|
||||
parser.canParse({
|
||||
buffer: Buffer.from(''),
|
||||
mimetype: 'application/pdf',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signed-amount CSV (Chase style)', () => {
|
||||
it('parses negatives as EXPENSE and positives as INCOME', async () => {
|
||||
const result = await parser.parse(
|
||||
{ buffer: loadFixture('chase-signed.csv'), originalname: 'chase.csv' },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
|
||||
expect(result.rows).toHaveLength(4);
|
||||
const [r0, r1, r2, r3] = result.rows;
|
||||
|
||||
expect(r0.date).toBe('2026-04-02');
|
||||
expect(r0.amount).toBe(42.1);
|
||||
expect(r0.type).toBe('EXPENSE');
|
||||
expect(r0.description).toContain('AMZN');
|
||||
|
||||
expect(r1.amount).toBe(2500);
|
||||
expect(r1.type).toBe('INCOME');
|
||||
expect(r1.description).toMatch(/payroll/i);
|
||||
|
||||
expect(r2.amount).toBe(6.75);
|
||||
expect(r2.type).toBe('EXPENSE');
|
||||
|
||||
expect(r3.amount).toBe(150);
|
||||
expect(r3.type).toBe('EXPENSE');
|
||||
|
||||
for (const r of result.rows) {
|
||||
expect(r.amount).toBeGreaterThan(0);
|
||||
expect(r.confidence).toBeGreaterThanOrEqual(0.9);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('debit/credit columns (BoA style)', () => {
|
||||
it('uses Debit column → EXPENSE, Credit column → INCOME', async () => {
|
||||
const result = await parser.parse(
|
||||
{
|
||||
buffer: loadFixture('boa-debit-credit.csv'),
|
||||
originalname: 'boa.csv',
|
||||
},
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
|
||||
expect(result.rows).toHaveLength(4);
|
||||
const [r0, r1, r2, r3] = result.rows;
|
||||
expect(r0.amount).toBe(82.41);
|
||||
expect(r0.type).toBe('EXPENSE');
|
||||
expect(r1.amount).toBe(15.0);
|
||||
expect(r1.type).toBe('INCOME');
|
||||
expect(r2.amount).toBe(38.2);
|
||||
expect(r2.type).toBe('EXPENSE');
|
||||
expect(r3.amount).toBe(3.42);
|
||||
expect(r3.type).toBe('INCOME');
|
||||
});
|
||||
});
|
||||
|
||||
describe('positive-only amount on a credit-card account (Amex style)', () => {
|
||||
it('treats positive rows as EXPENSE (charges) and negative as INCOME (payments) on CREDIT accounts', async () => {
|
||||
const result = await parser.parse(
|
||||
{ buffer: loadFixture('amex-credit.csv'), originalname: 'amex.csv' },
|
||||
{ account: creditAccount },
|
||||
);
|
||||
|
||||
expect(result.rows).toHaveLength(4);
|
||||
const [r0, , r2] = result.rows;
|
||||
expect(r0.amount).toBe(52.1);
|
||||
expect(r0.type).toBe('EXPENSE');
|
||||
// The autopay payment line is a negative number on the statement.
|
||||
expect(r2.amount).toBe(450);
|
||||
expect(r2.type).toBe('INCOME');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown headers', () => {
|
||||
it('returns needsMapping when guess is not usable', async () => {
|
||||
const result = await parser.parse(
|
||||
{
|
||||
buffer: Buffer.from('Col1,Col2,Col3\nfoo,bar,baz\nqux,quux,corge\n'),
|
||||
originalname: 'weird.csv',
|
||||
},
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
|
||||
expect(result.rows).toHaveLength(0);
|
||||
expect(result.needsMapping).toBeDefined();
|
||||
expect(result.needsMapping!.headers).toEqual(['Col1', 'Col2', 'Col3']);
|
||||
expect(result.needsMapping!.sample.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('uses an explicit mapping when provided', async () => {
|
||||
const buf = Buffer.from(
|
||||
'When,What,How Much,Direction\n2026-04-02,Coffee Shop,4.50,out\n2026-04-03,Side Gig,200.00,in\n',
|
||||
);
|
||||
const result = await parser.parse(
|
||||
{ buffer: buf, originalname: 'custom.csv' },
|
||||
{
|
||||
account: checkingAccount,
|
||||
mapping: {
|
||||
date: 'When',
|
||||
description: 'What',
|
||||
amount: 'How Much',
|
||||
type: 'Direction',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.rows).toHaveLength(2);
|
||||
expect(result.rows[0].description).toBe('Coffee Shop');
|
||||
expect(result.rows[0].amount).toBe(4.5);
|
||||
expect(result.rows[0].type).toBe('EXPENSE');
|
||||
expect(result.rows[1].type).toBe('INCOME');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('strips a UTF-8 BOM before parsing', async () => {
|
||||
const csv = 'Date,Description,Amount\n2026-04-02,Coffee,-3.50\n';
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from(csv), originalname: 'bom.csv' },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.rows[0].amount).toBe(3.5);
|
||||
});
|
||||
|
||||
it('handles semicolon-delimited CSV (EU banks)', async () => {
|
||||
const csv =
|
||||
'Date;Description;Amount\n2026-04-02;Bäckerei;-5,40\n2026-04-03;Gehalt;1500,00\n';
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from(csv), originalname: 'eu.csv' },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
expect(result.rows).toHaveLength(2);
|
||||
expect(result.rows[0].amount).toBeCloseTo(5.4, 2);
|
||||
expect(result.rows[1].amount).toBe(1500);
|
||||
});
|
||||
|
||||
it('skips rows missing required fields rather than crashing', async () => {
|
||||
const csv =
|
||||
'Date,Description,Amount\n2026-04-02,Good,-10\n,Missing date,-20\n2026-04-04,Missing amount,\n';
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from(csv), originalname: 'partial.csv' },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns an empty result on an empty CSV without crashing', async () => {
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from(''), originalname: 'empty.csv' },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
expect(result.rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('parses EU-format dates (DD.MM.YYYY)', async () => {
|
||||
const csv = 'Date,Description,Amount\n02.04.2026,EU Coffee,-3.50\n';
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from(csv), originalname: 'eu.csv' },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.rows[0].date).toBe('2026-04-02');
|
||||
});
|
||||
|
||||
it('falls back to Date parsing for non-standard date formats', async () => {
|
||||
const csv = 'Date,Description,Amount\nApr 2 2026,Coffee,-3.50\n';
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from(csv), originalname: 'date.csv' },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.rows[0].date).toBe('2026-04-02');
|
||||
});
|
||||
|
||||
it('warns when a row has an unparseable date', async () => {
|
||||
const csv = 'Date,Description,Amount\nnot-a-date,Mystery,-5\n';
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from(csv), originalname: 'bad.csv' },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
expect(result.rows).toHaveLength(0);
|
||||
expect(result.warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('accepts two-digit US-format years', async () => {
|
||||
const csv = 'Date,Description,Amount\n4/2/26,Coffee,-3.50\n';
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from(csv), originalname: 'short.csv' },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.rows[0].date).toBe('2026-04-02');
|
||||
});
|
||||
|
||||
it('uses an explicit Dr/Cr type column override on a positive amount', async () => {
|
||||
const csv =
|
||||
'Date,Description,Amount,Dr/Cr\n2026-04-02,Refund,15.00,CR\n2026-04-03,Coffee,3.50,DR\n';
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from(csv), originalname: 'drcr.csv' },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
expect(result.rows).toHaveLength(2);
|
||||
expect(result.rows[0].type).toBe('INCOME');
|
||||
expect(result.rows[1].type).toBe('EXPENSE');
|
||||
});
|
||||
|
||||
it('rejects rows with unparseable amounts gracefully', async () => {
|
||||
const csv = 'Date,Description,Amount\n2026-04-02,Coffee,not-a-number\n';
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from(csv), originalname: 'badamt.csv' },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
expect(result.rows).toHaveLength(0);
|
||||
expect(result.warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('handles a thousands-separated amount like 1,234.56', async () => {
|
||||
const csv =
|
||||
'Date,Description,Amount\n2026-04-02,Big Coffee,"-1,234.56"\n';
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from(csv), originalname: 'big.csv' },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.rows[0].amount).toBe(1234.56);
|
||||
});
|
||||
|
||||
it('handles application/vnd.ms-excel as a CSV mimetype', () => {
|
||||
expect(
|
||||
parser.canParse({
|
||||
buffer: Buffer.from(''),
|
||||
mimetype: 'application/vnd.ms-excel',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('parses EU thousands-separated amounts (1.234,56 form)', async () => {
|
||||
const csv = 'Date;Description;Amount\n2026-04-02;Big Coffee;-1.234,56\n';
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from(csv), originalname: 'eu-thousands.csv' },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.rows[0].amount).toBeCloseTo(1234.56, 2);
|
||||
});
|
||||
|
||||
it('parses comma-only amounts with no decimals as integers', async () => {
|
||||
const csv = 'Date,Description,Amount\n2026-04-02,Big Coffee,"1,234"\n';
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from(csv), originalname: 'int.csv' },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.rows[0].amount).toBe(1234);
|
||||
});
|
||||
|
||||
it('defaults to INCOME (low confidence) on assets when type column has unknown value', async () => {
|
||||
const csv =
|
||||
'Date,Description,Amount,Type\n2026-04-02,Mystery,99.00,UNKNOWN\n';
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from(csv), originalname: 'unk.csv' },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.rows[0].type).toBe('INCOME');
|
||||
expect(result.rows[0].confidence).toBeLessThan(0.9);
|
||||
});
|
||||
|
||||
it('skips debit/credit rows where both columns are empty', async () => {
|
||||
const csv = 'Date,Description,Debit,Credit\n2026-04-02,No money,,\n';
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from(csv), originalname: 'empty.csv' },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
expect(result.rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips zero-amount rows in signed mode', async () => {
|
||||
const csv = 'Date,Description,Amount\n2026-04-02,Free,0.00\n';
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from(csv), originalname: 'zero.csv' },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
expect(result.rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import * as Papa from 'papaparse';
|
||||
import { AccountType } from '@prisma/client';
|
||||
import {
|
||||
ColumnMapping,
|
||||
ParseOptions,
|
||||
ParseResult,
|
||||
ParsedRow,
|
||||
ParserFileInput,
|
||||
StatementParser,
|
||||
} from './parser.interface';
|
||||
import { guessColumnMapping, isMappingUsable } from './column-mapping';
|
||||
|
||||
const LIABILITY_TYPES: AccountType[] = [AccountType.CREDIT, AccountType.LOAN];
|
||||
|
||||
const POSITIVE_TYPE_KEYWORDS = [
|
||||
'credit',
|
||||
'deposit',
|
||||
'income',
|
||||
'in',
|
||||
'cr',
|
||||
'refund',
|
||||
'payment received',
|
||||
];
|
||||
const NEGATIVE_TYPE_KEYWORDS = [
|
||||
'debit',
|
||||
'sale',
|
||||
'purchase',
|
||||
'withdrawal',
|
||||
'expense',
|
||||
'out',
|
||||
'dr',
|
||||
'fee',
|
||||
];
|
||||
|
||||
function stripBom(input: string): string {
|
||||
return input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;
|
||||
}
|
||||
|
||||
function normalizeWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function parseDate(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
// YYYY-MM-DD
|
||||
const iso = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`;
|
||||
// MM/DD/YYYY or M/D/YYYY
|
||||
const us = trimmed.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})/);
|
||||
if (us) {
|
||||
const yyyy = us[3].length === 2 ? `20${us[3]}` : us[3];
|
||||
return `${yyyy}-${us[1].padStart(2, '0')}-${us[2].padStart(2, '0')}`;
|
||||
}
|
||||
// DD.MM.YYYY (EU)
|
||||
const eu = trimmed.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2,4})/);
|
||||
if (eu) {
|
||||
const yyyy = eu[3].length === 2 ? `20${eu[3]}` : eu[3];
|
||||
return `${yyyy}-${eu[2].padStart(2, '0')}-${eu[1].padStart(2, '0')}`;
|
||||
}
|
||||
const fallback = new Date(trimmed);
|
||||
if (!isNaN(fallback.getTime())) {
|
||||
const y = fallback.getUTCFullYear();
|
||||
const m = String(fallback.getUTCMonth() + 1).padStart(2, '0');
|
||||
const d = String(fallback.getUTCDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseNumber(input: string): number | null {
|
||||
const cleaned = input.replace(/[$£€\s]/g, '');
|
||||
if (!cleaned) return null;
|
||||
// Handle "1.234,56" (EU) vs "1,234.56" (US). If both present, the LAST one is the decimal.
|
||||
let normalized = cleaned;
|
||||
if (cleaned.includes(',') && cleaned.includes('.')) {
|
||||
if (cleaned.lastIndexOf(',') > cleaned.lastIndexOf('.')) {
|
||||
normalized = cleaned.replace(/\./g, '').replace(',', '.');
|
||||
} else {
|
||||
normalized = cleaned.replace(/,/g, '');
|
||||
}
|
||||
} else if (cleaned.includes(',') && !cleaned.includes('.')) {
|
||||
// Pure comma — treat as decimal if there's exactly one and 1-2 digits after.
|
||||
const parts = cleaned.split(',');
|
||||
if (parts.length === 2 && parts[1].length <= 2) {
|
||||
normalized = `${parts[0]}.${parts[1]}`;
|
||||
} else {
|
||||
normalized = cleaned.replace(/,/g, '');
|
||||
}
|
||||
}
|
||||
const n = parseFloat(normalized);
|
||||
return isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
function rowHasPositiveType(typeValue: string | undefined): boolean | null {
|
||||
if (!typeValue) return null;
|
||||
const lower = typeValue.toLowerCase().trim();
|
||||
if (POSITIVE_TYPE_KEYWORDS.some((k) => lower === k || lower.includes(k))) {
|
||||
return true;
|
||||
}
|
||||
if (NEGATIVE_TYPE_KEYWORDS.some((k) => lower === k || lower.includes(k))) {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface InterpretedAmount {
|
||||
amount: number;
|
||||
positive: boolean;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
function interpretAmount(
|
||||
row: Record<string, string>,
|
||||
mapping: ColumnMapping,
|
||||
): InterpretedAmount | null {
|
||||
// Debit/Credit pair takes precedence — it's unambiguous.
|
||||
if (mapping.debit && mapping.credit) {
|
||||
const debit = parseNumber(row[mapping.debit] ?? '');
|
||||
const credit = parseNumber(row[mapping.credit] ?? '');
|
||||
if (debit && debit !== 0) {
|
||||
return { amount: Math.abs(debit), positive: false, confidence: 0.98 };
|
||||
}
|
||||
if (credit && credit !== 0) {
|
||||
return { amount: Math.abs(credit), positive: true, confidence: 0.98 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!mapping.amount) return null;
|
||||
const raw = parseNumber(row[mapping.amount] ?? '');
|
||||
if (raw === null) return null;
|
||||
|
||||
if (raw < 0) {
|
||||
return { amount: -raw, positive: false, confidence: 0.95 };
|
||||
}
|
||||
if (raw > 0) {
|
||||
// Need to decide if positive means income or expense.
|
||||
if (mapping.type) {
|
||||
const inferred = rowHasPositiveType(row[mapping.type]);
|
||||
if (inferred !== null) {
|
||||
return { amount: raw, positive: inferred, confidence: 0.95 };
|
||||
}
|
||||
}
|
||||
// No type column — default depends on account type, decided by caller.
|
||||
return { amount: raw, positive: true, confidence: 0.7 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveType(
|
||||
interp: InterpretedAmount,
|
||||
accountType: AccountType,
|
||||
hasAmountOnly: boolean,
|
||||
): { type: 'INCOME' | 'EXPENSE'; confidence: number } {
|
||||
// For debit/credit columns: positive=credit=INCOME on assets, INCOME on liabilities too
|
||||
// (e.g., refund on credit card decreases debt = INCOME by our model).
|
||||
// For a single signed-amount column where positive defaults to income on
|
||||
// assets but EXPENSE on credit cards (purchases come in as positives on
|
||||
// many credit-card statements).
|
||||
const isLiability = LIABILITY_TYPES.includes(accountType);
|
||||
if (hasAmountOnly && isLiability) {
|
||||
// Reverse: positives on credit cards are charges (EXPENSE), negatives are payments (INCOME).
|
||||
if (interp.positive) {
|
||||
return { type: 'EXPENSE', confidence: interp.confidence };
|
||||
}
|
||||
return { type: 'INCOME', confidence: interp.confidence };
|
||||
}
|
||||
return {
|
||||
type: interp.positive ? 'INCOME' : 'EXPENSE',
|
||||
confidence: interp.confidence,
|
||||
};
|
||||
}
|
||||
|
||||
function detectDelimiter(sample: string): string {
|
||||
const firstLine = sample.split(/\r?\n/)[0] ?? '';
|
||||
const candidates = [',', ';', '\t', '|'];
|
||||
let best = ',';
|
||||
let bestCount = 0;
|
||||
for (const c of candidates) {
|
||||
const count = firstLine.split(c).length;
|
||||
if (count > bestCount) {
|
||||
bestCount = count;
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export class CsvParser implements StatementParser {
|
||||
format = 'csv' as const;
|
||||
|
||||
canParse(file: ParserFileInput): boolean {
|
||||
if (file.mimetype === 'text/csv') return true;
|
||||
if (file.mimetype === 'application/vnd.ms-excel') return true;
|
||||
if (file.originalname?.toLowerCase().endsWith('.csv')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
parse(file: ParserFileInput, options: ParseOptions): Promise<ParseResult> {
|
||||
try {
|
||||
return Promise.resolve(this.parseSync(file, options));
|
||||
} catch (err) {
|
||||
return Promise.reject(
|
||||
err instanceof Error ? err : new Error(String(err)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private parseSync(file: ParserFileInput, options: ParseOptions): ParseResult {
|
||||
const raw = stripBom(file.buffer.toString('utf8'));
|
||||
if (!raw.trim()) {
|
||||
return { rows: [], warnings: [] };
|
||||
}
|
||||
|
||||
const delimiter = detectDelimiter(raw);
|
||||
const parsed = Papa.parse<Record<string, string>>(raw, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
delimiter,
|
||||
});
|
||||
|
||||
const headers = parsed.meta.fields ?? [];
|
||||
const rawRows = parsed.data ?? [];
|
||||
|
||||
const warnings: string[] = [];
|
||||
|
||||
const mapping = options.mapping ?? guessColumnMapping(headers);
|
||||
if (!isMappingUsable(mapping)) {
|
||||
return {
|
||||
rows: [],
|
||||
warnings,
|
||||
needsMapping: {
|
||||
headers,
|
||||
sample: rawRows
|
||||
.slice(0, 5)
|
||||
.map((r) => headers.map((h) => r[h] ?? '')),
|
||||
guess: mapping,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const rows: ParsedRow[] = [];
|
||||
const hasAmountOnly =
|
||||
!!mapping.amount && !(mapping.debit && mapping.credit);
|
||||
|
||||
rawRows.forEach((row, i) => {
|
||||
const dateRaw = mapping.date ? row[mapping.date] : '';
|
||||
const date = parseDate(dateRaw ?? '');
|
||||
const description = mapping.description
|
||||
? normalizeWhitespace(row[mapping.description] ?? '')
|
||||
: '';
|
||||
const interp = interpretAmount(row, mapping);
|
||||
|
||||
if (!date || !description || !interp) {
|
||||
warnings.push(
|
||||
`Row ${i + 1}: skipped — missing date, description, or amount`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const derived = deriveType(interp, options.account.type, hasAmountOnly);
|
||||
rows.push({
|
||||
sourceIndex: i,
|
||||
date,
|
||||
amount: Math.round(interp.amount * 100) / 100,
|
||||
type: derived.type,
|
||||
description,
|
||||
rawMemo: description,
|
||||
confidence: derived.confidence,
|
||||
});
|
||||
});
|
||||
|
||||
return { rows, warnings };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { OfxParser } from './ofx.parser';
|
||||
|
||||
jest.mock('@prisma/client', () => ({
|
||||
AccountType: {
|
||||
CHECKING: 'CHECKING',
|
||||
SAVINGS: 'SAVINGS',
|
||||
CREDIT: 'CREDIT',
|
||||
LOAN: 'LOAN',
|
||||
STOCK: 'STOCK',
|
||||
CASH: 'CASH',
|
||||
INVESTMENT: 'INVESTMENT',
|
||||
RETIREMENT: 'RETIREMENT',
|
||||
},
|
||||
}));
|
||||
|
||||
const fixturesDir = path.join(__dirname, '../../../test/fixtures/statements');
|
||||
const loadFixture = (name: string) =>
|
||||
fs.readFileSync(path.join(fixturesDir, name));
|
||||
|
||||
describe('OfxParser', () => {
|
||||
const parser = new OfxParser();
|
||||
const checkingAccount = { type: AccountType.CHECKING };
|
||||
const savingsAccount = { type: AccountType.SAVINGS };
|
||||
const creditAccount = { type: AccountType.CREDIT };
|
||||
|
||||
describe('canParse', () => {
|
||||
it('accepts buffers beginning with OFXHEADER (SGML)', () => {
|
||||
expect(
|
||||
parser.canParse({
|
||||
buffer: Buffer.from('OFXHEADER:100\nDATA:OFXSGML\n'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
it('accepts XML OFX with the <?OFX header', () => {
|
||||
expect(
|
||||
parser.canParse({
|
||||
buffer: Buffer.from('<?xml version="1.0"?>\n<?OFX VERSION="200"?>'),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
it('accepts .ofx and .qfx file extensions', () => {
|
||||
expect(
|
||||
parser.canParse({ buffer: Buffer.from(''), originalname: 'foo.ofx' }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
parser.canParse({ buffer: Buffer.from(''), originalname: 'foo.qfx' }),
|
||||
).toBe(true);
|
||||
});
|
||||
it('rejects unrelated content', () => {
|
||||
expect(
|
||||
parser.canParse({ buffer: Buffer.from('Date,Description,Amount\n') }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OFX 1.x SGML', () => {
|
||||
it('extracts STMTTRN rows with FITID as externalId', async () => {
|
||||
const result = await parser.parse(
|
||||
{ buffer: loadFixture('sample-v1.ofx'), originalname: 'sample.ofx' },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
|
||||
expect(result.rows).toHaveLength(3);
|
||||
const [r0, r1, r2] = result.rows;
|
||||
expect(r0.date).toBe('2026-04-02');
|
||||
expect(r0.amount).toBe(42.1);
|
||||
expect(r0.type).toBe('EXPENSE');
|
||||
expect(r0.externalId).toBe('20260402-001');
|
||||
expect(r0.description).toContain('AMZN');
|
||||
|
||||
expect(r1.amount).toBe(2500);
|
||||
expect(r1.type).toBe('INCOME');
|
||||
expect(r1.externalId).toBe('20260403-001');
|
||||
|
||||
expect(r2.amount).toBe(6.75);
|
||||
expect(r2.type).toBe('EXPENSE');
|
||||
expect(r2.description).toMatch(/STARBUCKS/);
|
||||
// MEMO should be captured as rawMemo
|
||||
expect(r2.rawMemo).toBe('Coffee');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OFX 2.x XML', () => {
|
||||
it('extracts transactions from XML-format OFX', async () => {
|
||||
const result = await parser.parse(
|
||||
{ buffer: loadFixture('sample-v2.ofx'), originalname: 'sample-v2.ofx' },
|
||||
{ account: savingsAccount },
|
||||
);
|
||||
|
||||
expect(result.rows).toHaveLength(2);
|
||||
expect(result.rows[0].type).toBe('INCOME');
|
||||
expect(result.rows[0].amount).toBe(3.42);
|
||||
expect(result.rows[1].type).toBe('EXPENSE');
|
||||
expect(result.rows[1].amount).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QFX (credit card) — CCSTMTRS branch', () => {
|
||||
it('inverts sign for credit-card statements: negative = charge (EXPENSE), positive = payment (INCOME)', async () => {
|
||||
const result = await parser.parse(
|
||||
{
|
||||
buffer: loadFixture('sample-credit-card.qfx'),
|
||||
originalname: 'cc.qfx',
|
||||
},
|
||||
{ account: creditAccount },
|
||||
);
|
||||
|
||||
expect(result.rows).toHaveLength(2);
|
||||
const [charge, payment] = result.rows;
|
||||
// -52.10 on a credit card = charge = EXPENSE
|
||||
expect(charge.amount).toBe(52.1);
|
||||
expect(charge.type).toBe('EXPENSE');
|
||||
// 450 positive on a credit card = autopay payment = INCOME
|
||||
expect(payment.amount).toBe(450);
|
||||
expect(payment.type).toBe('INCOME');
|
||||
});
|
||||
});
|
||||
|
||||
describe('robustness', () => {
|
||||
it('handles a single STMTTRN (not an array) gracefully', async () => {
|
||||
const single = `OFXHEADER:100
|
||||
DATA:OFXSGML
|
||||
|
||||
<OFX>
|
||||
<BANKMSGSRSV1>
|
||||
<STMTTRNRS>
|
||||
<STMTRS>
|
||||
<BANKACCTFROM>
|
||||
<ACCTTYPE>CHECKING
|
||||
</BANKACCTFROM>
|
||||
<BANKTRANLIST>
|
||||
<STMTTRN>
|
||||
<TRNTYPE>DEBIT
|
||||
<DTPOSTED>20260402
|
||||
<TRNAMT>-10.00
|
||||
<FITID>SINGLE-1
|
||||
<NAME>Single transaction
|
||||
</STMTTRN>
|
||||
</BANKTRANLIST>
|
||||
</STMTRS>
|
||||
</STMTTRNRS>
|
||||
</BANKMSGSRSV1>
|
||||
</OFX>`;
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from(single) },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.rows[0].externalId).toBe('SINGLE-1');
|
||||
});
|
||||
|
||||
it('skips entries without dates or amounts and logs a warning', async () => {
|
||||
const broken = `OFXHEADER:100
|
||||
DATA:OFXSGML
|
||||
|
||||
<OFX>
|
||||
<BANKMSGSRSV1>
|
||||
<STMTTRNRS>
|
||||
<STMTRS>
|
||||
<BANKACCTFROM>
|
||||
<ACCTTYPE>CHECKING
|
||||
</BANKACCTFROM>
|
||||
<BANKTRANLIST>
|
||||
<STMTTRN>
|
||||
<TRNTYPE>DEBIT
|
||||
<DTPOSTED>20260402
|
||||
<TRNAMT>-10.00
|
||||
<FITID>OK-1
|
||||
<NAME>OK row
|
||||
</STMTTRN>
|
||||
<STMTTRN>
|
||||
<TRNTYPE>DEBIT
|
||||
<FITID>BROKEN-1
|
||||
<NAME>Missing date and amount
|
||||
</STMTTRN>
|
||||
</BANKTRANLIST>
|
||||
</STMTRS>
|
||||
</STMTTRNRS>
|
||||
</BANKMSGSRSV1>
|
||||
</OFX>`;
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from(broken) },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('throws a friendly error when content is not OFX at all', async () => {
|
||||
await expect(
|
||||
parser.parse(
|
||||
{ buffer: Buffer.from('not really ofx') },
|
||||
{ account: checkingAccount },
|
||||
),
|
||||
).rejects.toThrow(/OFX/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { AccountType } from '@prisma/client';
|
||||
import * as ofxLib from 'node-ofx-parser';
|
||||
import type {
|
||||
OfxParsed,
|
||||
OfxRoot,
|
||||
OfxStatement,
|
||||
OfxTransaction,
|
||||
OfxTransactionList,
|
||||
} from 'node-ofx-parser';
|
||||
import {
|
||||
ParseOptions,
|
||||
ParseResult,
|
||||
ParsedRow,
|
||||
ParserFileInput,
|
||||
StatementParser,
|
||||
} from './parser.interface';
|
||||
|
||||
const LIABILITY_TYPES: AccountType[] = [AccountType.CREDIT, AccountType.LOAN];
|
||||
|
||||
function asArray<T>(value: T | T[] | undefined): T[] {
|
||||
if (value === undefined || value === null) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
function parseOfxDate(raw: string | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
const cleaned = raw.trim();
|
||||
// OFX dates are YYYYMMDD[HHMMSS[.XXX]][TZ]
|
||||
const match = cleaned.match(/^(\d{4})(\d{2})(\d{2})/);
|
||||
if (!match) return null;
|
||||
return `${match[1]}-${match[2]}-${match[3]}`;
|
||||
}
|
||||
|
||||
function parseAmount(raw: string | undefined): number | null {
|
||||
if (raw === undefined || raw === null) return null;
|
||||
const n = parseFloat(raw.trim());
|
||||
return isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
function normalizeString(input: string | undefined): string {
|
||||
if (!input) return '';
|
||||
return input.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function findTransactionLists(root: OfxRoot): OfxTransactionList[] {
|
||||
const lists: OfxTransactionList[] = [];
|
||||
const bankResp = asArray(root.BANKMSGSRSV1?.STMTTRNRS);
|
||||
for (const resp of bankResp) {
|
||||
const stmt: OfxStatement | undefined = resp.STMTRS;
|
||||
if (stmt?.BANKTRANLIST) lists.push(stmt.BANKTRANLIST);
|
||||
}
|
||||
const ccResp = asArray(root.CREDITCARDMSGSRSV1?.CCSTMTTRNRS);
|
||||
for (const resp of ccResp) {
|
||||
const stmt: OfxStatement | undefined = resp.CCSTMTRS;
|
||||
if (stmt?.BANKTRANLIST) lists.push(stmt.BANKTRANLIST);
|
||||
}
|
||||
return lists;
|
||||
}
|
||||
|
||||
export class OfxParser implements StatementParser {
|
||||
format = 'ofx' as const;
|
||||
|
||||
canParse(file: ParserFileInput): boolean {
|
||||
const name = file.originalname?.toLowerCase() ?? '';
|
||||
if (name.endsWith('.ofx') || name.endsWith('.qfx')) return true;
|
||||
const head = file.buffer.slice(0, 256).toString('utf8');
|
||||
if (/OFXHEADER\s*[:=]/i.test(head)) return true;
|
||||
if (/<\?OFX\b/i.test(head)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
parse(file: ParserFileInput, options: ParseOptions): Promise<ParseResult> {
|
||||
try {
|
||||
return Promise.resolve(this.parseSync(file, options));
|
||||
} catch (err) {
|
||||
return Promise.reject(
|
||||
err instanceof Error ? err : new Error(String(err)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private parseSync(file: ParserFileInput, options: ParseOptions): ParseResult {
|
||||
const raw = file.buffer.toString('utf8');
|
||||
let parsed: OfxParsed;
|
||||
try {
|
||||
parsed = ofxLib.parse(raw);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Unable to read this OFX/QFX file. The file may be corrupt or in an unsupported format.',
|
||||
);
|
||||
}
|
||||
const root = parsed.OFX;
|
||||
if (!root || typeof root !== 'object') {
|
||||
throw new Error('This file does not appear to be an OFX/QFX statement.');
|
||||
}
|
||||
|
||||
const isLiability = LIABILITY_TYPES.includes(options.account.type);
|
||||
const lists = findTransactionLists(root);
|
||||
const rows: ParsedRow[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
let sourceIndex = 0;
|
||||
for (const list of lists) {
|
||||
const txns: OfxTransaction[] = asArray(list.STMTTRN);
|
||||
for (const t of txns) {
|
||||
const date = parseOfxDate(t.DTPOSTED);
|
||||
const amountRaw = parseAmount(t.TRNAMT);
|
||||
const description =
|
||||
normalizeString(t.NAME) || normalizeString(t.PAYEE?.NAME);
|
||||
if (!date || amountRaw === null || !description) {
|
||||
warnings.push(
|
||||
`Skipped transaction at position ${sourceIndex + 1}: missing date, amount, or description`,
|
||||
);
|
||||
sourceIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const absAmount = Math.abs(amountRaw);
|
||||
const positive = amountRaw > 0;
|
||||
let type: 'INCOME' | 'EXPENSE';
|
||||
if (isLiability) {
|
||||
// Credit-card semantics: negative TRNAMT = charge (EXPENSE), positive = payment (INCOME).
|
||||
type = positive ? 'INCOME' : 'EXPENSE';
|
||||
} else {
|
||||
type = positive ? 'INCOME' : 'EXPENSE';
|
||||
}
|
||||
|
||||
rows.push({
|
||||
sourceIndex,
|
||||
date,
|
||||
amount: Math.round(absAmount * 100) / 100,
|
||||
type,
|
||||
description,
|
||||
externalId: t.FITID ? t.FITID.trim() : undefined,
|
||||
rawMemo: t.MEMO ? normalizeString(t.MEMO) : undefined,
|
||||
confidence: 0.98,
|
||||
});
|
||||
sourceIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { rows, warnings };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { AccountType } from '@prisma/client';
|
||||
|
||||
export type ParsedRowType = 'INCOME' | 'EXPENSE';
|
||||
|
||||
export interface ParsedRow {
|
||||
sourceIndex: number;
|
||||
date: string;
|
||||
amount: number;
|
||||
type: ParsedRowType;
|
||||
description: string;
|
||||
externalId?: string;
|
||||
rawMemo?: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface ColumnMapping {
|
||||
date?: string;
|
||||
description?: string;
|
||||
amount?: string;
|
||||
debit?: string;
|
||||
credit?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface NeedsMapping {
|
||||
headers: string[];
|
||||
sample: string[][];
|
||||
guess: ColumnMapping;
|
||||
}
|
||||
|
||||
export interface ParseResult {
|
||||
rows: ParsedRow[];
|
||||
warnings: string[];
|
||||
needsMapping?: NeedsMapping;
|
||||
}
|
||||
|
||||
export interface ParserFileInput {
|
||||
buffer: Buffer;
|
||||
mimetype?: string;
|
||||
originalname?: string;
|
||||
}
|
||||
|
||||
export interface ParseOptions {
|
||||
mapping?: ColumnMapping;
|
||||
account: { type: AccountType };
|
||||
}
|
||||
|
||||
export interface StatementParser {
|
||||
format: 'csv' | 'ofx' | 'pdf';
|
||||
canParse(file: ParserFileInput): boolean;
|
||||
parse(file: ParserFileInput, options: ParseOptions): Promise<ParseResult>;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { PdfParser, __setPdfParseForTesting } from './pdf.parser';
|
||||
|
||||
jest.mock('@prisma/client', () => ({
|
||||
AccountType: {
|
||||
CHECKING: 'CHECKING',
|
||||
SAVINGS: 'SAVINGS',
|
||||
CREDIT: 'CREDIT',
|
||||
LOAN: 'LOAN',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('PdfParser', () => {
|
||||
const parser = new PdfParser();
|
||||
const checkingAccount = { type: AccountType.CHECKING };
|
||||
const creditAccount = { type: AccountType.CREDIT };
|
||||
|
||||
afterEach(() => {
|
||||
__setPdfParseForTesting(null);
|
||||
});
|
||||
|
||||
describe('canParse', () => {
|
||||
it('accepts PDF mime, .pdf extension, and %PDF magic bytes', () => {
|
||||
expect(
|
||||
parser.canParse({
|
||||
buffer: Buffer.from(''),
|
||||
mimetype: 'application/pdf',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
parser.canParse({ buffer: Buffer.from(''), originalname: 'foo.pdf' }),
|
||||
).toBe(true);
|
||||
expect(parser.canParse({ buffer: Buffer.from('%PDF-1.4\n') })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-PDF content', () => {
|
||||
expect(
|
||||
parser.canParse({
|
||||
buffer: Buffer.from('Date,Amount\n'),
|
||||
mimetype: 'text/csv',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('text extraction', () => {
|
||||
it('detects rows by date+amount patterns and normalizes them', async () => {
|
||||
__setPdfParseForTesting({
|
||||
parse: async () => ({
|
||||
text: `Chase Statement
|
||||
04/02/2026 AMZN MKTP US*ABC123 -42.10
|
||||
04/03/2026 DIRECT DEPOSIT PAYROLL 2500.00
|
||||
04/04/2026 STARBUCKS #4321 -6.75`,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from('%PDF-1.4'), originalname: 'chase.pdf' },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
|
||||
expect(result.rows).toHaveLength(3);
|
||||
const [r0, r1, r2] = result.rows;
|
||||
expect(r0.date).toBe('2026-04-02');
|
||||
expect(r0.amount).toBe(42.1);
|
||||
expect(r0.type).toBe('EXPENSE');
|
||||
expect(r0.description).toMatch(/AMZN/);
|
||||
expect(r0.confidence).toBeLessThan(0.9);
|
||||
|
||||
expect(r1.amount).toBe(2500);
|
||||
expect(r1.type).toBe('INCOME');
|
||||
|
||||
expect(r2.amount).toBe(6.75);
|
||||
});
|
||||
|
||||
it('parses YYYY-MM-DD and "Apr 2, 2026" date formats', async () => {
|
||||
__setPdfParseForTesting({
|
||||
parse: async () => ({
|
||||
text: `2026-04-02 Coffee Shop -3.50
|
||||
Apr 3, 2026 Refund 15.00`,
|
||||
}),
|
||||
});
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from('%PDF-1.4') },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
expect(result.rows).toHaveLength(2);
|
||||
expect(result.rows[0].date).toBe('2026-04-02');
|
||||
expect(result.rows[1].date).toBe('2026-04-03');
|
||||
expect(result.rows[1].type).toBe('INCOME');
|
||||
});
|
||||
|
||||
it('honors trailing CR/DR markers (common in some statements)', async () => {
|
||||
__setPdfParseForTesting({
|
||||
parse: async () => ({
|
||||
text: `04/02/2026 Coffee Shop 25.00 DR
|
||||
04/03/2026 Refund 10.00 CR`,
|
||||
}),
|
||||
});
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from('%PDF-1.4') },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
expect(result.rows[0].type).toBe('EXPENSE');
|
||||
expect(result.rows[1].type).toBe('INCOME');
|
||||
});
|
||||
|
||||
it('inverts sign semantics on credit-card accounts', async () => {
|
||||
__setPdfParseForTesting({
|
||||
parse: async () => ({
|
||||
text: `04/02/2026 RESTAURANT XYZ 52.10
|
||||
04/15/2026 AUTOPAY PAYMENT - THANK YOU -450.00`,
|
||||
}),
|
||||
});
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from('%PDF-1.4') },
|
||||
{ account: creditAccount },
|
||||
);
|
||||
// 52.10 positive on a credit card = charge = EXPENSE
|
||||
expect(result.rows[0].type).toBe('EXPENSE');
|
||||
// -450 on a credit card = payment = INCOME
|
||||
expect(result.rows[1].type).toBe('INCOME');
|
||||
});
|
||||
|
||||
it('handles thousands-separated amounts', async () => {
|
||||
__setPdfParseForTesting({
|
||||
parse: async () => ({
|
||||
text: `04/02/2026 LARGE BILL 1,234.56`,
|
||||
}),
|
||||
});
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from('%PDF-1.4') },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
expect(result.rows[0].amount).toBeCloseTo(1234.56, 2);
|
||||
});
|
||||
|
||||
it('skips lines without an obvious date or amount', async () => {
|
||||
__setPdfParseForTesting({
|
||||
parse: async () => ({
|
||||
text: `Statement Period
|
||||
Page 1 of 2
|
||||
04/02/2026 Coffee Shop -3.50
|
||||
Account Number: 1234`,
|
||||
}),
|
||||
});
|
||||
const result = await parser.parse(
|
||||
{ buffer: Buffer.from('%PDF-1.4') },
|
||||
{ account: checkingAccount },
|
||||
);
|
||||
expect(result.rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error cases', () => {
|
||||
it('throws a friendly error on scanned (image-only) PDFs', async () => {
|
||||
__setPdfParseForTesting({
|
||||
parse: async () => ({ text: '' }),
|
||||
});
|
||||
await expect(
|
||||
parser.parse(
|
||||
{ buffer: Buffer.from('%PDF-1.4'), originalname: 'scanned.pdf' },
|
||||
{ account: checkingAccount },
|
||||
),
|
||||
).rejects.toThrow(/scanned image/i);
|
||||
});
|
||||
|
||||
it('throws when no transaction rows can be detected', async () => {
|
||||
__setPdfParseForTesting({
|
||||
parse: async () => ({ text: 'Just a header. No transactions here.' }),
|
||||
});
|
||||
await expect(
|
||||
parser.parse(
|
||||
{ buffer: Buffer.from('%PDF-1.4'), originalname: 'empty.pdf' },
|
||||
{ account: checkingAccount },
|
||||
),
|
||||
).rejects.toThrow(/No transactions detected/i);
|
||||
});
|
||||
|
||||
it('wraps an underlying library error with a clear message', async () => {
|
||||
__setPdfParseForTesting({
|
||||
parse: async () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
parser.parse(
|
||||
{ buffer: Buffer.from('%PDF-1.4'), originalname: 'broken.pdf' },
|
||||
{ account: checkingAccount },
|
||||
),
|
||||
).rejects.toThrow(/Could not read this PDF: boom/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { PDFParse } from 'pdf-parse';
|
||||
import {
|
||||
ParseOptions,
|
||||
ParseResult,
|
||||
ParsedRow,
|
||||
ParserFileInput,
|
||||
StatementParser,
|
||||
} from './parser.interface';
|
||||
|
||||
const LIABILITY_TYPES: AccountType[] = [AccountType.CREDIT, AccountType.LOAN];
|
||||
|
||||
// Date patterns we recognize at the start of a transaction line.
|
||||
const DATE_PATTERNS: {
|
||||
regex: RegExp;
|
||||
toIso: (m: RegExpMatchArray) => string;
|
||||
}[] = [
|
||||
{
|
||||
regex: /^(\d{4})-(\d{2})-(\d{2})/,
|
||||
toIso: (m) => `${m[1]}-${m[2]}-${m[3]}`,
|
||||
},
|
||||
{
|
||||
regex: /^(\d{1,2})\/(\d{1,2})\/(\d{2,4})/,
|
||||
toIso: (m) => {
|
||||
const yyyy = m[3].length === 2 ? `20${m[3]}` : m[3];
|
||||
return `${yyyy}-${m[1].padStart(2, '0')}-${m[2].padStart(2, '0')}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
regex:
|
||||
/^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\.?\s+(\d{1,2}),?\s+(\d{4})/i,
|
||||
toIso: (m) => {
|
||||
const months: Record<string, string> = {
|
||||
jan: '01',
|
||||
feb: '02',
|
||||
mar: '03',
|
||||
apr: '04',
|
||||
may: '05',
|
||||
jun: '06',
|
||||
jul: '07',
|
||||
aug: '08',
|
||||
sep: '09',
|
||||
oct: '10',
|
||||
nov: '11',
|
||||
dec: '12',
|
||||
};
|
||||
return `${m[3]}-${months[m[1].toLowerCase().slice(0, 3)]}-${m[2].padStart(2, '0')}`;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Amount at the end of a line: optional sign, digits with optional thousands
|
||||
// separators, optional decimals, optional trailing CR/DR marker.
|
||||
const AMOUNT_REGEX =
|
||||
/(-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})?|\d+\.\d{2})(\s*(CR|DR))?\s*$/i;
|
||||
|
||||
function parseAmount(input: string): number | null {
|
||||
const cleaned = input.replace(/[$,\s]/g, '');
|
||||
const n = parseFloat(cleaned);
|
||||
return isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
export interface PdfTextResult {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export type PdfTextExtractor = (buffer: Buffer) => Promise<PdfTextResult>;
|
||||
|
||||
const defaultExtractor: PdfTextExtractor = async (buffer) => {
|
||||
const parser = new PDFParse({ data: buffer });
|
||||
try {
|
||||
const result = await parser.getText();
|
||||
return { text: result.text ?? '' };
|
||||
} finally {
|
||||
try {
|
||||
await parser.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let extractor: PdfTextExtractor = defaultExtractor;
|
||||
|
||||
// Test seam — specs swap in a fake extractor without touching the real pdfjs.
|
||||
export function __setPdfParseForTesting(
|
||||
fake: { parse: (buffer: Buffer) => Promise<PdfTextResult> } | null,
|
||||
): void {
|
||||
extractor = fake ? (b) => fake.parse(b) : defaultExtractor;
|
||||
}
|
||||
|
||||
interface ExtractedLine {
|
||||
date: string;
|
||||
amountRaw: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
function extractTransactionLines(text: string): ExtractedLine[] {
|
||||
const out: ExtractedLine[] = [];
|
||||
const lines = text.split(/\r?\n/);
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
let date: string | null = null;
|
||||
let rest = line;
|
||||
for (const { regex, toIso } of DATE_PATTERNS) {
|
||||
const match = line.match(regex);
|
||||
if (match) {
|
||||
date = toIso(match);
|
||||
rest = line.slice(match[0].length).trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!date) continue;
|
||||
const amountMatch = rest.match(AMOUNT_REGEX);
|
||||
if (!amountMatch) continue;
|
||||
const description = rest.slice(0, amountMatch.index).trim();
|
||||
if (!description) continue;
|
||||
out.push({
|
||||
date,
|
||||
amountRaw: amountMatch[1] + (amountMatch[2] ?? ''),
|
||||
description,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export class PdfParser implements StatementParser {
|
||||
format = 'pdf' as const;
|
||||
|
||||
canParse(file: ParserFileInput): boolean {
|
||||
if (file.mimetype === 'application/pdf') return true;
|
||||
if (file.originalname?.toLowerCase().endsWith('.pdf')) return true;
|
||||
if (file.buffer.slice(0, 4).toString('ascii') === '%PDF') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async parse(
|
||||
file: ParserFileInput,
|
||||
options: ParseOptions,
|
||||
): Promise<ParseResult> {
|
||||
let extracted: PdfTextResult;
|
||||
try {
|
||||
extracted = await extractor(file.buffer);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
err instanceof Error
|
||||
? `Could not read this PDF: ${err.message}`
|
||||
: 'Could not read this PDF.',
|
||||
);
|
||||
}
|
||||
const text = extracted.text ?? '';
|
||||
if (!text.trim()) {
|
||||
throw new Error(
|
||||
'This PDF appears to be a scanned image. Please use the CSV or OFX/QFX export from your bank instead.',
|
||||
);
|
||||
}
|
||||
|
||||
const lines = extractTransactionLines(text);
|
||||
if (lines.length === 0) {
|
||||
throw new Error(
|
||||
'No transactions detected in this PDF. Try the CSV or OFX/QFX export from your bank for more reliable parsing.',
|
||||
);
|
||||
}
|
||||
|
||||
const isLiability = LIABILITY_TYPES.includes(options.account.type);
|
||||
const rows: ParsedRow[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
lines.forEach((line, sourceIndex) => {
|
||||
const trailingMarker = line.amountRaw
|
||||
.match(/(CR|DR)$/i)?.[1]
|
||||
?.toUpperCase();
|
||||
const cleanedAmount = line.amountRaw.replace(/(CR|DR)$/i, '');
|
||||
const num = parseAmount(cleanedAmount);
|
||||
if (num === null || num === 0) {
|
||||
warnings.push(`Row ${sourceIndex + 1}: could not parse amount`);
|
||||
return;
|
||||
}
|
||||
let positive: boolean;
|
||||
if (trailingMarker === 'CR') positive = true;
|
||||
else if (trailingMarker === 'DR') positive = false;
|
||||
else positive = num > 0;
|
||||
const absAmount = Math.abs(num);
|
||||
|
||||
let type: 'INCOME' | 'EXPENSE';
|
||||
if (isLiability) {
|
||||
// Credit-card / loan: positive amount = charge = EXPENSE (debt grows);
|
||||
// negative or CR-marked amount = payment/refund = INCOME.
|
||||
type = positive ? 'EXPENSE' : 'INCOME';
|
||||
} else {
|
||||
type = positive ? 'INCOME' : 'EXPENSE';
|
||||
}
|
||||
|
||||
rows.push({
|
||||
sourceIndex,
|
||||
date: line.date,
|
||||
amount: Math.round(absAmount * 100) / 100,
|
||||
type,
|
||||
description: line.description,
|
||||
rawMemo: line.description,
|
||||
confidence: 0.6,
|
||||
});
|
||||
});
|
||||
|
||||
return { rows, warnings };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { StatementsController } from './statements.controller';
|
||||
import { StatementsService } from './statements.service';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
|
||||
describe('StatementsController', () => {
|
||||
let controller: StatementsController;
|
||||
const mockUser = { id: 'user-1' } as any;
|
||||
const mockService = {
|
||||
parse: jest.fn().mockResolvedValue({
|
||||
format: 'csv',
|
||||
account: { id: 'acc-1', name: 'Checking', type: 'CHECKING' },
|
||||
rows: [],
|
||||
warnings: [],
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
mockService.parse.mockResolvedValue({
|
||||
format: 'csv',
|
||||
account: { id: 'acc-1', name: 'Checking', type: 'CHECKING' },
|
||||
rows: [],
|
||||
warnings: [],
|
||||
});
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [StatementsController],
|
||||
providers: [{ provide: StatementsService, useValue: mockService }],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
controller = module.get<StatementsController>(StatementsController);
|
||||
});
|
||||
|
||||
it('forwards the upload buffer + accountId to the service', async () => {
|
||||
const file: any = {
|
||||
buffer: Buffer.from('Date,Amount\n2026-04-01,10\n'),
|
||||
mimetype: 'text/csv',
|
||||
originalname: 'x.csv',
|
||||
};
|
||||
await controller.parse(mockUser, file, {
|
||||
accountId: 'acc-1',
|
||||
} as any);
|
||||
expect(mockService.parse).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
expect.objectContaining({
|
||||
buffer: file.buffer,
|
||||
mimetype: 'text/csv',
|
||||
originalname: 'x.csv',
|
||||
}),
|
||||
'acc-1',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects the request when no file is attached', () => {
|
||||
expect(() =>
|
||||
controller.parse(
|
||||
mockUser,
|
||||
undefined as any,
|
||||
{
|
||||
accountId: 'acc-1',
|
||||
} as any,
|
||||
),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('passes mapping through to the service when provided', async () => {
|
||||
const file: any = {
|
||||
buffer: Buffer.from('a,b\n1,2'),
|
||||
mimetype: 'text/csv',
|
||||
originalname: 'x.csv',
|
||||
};
|
||||
const mapping = { date: 'a', amount: 'b', description: 'a' } as any;
|
||||
await controller.parse(mockUser, file, {
|
||||
accountId: 'acc-1',
|
||||
mapping,
|
||||
} as any);
|
||||
expect(mockService.parse).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
expect.anything(),
|
||||
'acc-1',
|
||||
mapping,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { StatementsService } from './statements.service';
|
||||
import { ParseStatementDto } from './dto/parse-statement.dto';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
import { CurrentUser } from '../auth/user.decorator';
|
||||
import type { User } from '@prisma/client';
|
||||
|
||||
const STATEMENT_FILE_SIZE_LIMIT = 10 * 1024 * 1024;
|
||||
|
||||
@Controller('statements')
|
||||
@UseGuards(AuthGuard)
|
||||
export class StatementsController {
|
||||
constructor(private readonly statementsService: StatementsService) {}
|
||||
|
||||
@Post('parse')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: STATEMENT_FILE_SIZE_LIMIT },
|
||||
}),
|
||||
)
|
||||
parse(
|
||||
@CurrentUser() user: User,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body() body: ParseStatementDto,
|
||||
) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file uploaded');
|
||||
}
|
||||
return this.statementsService.parse(
|
||||
user.id,
|
||||
{
|
||||
buffer: file.buffer,
|
||||
mimetype: file.mimetype,
|
||||
originalname: file.originalname,
|
||||
},
|
||||
body.accountId,
|
||||
body.mapping,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { StatementsService } from './statements.service';
|
||||
import { StatementsController } from './statements.controller';
|
||||
import { DuplicateDetectorService } from './duplicate-detector.service';
|
||||
import { CsvParser } from './parsers/csv.parser';
|
||||
import { OfxParser } from './parsers/ofx.parser';
|
||||
import { PdfParser } from './parsers/pdf.parser';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [StatementsController],
|
||||
providers: [
|
||||
StatementsService,
|
||||
DuplicateDetectorService,
|
||||
CsvParser,
|
||||
OfxParser,
|
||||
PdfParser,
|
||||
],
|
||||
})
|
||||
export class StatementsModule {}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { StatementsService } from './statements.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { DuplicateDetectorService } from './duplicate-detector.service';
|
||||
import { CsvParser } from './parsers/csv.parser';
|
||||
import { OfxParser } from './parsers/ofx.parser';
|
||||
import { PdfParser } from './parsers/pdf.parser';
|
||||
import { AccountType } from '@prisma/client';
|
||||
|
||||
jest.mock('@prisma/client', () => ({
|
||||
PrismaClient: class {},
|
||||
AccountType: {
|
||||
CHECKING: 'CHECKING',
|
||||
SAVINGS: 'SAVINGS',
|
||||
CREDIT: 'CREDIT',
|
||||
LOAN: 'LOAN',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('StatementsService', () => {
|
||||
let service: StatementsService;
|
||||
const account = {
|
||||
id: 'acc-1',
|
||||
name: 'Chase Checking',
|
||||
type: AccountType.CHECKING,
|
||||
};
|
||||
const mockPrisma: any = {
|
||||
account: { findFirst: jest.fn() },
|
||||
};
|
||||
const mockDedupe: any = {
|
||||
classify: jest.fn(),
|
||||
};
|
||||
|
||||
const stubParser = (over: any = {}) => ({
|
||||
format: 'csv',
|
||||
canParse: jest.fn(() => false),
|
||||
parse: jest.fn(async () => ({ rows: [], warnings: [] })),
|
||||
...over,
|
||||
});
|
||||
|
||||
let csv: any;
|
||||
let ofx: any;
|
||||
let pdf: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
csv = stubParser({ format: 'csv' });
|
||||
ofx = stubParser({ format: 'ofx' });
|
||||
pdf = stubParser({ format: 'pdf' });
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
StatementsService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{ provide: DuplicateDetectorService, useValue: mockDedupe },
|
||||
{ provide: CsvParser, useValue: csv },
|
||||
{ provide: OfxParser, useValue: ofx },
|
||||
{ provide: PdfParser, useValue: pdf },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<StatementsService>(StatementsService);
|
||||
});
|
||||
|
||||
it('rejects an empty buffer', async () => {
|
||||
await expect(
|
||||
service.parse('u1', { buffer: Buffer.alloc(0) }, 'acc-1'),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects when the account is not owned by the user', async () => {
|
||||
mockPrisma.account.findFirst.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.parse('u1', { buffer: Buffer.from('x') }, 'acc-1'),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('rejects when no parser can handle the file', async () => {
|
||||
mockPrisma.account.findFirst.mockResolvedValue(account);
|
||||
csv.canParse.mockReturnValue(false);
|
||||
ofx.canParse.mockReturnValue(false);
|
||||
pdf.canParse.mockReturnValue(false);
|
||||
await expect(
|
||||
service.parse(
|
||||
'u1',
|
||||
{ buffer: Buffer.from('not a statement'), mimetype: 'text/plain' },
|
||||
'acc-1',
|
||||
),
|
||||
).rejects.toThrow(/Unsupported file format/);
|
||||
});
|
||||
|
||||
it('dispatches to the first parser that can handle the file', async () => {
|
||||
mockPrisma.account.findFirst.mockResolvedValue(account);
|
||||
ofx.canParse.mockReturnValue(true);
|
||||
ofx.parse.mockResolvedValue({
|
||||
rows: [
|
||||
{
|
||||
sourceIndex: 0,
|
||||
date: '2026-04-10',
|
||||
amount: 50,
|
||||
type: 'EXPENSE',
|
||||
description: 'Test',
|
||||
confidence: 0.98,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
mockDedupe.classify.mockResolvedValue(
|
||||
new Map([[0, { status: 'new', confidence: 0.98 }]]),
|
||||
);
|
||||
|
||||
const result = await service.parse(
|
||||
'u1',
|
||||
{ buffer: Buffer.from('OFXHEADER:100'), originalname: 'x.ofx' },
|
||||
'acc-1',
|
||||
);
|
||||
|
||||
expect(result.format).toBe('ofx');
|
||||
expect(result.account).toEqual(account);
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.rows[0].status).toBe('new');
|
||||
expect(csv.parse).not.toHaveBeenCalled();
|
||||
expect(pdf.parse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns needsMapping without running dedupe', async () => {
|
||||
mockPrisma.account.findFirst.mockResolvedValue(account);
|
||||
csv.canParse.mockReturnValue(true);
|
||||
csv.parse.mockResolvedValue({
|
||||
rows: [],
|
||||
warnings: ['some warning'],
|
||||
needsMapping: { headers: ['A', 'B'], sample: [['1', '2']], guess: {} },
|
||||
});
|
||||
|
||||
const result = await service.parse(
|
||||
'u1',
|
||||
{ buffer: Buffer.from('A,B\n1,2'), originalname: 'x.csv' },
|
||||
'acc-1',
|
||||
);
|
||||
|
||||
expect(result.needsMapping).toBeDefined();
|
||||
expect(result.rows).toHaveLength(0);
|
||||
expect(mockDedupe.classify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rewrites parser errors as BadRequestException with the original message', async () => {
|
||||
mockPrisma.account.findFirst.mockResolvedValue(account);
|
||||
pdf.canParse.mockReturnValue(true);
|
||||
pdf.parse.mockRejectedValue(new Error('Scanned PDF — not supported'));
|
||||
|
||||
await expect(
|
||||
service.parse(
|
||||
'u1',
|
||||
{ buffer: Buffer.from('%PDF-1.4'), originalname: 'x.pdf' },
|
||||
'acc-1',
|
||||
),
|
||||
).rejects.toThrow(/Scanned PDF/);
|
||||
});
|
||||
|
||||
it('passes mapping through to the parser', async () => {
|
||||
mockPrisma.account.findFirst.mockResolvedValue(account);
|
||||
csv.canParse.mockReturnValue(true);
|
||||
csv.parse.mockResolvedValue({ rows: [], warnings: [] });
|
||||
mockDedupe.classify.mockResolvedValue(new Map());
|
||||
|
||||
const mapping = { date: 'When', description: 'What', amount: 'How Much' };
|
||||
await service.parse(
|
||||
'u1',
|
||||
{ buffer: Buffer.from('x'), originalname: 'x.csv' },
|
||||
'acc-1',
|
||||
mapping,
|
||||
);
|
||||
|
||||
expect(csv.parse).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ mapping, account }),
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults each row to new when the classifier returns no entry for it', async () => {
|
||||
mockPrisma.account.findFirst.mockResolvedValue(account);
|
||||
csv.canParse.mockReturnValue(true);
|
||||
csv.parse.mockResolvedValue({
|
||||
rows: [
|
||||
{
|
||||
sourceIndex: 0,
|
||||
date: '2026-04-10',
|
||||
amount: 25,
|
||||
type: 'EXPENSE',
|
||||
description: 'Coffee',
|
||||
confidence: 0.7,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
// Classifier returns an empty map — code must fall back to row.confidence.
|
||||
mockDedupe.classify.mockResolvedValue(new Map());
|
||||
|
||||
const result = await service.parse(
|
||||
'u1',
|
||||
{ buffer: Buffer.from('x'), originalname: 'x.csv' },
|
||||
'acc-1',
|
||||
);
|
||||
expect(result.rows[0].status).toBe('new');
|
||||
expect(result.rows[0].confidence).toBe(0.7);
|
||||
});
|
||||
|
||||
it('uses a generic message when a parser throws a non-Error value', async () => {
|
||||
mockPrisma.account.findFirst.mockResolvedValue(account);
|
||||
csv.canParse.mockReturnValue(true);
|
||||
csv.parse.mockRejectedValue('string-not-an-error');
|
||||
await expect(
|
||||
service.parse(
|
||||
'u1',
|
||||
{ buffer: Buffer.from('x'), originalname: 'x.csv' },
|
||||
'acc-1',
|
||||
),
|
||||
).rejects.toThrow(/Unable to read/);
|
||||
});
|
||||
|
||||
it('merges duplicate-detector status, confidence, and metadata into each row', async () => {
|
||||
mockPrisma.account.findFirst.mockResolvedValue(account);
|
||||
csv.canParse.mockReturnValue(true);
|
||||
csv.parse.mockResolvedValue({
|
||||
rows: [
|
||||
{
|
||||
sourceIndex: 0,
|
||||
date: '2026-04-10',
|
||||
amount: 25,
|
||||
type: 'EXPENSE',
|
||||
description: 'Coffee',
|
||||
confidence: 0.95,
|
||||
},
|
||||
{
|
||||
sourceIndex: 1,
|
||||
date: '2026-04-11',
|
||||
amount: 200,
|
||||
type: 'EXPENSE',
|
||||
description: 'Transfer',
|
||||
confidence: 0.95,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
mockDedupe.classify.mockResolvedValue(
|
||||
new Map<number, any>([
|
||||
[
|
||||
0,
|
||||
{
|
||||
status: 'duplicate',
|
||||
confidence: 1,
|
||||
duplicateOf: {
|
||||
id: 'existing-1',
|
||||
date: '2026-04-10',
|
||||
amount: 25,
|
||||
description: 'Coffee',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
1,
|
||||
{
|
||||
status: 'possible_transfer',
|
||||
confidence: 0.85,
|
||||
transferCandidate: {
|
||||
accountId: 'acc-2',
|
||||
accountName: 'Savings',
|
||||
matchedTransactionId: 'cross-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service.parse(
|
||||
'u1',
|
||||
{ buffer: Buffer.from('x'), originalname: 'x.csv' },
|
||||
'acc-1',
|
||||
);
|
||||
|
||||
expect(result.rows[0].status).toBe('duplicate');
|
||||
expect(result.rows[0].duplicateOf?.id).toBe('existing-1');
|
||||
expect(result.rows[1].status).toBe('possible_transfer');
|
||||
expect(result.rows[1].transferCandidate?.accountName).toBe('Savings');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CsvParser } from './parsers/csv.parser';
|
||||
import { OfxParser } from './parsers/ofx.parser';
|
||||
import { PdfParser } from './parsers/pdf.parser';
|
||||
import {
|
||||
ColumnMapping,
|
||||
ParseResult,
|
||||
ParserFileInput,
|
||||
StatementParser,
|
||||
} from './parsers/parser.interface';
|
||||
import {
|
||||
ClassifiedRow,
|
||||
DuplicateDetectorService,
|
||||
} from './duplicate-detector.service';
|
||||
|
||||
export interface ParsedTransactionResponse {
|
||||
sourceIndex: number;
|
||||
date: string;
|
||||
amount: number;
|
||||
type: 'INCOME' | 'EXPENSE';
|
||||
description: string;
|
||||
externalId?: string;
|
||||
status: ClassifiedRow['status'];
|
||||
confidence: number;
|
||||
duplicateOf?: ClassifiedRow['duplicateOf'];
|
||||
transferCandidate?: ClassifiedRow['transferCandidate'];
|
||||
}
|
||||
|
||||
export interface ParseStatementResponse {
|
||||
format: 'csv' | 'ofx' | 'pdf';
|
||||
account: { id: string; name: string; type: string };
|
||||
rows: ParsedTransactionResponse[];
|
||||
warnings: string[];
|
||||
needsMapping?: ParseResult['needsMapping'];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StatementsService {
|
||||
private readonly parsers: StatementParser[];
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private duplicateDetector: DuplicateDetectorService,
|
||||
csv: CsvParser,
|
||||
ofx: OfxParser,
|
||||
pdf: PdfParser,
|
||||
) {
|
||||
// Order matters: OFX is sniffed first (most specific), then CSV, then PDF.
|
||||
this.parsers = [ofx, csv, pdf];
|
||||
}
|
||||
|
||||
async parse(
|
||||
userId: string,
|
||||
file: ParserFileInput,
|
||||
accountId: string,
|
||||
mapping?: ColumnMapping,
|
||||
): Promise<ParseStatementResponse> {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('No file uploaded');
|
||||
}
|
||||
const account = await this.prisma.account.findFirst({
|
||||
where: { id: accountId, userId },
|
||||
select: { id: true, name: true, type: true },
|
||||
});
|
||||
if (!account) {
|
||||
throw new NotFoundException('Account not found');
|
||||
}
|
||||
|
||||
const parser = this.parsers.find((p) => p.canParse(file));
|
||||
if (!parser) {
|
||||
throw new BadRequestException(
|
||||
'Unsupported file format. Please upload a CSV, OFX, QFX, or PDF statement.',
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: ParseResult;
|
||||
try {
|
||||
parsed = await parser.parse(file, { account, mapping });
|
||||
} catch (err) {
|
||||
throw new BadRequestException(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Unable to read this statement file.',
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.needsMapping) {
|
||||
return {
|
||||
format: parser.format,
|
||||
account,
|
||||
rows: [],
|
||||
warnings: parsed.warnings,
|
||||
needsMapping: parsed.needsMapping,
|
||||
};
|
||||
}
|
||||
|
||||
const classifications = await this.duplicateDetector.classify(
|
||||
userId,
|
||||
accountId,
|
||||
parsed.rows,
|
||||
);
|
||||
|
||||
const rows: ParsedTransactionResponse[] = parsed.rows.map((r) => {
|
||||
const c = classifications.get(r.sourceIndex);
|
||||
return {
|
||||
sourceIndex: r.sourceIndex,
|
||||
date: r.date,
|
||||
amount: r.amount,
|
||||
type: r.type,
|
||||
description: r.description,
|
||||
externalId: r.externalId,
|
||||
status: c?.status ?? 'new',
|
||||
confidence: c?.confidence ?? r.confidence,
|
||||
duplicateOf: c?.duplicateOf,
|
||||
transferCandidate: c?.transferCandidate,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
format: parser.format,
|
||||
account,
|
||||
rows,
|
||||
warnings: parsed.warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { CreateTransactionDto } from './create-transaction.dto';
|
||||
|
||||
export const BULK_TRANSACTION_MAX = 500;
|
||||
|
||||
export class BulkCreateTransactionsDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(BULK_TRANSACTION_MAX)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateTransactionDto)
|
||||
transactions: CreateTransactionDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
source?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
sourceLabel?: string;
|
||||
}
|
||||
@@ -41,4 +41,8 @@ export class CreateTransactionDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
receiptPath?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
externalId?: string;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ describe('TransactionsController', () => {
|
||||
|
||||
const mockService = {
|
||||
create: jest.fn().mockResolvedValue(mockTransaction),
|
||||
createMany: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ created: 2, ids: ['txn-1', 'txn-2'] }),
|
||||
findAll: jest.fn().mockResolvedValue({
|
||||
data: [mockTransaction],
|
||||
total: 1,
|
||||
@@ -106,4 +109,36 @@ describe('TransactionsController', () => {
|
||||
expect(mockService.remove).toHaveBeenCalledWith('user-123', 'txn-1');
|
||||
expect(result).toEqual(mockTransaction);
|
||||
});
|
||||
|
||||
it('forwards bulk imports to the service with source metadata', async () => {
|
||||
const dto = {
|
||||
transactions: [
|
||||
{
|
||||
accountId: 'acc-1',
|
||||
amount: 10,
|
||||
type: TransactionType.EXPENSE,
|
||||
description: 'A',
|
||||
date: '2026-04-01',
|
||||
},
|
||||
{
|
||||
accountId: 'acc-1',
|
||||
amount: 20,
|
||||
type: TransactionType.EXPENSE,
|
||||
description: 'B',
|
||||
date: '2026-04-02',
|
||||
},
|
||||
],
|
||||
source: 'statement-import',
|
||||
sourceLabel: 'chase-2026-04.csv',
|
||||
} as any;
|
||||
|
||||
const result = await controller.bulk(mockUser, dto);
|
||||
|
||||
expect(mockService.createMany).toHaveBeenCalledWith(
|
||||
'user-123',
|
||||
dto.transactions,
|
||||
{ source: 'statement-import', sourceLabel: 'chase-2026-04.csv' },
|
||||
);
|
||||
expect(result.created).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TransactionsService } from './transactions.service';
|
||||
import type { TransactionFilters } from './transactions.service';
|
||||
import { CreateTransactionDto } from './dto/create-transaction.dto';
|
||||
import { UpdateTransactionDto } from './dto/update-transaction.dto';
|
||||
import { BulkCreateTransactionsDto } from './dto/bulk-create-transactions.dto';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
import { CurrentUser } from '../auth/user.decorator';
|
||||
import type { User } from '@prisma/client';
|
||||
@@ -27,6 +28,14 @@ export class TransactionsController {
|
||||
return this.transactionsService.create(user.id, dto);
|
||||
}
|
||||
|
||||
@Post('bulk')
|
||||
bulk(@CurrentUser() user: User, @Body() dto: BulkCreateTransactionsDto) {
|
||||
return this.transactionsService.createMany(user.id, dto.transactions, {
|
||||
source: dto.source,
|
||||
sourceLabel: dto.sourceLabel,
|
||||
});
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(@CurrentUser() user: User, @Query() filters: TransactionFilters) {
|
||||
return this.transactionsService.findAll(user.id, filters);
|
||||
|
||||
@@ -714,4 +714,243 @@ describe('TransactionsService', () => {
|
||||
expect(call.take).toBe(10000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMany', () => {
|
||||
const incomeDto = (over: Partial<any> = {}) => ({
|
||||
accountId: 'acc-1',
|
||||
amount: 100,
|
||||
type: TransactionType.INCOME,
|
||||
description: 'Paycheck',
|
||||
date: '2026-04-01',
|
||||
...over,
|
||||
});
|
||||
const expenseDto = (over: Partial<any> = {}) => ({
|
||||
accountId: 'acc-1',
|
||||
amount: 25,
|
||||
type: TransactionType.EXPENSE,
|
||||
description: 'Coffee',
|
||||
date: '2026-04-02',
|
||||
...over,
|
||||
});
|
||||
|
||||
it('creates every row and returns the new ids', async () => {
|
||||
mockPrisma.account.findMany.mockResolvedValue([
|
||||
{ id: 'acc-1', type: AccountType.CHECKING },
|
||||
]);
|
||||
let n = 0;
|
||||
txClient.transaction.create.mockImplementation(async () => ({
|
||||
...baseTxn,
|
||||
id: `txn-${++n}`,
|
||||
}));
|
||||
|
||||
const result = await service.createMany(userId, [
|
||||
incomeDto({ description: 'A' }),
|
||||
expenseDto({ description: 'B' }),
|
||||
expenseDto({ description: 'C' }),
|
||||
]);
|
||||
|
||||
expect(result.created).toBe(3);
|
||||
expect(result.ids).toEqual(['txn-1', 'txn-2', 'txn-3']);
|
||||
expect(txClient.transaction.create).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('applies balance deltas to each account, asset and liability mixed', async () => {
|
||||
mockPrisma.account.findMany.mockResolvedValue([
|
||||
{ id: 'acc-1', type: AccountType.CHECKING },
|
||||
{ id: 'acc-cc', type: AccountType.CREDIT },
|
||||
]);
|
||||
txClient.transaction.create.mockImplementation(async () => ({
|
||||
...baseTxn,
|
||||
}));
|
||||
|
||||
await service.createMany(userId, [
|
||||
incomeDto({ accountId: 'acc-1', amount: 500 }),
|
||||
expenseDto({ accountId: 'acc-1', amount: 30 }),
|
||||
expenseDto({ accountId: 'acc-cc', amount: 70 }),
|
||||
]);
|
||||
|
||||
// Asset +500 INCOME, -30 EXPENSE
|
||||
expect(txClient.account.update).toHaveBeenCalledWith({
|
||||
where: { id: 'acc-1' },
|
||||
data: { balance: { increment: 500 } },
|
||||
});
|
||||
expect(txClient.account.update).toHaveBeenCalledWith({
|
||||
where: { id: 'acc-1' },
|
||||
data: { balance: { decrement: 30 } },
|
||||
});
|
||||
// Liability +70 EXPENSE (debt grows)
|
||||
expect(txClient.account.update).toHaveBeenCalledWith({
|
||||
where: { id: 'acc-cc' },
|
||||
data: { balance: { increment: 70 } },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects when any row references an account not owned by the user', async () => {
|
||||
mockPrisma.account.findMany.mockResolvedValue([
|
||||
{ id: 'acc-1', type: AccountType.CHECKING },
|
||||
]);
|
||||
await expect(
|
||||
service.createMany(userId, [
|
||||
incomeDto(),
|
||||
expenseDto({ accountId: 'stranger' }),
|
||||
]),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('validates transfer rows require a destination', async () => {
|
||||
mockPrisma.account.findMany.mockResolvedValue([
|
||||
{ id: 'acc-1', type: AccountType.CHECKING },
|
||||
]);
|
||||
await expect(
|
||||
service.createMany(userId, [
|
||||
{ ...incomeDto(), type: TransactionType.TRANSFER } as any,
|
||||
]),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('handles TRANSFER rows updating both source and destination', async () => {
|
||||
mockPrisma.account.findMany.mockResolvedValue([
|
||||
{ id: 'acc-1', type: AccountType.CHECKING },
|
||||
{ id: 'acc-2', type: AccountType.SAVINGS },
|
||||
]);
|
||||
txClient.transaction.create.mockImplementation(async () => ({
|
||||
...baseTxn,
|
||||
accountId: 'acc-1',
|
||||
destinationAccountId: 'acc-2',
|
||||
amount: 200,
|
||||
type: TransactionType.TRANSFER,
|
||||
}));
|
||||
|
||||
await service.createMany(userId, [
|
||||
{
|
||||
accountId: 'acc-1',
|
||||
destinationAccountId: 'acc-2',
|
||||
amount: 200,
|
||||
type: TransactionType.TRANSFER,
|
||||
description: 'Move',
|
||||
date: '2026-04-03',
|
||||
} as any,
|
||||
]);
|
||||
|
||||
expect(txClient.account.update).toHaveBeenCalledWith({
|
||||
where: { id: 'acc-1' },
|
||||
data: { balance: { decrement: 200 } },
|
||||
});
|
||||
expect(txClient.account.update).toHaveBeenCalledWith({
|
||||
where: { id: 'acc-2' },
|
||||
data: { balance: { increment: 200 } },
|
||||
});
|
||||
});
|
||||
|
||||
it('chunks into batches of 50 to keep $transaction calls bounded', async () => {
|
||||
mockPrisma.account.findMany.mockResolvedValue([
|
||||
{ id: 'acc-1', type: AccountType.CHECKING },
|
||||
]);
|
||||
txClient.transaction.create.mockImplementation(async () => ({
|
||||
...baseTxn,
|
||||
}));
|
||||
|
||||
const rows = Array.from({ length: 120 }, () => expenseDto());
|
||||
await service.createMany(userId, rows);
|
||||
|
||||
// 120 rows / 50 per chunk = 3 chunks (50 + 50 + 20)
|
||||
expect(mockPrisma.$transaction).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('writes one ActivityLog entry per chunk, not per transaction', async () => {
|
||||
mockPrisma.account.findMany.mockResolvedValue([
|
||||
{ id: 'acc-1', type: AccountType.CHECKING },
|
||||
]);
|
||||
txClient.transaction.create.mockImplementation(async () => ({
|
||||
...baseTxn,
|
||||
}));
|
||||
|
||||
const rows = Array.from({ length: 60 }, () => expenseDto());
|
||||
await service.createMany(userId, rows, {
|
||||
source: 'statement-import',
|
||||
sourceLabel: 'chase.csv',
|
||||
});
|
||||
|
||||
// 60 rows = 2 chunks, expect 2 log entries (not 60)
|
||||
expect(mockActivityLog.log).toHaveBeenCalledTimes(2);
|
||||
expect(mockActivityLog.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId,
|
||||
entityType: 'TRANSACTION',
|
||||
action: 'CREATE',
|
||||
summary: expect.stringMatching(
|
||||
/Imported \d+ transaction.*chase\.csv/,
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('encrypts notes per row when provided', async () => {
|
||||
mockPrisma.account.findMany.mockResolvedValue([
|
||||
{ id: 'acc-1', type: AccountType.CHECKING },
|
||||
]);
|
||||
txClient.transaction.create.mockImplementation(async () => ({
|
||||
...baseTxn,
|
||||
}));
|
||||
|
||||
await service.createMany(userId, [
|
||||
expenseDto({ notes: 'private' }),
|
||||
expenseDto({ notes: 'also private' }),
|
||||
]);
|
||||
|
||||
expect(mockEncryption.encryptField).toHaveBeenCalledWith('private');
|
||||
expect(mockEncryption.encryptField).toHaveBeenCalledWith('also private');
|
||||
});
|
||||
|
||||
it('persists externalId when provided', async () => {
|
||||
mockPrisma.account.findMany.mockResolvedValue([
|
||||
{ id: 'acc-1', type: AccountType.CHECKING },
|
||||
]);
|
||||
txClient.transaction.create.mockImplementation(async () => ({
|
||||
...baseTxn,
|
||||
}));
|
||||
|
||||
await service.createMany(userId, [
|
||||
expenseDto({ externalId: 'BANK-FITID-1' }),
|
||||
]);
|
||||
|
||||
const data = txClient.transaction.create.mock.calls[0][0].data;
|
||||
expect(data.externalId).toBe('BANK-FITID-1');
|
||||
});
|
||||
|
||||
it('rejects an empty input array', async () => {
|
||||
await expect(service.createMany(userId, [])).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports actual created count when a chunk fails partway', async () => {
|
||||
mockPrisma.account.findMany.mockResolvedValue([
|
||||
{ id: 'acc-1', type: AccountType.CHECKING },
|
||||
]);
|
||||
let chunkIndex = 0;
|
||||
mockPrisma.$transaction = jest.fn(async (cb: any) => {
|
||||
// Second chunk throws to simulate partial failure across chunks.
|
||||
chunkIndex += 1;
|
||||
if (chunkIndex === 2) {
|
||||
throw new Error('boom');
|
||||
}
|
||||
return cb(txClient);
|
||||
});
|
||||
txClient.transaction.create.mockImplementation(async () => ({
|
||||
...baseTxn,
|
||||
id: 'txn-x',
|
||||
}));
|
||||
|
||||
const rows = Array.from({ length: 60 }, () => expenseDto());
|
||||
const result = await service.createMany(userId, rows);
|
||||
// First chunk (50) succeeded; second chunk (10) threw and is discarded.
|
||||
expect(result.created).toBe(50);
|
||||
expect(result.partial).toEqual({
|
||||
attempted: 60,
|
||||
failed: 10,
|
||||
error: expect.stringContaining('boom'),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,20 @@ import {
|
||||
} from '@prisma/client';
|
||||
|
||||
export const EXPORT_ROW_CAP = 10000;
|
||||
export const BULK_CHUNK_SIZE = 50;
|
||||
export const BULK_TX_TIMEOUT_MS = 15_000;
|
||||
export const BULK_TX_MAX_WAIT_MS = 5_000;
|
||||
|
||||
export interface BulkSource {
|
||||
source?: string;
|
||||
sourceLabel?: string;
|
||||
}
|
||||
|
||||
export interface BulkCreateResult {
|
||||
created: number;
|
||||
ids: string[];
|
||||
partial?: { attempted: number; failed: number; error: string };
|
||||
}
|
||||
|
||||
export interface TransactionFilters {
|
||||
accountId?: string;
|
||||
@@ -213,6 +227,147 @@ export class TransactionsService {
|
||||
return this.decryptTransaction(txn);
|
||||
}
|
||||
|
||||
async createMany(
|
||||
userId: string,
|
||||
dtos: CreateTransactionDto[],
|
||||
source: BulkSource = {},
|
||||
): Promise<BulkCreateResult> {
|
||||
if (!dtos || dtos.length === 0) {
|
||||
throw new BadRequestException('At least one transaction is required');
|
||||
}
|
||||
|
||||
const accountIds = new Set<string>();
|
||||
for (const dto of dtos) {
|
||||
accountIds.add(dto.accountId);
|
||||
if (dto.type === TransactionType.TRANSFER) {
|
||||
if (!dto.destinationAccountId) {
|
||||
throw new BadRequestException(
|
||||
'destinationAccountId is required for TRANSFER transactions',
|
||||
);
|
||||
}
|
||||
if (dto.destinationAccountId === dto.accountId) {
|
||||
throw new BadRequestException(
|
||||
'Source and destination accounts must differ',
|
||||
);
|
||||
}
|
||||
accountIds.add(dto.destinationAccountId);
|
||||
}
|
||||
}
|
||||
|
||||
const accounts = await this.prisma.account.findMany({
|
||||
where: { userId, id: { in: Array.from(accountIds) } },
|
||||
select: { id: true, type: true },
|
||||
});
|
||||
if (accounts.length !== accountIds.size) {
|
||||
throw new NotFoundException('One or more accounts not found');
|
||||
}
|
||||
const typeById = new Map(accounts.map((a) => [a.id, a.type]));
|
||||
|
||||
const allIds: string[] = [];
|
||||
let partial: BulkCreateResult['partial'];
|
||||
|
||||
for (let i = 0; i < dtos.length; i += BULK_CHUNK_SIZE) {
|
||||
const chunk = dtos.slice(i, i + BULK_CHUNK_SIZE);
|
||||
try {
|
||||
const chunkIds = await this.prisma.$transaction(
|
||||
async (tx) => {
|
||||
const ids: string[] = [];
|
||||
for (const dto of chunk) {
|
||||
const created = await this.applyTransactionWithinTx(
|
||||
tx,
|
||||
userId,
|
||||
dto,
|
||||
typeById,
|
||||
);
|
||||
ids.push(created.id);
|
||||
}
|
||||
await this.activityLog.log({
|
||||
userId,
|
||||
entityType: EntityType.TRANSACTION,
|
||||
entityId: ids[0],
|
||||
action: ActivityAction.CREATE,
|
||||
summary: this.bulkImportSummary(ids.length, source),
|
||||
snapshot: {
|
||||
count: ids.length,
|
||||
ids,
|
||||
source: source.source ?? 'bulk',
|
||||
label: source.sourceLabel ?? null,
|
||||
},
|
||||
tx,
|
||||
});
|
||||
return ids;
|
||||
},
|
||||
{ timeout: BULK_TX_TIMEOUT_MS, maxWait: BULK_TX_MAX_WAIT_MS },
|
||||
);
|
||||
allIds.push(...chunkIds);
|
||||
} catch (err) {
|
||||
const remaining = dtos.length - i;
|
||||
partial = {
|
||||
attempted: dtos.length,
|
||||
failed: remaining,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return partial
|
||||
? { created: allIds.length, ids: allIds, partial }
|
||||
: { created: allIds.length, ids: allIds };
|
||||
}
|
||||
|
||||
private async applyTransactionWithinTx(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
dto: CreateTransactionDto,
|
||||
typeById: Map<string, AccountType>,
|
||||
): Promise<{ id: string }> {
|
||||
const data: Prisma.TransactionUncheckedCreateInput = {
|
||||
...dto,
|
||||
userId,
|
||||
date: parseDateInput(dto.date),
|
||||
};
|
||||
if (data.notes) {
|
||||
data.notes = this.encryption.encryptField(data.notes);
|
||||
}
|
||||
if (dto.type !== TransactionType.TRANSFER) {
|
||||
data.destinationAccountId = null;
|
||||
}
|
||||
|
||||
const created = await tx.transaction.create({ data });
|
||||
|
||||
const primaryType = typeById.get(dto.accountId)!;
|
||||
await tx.account.update({
|
||||
where: { id: dto.accountId },
|
||||
data: {
|
||||
balance: asPrismaUpdate(
|
||||
signedDelta(primaryType, 'primary', dto.type, dto.amount),
|
||||
),
|
||||
},
|
||||
});
|
||||
if (dto.type === TransactionType.TRANSFER && dto.destinationAccountId) {
|
||||
const destType = typeById.get(dto.destinationAccountId)!;
|
||||
await tx.account.update({
|
||||
where: { id: dto.destinationAccountId },
|
||||
data: {
|
||||
balance: asPrismaUpdate(
|
||||
signedDelta(destType, 'destination', dto.type, dto.amount),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { id: created.id };
|
||||
}
|
||||
|
||||
private bulkImportSummary(count: number, source: BulkSource): string {
|
||||
const noun = count === 1 ? 'transaction' : 'transactions';
|
||||
if (source.sourceLabel) {
|
||||
return `Imported ${count} ${noun} from ${source.sourceLabel}`;
|
||||
}
|
||||
return `Imported ${count} ${noun}`;
|
||||
}
|
||||
|
||||
async findAll(userId: string, filters: TransactionFilters) {
|
||||
const { accountId, categoryId, type, startDate, endDate } = filters;
|
||||
const all = filters.all === true || filters.all === 'true';
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Hand-written types for node-ofx-parser. The package ships without
|
||||
// declarations; this captures just the structure the statement-import code
|
||||
// actually consumes (OFX bank + credit-card transactions and the FITID
|
||||
// dedupe key).
|
||||
|
||||
declare module 'node-ofx-parser' {
|
||||
export interface OfxTransaction {
|
||||
TRNTYPE?: string;
|
||||
DTPOSTED?: string;
|
||||
TRNAMT?: string;
|
||||
FITID?: string;
|
||||
NAME?: string;
|
||||
MEMO?: string;
|
||||
PAYEE?: { NAME?: string };
|
||||
CHECKNUM?: string;
|
||||
}
|
||||
|
||||
export interface OfxTransactionList {
|
||||
DTSTART?: string;
|
||||
DTEND?: string;
|
||||
STMTTRN?: OfxTransaction | OfxTransaction[];
|
||||
}
|
||||
|
||||
export interface OfxStatement {
|
||||
BANKACCTFROM?: { ACCTTYPE?: string };
|
||||
CCACCTFROM?: { ACCTID?: string };
|
||||
BANKTRANLIST?: OfxTransactionList;
|
||||
LEDGERBAL?: { BALAMT?: string; DTASOF?: string };
|
||||
}
|
||||
|
||||
export interface OfxBankResponse {
|
||||
STMTRS?: OfxStatement;
|
||||
}
|
||||
|
||||
export interface OfxCcResponse {
|
||||
CCSTMTRS?: OfxStatement;
|
||||
}
|
||||
|
||||
export interface OfxRoot {
|
||||
SIGNONMSGSRSV1?: unknown;
|
||||
BANKMSGSRSV1?: {
|
||||
STMTTRNRS?: OfxBankResponse | OfxBankResponse[];
|
||||
};
|
||||
CREDITCARDMSGSRSV1?: {
|
||||
CCSTMTTRNRS?: OfxCcResponse | OfxCcResponse[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface OfxParsed {
|
||||
OFX?: OfxRoot | string;
|
||||
header?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function parse(data: string): OfxParsed;
|
||||
export function serialize(
|
||||
header: Record<string, string>,
|
||||
body: unknown,
|
||||
): string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
Date,Description,Amount
|
||||
2026-04-02,RESTAURANT XYZ,52.10
|
||||
2026-04-03,GROCERY OUTLET,89.45
|
||||
2026-04-04,AUTOPAY PAYMENT - THANK YOU,-450.00
|
||||
2026-04-05,GAS STATION 24,40.00
|
||||
|
@@ -0,0 +1,5 @@
|
||||
Date,Description,Debit,Credit
|
||||
2026-04-02,Whole Foods Market,82.41,
|
||||
2026-04-03,Refund - Returned Item,,15.00
|
||||
2026-04-04,Gas Station #12,38.20,
|
||||
2026-04-05,Interest Earned,,3.42
|
||||
|
@@ -0,0 +1,5 @@
|
||||
Posting Date,Description,Amount,Type,Balance
|
||||
04/02/2026,"AMZN MKTP US*ABC123",-42.10,Sale,1957.90
|
||||
04/03/2026,"DIRECT DEPOSIT PAYROLL",2500.00,Direct Deposit,4457.90
|
||||
04/04/2026,"STARBUCKS #4321",-6.75,Sale,4451.15
|
||||
04/05/2026,"VENMO PAYMENT",-150.00,Misc Debit,4301.15
|
||||
|
@@ -0,0 +1,4 @@
|
||||
When,What,How Much,Direction
|
||||
2026-04-02,Coffee Shop,4.50,out
|
||||
2026-04-03,Side Gig,200.00,in
|
||||
2026-04-04,Bookstore,28.75,out
|
||||
|
@@ -0,0 +1,60 @@
|
||||
OFXHEADER:100
|
||||
DATA:OFXSGML
|
||||
VERSION:102
|
||||
SECURITY:NONE
|
||||
ENCODING:USASCII
|
||||
CHARSET:1252
|
||||
COMPRESSION:NONE
|
||||
OLDFILEUID:NONE
|
||||
NEWFILEUID:NONE
|
||||
|
||||
<OFX>
|
||||
<SIGNONMSGSRSV1>
|
||||
<SONRS>
|
||||
<STATUS>
|
||||
<CODE>0
|
||||
<SEVERITY>INFO
|
||||
</STATUS>
|
||||
<DTSERVER>20260415120000
|
||||
<LANGUAGE>ENG
|
||||
<FI>
|
||||
<ORG>Some Bank
|
||||
<FID>10898
|
||||
</FI>
|
||||
<INTU.BID>10898
|
||||
</SONRS>
|
||||
</SIGNONMSGSRSV1>
|
||||
<CREDITCARDMSGSRSV1>
|
||||
<CCSTMTTRNRS>
|
||||
<TRNUID>1
|
||||
<STATUS>
|
||||
<CODE>0
|
||||
<SEVERITY>INFO
|
||||
</STATUS>
|
||||
<CCSTMTRS>
|
||||
<CURDEF>USD
|
||||
<CCACCTFROM>
|
||||
<ACCTID>4111111111111111
|
||||
</CCACCTFROM>
|
||||
<BANKTRANLIST>
|
||||
<DTSTART>20260401
|
||||
<DTEND>20260430
|
||||
<STMTTRN>
|
||||
<TRNTYPE>DEBIT
|
||||
<DTPOSTED>20260402
|
||||
<TRNAMT>-52.10
|
||||
<FITID>CC-001
|
||||
<NAME>RESTAURANT XYZ
|
||||
</STMTTRN>
|
||||
<STMTTRN>
|
||||
<TRNTYPE>CREDIT
|
||||
<DTPOSTED>20260415
|
||||
<TRNAMT>450.00
|
||||
<FITID>CC-002
|
||||
<NAME>AUTOPAY PAYMENT - THANK YOU
|
||||
</STMTTRN>
|
||||
</BANKTRANLIST>
|
||||
</CCSTMTRS>
|
||||
</CCSTMTTRNRS>
|
||||
</CREDITCARDMSGSRSV1>
|
||||
</OFX>
|
||||
@@ -0,0 +1,69 @@
|
||||
OFXHEADER:100
|
||||
DATA:OFXSGML
|
||||
VERSION:102
|
||||
SECURITY:NONE
|
||||
ENCODING:USASCII
|
||||
CHARSET:1252
|
||||
COMPRESSION:NONE
|
||||
OLDFILEUID:NONE
|
||||
NEWFILEUID:NONE
|
||||
|
||||
<OFX>
|
||||
<SIGNONMSGSRSV1>
|
||||
<SONRS>
|
||||
<STATUS>
|
||||
<CODE>0
|
||||
<SEVERITY>INFO
|
||||
</STATUS>
|
||||
<DTSERVER>20260415120000
|
||||
<LANGUAGE>ENG
|
||||
</SONRS>
|
||||
</SIGNONMSGSRSV1>
|
||||
<BANKMSGSRSV1>
|
||||
<STMTTRNRS>
|
||||
<TRNUID>1
|
||||
<STATUS>
|
||||
<CODE>0
|
||||
<SEVERITY>INFO
|
||||
</STATUS>
|
||||
<STMTRS>
|
||||
<CURDEF>USD
|
||||
<BANKACCTFROM>
|
||||
<BANKID>123456789
|
||||
<ACCTID>987654321
|
||||
<ACCTTYPE>CHECKING
|
||||
</BANKACCTFROM>
|
||||
<BANKTRANLIST>
|
||||
<DTSTART>20260401
|
||||
<DTEND>20260430
|
||||
<STMTTRN>
|
||||
<TRNTYPE>DEBIT
|
||||
<DTPOSTED>20260402
|
||||
<TRNAMT>-42.10
|
||||
<FITID>20260402-001
|
||||
<NAME>AMZN MKTP US*ABC123
|
||||
</STMTTRN>
|
||||
<STMTTRN>
|
||||
<TRNTYPE>CREDIT
|
||||
<DTPOSTED>20260403
|
||||
<TRNAMT>2500.00
|
||||
<FITID>20260403-001
|
||||
<NAME>DIRECT DEPOSIT PAYROLL
|
||||
</STMTTRN>
|
||||
<STMTTRN>
|
||||
<TRNTYPE>DEBIT
|
||||
<DTPOSTED>20260404
|
||||
<TRNAMT>-6.75
|
||||
<FITID>20260404-001
|
||||
<NAME>STARBUCKS #4321
|
||||
<MEMO>Coffee
|
||||
</STMTTRN>
|
||||
</BANKTRANLIST>
|
||||
<LEDGERBAL>
|
||||
<BALAMT>4301.15
|
||||
<DTASOF>20260430
|
||||
</LEDGERBAL>
|
||||
</STMTRS>
|
||||
</STMTTRNRS>
|
||||
</BANKMSGSRSV1>
|
||||
</OFX>
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?OFX OFXHEADER="200" VERSION="200" SECURITY="NONE" OLDFILEUID="NONE" NEWFILEUID="NONE"?>
|
||||
<OFX>
|
||||
<SIGNONMSGSRSV1>
|
||||
<SONRS>
|
||||
<STATUS>
|
||||
<CODE>0</CODE>
|
||||
<SEVERITY>INFO</SEVERITY>
|
||||
</STATUS>
|
||||
<DTSERVER>20260415120000</DTSERVER>
|
||||
<LANGUAGE>ENG</LANGUAGE>
|
||||
</SONRS>
|
||||
</SIGNONMSGSRSV1>
|
||||
<BANKMSGSRSV1>
|
||||
<STMTTRNRS>
|
||||
<TRNUID>1</TRNUID>
|
||||
<STATUS>
|
||||
<CODE>0</CODE>
|
||||
<SEVERITY>INFO</SEVERITY>
|
||||
</STATUS>
|
||||
<STMTRS>
|
||||
<CURDEF>USD</CURDEF>
|
||||
<BANKACCTFROM>
|
||||
<BANKID>123</BANKID>
|
||||
<ACCTID>456</ACCTID>
|
||||
<ACCTTYPE>SAVINGS</ACCTTYPE>
|
||||
</BANKACCTFROM>
|
||||
<BANKTRANLIST>
|
||||
<DTSTART>20260401</DTSTART>
|
||||
<DTEND>20260430</DTEND>
|
||||
<STMTTRN>
|
||||
<TRNTYPE>CREDIT</TRNTYPE>
|
||||
<DTPOSTED>20260405</DTPOSTED>
|
||||
<TRNAMT>3.42</TRNAMT>
|
||||
<FITID>INT-001</FITID>
|
||||
<NAME>Interest Earned</NAME>
|
||||
</STMTTRN>
|
||||
<STMTTRN>
|
||||
<TRNTYPE>DEBIT</TRNTYPE>
|
||||
<DTPOSTED>20260410</DTPOSTED>
|
||||
<TRNAMT>-500.00</TRNAMT>
|
||||
<FITID>XFER-001</FITID>
|
||||
<NAME>Transfer to checking</NAME>
|
||||
</STMTTRN>
|
||||
</BANKTRANLIST>
|
||||
<LEDGERBAL>
|
||||
<BALAMT>5000.00</BALAMT>
|
||||
<DTASOF>20260430</DTASOF>
|
||||
</LEDGERBAL>
|
||||
</STMTRS>
|
||||
</STMTTRNRS>
|
||||
</BANKMSGSRSV1>
|
||||
</OFX>
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tehriehlbudget-frontend",
|
||||
"private": true,
|
||||
"version": "0.3.2",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ImportStatementDialog, type ParseResponse } from './ImportStatementDialog';
|
||||
|
||||
const bulkCreateTransactions = vi.fn();
|
||||
const fetchTransactions = vi.fn();
|
||||
const fetchAccounts = vi.fn();
|
||||
|
||||
const accounts = [
|
||||
{
|
||||
id: 'acc-1',
|
||||
userId: 'u',
|
||||
name: 'Chase Checking',
|
||||
type: 'CHECKING' as const,
|
||||
balance: 1000,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
},
|
||||
{
|
||||
id: 'acc-2',
|
||||
userId: 'u',
|
||||
name: 'Savings',
|
||||
type: 'SAVINGS' as const,
|
||||
balance: 5000,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock('@/stores/transactions', () => ({
|
||||
useTransactionsStore: () => ({
|
||||
bulkCreateTransactions,
|
||||
fetchTransactions,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/accounts', () => ({
|
||||
useAccountsStore: () => ({
|
||||
accounts,
|
||||
fetchAccounts,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/supabase', () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getSession: async () => ({
|
||||
data: { session: { access_token: 'fake-token' } },
|
||||
}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/runtime-config', () => ({
|
||||
getConfig: () => 'http://api.test',
|
||||
}));
|
||||
|
||||
function newRowResponse(over: any = {}) {
|
||||
return {
|
||||
sourceIndex: 0,
|
||||
date: '2026-04-10',
|
||||
amount: 42.1,
|
||||
type: 'EXPENSE',
|
||||
description: 'Coffee',
|
||||
confidence: 0.95,
|
||||
status: 'new',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function mockParseResponse(rows: any[], extra: Partial<ParseResponse> = {}) {
|
||||
return {
|
||||
format: 'csv' as const,
|
||||
account: { id: 'acc-1', name: 'Chase Checking', type: 'CHECKING' },
|
||||
rows,
|
||||
warnings: [],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function mockFetchOnce(payload: any, ok = true) {
|
||||
(globalThis as any).fetch = vi.fn(async () => ({
|
||||
ok,
|
||||
status: ok ? 200 : 400,
|
||||
json: async () => payload,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('ImportStatementDialog', () => {
|
||||
beforeEach(() => {
|
||||
bulkCreateTransactions.mockReset();
|
||||
bulkCreateTransactions.mockResolvedValue({
|
||||
created: 1,
|
||||
ids: ['new-1'],
|
||||
});
|
||||
fetchTransactions.mockReset();
|
||||
fetchTransactions.mockResolvedValue(undefined);
|
||||
fetchAccounts.mockReset();
|
||||
});
|
||||
|
||||
function open(extras: any = {}) {
|
||||
return render(
|
||||
<ImportStatementDialog open onOpenChange={vi.fn()} defaultAccountId="acc-1" {...extras} />,
|
||||
);
|
||||
}
|
||||
|
||||
function chooseFile(name = 'test.csv') {
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['Date,Amount\n2026-04-01,10\n'], name, {
|
||||
type: 'text/csv',
|
||||
});
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
return file;
|
||||
}
|
||||
|
||||
it('renders the upload step initially', () => {
|
||||
open();
|
||||
expect(screen.getByText(/Upload a CSV, OFX, QFX, or PDF/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /continue/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables Continue once a file is chosen', () => {
|
||||
open();
|
||||
chooseFile();
|
||||
expect(screen.getByTestId('selected-file')).toHaveTextContent('test.csv');
|
||||
expect(screen.getByRole('button', { name: /continue/i })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('after parsing a clean statement, advances to Review with rows', async () => {
|
||||
mockFetchOnce(
|
||||
mockParseResponse([
|
||||
newRowResponse({ sourceIndex: 0, description: 'Coffee', status: 'new' }),
|
||||
newRowResponse({
|
||||
sourceIndex: 1,
|
||||
description: 'Payday',
|
||||
type: 'INCOME',
|
||||
amount: 2000,
|
||||
status: 'new',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
open();
|
||||
chooseFile();
|
||||
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Review each row/i)).toBeInTheDocument());
|
||||
expect(screen.getAllByRole('row').length).toBeGreaterThan(1);
|
||||
expect(screen.getByRole('button', { name: /Continue to confirm/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the Confirm step with the count in the primary button label', async () => {
|
||||
mockFetchOnce(
|
||||
mockParseResponse([
|
||||
newRowResponse({ sourceIndex: 0 }),
|
||||
newRowResponse({ sourceIndex: 1, description: 'B' }),
|
||||
]),
|
||||
);
|
||||
open();
|
||||
chooseFile();
|
||||
fireEvent.click(screen.getByRole('button', { name: /^continue$/i }));
|
||||
await waitFor(() => expect(screen.getByText(/Review each row/i)).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Continue to confirm/i }));
|
||||
|
||||
expect(screen.getByRole('button', { name: /Import 2 transactions/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Back to review/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Back to review preserves the row selection state', async () => {
|
||||
mockFetchOnce(
|
||||
mockParseResponse([
|
||||
newRowResponse({ sourceIndex: 0, description: 'Coffee' }),
|
||||
newRowResponse({ sourceIndex: 1, description: 'Lunch' }),
|
||||
]),
|
||||
);
|
||||
open();
|
||||
chooseFile();
|
||||
fireEvent.click(screen.getByRole('button', { name: /^continue$/i }));
|
||||
await waitFor(() => expect(screen.getByText(/Review each row/i)).toBeInTheDocument());
|
||||
// Uncheck the first row.
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
expect(checkboxes[0]).toBeChecked();
|
||||
fireEvent.click(checkboxes[0]);
|
||||
expect(checkboxes[0]).not.toBeChecked();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Continue to confirm/i }));
|
||||
expect(screen.getByRole('button', { name: /Import 1 transaction/i })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Back to review/i }));
|
||||
const recheckboxes = screen.getAllByRole('checkbox');
|
||||
expect(recheckboxes[0]).not.toBeChecked();
|
||||
expect(recheckboxes[1]).toBeChecked();
|
||||
});
|
||||
|
||||
it('does NOT call bulkCreateTransactions until Confirm is clicked', async () => {
|
||||
mockFetchOnce(mockParseResponse([newRowResponse({ sourceIndex: 0 })]));
|
||||
open();
|
||||
chooseFile();
|
||||
fireEvent.click(screen.getByRole('button', { name: /^continue$/i }));
|
||||
await waitFor(() => expect(screen.getByText(/Review each row/i)).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', { name: /Continue to confirm/i }));
|
||||
expect(bulkCreateTransactions).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Import 1 transaction/i }));
|
||||
await waitFor(() => expect(bulkCreateTransactions).toHaveBeenCalled());
|
||||
const [rows, source] = bulkCreateTransactions.mock.calls[0];
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(source.kind).toBe('statement-import');
|
||||
});
|
||||
|
||||
it('defaults duplicate rows to unchecked', async () => {
|
||||
mockFetchOnce(
|
||||
mockParseResponse([
|
||||
newRowResponse({
|
||||
sourceIndex: 0,
|
||||
status: 'duplicate',
|
||||
duplicateOf: {
|
||||
id: 'existing',
|
||||
date: '2026-04-10',
|
||||
amount: 42.1,
|
||||
description: 'Coffee',
|
||||
},
|
||||
}),
|
||||
newRowResponse({ sourceIndex: 1, description: 'Lunch' }),
|
||||
]),
|
||||
);
|
||||
open();
|
||||
chooseFile();
|
||||
fireEvent.click(screen.getByRole('button', { name: /^continue$/i }));
|
||||
await waitFor(() => expect(screen.getByText(/Review each row/i)).toBeInTheDocument());
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
expect(checkboxes[0]).not.toBeChecked();
|
||||
expect(checkboxes[1]).toBeChecked();
|
||||
});
|
||||
|
||||
it('shows the Column Mapping step when the backend asks for one', async () => {
|
||||
mockFetchOnce(
|
||||
mockParseResponse([], {
|
||||
needsMapping: {
|
||||
headers: ['Col1', 'Col2', 'Col3'],
|
||||
sample: [['1', '2', '3']],
|
||||
guess: {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
open();
|
||||
chooseFile();
|
||||
fireEvent.click(screen.getByRole('button', { name: /^continue$/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Tell us which field each column/i)).toBeInTheDocument(),
|
||||
);
|
||||
// Headers are listed as table cells
|
||||
expect(screen.getByText('Col1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Col2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Col3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a parse error inline', async () => {
|
||||
mockFetchOnce({ message: 'This file is corrupt' }, false);
|
||||
open();
|
||||
chooseFile();
|
||||
fireEvent.click(screen.getByRole('button', { name: /^continue$/i }));
|
||||
await waitFor(() => expect(screen.getByText('This file is corrupt')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,954 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Upload, ArrowRight } from 'lucide-react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { getConfig } from '@/lib/runtime-config';
|
||||
import { formatDate, toDateInputValue } from '@/lib/dates';
|
||||
import { useTransactionsStore, type BulkCreateInput } from '@/stores/transactions';
|
||||
import { useAccountsStore } from '@/stores/accounts';
|
||||
import type { Account } from '@/stores/accounts';
|
||||
|
||||
type RowStatus = 'new' | 'duplicate' | 'needs_review' | 'possible_transfer';
|
||||
type RowType = 'INCOME' | 'EXPENSE' | 'TRANSFER';
|
||||
|
||||
export interface DuplicateMatch {
|
||||
id: string;
|
||||
date: string;
|
||||
amount: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface TransferCandidate {
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
matchedTransactionId: string;
|
||||
}
|
||||
|
||||
export interface ParsedRowResponse {
|
||||
sourceIndex: number;
|
||||
date: string;
|
||||
amount: number;
|
||||
type: 'INCOME' | 'EXPENSE';
|
||||
description: string;
|
||||
externalId?: string;
|
||||
status: RowStatus;
|
||||
confidence: number;
|
||||
duplicateOf?: DuplicateMatch;
|
||||
transferCandidate?: TransferCandidate;
|
||||
}
|
||||
|
||||
export interface ColumnMapping {
|
||||
date?: string;
|
||||
description?: string;
|
||||
amount?: string;
|
||||
debit?: string;
|
||||
credit?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface ParseResponse {
|
||||
format: 'csv' | 'ofx' | 'pdf';
|
||||
account: { id: string; name: string; type: string };
|
||||
rows: ParsedRowResponse[];
|
||||
warnings: string[];
|
||||
needsMapping?: {
|
||||
headers: string[];
|
||||
sample: string[][];
|
||||
guess: ColumnMapping;
|
||||
};
|
||||
}
|
||||
|
||||
interface ReviewRow {
|
||||
sourceIndex: number;
|
||||
date: string;
|
||||
amount: number;
|
||||
type: RowType;
|
||||
description: string;
|
||||
externalId?: string;
|
||||
status: RowStatus;
|
||||
duplicateOf?: DuplicateMatch;
|
||||
transferCandidate?: TransferCandidate;
|
||||
destinationAccountId?: string;
|
||||
included: boolean;
|
||||
}
|
||||
|
||||
type Step = 'upload' | 'mapping' | 'review' | 'confirm' | 'submitting';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
defaultAccountId?: string;
|
||||
onImported?: (counts: { created: number; skipped: number }) => void;
|
||||
}
|
||||
|
||||
const MAPPING_FIELDS: { value: keyof ColumnMapping | 'ignore'; label: string }[] = [
|
||||
{ value: 'date', label: 'Date' },
|
||||
{ value: 'description', label: 'Description' },
|
||||
{ value: 'amount', label: 'Amount (signed)' },
|
||||
{ value: 'debit', label: 'Debit (withdrawals)' },
|
||||
{ value: 'credit', label: 'Credit (deposits)' },
|
||||
{ value: 'type', label: 'Type (Dr/Cr or income/expense)' },
|
||||
{ value: 'ignore', label: 'Ignore' },
|
||||
];
|
||||
|
||||
function currency(n: number): string {
|
||||
return `$${Number(n).toLocaleString('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`;
|
||||
}
|
||||
|
||||
function statusBadge(status: RowStatus) {
|
||||
switch (status) {
|
||||
case 'duplicate':
|
||||
return (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Duplicate
|
||||
</Badge>
|
||||
);
|
||||
case 'needs_review':
|
||||
return (
|
||||
<Badge variant="secondary" className="bg-amber-200 text-amber-900 text-xs">
|
||||
Needs review
|
||||
</Badge>
|
||||
);
|
||||
case 'possible_transfer':
|
||||
return (
|
||||
<Badge variant="secondary" className="bg-sky-200 text-sky-900 text-xs">
|
||||
Possible transfer
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
New
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function defaultIncluded(status: RowStatus): boolean {
|
||||
if (status === 'duplicate') return false;
|
||||
if (status === 'needs_review') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function uploadAndParse(
|
||||
file: File,
|
||||
accountId: string,
|
||||
mapping?: ColumnMapping,
|
||||
): Promise<ParseResponse> {
|
||||
const { data: session } = await supabase.auth.getSession();
|
||||
const token = session.session?.access_token;
|
||||
const apiUrl = getConfig('VITE_API_URL') || 'http://localhost:3000';
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('accountId', accountId);
|
||||
if (mapping) fd.append('mapping', JSON.stringify(mapping));
|
||||
const res = await fetch(`${apiUrl}/statements/parse`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(err.message || 'Failed to parse statement');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function ImportStatementDialog({ open, onOpenChange, defaultAccountId, onImported }: Props) {
|
||||
const { accounts, fetchAccounts } = useAccountsStore();
|
||||
const { bulkCreateTransactions, fetchTransactions } = useTransactionsStore();
|
||||
|
||||
const [step, setStep] = useState<Step>('upload');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [accountId, setAccountId] = useState<string>(defaultAccountId ?? '');
|
||||
const [parseResult, setParseResult] = useState<ParseResponse | null>(null);
|
||||
const [mapping, setMapping] = useState<ColumnMapping>({});
|
||||
const [rows, setRows] = useState<ReviewRow[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [working, setWorking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchAccounts();
|
||||
setStep('upload');
|
||||
setFile(null);
|
||||
setAccountId(defaultAccountId ?? '');
|
||||
setParseResult(null);
|
||||
setMapping({});
|
||||
setRows([]);
|
||||
setError(null);
|
||||
setWorking(false);
|
||||
}
|
||||
}, [open, defaultAccountId, fetchAccounts]);
|
||||
|
||||
const selectedAccount = accounts.find((a) => a.id === accountId);
|
||||
|
||||
const handleParse = async (currentMapping?: ColumnMapping) => {
|
||||
if (!file || !accountId) return;
|
||||
setError(null);
|
||||
setWorking(true);
|
||||
try {
|
||||
const result = await uploadAndParse(file, accountId, currentMapping);
|
||||
setParseResult(result);
|
||||
if (result.needsMapping) {
|
||||
setMapping(result.needsMapping.guess);
|
||||
setStep('mapping');
|
||||
} else {
|
||||
const reviewRows: ReviewRow[] = result.rows.map((r) => ({
|
||||
sourceIndex: r.sourceIndex,
|
||||
date: r.date,
|
||||
amount: r.amount,
|
||||
type: r.type as RowType,
|
||||
description: r.description,
|
||||
externalId: r.externalId,
|
||||
status: r.status,
|
||||
duplicateOf: r.duplicateOf,
|
||||
transferCandidate: r.transferCandidate,
|
||||
included: defaultIncluded(r.status),
|
||||
}));
|
||||
setRows(reviewRows);
|
||||
setStep('review');
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not parse this statement');
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMappingSubmit = async () => {
|
||||
await handleParse(mapping);
|
||||
};
|
||||
|
||||
const updateRow = (sourceIndex: number, patch: Partial<ReviewRow>) => {
|
||||
setRows((prev) => prev.map((r) => (r.sourceIndex === sourceIndex ? { ...r, ...patch } : r)));
|
||||
};
|
||||
|
||||
const markAllIncluded = (included: boolean) =>
|
||||
setRows((prev) => prev.map((r) => ({ ...r, included })));
|
||||
const skipAllDuplicates = () =>
|
||||
setRows((prev) => prev.map((r) => (r.status === 'duplicate' ? { ...r, included: false } : r)));
|
||||
|
||||
const selected = rows.filter((r) => r.included);
|
||||
const duplicates = rows.filter((r) => r.status === 'duplicate');
|
||||
const needsReview = rows.filter((r) => r.status === 'needs_review');
|
||||
const transfers = rows.filter((r) => r.type === 'TRANSFER' || r.status === 'possible_transfer');
|
||||
|
||||
const balanceDelta = useMemo(() => {
|
||||
const byAccount = new Map<string, { name: string; delta: number; type: Account['type'] }>();
|
||||
for (const row of selected) {
|
||||
const acc = accounts.find((a) => a.id === accountId);
|
||||
if (!acc) continue;
|
||||
const isLiability = acc.type === 'CREDIT' || acc.type === 'LOAN';
|
||||
let signed = 0;
|
||||
if (row.type === 'INCOME') {
|
||||
signed = isLiability ? -row.amount : row.amount;
|
||||
} else if (row.type === 'EXPENSE') {
|
||||
signed = isLiability ? row.amount : -row.amount;
|
||||
} else if (row.type === 'TRANSFER') {
|
||||
// The "source" side of a transfer is what runs through the selected account
|
||||
signed = isLiability ? row.amount : -row.amount;
|
||||
}
|
||||
const existing = byAccount.get(acc.id);
|
||||
byAccount.set(acc.id, {
|
||||
name: acc.name,
|
||||
type: acc.type,
|
||||
delta: (existing?.delta ?? 0) + signed,
|
||||
});
|
||||
if (row.type === 'TRANSFER' && row.destinationAccountId) {
|
||||
const destAcc = accounts.find((a) => a.id === row.destinationAccountId);
|
||||
if (destAcc) {
|
||||
const destIsLiab = destAcc.type === 'CREDIT' || destAcc.type === 'LOAN';
|
||||
const destDelta = destIsLiab ? -row.amount : row.amount;
|
||||
const destExisting = byAccount.get(destAcc.id);
|
||||
byAccount.set(destAcc.id, {
|
||||
name: destAcc.name,
|
||||
type: destAcc.type,
|
||||
delta: (destExisting?.delta ?? 0) + destDelta,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return byAccount;
|
||||
}, [selected, accounts]);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setStep('submitting');
|
||||
setError(null);
|
||||
try {
|
||||
const payload: BulkCreateInput[] = selected.map((r) => {
|
||||
const row: BulkCreateInput = {
|
||||
accountId,
|
||||
amount: r.amount,
|
||||
type: r.type,
|
||||
description: r.description,
|
||||
date: r.date,
|
||||
};
|
||||
if (r.externalId) row.externalId = r.externalId;
|
||||
if (r.type === 'TRANSFER' && r.destinationAccountId) {
|
||||
row.destinationAccountId = r.destinationAccountId;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
const result = await bulkCreateTransactions(payload, {
|
||||
kind: 'statement-import',
|
||||
label: file?.name ?? 'statement',
|
||||
});
|
||||
onImported?.({
|
||||
created: result.created,
|
||||
skipped: rows.length - selected.length,
|
||||
});
|
||||
await fetchTransactions({}, 1);
|
||||
onOpenChange(false);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Import failed');
|
||||
setStep('confirm');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import statement</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{step === 'upload' && (
|
||||
<UploadStep
|
||||
accounts={accounts}
|
||||
accountId={accountId}
|
||||
onAccountChange={setAccountId}
|
||||
file={file}
|
||||
onFileChange={setFile}
|
||||
onSubmit={() => handleParse()}
|
||||
working={working}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'mapping' && parseResult?.needsMapping && (
|
||||
<MappingStep
|
||||
headers={parseResult.needsMapping.headers}
|
||||
sample={parseResult.needsMapping.sample}
|
||||
mapping={mapping}
|
||||
onMappingChange={setMapping}
|
||||
onBack={() => setStep('upload')}
|
||||
onSubmit={handleMappingSubmit}
|
||||
working={working}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'review' && (
|
||||
<ReviewStep
|
||||
rows={rows}
|
||||
accounts={accounts}
|
||||
sourceAccount={selectedAccount}
|
||||
onUpdateRow={updateRow}
|
||||
onIncludeAll={() => markAllIncluded(true)}
|
||||
onSkipDuplicates={skipAllDuplicates}
|
||||
onClearAll={() => markAllIncluded(false)}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onContinue={() => setStep('confirm')}
|
||||
warnings={parseResult?.warnings ?? []}
|
||||
counts={{
|
||||
selected: selected.length,
|
||||
duplicates: duplicates.length,
|
||||
needsReview: needsReview.length,
|
||||
possibleTransfers: transfers.length,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(step === 'confirm' || step === 'submitting') && (
|
||||
<ConfirmStep
|
||||
counts={{
|
||||
selected: selected.length,
|
||||
duplicates: duplicates.length,
|
||||
needsReview: needsReview.length,
|
||||
transfers: transfers.length,
|
||||
}}
|
||||
balanceDelta={balanceDelta}
|
||||
sourceAccount={selectedAccount}
|
||||
selectedRows={selected}
|
||||
accounts={accounts}
|
||||
onBack={() => setStep('review')}
|
||||
onConfirm={handleConfirm}
|
||||
submitting={step === 'submitting'}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Step renderers ---
|
||||
|
||||
interface UploadStepProps {
|
||||
accounts: Account[];
|
||||
accountId: string;
|
||||
onAccountChange: (id: string) => void;
|
||||
file: File | null;
|
||||
onFileChange: (file: File | null) => void;
|
||||
onSubmit: () => void;
|
||||
working: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function UploadStep({
|
||||
accounts,
|
||||
accountId,
|
||||
onAccountChange,
|
||||
file,
|
||||
onFileChange,
|
||||
onSubmit,
|
||||
working,
|
||||
error,
|
||||
}: UploadStepProps) {
|
||||
const accountName = (id: string | null | undefined) =>
|
||||
id ? (accounts.find((a) => a.id === id)?.name ?? '') : '';
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upload a CSV, OFX, QFX, or PDF statement to bulk-import its transactions into one of your
|
||||
accounts. Duplicates against your existing transactions will be flagged for review.
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Import into account</label>
|
||||
<Select value={accountId} onValueChange={(v) => onAccountChange(v ?? '')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select account">
|
||||
{(v: string | undefined) => accountName(v) || 'Select account'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{accounts.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm hover:bg-accent">
|
||||
<Upload className="size-4 text-muted-foreground" />
|
||||
<span>{file ? 'Change file' : 'Choose statement file'}</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.ofx,.qfx,.pdf,application/pdf,text/csv"
|
||||
onChange={(e) => onFileChange(e.target.files?.[0] || null)}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
{file && (
|
||||
<p
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
title={file.name}
|
||||
data-testid="selected-file"
|
||||
>
|
||||
{file.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={onSubmit} disabled={!file || !accountId || working}>
|
||||
{working ? 'Parsing…' : 'Continue'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface MappingStepProps {
|
||||
headers: string[];
|
||||
sample: string[][];
|
||||
mapping: ColumnMapping;
|
||||
onMappingChange: (m: ColumnMapping) => void;
|
||||
onBack: () => void;
|
||||
onSubmit: () => void;
|
||||
working: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function MappingStep({
|
||||
headers,
|
||||
sample,
|
||||
mapping,
|
||||
onMappingChange,
|
||||
onBack,
|
||||
onSubmit,
|
||||
working,
|
||||
error,
|
||||
}: MappingStepProps) {
|
||||
const fieldOfHeader = (h: string): string => {
|
||||
for (const [k, v] of Object.entries(mapping)) {
|
||||
if (v === h) return k;
|
||||
}
|
||||
return 'ignore';
|
||||
};
|
||||
const setFieldForHeader = (h: string, field: string) => {
|
||||
const next: ColumnMapping = { ...mapping };
|
||||
// Clear any prior assignment of this field
|
||||
(Object.keys(next) as (keyof ColumnMapping)[]).forEach((k) => {
|
||||
if (next[k] === h) delete next[k];
|
||||
});
|
||||
if (field !== 'ignore') {
|
||||
// Also un-set if another header was using this field
|
||||
(Object.keys(next) as (keyof ColumnMapping)[]).forEach((k) => {
|
||||
if (k === field) delete next[k];
|
||||
});
|
||||
(next as Record<string, string>)[field] = h;
|
||||
}
|
||||
onMappingChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We couldn't auto-detect the columns in this file. Tell us which field each column
|
||||
represents.
|
||||
</p>
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Column</TableHead>
|
||||
<TableHead>Maps to</TableHead>
|
||||
<TableHead>Sample values</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{headers.map((h, i) => (
|
||||
<TableRow key={h + i}>
|
||||
<TableCell className="font-medium">{h}</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={fieldOfHeader(h)}
|
||||
onValueChange={(v) => setFieldForHeader(h, v ?? 'ignore')}
|
||||
>
|
||||
<SelectTrigger className="min-w-[12rem]">
|
||||
<SelectValue>
|
||||
{(v: string | undefined) =>
|
||||
MAPPING_FIELDS.find((f) => f.value === (v ?? 'ignore'))?.label ??
|
||||
'Ignore'
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MAPPING_FIELDS.map((f) => (
|
||||
<SelectItem key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{sample
|
||||
.map((row) => row[i])
|
||||
.filter(Boolean)
|
||||
.slice(0, 3)
|
||||
.join(', ')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onBack} disabled={working}>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={onSubmit} disabled={working}>
|
||||
{working ? 'Parsing…' : 'Continue'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReviewStepProps {
|
||||
rows: ReviewRow[];
|
||||
accounts: Account[];
|
||||
sourceAccount: Account | undefined;
|
||||
onUpdateRow: (i: number, patch: Partial<ReviewRow>) => void;
|
||||
onIncludeAll: () => void;
|
||||
onSkipDuplicates: () => void;
|
||||
onClearAll: () => void;
|
||||
onCancel: () => void;
|
||||
onContinue: () => void;
|
||||
warnings: string[];
|
||||
counts: {
|
||||
selected: number;
|
||||
duplicates: number;
|
||||
needsReview: number;
|
||||
possibleTransfers: number;
|
||||
};
|
||||
}
|
||||
|
||||
function ReviewStep({
|
||||
rows,
|
||||
accounts,
|
||||
sourceAccount,
|
||||
onUpdateRow,
|
||||
onIncludeAll,
|
||||
onSkipDuplicates,
|
||||
onClearAll,
|
||||
onCancel,
|
||||
onContinue,
|
||||
warnings,
|
||||
counts,
|
||||
}: ReviewStepProps) {
|
||||
const otherAccounts = accounts.filter((a) => a.id !== sourceAccount?.id);
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Review each row before import. Edit fields inline if needed; uncheck anything you don't
|
||||
want to import.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onIncludeAll}>
|
||||
Include all
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onSkipDuplicates}>
|
||||
Skip duplicates
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onClearAll}>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{warnings.length > 0 && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-2 text-xs text-amber-900">
|
||||
{warnings.slice(0, 3).map((w, i) => (
|
||||
<p key={i}>{w}</p>
|
||||
))}
|
||||
{warnings.length > 3 && (
|
||||
<p className="mt-1">
|
||||
And {warnings.length - 3} more — open the developer console for details.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-[55vh] overflow-y-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8" />
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((r) => (
|
||||
<TableRow
|
||||
key={r.sourceIndex}
|
||||
className={r.status === 'duplicate' ? 'bg-red-50/50' : undefined}
|
||||
>
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Include row ${r.sourceIndex + 1}`}
|
||||
checked={r.included}
|
||||
onChange={(e) => onUpdateRow(r.sourceIndex, { included: e.target.checked })}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="date"
|
||||
className="h-8 w-[8.5rem]"
|
||||
value={toDateInputValue(r.date)}
|
||||
onChange={(e) => onUpdateRow(r.sourceIndex, { date: e.target.value })}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
className="h-8 min-w-[12rem]"
|
||||
value={r.description}
|
||||
onChange={(e) => onUpdateRow(r.sourceIndex, { description: e.target.value })}
|
||||
/>
|
||||
{r.duplicateOf && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Matches existing {formatDate(r.duplicateOf.date)} ·{' '}
|
||||
{currency(r.duplicateOf.amount)} · {r.duplicateOf.description}
|
||||
</p>
|
||||
)}
|
||||
{r.transferCandidate && !r.destinationAccountId && (
|
||||
<p className="mt-1 text-xs text-sky-900">
|
||||
Possible transfer with {r.transferCandidate.accountName}. Select a
|
||||
destination to mark as transfer.
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="h-8 w-24 text-right"
|
||||
value={r.amount}
|
||||
onChange={(e) =>
|
||||
onUpdateRow(r.sourceIndex, {
|
||||
amount: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={r.type}
|
||||
onValueChange={(v) => {
|
||||
const newType = (v ?? 'EXPENSE') as RowType;
|
||||
onUpdateRow(r.sourceIndex, {
|
||||
type: newType,
|
||||
destinationAccountId:
|
||||
newType === 'TRANSFER'
|
||||
? (r.destinationAccountId ?? r.transferCandidate?.accountId ?? '')
|
||||
: undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[7rem]">
|
||||
<SelectValue>
|
||||
{(v: string | undefined) =>
|
||||
(v ?? 'EXPENSE').charAt(0) + (v ?? 'EXPENSE').slice(1).toLowerCase()
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="INCOME">Income</SelectItem>
|
||||
<SelectItem value="EXPENSE">Expense</SelectItem>
|
||||
<SelectItem value="TRANSFER">Transfer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{r.type === 'TRANSFER' && (
|
||||
<Select
|
||||
value={r.destinationAccountId ?? ''}
|
||||
onValueChange={(v) =>
|
||||
onUpdateRow(r.sourceIndex, {
|
||||
destinationAccountId: v ?? '',
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 w-[10rem]">
|
||||
<SelectValue placeholder="To account">
|
||||
{(v: string | undefined) =>
|
||||
accounts.find((a) => a.id === v)?.name ?? 'To account'
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{otherAccounts.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{statusBadge(r.status)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{counts.selected} selected · {counts.duplicates} duplicates · {counts.needsReview} need
|
||||
review · {counts.possibleTransfers} possible transfers
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onContinue}
|
||||
disabled={
|
||||
counts.selected === 0 ||
|
||||
rows.some((r) => r.included && r.type === 'TRANSFER' && !r.destinationAccountId)
|
||||
}
|
||||
>
|
||||
Continue to confirm <ArrowRight className="ml-1 size-4" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConfirmStepProps {
|
||||
counts: {
|
||||
selected: number;
|
||||
duplicates: number;
|
||||
needsReview: number;
|
||||
transfers: number;
|
||||
};
|
||||
balanceDelta: Map<string, { name: string; delta: number; type: Account['type'] }>;
|
||||
sourceAccount: Account | undefined;
|
||||
selectedRows: ReviewRow[];
|
||||
accounts: Account[];
|
||||
onBack: () => void;
|
||||
onConfirm: () => void;
|
||||
submitting: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function ConfirmStep({
|
||||
counts,
|
||||
balanceDelta,
|
||||
sourceAccount,
|
||||
selectedRows,
|
||||
accounts,
|
||||
onBack,
|
||||
onConfirm,
|
||||
submitting,
|
||||
error,
|
||||
}: ConfirmStepProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="rounded-md border bg-card p-3">
|
||||
<p className="text-2xl font-semibold">{counts.selected}</p>
|
||||
<p className="text-xs text-muted-foreground">To import</p>
|
||||
</div>
|
||||
<div className="rounded-md border bg-card p-3">
|
||||
<p className="text-2xl font-semibold">{counts.duplicates}</p>
|
||||
<p className="text-xs text-muted-foreground">Duplicates skipped</p>
|
||||
</div>
|
||||
<div className="rounded-md border bg-card p-3">
|
||||
<p className="text-2xl font-semibold">{counts.needsReview}</p>
|
||||
<p className="text-xs text-muted-foreground">Needed review</p>
|
||||
</div>
|
||||
<div className="rounded-md border bg-card p-3">
|
||||
<p className="text-2xl font-semibold">{counts.transfers}</p>
|
||||
<p className="text-xs text-muted-foreground">Transfers</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{balanceDelta.size > 0 && (
|
||||
<div className="rounded-md border p-3">
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
Projected balance impact
|
||||
</p>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Array.from(balanceDelta.entries()).map(([id, info]) => {
|
||||
const acc = accounts.find((a) => a.id === id);
|
||||
const current = Number(acc?.balance ?? 0);
|
||||
const projected = current + info.delta;
|
||||
return (
|
||||
<li key={id} className="flex justify-between gap-4">
|
||||
<span>{info.name}</span>
|
||||
<span>
|
||||
{currency(current)} <ArrowRight className="inline size-3" />{' '}
|
||||
<span className={info.delta >= 0 ? 'text-emerald-700' : 'text-red-700'}>
|
||||
{currency(projected)}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs underline-offset-2 hover:underline"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
>
|
||||
{expanded ? 'Hide' : 'Show'} the {counts.selected} transaction
|
||||
{counts.selected === 1 ? '' : 's'} about to be imported
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="max-h-[40vh] overflow-y-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Account</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedRows.map((r) => (
|
||||
<TableRow key={r.sourceIndex}>
|
||||
<TableCell>{formatDate(r.date)}</TableCell>
|
||||
<TableCell>{r.description}</TableCell>
|
||||
<TableCell className="text-right">{currency(r.amount)}</TableCell>
|
||||
<TableCell>{r.type.charAt(0) + r.type.slice(1).toLowerCase()}</TableCell>
|
||||
<TableCell>
|
||||
{sourceAccount?.name ?? '—'}
|
||||
{r.destinationAccountId && (
|
||||
<>
|
||||
{' '}
|
||||
<ArrowRight className="inline size-3" />{' '}
|
||||
{accounts.find((a) => a.id === r.destinationAccountId)?.name ?? '—'}
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onBack} disabled={submitting}>
|
||||
Back to review
|
||||
</Button>
|
||||
<Button onClick={onConfirm} disabled={counts.selected === 0 || submitting}>
|
||||
{submitting
|
||||
? 'Importing…'
|
||||
: `Import ${counts.selected} transaction${counts.selected === 1 ? '' : 's'}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -40,12 +40,14 @@ import {
|
||||
Trash2,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
Upload,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import { TransactionForm, type TransactionFormData } from '@/components/TransactionForm';
|
||||
import { ReceiptViewer } from '@/components/ReceiptViewer';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { ExportTransactionsDialog } from '@/components/ExportTransactionsDialog';
|
||||
import { ImportStatementDialog } from '@/components/ImportStatementDialog';
|
||||
import { formatDate } from '@/lib/dates';
|
||||
|
||||
const TRANSACTION_TYPES = ['INCOME', 'EXPENSE', 'TRANSFER'] as const;
|
||||
@@ -74,6 +76,8 @@ export function Transactions() {
|
||||
const [viewingReceipt, setViewingReceipt] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState<Transaction | null>(null);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importToast, setImportToast] = useState<string | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -129,6 +133,9 @@ export function Transactions() {
|
||||
<Button variant="outline" onClick={() => setExportOpen(true)}>
|
||||
<Download className="mr-2 size-4" /> Export CSV
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setImportOpen(true)}>
|
||||
<Upload className="mr-2 size-4" /> Import Statement
|
||||
</Button>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger render={<Button />}>
|
||||
<Plus className="mr-2 size-4" /> Add Transaction
|
||||
@@ -392,6 +399,36 @@ export function Transactions() {
|
||||
|
||||
<ReceiptViewer receiptPath={viewingReceipt} onClose={() => setViewingReceipt(null)} />
|
||||
|
||||
<ImportStatementDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
defaultAccountId={filters.accountId}
|
||||
onImported={({ created, skipped }) => {
|
||||
const skippedNote =
|
||||
skipped > 0 ? ` Skipped ${skipped} duplicate${skipped === 1 ? '' : 's'}.` : '';
|
||||
setImportToast(
|
||||
`Imported ${created} transaction${created === 1 ? '' : 's'}.${skippedNote}`,
|
||||
);
|
||||
fetchAccounts();
|
||||
}}
|
||||
/>
|
||||
|
||||
{importToast && (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed right-4 bottom-4 z-50 rounded-md border bg-card px-3 py-2 text-sm shadow"
|
||||
onAnimationEnd={() => setImportToast(null)}
|
||||
>
|
||||
{importToast}
|
||||
<button
|
||||
className="ml-3 text-xs underline-offset-2 hover:underline"
|
||||
onClick={() => setImportToast(null)}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleting}
|
||||
onOpenChange={(open) => !open && setDeleting(null)}
|
||||
@@ -422,6 +459,7 @@ export function Transactions() {
|
||||
open={exportOpen}
|
||||
onOpenChange={setExportOpen}
|
||||
baseFilters={filters}
|
||||
accountName={accountName(filters.accountId)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -97,6 +97,53 @@ describe('useTransactionsStore', () => {
|
||||
expect(txns[1]).toEqual({ id: '2', description: 'other' });
|
||||
});
|
||||
|
||||
describe('bulkCreateTransactions', () => {
|
||||
it('posts the rows to /transactions/bulk along with the source metadata', async () => {
|
||||
mockApi.post.mockResolvedValue({ created: 2, ids: ['a', 'b'] });
|
||||
|
||||
const rows = [
|
||||
{
|
||||
accountId: 'acc-1',
|
||||
amount: 5,
|
||||
type: 'EXPENSE' as const,
|
||||
description: 'Coffee',
|
||||
date: '2026-04-10',
|
||||
},
|
||||
{
|
||||
accountId: 'acc-1',
|
||||
amount: 200,
|
||||
type: 'INCOME' as const,
|
||||
description: 'Refund',
|
||||
date: '2026-04-11',
|
||||
},
|
||||
];
|
||||
const result = await useTransactionsStore.getState().bulkCreateTransactions(rows, {
|
||||
kind: 'statement-import',
|
||||
label: 'chase-2026-04.csv',
|
||||
});
|
||||
|
||||
expect(mockApi.post).toHaveBeenCalledWith('/transactions/bulk', {
|
||||
transactions: rows,
|
||||
source: 'statement-import',
|
||||
sourceLabel: 'chase-2026-04.csv',
|
||||
});
|
||||
expect(result).toEqual({ created: 2, ids: ['a', 'b'] });
|
||||
});
|
||||
|
||||
it('propagates a partial-failure result from the backend', async () => {
|
||||
mockApi.post.mockResolvedValue({
|
||||
created: 50,
|
||||
ids: Array.from({ length: 50 }, (_, i) => `id-${i}`),
|
||||
partial: { attempted: 60, failed: 10, error: 'timeout' },
|
||||
});
|
||||
const result = await useTransactionsStore.getState().bulkCreateTransactions([], {
|
||||
kind: 'statement-import',
|
||||
label: 'big.csv',
|
||||
});
|
||||
expect(result.partial?.failed).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchAllTransactions', () => {
|
||||
it('hits /transactions?all=true and returns the data array directly without writing to store', async () => {
|
||||
const data = [{ id: '1' }, { id: '2' }];
|
||||
|
||||
@@ -28,6 +28,24 @@ export interface TransactionFilters {
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export interface BulkCreateInput {
|
||||
accountId: string;
|
||||
destinationAccountId?: string;
|
||||
categoryId?: string;
|
||||
amount: number;
|
||||
type: 'INCOME' | 'EXPENSE' | 'TRANSFER';
|
||||
description: string;
|
||||
notes?: string;
|
||||
date: string;
|
||||
externalId?: string;
|
||||
}
|
||||
|
||||
export interface BulkCreateResult {
|
||||
created: number;
|
||||
ids: string[];
|
||||
partial?: { attempted: number; failed: number; error: string };
|
||||
}
|
||||
|
||||
interface TransactionsState {
|
||||
transactions: Transaction[];
|
||||
total: number;
|
||||
@@ -36,6 +54,10 @@ interface TransactionsState {
|
||||
fetchTransactions: (filters?: TransactionFilters, page?: number) => Promise<void>;
|
||||
fetchAllTransactions: (filters?: TransactionFilters) => Promise<Transaction[]>;
|
||||
createTransaction: (data: Partial<Transaction>) => Promise<void>;
|
||||
bulkCreateTransactions: (
|
||||
rows: BulkCreateInput[],
|
||||
source: { kind: 'statement-import'; label: string },
|
||||
) => Promise<BulkCreateResult>;
|
||||
updateTransaction: (id: string, data: Partial<Transaction>) => Promise<void>;
|
||||
deleteTransaction: (id: string) => Promise<void>;
|
||||
}
|
||||
@@ -85,6 +107,14 @@ export const useTransactionsStore = create<TransactionsState>((set) => ({
|
||||
set((state) => ({ transactions: [transaction, ...state.transactions] }));
|
||||
},
|
||||
|
||||
bulkCreateTransactions: async (rows, source) => {
|
||||
return api.post<BulkCreateResult>('/transactions/bulk', {
|
||||
transactions: rows,
|
||||
source: source.kind,
|
||||
sourceLabel: source.label,
|
||||
});
|
||||
},
|
||||
|
||||
updateTransaction: async (id, data) => {
|
||||
const updated = await api.patch<Transaction>(`/transactions/${id}`, data);
|
||||
set((state) => ({
|
||||
|
||||
Reference in New Issue
Block a user