kinda there

This commit is contained in:
2025-07-18 23:02:01 -04:00
parent 3e7572f3a3
commit 3225ebb986
28 changed files with 1665 additions and 475 deletions

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"vscode-postgres.defaultConnection": "sql.caseytimm.com"
}

162
package-lock.json generated
View File

@ -8,7 +8,9 @@
"name": "budget", "name": "budget",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@auth/sveltekit": "^1.10.0",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"cron": "^4.3.2",
"postgres": "^3.4.7", "postgres": "^3.4.7",
"tailwindcss": "^4.1.11" "tailwindcss": "^4.1.11"
}, },
@ -37,6 +39,63 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@auth/core": {
"version": "0.40.0",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.40.0.tgz",
"integrity": "sha512-n53uJE0RH5SqZ7N1xZoMKekbHfQgjd0sAEyUbE+IYJnmuQkbvuZnXItCU7d+i7Fj8VGOgqvNO7Mw4YfBTlZeQw==",
"license": "ISC",
"dependencies": {
"@panva/hkdf": "^1.2.1",
"jose": "^6.0.6",
"oauth4webapi": "^3.3.0",
"preact": "10.24.3",
"preact-render-to-string": "6.5.11"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"nodemailer": "^6.8.0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/@auth/sveltekit": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@auth/sveltekit/-/sveltekit-1.10.0.tgz",
"integrity": "sha512-nTKS3FoFvgdqUwb7a8HZpLxDlx+pHndygcodM16J/iFHbe/0wha0MUCuTkVeUYZuKwL63L2ujmMAC1WEoki2+g==",
"license": "ISC",
"dependencies": {
"@auth/core": "0.40.0",
"set-cookie-parser": "^2.7.0"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.3",
"@sveltejs/kit": "^1.0.0 || ^2.0.0",
"nodemailer": "^6.6.5",
"svelte": "^3.54.0 || ^4.0.0 || ^5.0.0-0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.5", "version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz",
@ -497,11 +556,19 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@panva/hkdf": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@polka/url": { "node_modules/@polka/url": {
"version": "1.0.0-next.29", "version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
@ -768,7 +835,6 @@
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz",
"integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==", "integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"acorn": "^8.9.0" "acorn": "^8.9.0"
@ -788,7 +854,6 @@
"version": "2.22.2", "version": "2.22.2",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.22.2.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.22.2.tgz",
"integrity": "sha512-2MvEpSYabUrsJAoq5qCOBGAlkICjfjunrnLcx3YAk2XV7TvAIhomlKsAgR4H/4uns5rAfYmj7Wet5KRtc8dPIg==", "integrity": "sha512-2MvEpSYabUrsJAoq5qCOBGAlkICjfjunrnLcx3YAk2XV7TvAIhomlKsAgR4H/4uns5rAfYmj7Wet5KRtc8dPIg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sveltejs/acorn-typescript": "^1.0.5", "@sveltejs/acorn-typescript": "^1.0.5",
@ -821,7 +886,6 @@
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.0.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.0.tgz",
"integrity": "sha512-wojIS/7GYnJDYIg1higWj2ROA6sSRWvcR1PO/bqEyFr/5UZah26c8Cz4u0NaqjPeVltzsVpt2Tm8d2io0V+4Tw==", "integrity": "sha512-wojIS/7GYnJDYIg1higWj2ROA6sSRWvcR1PO/bqEyFr/5UZah26c8Cz4u0NaqjPeVltzsVpt2Tm8d2io0V+4Tw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
@ -843,7 +907,6 @@
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz",
"integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"debug": "^4.3.7" "debug": "^4.3.7"
@ -1139,7 +1202,6 @@
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/estree": { "node_modules/@types/estree": {
@ -1148,11 +1210,16 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/luxon": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz",
"integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==",
"license": "MIT"
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
@ -1165,7 +1232,6 @@
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -1175,7 +1241,6 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -1194,7 +1259,6 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@ -1204,12 +1268,24 @@
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/cron": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/cron/-/cron-4.3.2.tgz",
"integrity": "sha512-JxBBnf5zRz+NhW9XcP16gwUKAKIimy2G0QCCQu8kk5XwM4aCGwMt+nntouAfXF9A57965XzB6hitBlJAz5Ts6w==",
"license": "MIT",
"dependencies": {
"@types/luxon": "~3.6.0",
"luxon": "~3.7.0"
},
"engines": {
"node": ">=18.x"
}
},
"node_modules/cssesc": { "node_modules/cssesc": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -1237,7 +1313,6 @@
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@ -1255,7 +1330,6 @@
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@ -1274,7 +1348,6 @@
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz",
"integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==", "integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
@ -1334,14 +1407,12 @@
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/esrap": { "node_modules/esrap": {
"version": "1.4.9", "version": "1.4.9",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.9.tgz", "resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.9.tgz",
"integrity": "sha512-3OMlcd0a03UGuZpPeUC1HxR3nA23l+HEyCiZw3b3FumJIN9KphoGzDJKMXI1S72jVS1dsenDyQC0kJlO1U9E1g==", "integrity": "sha512-3OMlcd0a03UGuZpPeUC1HxR3nA23l+HEyCiZw3b3FumJIN9KphoGzDJKMXI1S72jVS1dsenDyQC0kJlO1U9E1g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15" "@jridgewell/sourcemap-codec": "^1.4.15"
@ -1385,7 +1456,6 @@
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/estree": "^1.0.6" "@types/estree": "^1.0.6"
@ -1400,11 +1470,19 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/jose": {
"version": "6.0.12",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.0.12.tgz",
"integrity": "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/kleur": { "node_modules/kleur": {
"version": "4.1.5", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@ -1642,7 +1720,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.castarray": { "node_modules/lodash.castarray": {
@ -1666,6 +1743,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/luxon": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.1.tgz",
"integrity": "sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.17", "version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
@ -1715,7 +1801,6 @@
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=4" "node": ">=4"
@ -1725,7 +1810,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@ -1735,7 +1819,6 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
@ -1756,6 +1839,15 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/oauth4webapi": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.6.0.tgz",
"integrity": "sha512-OwXPTXjKPOldTpAa19oksrX9TYHA0rt+VcUFTkJ7QKwgmevPpNm9Cn5vFZUtIo96FiU6AfPuUUGzoXqgOzibWg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -1829,6 +1921,25 @@
"url": "https://github.com/sponsors/porsager" "url": "https://github.com/sponsors/porsager"
} }
}, },
"node_modules/preact": {
"version": "10.24.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-render-to-string": {
"version": "6.5.11",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz",
"integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==",
"license": "MIT",
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.6.2", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
@ -1899,7 +2010,6 @@
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"mri": "^1.1.0" "mri": "^1.1.0"
@ -1912,14 +2022,12 @@
"version": "2.7.1", "version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/sirv": { "node_modules/sirv": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz",
"integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==", "integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@polka/url": "^1.0.0-next.24", "@polka/url": "^1.0.0-next.24",
@ -1943,7 +2051,6 @@
"version": "5.34.9", "version": "5.34.9",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.34.9.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.34.9.tgz",
"integrity": "sha512-sld35zFpooaSRSj4qw8Vl/cyyK0/sLQq9qhJ7BGZo/Kd0ggYtEnvNYLlzhhoqYsYQzA0hJqkzt3RBO/8KoTZOg==", "integrity": "sha512-sld35zFpooaSRSj4qw8Vl/cyyK0/sLQq9qhJ7BGZo/Kd0ggYtEnvNYLlzhhoqYsYQzA0hJqkzt3RBO/8KoTZOg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.3.0", "@ampproject/remapping": "^2.3.0",
@ -2017,7 +2124,6 @@
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@ -2108,7 +2214,6 @@
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.7.tgz", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.7.tgz",
"integrity": "sha512-eRWXLBbJjW3X5z5P5IHcSm2yYbYRPb2kQuc+oqsbAl99WB5kVsPbiiox+cymo8twTzifA6itvhr2CmjnaZZp0Q==", "integrity": "sha512-eRWXLBbJjW3X5z5P5IHcSm2yYbYRPb2kQuc+oqsbAl99WB5kVsPbiiox+cymo8twTzifA6itvhr2CmjnaZZp0Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"tests/deps/*", "tests/deps/*",
@ -2136,7 +2241,6 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==", "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
"dev": true,
"license": "MIT" "license": "MIT"
} }
} }

View File

@ -23,7 +23,9 @@
"vite": "^6.2.6" "vite": "^6.2.6"
}, },
"dependencies": { "dependencies": {
"@auth/sveltekit": "^1.10.0",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"cron": "^4.3.2",
"postgres": "^3.4.7", "postgres": "^3.4.7",
"tailwindcss": "^4.1.11" "tailwindcss": "^4.1.11"
} }

18
src/hooks.server.js Normal file
View File

@ -0,0 +1,18 @@
import { CronJob } from 'cron';
import { fetchAccounts } from '$lib/simplefin';
import type { Handle } from "@sveltejs/kit";
const job = new CronJob(
'10 0 * * * *', // cronTime
async function () {
const statDate = Math.floor(new Date(new Date().getFullYear(), new Date().getMonth()).getTime() / 1000);
const res = await fetchAccounts(startDate);
await updateAccounts(res);
await runRules();
}, // onTick
null, // onComplete
true, // start
'America/Detroit' // timeZone
);

View File

@ -1,173 +1,211 @@
// db.js // db.js
import postgres from 'postgres' import postgres from 'postgres';
const db = postgres({ host:'192.168.1.126', username:'budget', password:'budget', database:'budget'}) // will use psql environment variables const db = postgres({
host: '192.168.1.126',
username: 'budget',
password: 'budget',
database: 'budget'
}); // will use psql environment variables
export { db };
export async function getTotal() {
const result = await db`
select sum(balance) as total
from account
where in_total is true
`;
// result = Result [{ total: 1000.00 }]
return result[0].total;
}
export async function setAccountInTotal(accountId, total) { export async function setAccountInTotal(accountId, total) {
return await db` return await db`
update account update account
set in_total = ${total} set in_total = ${total}
where id = ${accountId} where id = ${accountId}
` `;
} }
export async function setAccountHide(accountId, hide) { export async function setAccountHide(accountId, hide) {
return await db` return await db`
update account update account
set hide = ${hide} set hide = ${hide}
where id = ${accountId} where id = ${accountId}
` `;
} }
export async function deleteBudget(id) { export async function addBudget(name, notes) {
return await db` const result = await db`
UPDATE budget insert into budget (name, notes)
SET delete = true values (${name}, ${notes})
WHERE id = ${id}
`
}
export async function createBudgetTable() {
return await db`
create table if not exists budget (
id serial primary key,
name text not null,
amount numeric(10,2) not null,
notes text,
delete boolean default false
)
`
}
export async function createBudgetTransactionTable() {
return await db`
create table if not exists budget_transaction (
id serial primary key,
budget_id integer not null references budget(id),
transaction_id text not null references transaction(id),
amount numeric(10,2) not null
)`
}
export async function addBudget(name, amount, notes) {
const result = await db`
insert into budget (name, amount, notes)
values (${name}, ${amount}, ${notes})
returning id returning id
` `;
// result = Result [{ id: 1 }] // result = Result [{ id: 1 }]
return result[0].id return result[0].id;
} }
export async function deleteBudget(id) { export async function deleteBudget(id) {
const result = await db` const result = await db`
delete from budget update budget
set delete = true
where id = ${id} where id = ${id}
` `;
// result = Result [{ id: 1 }] // result = Result [{ id: 1 }]
return result return result;
}
export async function restoreBudget(id) {
const result = await db`
update budget
set delete = false
where id = ${id}
`;
// result = Result [{ id: 1 }]
return result;
} }
export async function updateBudget(id, name, amount, notes) { export async function updateBudget(id, name, amount, notes) {
const result = await db` const result = await db`
update budget update budget
set name = ${name}, set name = ${name},
amount = ${amount}, amount = ${amount},
notes = ${notes} notes = ${notes}
where id = ${id} where id = ${id}
` `;
// result = Result [{ id: 1 }] // result = Result [{ id: 1 }]
return result return result;
} }
export async function getBudgetTransactions(id) { export async function getBudgetTransactions(id) {
// Fetch all transactions associated with a specific budget // Fetch all transactions associated with a specific budget
try { try {
const transactions = await db` let transactions = await db`
select select
transaction.id as id, transaction.id as id,
transaction.posted as posted, transaction.posted as posted,
transaction.amount as amount, transaction.amount as amount,
transaction.description as description, transaction.description as description,
transaction.pending as pending, transaction.pending as pending,
transaction.notes as notes, budget_transaction.notes as notes,
transaction.payee as payee,
budget_transaction.amount as budget_amount, budget_transaction.amount as budget_amount,
budget_transaction.id as budget_transaction_id budget_transaction.id as budget_transaction_id
from budget_transaction from budget_transaction
join transaction on budget_transaction.transaction_id = transaction.id join transaction on budget_transaction.transaction_id = transaction.id
where budget_transaction.budget_id = ${id} where budget_transaction.budget_id = ${id}
order by transaction.posted desc order by transaction.posted desc
` `;
console.log(`Fetched ${transactions.length} transactions for budget ${id}`);
// transactions = Result [{ id: 1, posted: 1633036800, amount: 50.00, description: "Grocery Store", pending: false, notes: "Weekly groceries" }, ...] transactions = transactions.map((t) => ({
return { transactions } ...t,
} date: new Date(t.posted * 1000)
catch { }));
await createBudgetTransactionTable(); // transactions = Result [{ id: 1, posted: 1633036800, amount: 50.00, description: "Grocery Store", pending: false, notes: "Weekly groceries" }, ...]
return [] return { transactions };
} } catch {
return [];
}
} }
export async function deleteBudgetTransaction(budgetId, transactionId) { export async function updateBudgetTransaction(id, amount, notes) {
// Delete a transaction from a budget // Delete a transaction from a budget
const result = await db` const result = await db`
update from budget_transaction
where id= ${id}
SET amount = ${amount}, notes = ${notes}
`;
// result = Result [{ id: 1 }]
return result;
}
export async function deleteBudgetTransaction(id) {
// Delete a transaction from a budget
const result = await db`
delete from budget_transaction delete from budget_transaction
where budget_id = ${budgetId} and transaction_id = ${transactionId} where id = ${id}
` `;
// result = Result [{ id: 1 }] // result = Result [{ id: 1 }]
return result return result;
} }
export async function addBudgetTransaction(budgetId, transactionId, amount) { export async function addBudgetTransaction(budgetId, transactionId, amount, notes, ruleId = null) {
// Add a transaction to a budget const existingTransactions = await db`
const result = await db` select amount from budget_transaction
insert into budget_transaction (budget_id, transaction_id, amount) where transaction_id = ${transactionId}
values (${budgetId}, ${transactionId}, ${amount}) `;
const realTransactionAmount = await db`
select amount from transaction
where id = ${transactionId}
`;
if (existingTransactions.length > 0) {
if (
existingTransactions.reduce((acc, curr) => acc + curr.amount, 0) + amount >
realTransactionAmount
) {
return -1;
}
}
// Add a transaction to a budget
const result = await db`
insert into budget_transaction (budget_id, transaction_id, amount, notes, rule_id)
values (${budgetId}, ${transactionId}, ${amount}, ${notes}, ${ruleId ?? null})
returning id returning id
` `;
// result = Result [{ id: 1 }] // result = Result [{ id: 1 }]
return result[0].id return result[0].id;
} }
export async function getBudgets() { export async function getBudgets() {
const budgets = await db` const budgets = await db`
select select
budget.id as id, budget.id as id,
budget.name as name, budget.name as name,
budget.amount as amount, budget.sum as sum,
budget.notes as notes budget.notes as notes
from budget from budget_with_sum as budget
WHERE budget.delete is false WHERE budget.delete is false
` `;
if (!budgets) { // budgets = Result [{ name: "Walter", age: 80 }, { name: 'Murray', age: 68 }, ...]
await createBudgetTable(); return budgets;
return await getBudgets() }
}
// budgets = Result [{ name: "Walter", age: 80 }, { name: 'Murray', age: 68 }, ...] export async function getBudget(id) {
return budgets const budget = await db`
select
budget.id as id,
budget.name as name,
budget.sum as sum,
budget.notes as notes,
budget.delete as delete
from budget_with_sum as budget
where budget.id = ${id}
`;
// budget = Result [{ id: 1, name: "Groceries", notes: "Monthly grocery budget" }]
if (!budget || budget.length === 0) {
return null;
}
return budget[0];
} }
export async function getDeletedBudgets() { export async function getDeletedBudgets() {
const budgets = await db` const budgets = await db`
select select
budget.id as id, budget.id as id,
budget.name as name, budget.name as name,
budget.amount as amount,
budget.notes as notes budget.notes as notes
from budget from budget
WHERE budget.delete is true WHERE budget.delete is true
` `;
if (!budgets) { // budgets = Result [{ name: "Walter", age: 80 }, { name: 'Murray', age: 68 }, ...]
await createBudgetTable(); return budgets;
return await getBudgets()
}
// budgets = Result [{ name: "Walter", age: 80 }, { name: 'Murray', age: 68 }, ...]
return budgets
} }
export async function getAccount(id) { export async function getAccount(id) {
const account = await db` const account = await db`
select select
account.id as id, account.id as id,
account.name as name, account.name as name,
@ -184,73 +222,89 @@ export async function getAccount(id) {
from account from account
left join org on account.org_id = org.id left join org on account.org_id = org.id
where account.id = ${id} where account.id = ${id}
` `;
if (!account || account.length === 0) { if (!account || account.length === 0) {
return null return null;
} }
return account[0]; return account[0];
} }
export async function getAccounts(age) { export async function getAccounts(age) {
const accounts = await db` const accounts = await db`
select select
account.id as id, account.id as id,
account.name as name, account.name as name,
balance balance
from account from account
where hide is false where hide is false
` `;
// users = Result [{ name: "Walter", age: 80 }, { name: 'Murray', age: 68 }, ...] // users = Result [{ name: "Walter", age: 80 }, { name: 'Murray', age: 68 }, ...]
return accounts return accounts;
}
export async function getBudgetTransactionsForAccount(accountID) {
const transactions = await db`
select budget_transaction.id as id,
budget_transaction.budget_id as budget_id,
budget_transaction.transaction_id as transaction_id,
budget_transaction.amount as amount,
budget_transaction.notes as notes
from budget_transaction
join transaction on budget_transaction.transaction_id = transaction.id
where transaction.account_id = ${accountID}
`;
return transactions;
} }
export async function getHiddenAccounts(age) { export async function getHiddenAccounts(age) {
const accounts = await db` const accounts = await db`
select select
account.id as id, account.id as id,
account.name as name account.name as name
from account from account
where account.hide is true where account.hide is true
` `;
// users = Result [{ name: "Walter", age: 80 }, { name: 'Murray', age: 68 }, ...] // users = Result [{ name: "Walter", age: 80 }, { name: 'Murray', age: 68 }, ...]
return accounts return accounts;
} }
export async function getTransactions(accountId) { export async function getTransactions(accountId) {
let transactions = await db` let transactions = await db`
select select
transaction.id as id, transaction.id as id,
transaction.posted as posted, transaction.posted as posted,
transaction.amount as amount, transaction.amount as amount,
transaction.description as description, transaction.description as description,
transaction.pending as pending, transaction.pending as pending,
transaction.notes as notes transaction.notes as notes,
transaction.payee as payee
from transaction from transaction
where account_id = ${accountId} where account_id = ${accountId}
order by posted desc order by posted desc
` `;
transactions = transactions.map((t) => ({ transactions = transactions.map((t) => ({
...t, date: new Date(t.posted * 1000) ...t,
})); date: new Date(t.posted * 1000)
return transactions }));
return transactions;
} }
export async function setTransactionNote(transactionId, note) { export async function setTransactionNote(transactionId, note) {
const result = await db` const result = await db`
update transaction update transaction
set notes = ${note} set notes = ${note}
where id = ${transactionId} where id = ${transactionId}
` `;
return result return result;
} }
export async function updateAccounts(data) { export async function updateAccounts(data) {
try { try {
console.log('Updating accounts with data:', data); console.log('Updating accounts with data:', data);
for (const account of data.accounts) { for (const account of data.accounts) {
// Upsert Org // Upsert Org
console.log(`Upserting org for account: ${account.id}`, account.org); console.log(`Upserting org for account: ${account.id}`, account.org);
await db` await db`
insert into org (id, domain, name, sfin_url, url) insert into org (id, domain, name, sfin_url, url)
values (${account.org.id}, ${account.org.domain ?? null}, ${account.org.name ?? null}, ${account.org.sfin_url ?? null}, ${account.org.url ?? null}) values (${account.org.id}, ${account.org.domain ?? null}, ${account.org.name ?? null}, ${account.org.sfin_url ?? null}, ${account.org.url ?? null})
on conflict (id) do update set on conflict (id) do update set
@ -259,9 +313,9 @@ export async function updateAccounts(data) {
sfin_url = excluded.sfin_url, sfin_url = excluded.sfin_url,
url = excluded.url url = excluded.url
`; `;
console.log(`Upserting account: ${account.id} (${account.name})`); console.log(`Upserting account: ${account.id} (${account.name})`);
// Upsert Account // Upsert Account
await db` await db`
insert into account (id, org_id, name, currency, balance, available_balance, balance_date) insert into account (id, org_id, name, currency, balance, available_balance, balance_date)
values ( values (
${account.id}, ${account.id},
@ -281,34 +335,34 @@ export async function updateAccounts(data) {
balance_date = excluded.balance_date balance_date = excluded.balance_date
`; `;
// Upsert Transactions // Upsert Transactions
if (account.transactions && account.transactions.length > 0) { if (account.transactions && account.transactions.length > 0) {
for (const txn of account.transactions) { for (const txn of account.transactions) {
let extraId = null; let extraId = null;
console.log(`Upserting transaction: ${txn.id} for account: ${account.id}`); console.log(`Upserting transaction: ${txn.id} for account: ${account.id}`);
if (txn.extra) { if (txn.extra) {
// Upsert TransactionExtra (insert only, update not needed for category) // Upsert TransactionExtra (insert only, update not needed for category)
const extraResult = await db` const extraResult = await db`
insert into transaction_extra (category) insert into transaction_extra (category)
values (${txn.extra.category ?? null}) values (${txn.extra.category ?? null})
on conflict (category) do nothing on conflict (category) do nothing
returning id returning id
`; `;
if (extraResult.length > 0) { if (extraResult.length > 0) {
extraId = extraResult[0].id; extraId = extraResult[0].id;
} else { } else {
// If already exists, fetch id // If already exists, fetch id
const existing = await db` const existing = await db`
select id from transaction_extra where category = ${txn.extra.category ?? null} select id from transaction_extra where category = ${txn.extra.category ?? null}
`; `;
if (existing.length > 0) { if (existing.length > 0) {
extraId = existing[0].id; extraId = existing[0].id;
} }
} }
} }
console.log(`Preparing to upsert transaction: ${txn.id} with data:`, txn); console.log(`Preparing to upsert transaction: ${txn.id} with data:`, txn);
await db` await db`
insert into transaction (id, account_id, posted, amount, description, pending, transacted_at) insert into transaction (id, account_id, posted, amount, description, pending, transacted_at, payee)
values ( values (
${txn.id}, ${txn.id},
${account.id}, ${account.id},
@ -316,7 +370,8 @@ export async function updateAccounts(data) {
${txn.amount ?? null}, ${txn.amount ?? null},
${txn.description ?? null}, ${txn.description ?? null},
${txn.pending ?? false}, ${txn.pending ?? false},
${txn.transacted_at ?? 0} ${txn.transacted_at ?? 0},
${txn.payee ?? null}
) )
on conflict (id) do update set on conflict (id) do update set
account_id = excluded.account_id, account_id = excluded.account_id,
@ -324,17 +379,200 @@ export async function updateAccounts(data) {
amount = excluded.amount, amount = excluded.amount,
description = excluded.description, description = excluded.description,
pending = excluded.pending, pending = excluded.pending,
transacted_at = excluded.transacted_at transacted_at = excluded.transacted_at,
payee = excluded.payee
`; `;
} }
} }
} }
return true; return true;
} catch (error) { } catch (error) {
console.error('updateAccounts error:', error); console.error('updateAccounts error:', error);
return false; return false;
} }
} }
export async function getRules(data) {
try {
const rules = await db`
select
id,
name,
description,
payee,
amount,
transdescription,
action,
priority,
action_amount,
account_id,
amount_is_precent,
use_priority
from rules
order by priority asc
`;
return rules;
} catch (error) {
console.error('getRules error:', error);
return [];
}
}
export default db export async function addRule(
name,
description,
payee,
amount,
transdescription,
action,
actionAmount,
account,
amount_is_precent,
priority,
use_priority
) {
try {
const result = await db`
insert into rules (name, description, payee, amount, transdescription, action, action_amount, account_id, amount_is_precent, priority, use_priority)
values (${name}, ${description}, ${payee}, ${amount}, ${transdescription}, ${action}, ${actionAmount}, ${account}, ${amount_is_precent}, ${priority}, ${use_priority})
`;
return result;
} catch (error) {
console.error('addRule error:', error);
return null;
}
}
export async function deleteRule(id) {
try {
const result = await db`
delete from rules
where id = ${id}
`;
return result;
} catch (error) {
console.error('deleteRule error:', error);
return null;
}
}
export async function updateRule(
name,
description,
payee,
amount,
transdescription,
action,
actionAmount,
account,
amount_is_precent,
priority,
use_priority,
id
) {
console.log(`Updating rule with id: ${id}`);
try {
const result = await db`
update rules
set name = ${name},
description = ${description},
payee = ${payee},
amount = ${amount},
transdescription = ${transdescription},
action = ${action},
action_amount = ${actionAmount},
account_id = ${account ?? null},
amount_is_precent = ${amount_is_precent},
priority = ${priority},
use_priority = ${use_priority}
where id = ${id}
`;
return result;
} catch (error) {
console.error('updateRule error:', error);
console.log(`
update rules
set name = ${name},
description = ${description},
payee = ${payee},
amount = ${amount},
transdescription = ${transdescription},
action = ${action},
action_amount = ${actionAmount},
account_id = ${account ?? null},
amount_is_precent = ${amount_is_precent},
use_priority = ${use_priority}
where id = ${id}
`);
return null;
}
}
export async function runRules() {
try {
const rules = await getRules();
let transactions = await db`
select id, account_id, payee, amount, description from transaction
`;
let budgetTransactions = await db`
select id, transaction_id, budget_id, notes, amount, rule_id from budget_transaction
`;
console.log(`Running ${rules.length} rules on ${transactions.length} transactions`);
for (const rule of rules.sort((a, b) => a.priority - b.priority)) {
console.log(
`Running rule: ${rule.name} (${rule.id}, ${rule.payee}, ${rule.amount}, ${rule.transdescription})`
);
console.log(
`Rule: payee: ${rule.payee}, amount: ${rule.amount}, transdescription: ${rule.transdescription}, account: ${rule.account_id}`
);
const amountRE = new RegExp(rule.amount);
const descriptionRE = new RegExp(rule.transdescription);
console.log(rule.account_id);
const accountRE = new RegExp(rule.account_id === null ? '.*' : rule.account_id);
console.log(accountRE);
const payeeRE = new RegExp(rule.payee);
for (const transaction of transactions) {
if (
amountRE.test(transaction.amount) &&
descriptionRE.test(transaction.description) &&
accountRE.test(transaction.account_id) &&
payeeRE.test(transaction.payee)
) {
if (
rule.action &&
!budgetTransactions.some(
(bt) => bt.transaction_id === transaction.id && bt.rule_id === rule.id
)
) {
const amount = rule.amount_is_precent
? (transaction.amount * rule.action_amount) / 100
: rule.action_amount;
const notes = `Rule - ${rule.name}`;
const budgetTransactionId = await addBudgetTransaction(
rule.action,
transaction.id,
amount,
notes,
rule.id
);
await db`
update transaction set processed = true
where id = ${transaction.id}`;
console.log(
`Added budget transaction ${budgetTransactionId} for rule ${rule.name} on transaction ${transaction.id}`
);
}
}
}
}
} catch (error) {
console.error('runRules error:', error);
}
}
export default db;

38
src/lib/editSymbol.svelte Normal file
View File

@ -0,0 +1,38 @@
<script module>
export { EditSymbol };
</script>
{#snippet EditSymbol(onClick)}
<svg
fill="#000000"
version="1.1"
id="Capa_1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="25px"
height="25px"
viewBox="0 0 494.936 494.936"
xml:space="preserve"
>
<g>
<g>
<path
fill="#FEFEFE"
d="M389.844,182.85c-6.743,0-12.21,5.467-12.21,12.21v222.968c0,23.562-19.174,42.735-42.736,42.735H67.157
c-23.562,0-42.736-19.174-42.736-42.735V150.285c0-23.562,19.174-42.735,42.736-42.735h267.741c6.743,0,12.21-5.467,12.21-12.21
s-5.467-12.21-12.21-12.21H67.157C30.126,83.13,0,113.255,0,150.285v267.743c0,37.029,30.126,67.155,67.157,67.155h267.741
c37.03,0,67.156-30.126,67.156-67.155V195.061C402.054,188.318,396.587,182.85,389.844,182.85z"
/>
<path
fill="#FEFEFE"
d="M483.876,20.791c-14.72-14.72-38.669-14.714-53.377,0L221.352,229.944c-0.28,0.28-3.434,3.559-4.251,5.396l-28.963,65.069
c-2.057,4.619-1.056,10.027,2.521,13.6c2.337,2.336,5.461,3.576,8.639,3.576c1.675,0,3.362-0.346,4.96-1.057l65.07-28.963
c1.83-0.815,5.114-3.97,5.396-4.25L483.876,74.169c7.131-7.131,11.06-16.61,11.06-26.692
C494.936,37.396,491.007,27.915,483.876,20.791z M466.61,56.897L257.457,266.05c-0.035,0.036-0.055,0.078-0.089,0.107
l-33.989,15.131L238.51,247.3c0.03-0.036,0.071-0.055,0.107-0.09L447.765,38.058c5.038-5.039,13.819-5.033,18.846,0.005
c2.518,2.51,3.905,5.855,3.905,9.414C470.516,51.036,469.127,54.38,466.61,56.897z"
/>
</g>
</g>
</svg>
{/snippet}

View File

@ -0,0 +1,13 @@
<script module>
export { loadingModal };
</script>
{#snippet loadingModal()}
<dialog id="my_modal_1" class="modal modal-open">
<div class="modal-box">
<div class="flex items-center justify-center">
<span class="loading loading-spinner loading-xl"></span>
</div>
</div>
</dialog>
{/snippet}

View File

@ -0,0 +1,51 @@
<script module>
export { settingsSymbol };
</script>
{#snippet settingsSymbol()}
<svg
fill="#000000"
height="20px"
width="20px"
version="1.1"
id="Capa_1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 478.703 478.703"
xml:space="preserve"
>
<g>
<g>
<path
fill="#FEFEFE"
d="M454.2,189.101l-33.6-5.7c-3.5-11.3-8-22.2-13.5-32.6l19.8-27.7c8.4-11.8,7.1-27.9-3.2-38.1l-29.8-29.8
c-5.6-5.6-13-8.7-20.9-8.7c-6.2,0-12.1,1.9-17.1,5.5l-27.8,19.8c-10.8-5.7-22.1-10.4-33.8-13.9l-5.6-33.2
c-2.4-14.3-14.7-24.7-29.2-24.7h-42.1c-14.5,0-26.8,10.4-29.2,24.7l-5.8,34c-11.2,3.5-22.1,8.1-32.5,13.7l-27.5-19.8
c-5-3.6-11-5.5-17.2-5.5c-7.9,0-15.4,3.1-20.9,8.7l-29.9,29.8c-10.2,10.2-11.6,26.3-3.2,38.1l20,28.1
c-5.5,10.5-9.9,21.4-13.3,32.7l-33.2,5.6c-14.3,2.4-24.7,14.7-24.7,29.2v42.1c0,14.5,10.4,26.8,24.7,29.2l34,5.8
c3.5,11.2,8.1,22.1,13.7,32.5l-19.7,27.4c-8.4,11.8-7.1,27.9,3.2,38.1l29.8,29.8c5.6,5.6,13,8.7,20.9,8.7c6.2,0,12.1-1.9,17.1-5.5
l28.1-20c10.1,5.3,20.7,9.6,31.6,13l5.6,33.6c2.4,14.3,14.7,24.7,29.2,24.7h42.2c14.5,0,26.8-10.4,29.2-24.7l5.7-33.6
c11.3-3.5,22.2-8,32.6-13.5l27.7,19.8c5,3.6,11,5.5,17.2,5.5l0,0c7.9,0,15.3-3.1,20.9-8.7l29.8-29.8c10.2-10.2,11.6-26.3,3.2-38.1
l-19.8-27.8c5.5-10.5,10.1-21.4,13.5-32.6l33.6-5.6c14.3-2.4,24.7-14.7,24.7-29.2v-42.1
C478.9,203.801,468.5,191.501,454.2,189.101z M451.9,260.401c0,1.3-0.9,2.4-2.2,2.6l-42,7c-5.3,0.9-9.5,4.8-10.8,9.9
c-3.8,14.7-9.6,28.8-17.4,41.9c-2.7,4.6-2.5,10.3,0.6,14.7l24.7,34.8c0.7,1,0.6,2.5-0.3,3.4l-29.8,29.8c-0.7,0.7-1.4,0.8-1.9,0.8
c-0.6,0-1.1-0.2-1.5-0.5l-34.7-24.7c-4.3-3.1-10.1-3.3-14.7-0.6c-13.1,7.8-27.2,13.6-41.9,17.4c-5.2,1.3-9.1,5.6-9.9,10.8l-7.1,42
c-0.2,1.3-1.3,2.2-2.6,2.2h-42.1c-1.3,0-2.4-0.9-2.6-2.2l-7-42c-0.9-5.3-4.8-9.5-9.9-10.8c-14.3-3.7-28.1-9.4-41-16.8
c-2.1-1.2-4.5-1.8-6.8-1.8c-2.7,0-5.5,0.8-7.8,2.5l-35,24.9c-0.5,0.3-1,0.5-1.5,0.5c-0.4,0-1.2-0.1-1.9-0.8l-29.8-29.8
c-0.9-0.9-1-2.3-0.3-3.4l24.6-34.5c3.1-4.4,3.3-10.2,0.6-14.8c-7.8-13-13.8-27.1-17.6-41.8c-1.4-5.1-5.6-9-10.8-9.9l-42.3-7.2
c-1.3-0.2-2.2-1.3-2.2-2.6v-42.1c0-1.3,0.9-2.4,2.2-2.6l41.7-7c5.3-0.9,9.6-4.8,10.9-10c3.7-14.7,9.4-28.9,17.1-42
c2.7-4.6,2.4-10.3-0.7-14.6l-24.9-35c-0.7-1-0.6-2.5,0.3-3.4l29.8-29.8c0.7-0.7,1.4-0.8,1.9-0.8c0.6,0,1.1,0.2,1.5,0.5l34.5,24.6
c4.4,3.1,10.2,3.3,14.8,0.6c13-7.8,27.1-13.8,41.8-17.6c5.1-1.4,9-5.6,9.9-10.8l7.2-42.3c0.2-1.3,1.3-2.2,2.6-2.2h42.1
c1.3,0,2.4,0.9,2.6,2.2l7,41.7c0.9,5.3,4.8,9.6,10,10.9c15.1,3.8,29.5,9.7,42.9,17.6c4.6,2.7,10.3,2.5,14.7-0.6l34.5-24.8
c0.5-0.3,1-0.5,1.5-0.5c0.4,0,1.2,0.1,1.9,0.8l29.8,29.8c0.9,0.9,1,2.3,0.3,3.4l-24.7,34.7c-3.1,4.3-3.3,10.1-0.6,14.7
c7.8,13.1,13.6,27.2,17.4,41.9c1.3,5.2,5.6,9.1,10.8,9.9l42,7.1c1.3,0.2,2.2,1.3,2.2,2.6v42.1H451.9z"
/>
<path
fill="#FEFEFE"
d="M239.4,136.001c-57,0-103.3,46.3-103.3,103.3s46.3,103.3,103.3,103.3s103.3-46.3,103.3-103.3S296.4,136.001,239.4,136.001
z M239.4,315.601c-42.1,0-76.3-34.2-76.3-76.3s34.2-76.3,76.3-76.3s76.3,34.2,76.3,76.3S281.5,315.601,239.4,315.601z"
/>
</g>
</g>
</svg>
{/snippet}

View File

@ -1,14 +1,17 @@
const url =
'https://19443E0E8171E175EC5DA0C69B35DD50197F234B9A74C00D27FD606121257ECF:DAA3702E2100CFFD3B544251E6D755E86B1EDDFBFCC7F6FA9CE77AB3677E60DE@beta-bridge.simplefin.org/simplefin';
export async function fetchAccounts(url, startDate) { export async function fetchAccounts(startDate) {
const { username, password, origin, pathname } = new URL(url); const { username, password, origin, pathname } = new URL(url);
const start = Math.floor(startDate.getTime() / 1000); const apiUrl = `${origin}${pathname}/accounts?start-date=${startDate}`;
const apiUrl = `${origin}${pathname}/accounts?start-date=${start}`; const headers = {};
const headers = {};
if (username && password) { console.log(`Fetching accounts from: ${apiUrl}`);
headers['Authorization'] = 'Basic ' + btoa(`${username}:${password}`);
}
const response = await fetch(apiUrl, { headers }); if (username && password) {
return await response.json(); headers['Authorization'] = 'Basic ' + btoa(`${username}:${password}`);
}
const response = await fetch(apiUrl, { headers });
return await response.json();
} }

18
src/lib/trashbin.svelte Normal file
View File

@ -0,0 +1,18 @@
<script module>
export { TrashBin };
</script>
{#snippet TrashBin()}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 30 30"
width="30px"
height="30px"
class="cursor-pointer hover:scale-110 transition"
>
<path
fill="#FEFEFE"
d="M 14.984375 2.4863281 A 1.0001 1.0001 0 0 0 14 3.5 L 14 4 L 8.5 4 A 1.0001 1.0001 0 0 0 7.4863281 5 L 6 5 A 1.0001 1.0001 0 1 0 6 7 L 24 7 A 1.0001 1.0001 0 1 0 24 5 L 22.513672 5 A 1.0001 1.0001 0 0 0 21.5 4 L 16 4 L 16 3.5 A 1.0001 1.0001 0 0 0 14.984375 2.4863281 z M 6 9 L 7.7929688 24.234375 C 7.9109687 25.241375 8.7633438 26 9.7773438 26 L 20.222656 26 C 21.236656 26 22.088031 25.241375 22.207031 24.234375 L 24 9 L 6 9 z"
/>
</svg>
{/snippet}

View File

@ -1,9 +1,10 @@
import { getAccounts, getBudgets } from "$lib/db"; import { getAccounts, getBudgets, getTotal } from '$lib/db';
import { get } from "svelte/store"; import { get } from 'svelte/store';
export async function load({ params }) { export async function load({ params }) {
let accounts = await getAccounts(); let accounts = await getAccounts();
let budgets = await getBudgets(); let budgets = await getBudgets();
let total = await getTotal();
return { accounts, budgets }; return { accounts, budgets, total };
} }

View File

@ -2,72 +2,55 @@
import '../app.css'; import '../app.css';
let { children, data } = $props(); let { children, data } = $props();
let budgets = $derived(data.budgets); let budgets = $derived(data.budgets);
let newBudget = $state({ let total = $derived(data.total);
name: '',
amount: 0,
notes: ''
});
async function saveBudget() {
let res = await fetch('/budget', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(newBudget)
});
if (res.ok) {
AddBudgetModal.close();
budgets.push({
id: res.text(), // Temporary ID, replace with actual ID from the server
...newBudget
});
newBudget = { name: '', amount: 0, notes: '' }; // Reset the form
// Optionally, you can refresh the budgets list or show a success message
} else {
console.error('Failed to save budget');
}
}
</script> </script>
<div class="drawer lg:drawer-open"> <div class="drawer lg:drawer-open">
<input id="my-drawer-2" type="checkbox" class="drawer-toggle" /> <input id="my-drawer-2" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col m-5"> <div class="drawer-content flex flex-col m-5">
<!-- Page content here --> <div class="navbar bg-base-100 shadow-sm">
<div class="flex-none">
<label
for="my-drawer-2"
class="btn btn-primary drawer-button lg:hidden btn-square btn-ghost"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block h-5 w-5 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
></path>
</svg>
</label>
</div>
<div class="flex-1">
<a class="btn btn-ghost text-xl">Timm Budget</a>
</div>
</div>
{@render children()} {@render children()}
<label for="my-drawer-2" class="btn btn-primary drawer-button lg:hidden"> Open drawer </label>
</div> </div>
<div class="drawer-side"> <div class="drawer-side">
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label> <label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
<ul class="menu bg-base-200 text-base-content min-h-full w-80 p-4"> <ul class="menu bg-base-200 text-base-content min-h-full w-80 p-4">
<li>
<a href="/">
<span class="text-lg font-bold">Total: {total}</span>
</a>
</li>
<li><div class="divider">Budgets</div></li> <li><div class="divider">Budgets</div></li>
{#each budgets as budget} {#each budgets as budget}
<li> <li>
<a href={`/budget/${budget.id}`}> <a href={`/budget/${budget.id}`}>
{budget.name} ({budget.amount}) {budget.name} ({budget.sum})
</a> </a>
</li> </li>
{/each} {/each}
<li>
<button class="btn btn-circle" aria-label="Add" onclick={() => AddBudgetModal.showModal()}>
<svg
fill="#F0F0F0F0"
height="800px"
width="800px"
version="1.1"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 500 500"
enable-background="new 0 0 500 500"
xml:space="preserve"
>
<path
d="M306,192h-48v-48c0-4.4-3.6-8-8-8s-8,3.6-8,8v48h-48c-4.4,0-8,3.6-8,8s3.6,8,8,8h48v48c0,4.4,3.6,8,8,8s8-3.6,8-8v-48h48
c4.4,0,8-3.6,8-8S310.4,192,306,192z"
/>
</svg>
</button>
</li>
<li><div class="divider">Accounts</div></li> <li><div class="divider">Accounts</div></li>
{#each data.accounts as account} {#each data.accounts as account}
<li> <li>
@ -77,31 +60,8 @@
</li> </li>
{/each} {/each}
<li><div class="divider"></div></li> <li><div class="divider"></div></li>
<li><a href="/settings">Settings</a></li> <li><a href="/rules">Rules</a></li>
<li><a href="/settings">Settings</a></li>
</ul> </ul>
</div> </div>
</div> </div>
<dialog id="AddBudgetModal" class="modal">
<div class="modal-box">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<div>
<input
bind:value={newBudget.name}
type="text"
placeholder="Budget Name"
class="input input-bordered w-full max-w-xs"
/>
<input
bind:value={newBudget.amount}
type="number"
placeholder="Budget Amount"
class="input input-bordered w-full max-w-xs mt-2"
/>
<textarea bind:value={newBudget.notes} class="textarea w-100"></textarea>
</div>
<button onclick={() => saveBudget()} class="btn btn-primary mt-4">Save</button>
</div>
</dialog>

View File

@ -1,15 +1,17 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { getAccount, getTransactions } from '$lib/db'; import { getAccount, getTransactions, getBudgets, getBudgetTransactionsForAccount } from '$lib/db';
/** @type {import('./$types').PageServerLoad} */ /** @type {import('./$types').PageServerLoad} */
export async function load({ params }) { export async function load({ params }) {
const transactions = await getTransactions(params.slug); const slug = params.slug;
const account = await getAccount(params.slug); const transactions = await getTransactions(slug);
const slug = params.slug; const account = await getAccount(slug);
const budgets = await getBudgets();
const budgetTransactions = await getBudgetTransactionsForAccount(slug);
if (transactions) { if (transactions) {
return {transactions, account}; return { transactions, account, budgets, slug, budgetTransactions };
} }
error(404, 'Not found'); error(404, 'Not found');
} }

View File

@ -1,10 +1,13 @@
<script> <script>
import { EditSymbol } from '$lib/editSymbol.svelte';
import { settingsSymbol } from '$lib/settingsSymbol.svelte';
let { data } = $props(); let { data } = $props();
console.log(data);
let trans = $derived(data.transactions); let trans = $derived(data.transactions);
let budgets = $derived(data.budgets);
let budgetTransactions = $derived(data.budgetTransactions);
let notes = $state(''); let notes = $state('');
let currentTransaction = $state(null); let currentTransaction = $state({ budget_id: null, amount: 0, notes: '' });
let account = $derived(data.account); let account = $derived(data.account);
let hide = $derived(account?.hide || false); let hide = $derived(account?.hide || false);
let inTotal = $derived(account?.in_total || false); let inTotal = $derived(account?.in_total || false);
@ -32,61 +35,34 @@
console.error('Failed to save notes'); console.error('Failed to save notes');
} }
} }
async function saveSettings() { async function saveSettings() {
let res = await fetch(`/api/account/${account.id}`, { let res = await fetch(`/api/account/${account.id}`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
hide: $state.snapshot(hide), hide: $state.snapshot(hide),
in_total: $state.snapshot(inTotal) in_total: $state.snapshot(inTotal)
}) })
}); });
if (res.ok) { if (res.ok) {
settings_modal.close(); settings_modal.close();
// Optionally, you can refresh the account data or show a success message // Optionally, you can refresh the account data or show a success message
} else { } else {
console.error('Failed to save settings'); console.error('Failed to save settings');
} }
} }
</script> </script>
<div class="flex mb-4"> <div class="flex mb-4">
<div class="w-128 flex-none justify-bottom"><h1 class="text-xl font-bold">{account?.name}</h1></div> <div class="w-128 flex-none justify-bottom">
<div class="w-64 grow">{account?.balance}</div> <h1 class="text-xl font-bold">{account?.name}</h1>
<div class="w-14 flex-none text-right"> </div>
<svg onclick={()=>settings_modal.showModal()} fill="#000000" height="20px" width="20px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" <div class="w-64 grow">{account?.balance}</div>
viewBox="0 0 478.703 478.703" xml:space="preserve"> <div class="w-14 flex-none text-right">
<g> <button class="btn btn-square btn-ghost">{@render settingsSymbol()} </button>
<g> </div>
<path fill="#FEFEFE" d="M454.2,189.101l-33.6-5.7c-3.5-11.3-8-22.2-13.5-32.6l19.8-27.7c8.4-11.8,7.1-27.9-3.2-38.1l-29.8-29.8
c-5.6-5.6-13-8.7-20.9-8.7c-6.2,0-12.1,1.9-17.1,5.5l-27.8,19.8c-10.8-5.7-22.1-10.4-33.8-13.9l-5.6-33.2
c-2.4-14.3-14.7-24.7-29.2-24.7h-42.1c-14.5,0-26.8,10.4-29.2,24.7l-5.8,34c-11.2,3.5-22.1,8.1-32.5,13.7l-27.5-19.8
c-5-3.6-11-5.5-17.2-5.5c-7.9,0-15.4,3.1-20.9,8.7l-29.9,29.8c-10.2,10.2-11.6,26.3-3.2,38.1l20,28.1
c-5.5,10.5-9.9,21.4-13.3,32.7l-33.2,5.6c-14.3,2.4-24.7,14.7-24.7,29.2v42.1c0,14.5,10.4,26.8,24.7,29.2l34,5.8
c3.5,11.2,8.1,22.1,13.7,32.5l-19.7,27.4c-8.4,11.8-7.1,27.9,3.2,38.1l29.8,29.8c5.6,5.6,13,8.7,20.9,8.7c6.2,0,12.1-1.9,17.1-5.5
l28.1-20c10.1,5.3,20.7,9.6,31.6,13l5.6,33.6c2.4,14.3,14.7,24.7,29.2,24.7h42.2c14.5,0,26.8-10.4,29.2-24.7l5.7-33.6
c11.3-3.5,22.2-8,32.6-13.5l27.7,19.8c5,3.6,11,5.5,17.2,5.5l0,0c7.9,0,15.3-3.1,20.9-8.7l29.8-29.8c10.2-10.2,11.6-26.3,3.2-38.1
l-19.8-27.8c5.5-10.5,10.1-21.4,13.5-32.6l33.6-5.6c14.3-2.4,24.7-14.7,24.7-29.2v-42.1
C478.9,203.801,468.5,191.501,454.2,189.101z M451.9,260.401c0,1.3-0.9,2.4-2.2,2.6l-42,7c-5.3,0.9-9.5,4.8-10.8,9.9
c-3.8,14.7-9.6,28.8-17.4,41.9c-2.7,4.6-2.5,10.3,0.6,14.7l24.7,34.8c0.7,1,0.6,2.5-0.3,3.4l-29.8,29.8c-0.7,0.7-1.4,0.8-1.9,0.8
c-0.6,0-1.1-0.2-1.5-0.5l-34.7-24.7c-4.3-3.1-10.1-3.3-14.7-0.6c-13.1,7.8-27.2,13.6-41.9,17.4c-5.2,1.3-9.1,5.6-9.9,10.8l-7.1,42
c-0.2,1.3-1.3,2.2-2.6,2.2h-42.1c-1.3,0-2.4-0.9-2.6-2.2l-7-42c-0.9-5.3-4.8-9.5-9.9-10.8c-14.3-3.7-28.1-9.4-41-16.8
c-2.1-1.2-4.5-1.8-6.8-1.8c-2.7,0-5.5,0.8-7.8,2.5l-35,24.9c-0.5,0.3-1,0.5-1.5,0.5c-0.4,0-1.2-0.1-1.9-0.8l-29.8-29.8
c-0.9-0.9-1-2.3-0.3-3.4l24.6-34.5c3.1-4.4,3.3-10.2,0.6-14.8c-7.8-13-13.8-27.1-17.6-41.8c-1.4-5.1-5.6-9-10.8-9.9l-42.3-7.2
c-1.3-0.2-2.2-1.3-2.2-2.6v-42.1c0-1.3,0.9-2.4,2.2-2.6l41.7-7c5.3-0.9,9.6-4.8,10.9-10c3.7-14.7,9.4-28.9,17.1-42
c2.7-4.6,2.4-10.3-0.7-14.6l-24.9-35c-0.7-1-0.6-2.5,0.3-3.4l29.8-29.8c0.7-0.7,1.4-0.8,1.9-0.8c0.6,0,1.1,0.2,1.5,0.5l34.5,24.6
c4.4,3.1,10.2,3.3,14.8,0.6c13-7.8,27.1-13.8,41.8-17.6c5.1-1.4,9-5.6,9.9-10.8l7.2-42.3c0.2-1.3,1.3-2.2,2.6-2.2h42.1
c1.3,0,2.4,0.9,2.6,2.2l7,41.7c0.9,5.3,4.8,9.6,10,10.9c15.1,3.8,29.5,9.7,42.9,17.6c4.6,2.7,10.3,2.5,14.7-0.6l34.5-24.8
c0.5-0.3,1-0.5,1.5-0.5c0.4,0,1.2,0.1,1.9,0.8l29.8,29.8c0.9,0.9,1,2.3,0.3,3.4l-24.7,34.7c-3.1,4.3-3.3,10.1-0.6,14.7
c7.8,13.1,13.6,27.2,17.4,41.9c1.3,5.2,5.6,9.1,10.8,9.9l42,7.1c1.3,0.2,2.2,1.3,2.2,2.6v42.1H451.9z"/>
<path fill="#FEFEFE" d="M239.4,136.001c-57,0-103.3,46.3-103.3,103.3s46.3,103.3,103.3,103.3s103.3-46.3,103.3-103.3S296.4,136.001,239.4,136.001
z M239.4,315.601c-42.1,0-76.3-34.2-76.3-76.3s34.2-76.3,76.3-76.3s76.3,34.2,76.3,76.3S281.5,315.601,239.4,315.601z"/>
</g>
</g>
</svg>
</div>
</div> </div>
<h1>Transcations</h1> <h1>Transcations</h1>
@ -94,18 +70,55 @@
<thead> <thead>
<tr> <tr>
<th>Date</th> <th>Date</th>
<th>Payee</th>
<th>Description</th> <th>Description</th>
<th>Amount</th> <th>Amount</th>
<th>Notes</th> <th>Notes</th>
<th>Budgets</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each trans as transaction} {#each trans as transaction}
<tr class="hover:bg-base-300" onclick={() => editNotes(transaction)}> {@const applicableBudgets = budgetTransactions.filter(
(bt) => bt.transaction_id === transaction.id
)}
{@const budgetTotal = applicableBudgets.reduce(
(accumulator, currentValue) => accumulator + Number(currentValue.amount),
0
)}
<tr class="hover:bg-base-300">
<td>{transaction.date.toDateString()}</td> <td>{transaction.date.toDateString()}</td>
<td>{transaction.payee ?? ''}</td>
<td>{transaction.description}</td> <td>{transaction.description}</td>
<td>{transaction.amount}</td> <td
><ul class="list bg-base-100 rounded-box shadow-md">
<li class="list-row">Amount: {transaction.amount}</li>
<li class="list-row">Budget: {budgetTotal.toFixed(2)}</li>
<li class="list-row">Remains: {(transaction.amount - budgetTotal).toFixed(2)}</li>
</ul></td
>
<td>{transaction.notes}</td> <td>{transaction.notes}</td>
<td>
<ul class="list bg-base-100 rounded-box shadow-md">
{#each applicableBudgets as budgetTransaction}
<li class="list-row">
<div class="flex">
<div class="flex-auto w-24">
{budgets.find((b) => b.id === budgetTransaction.budget_id)?.name}
</div>
<div class="flex-auto w-16">${budgetTransaction.amount}</div>
<div class="flex-auto w-48">{budgetTransaction.notes}</div>
</div>
</li>
{/each}
</ul></td
>
<td
><button class="btn btn-square btn-ghost" onclick={() => editNotes(transaction)}
>{@render EditSymbol()}</button
></td
>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
@ -116,14 +129,66 @@
<form method="dialog"> <form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button> <button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form> </form>
<h3>{currentTransaction?.description}</h3> <fieldset class="fieldset">
<h4>${currentTransaction?.amount}</h4> <p class="label">{currentTransaction?.description}</p>
<p>{currentTransaction?.date?.toDateString()}</p> <p class="label">${currentTransaction?.amount}</p>
<div> <p class="label">{currentTransaction?.date?.toDateString()}</p>
<legend class="fieldset-legend">Notes</legend>
<textarea bind:value={notes} class="textarea w-100"></textarea> <textarea bind:value={notes} class="textarea w-100"></textarea>
</div>
<button onclick={() => saveNotes()} class="btn btn-primary mt-4">Save</button> <button class="btn btn-neutral" onclick={() => saveNotes()}>Save</button>
<legend class="fieldset-legend">Add to budget</legend>
<select bind:value={currentTransaction.budget_id} class="select">
<option disabled selected>Pick a budget</option>
{#each budgets as budget}
<option value={budget.id}>{budget.name} - {budget.sum}</option>
{/each}
</select>
<legend class="fieldset-legend">Amount</legend>
<input
bind:value={currentTransaction.amount}
type="number"
class="input validator"
required
placeholder="Amount"
title="Amount"
/>
<legend class="fieldset-legend">Notes</legend>
<textarea bind:value={currentTransaction.notes} class="textarea w-100"></textarea>
<p class="validator-hint">Must a sensible number</p>
<button
class="btn btn-primary"
onclick={() => {
if (budget_id) {
fetch(`/api/budget/${budget_id}/transaction`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
transactionId: currentTransaction.id,
amount: currentTransaction.amount,
notes: currentTransaction.notes
})
}).then((res) => {
if (res.ok) {
// Optionally, you can refresh the UI or show a success message
console.log('Transaction added to budget successfully');
} else {
console.error('Failed to add transaction to budget');
}
});
} else {
console.error('No budget selected');
}
}}>Add to Budget</button
>
</fieldset>
</div> </div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog> </dialog>
<dialog id="settings_modal" class="modal"> <dialog id="settings_modal" class="modal">
@ -138,11 +203,14 @@
<input type="checkbox" bind:checked={hide} class="toggle" /> <input type="checkbox" bind:checked={hide} class="toggle" />
Hide Hide
</label> </label>
<label class="label"> <label class="label">
<input type="checkbox" bind:checked={inTotal} class="toggle" /> <input type="checkbox" bind:checked={inTotal} class="toggle" />
Use in total Use in total
</label> </label>
</fieldset> </fieldset>
<button onclick={() => saveSettings()} class="btn btn-primary mt-4">Save</button> <button onclick={() => saveSettings()} class="btn btn-primary mt-4">Save</button>
</div> </div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog> </dialog>

View File

@ -1,4 +1,4 @@
import { deleteBudget } from '$lib/db.js'; import { deleteBudget, restoreBudget } from '$lib/db.js';
export function DELETE({ params }) { export function DELETE({ params }) {
const { slug } = params; const { slug } = params;
@ -9,3 +9,11 @@ export function DELETE({ params }) {
.then(() => new Response(`Budget with slug ${slug} deleted successfully.`)) .then(() => new Response(`Budget with slug ${slug} deleted successfully.`))
.catch(err => new Response(`Error deleting budget: ${err.message}`, { status: 500 })); .catch(err => new Response(`Error deleting budget: ${err.message}`, { status: 500 }));
} }
export async function PUT({ params, request }) {
const { slug } = params;
restoreBudget(slug)
.then(() => new Response(`Budget with slug ${slug} restored successfully.`))
.catch(err => new Response(`Error restoring budget: ${err.message}`, { status: 500 }));
}

View File

@ -0,0 +1,46 @@
import { addBudgetTransaction, updateBudgetTransaction, deleteBudgetTransaction } from '$lib/db.js';
export async function POST({ params, request }) {
const { slug } = params;
let body = await request.json();
const { transactionId, amount, notes } = body;
console.log({ slug, transactionId, amount });
// Call the deleteBudget function from db.js (budgetId, transactionId, amount)
return addBudgetTransaction(slug, transactionId, amount, notes)
.then(() => new Response(`Budget transaction added successfully`, { status: 200 }))
.catch(
(err) => new Response(`Error adding transaction to budget ${err.message}`, { status: 500 })
);
}
export async function PATCH({ params, request }) {
const { slug } = params;
let body = await request.json();
const { amount, notes } = body;
console.log({ slug, transactionId, amount });
// Call the deleteBudget function from db.js (budgetId, transactionId, amount)
return updateBudgetTransaction(slug, amount, notes)
.then(() => new Response(`Budget transaction updated successfully`, { status: 200 }))
.catch(
(err) => new Response(`Error updating transaction in budget ${err.message}`, { status: 500 })
);
}
export async function DELETE({ params, request }) {
const { slug } = params;
const { transactionId } = slug;
console.log({ slug });
// Call the deleteBudget function from db.js (budgetId, transactionId)
return deleteBudgetTransaction(slug)
.then(() => new Response(`Budget transaction deleted successfully`, { status: 200 }))
.catch(
(err) =>
new Response(`Error deleting transaction from budget ${err.message}`, { status: 500 })
);
}

View File

@ -0,0 +1,19 @@
import { addRule, runRules } from '$lib/db.js';
export async function PUT({ params, request }) {
const { name, description, payee, amount, transdescription, action, actionAmount, account_id, amount_is_precent, priority, use_priority} = await request.json();
return addRule(name, description, payee, amount, transdescription, action, actionAmount, account_id, amount_is_precent, priority, use_priority)
.then(() => new Response(`Rule ${name} created successfully.`))
.catch(err => new Response(`Error creating rule: ${err.message}`, { status: 500 }));
}
export async function POST({ params, request }) {
return runRules()
.then(() => new Response(`Rules executed successfully.`))
}

View File

@ -0,0 +1,21 @@
import { updateRule, deleteRule } from '$lib/db.js';
export async function PATCH({ params, request }) {
const { slug } = params;
const { name, description, payee, amount, transdescription, action, actionAmount, account_id, amount_is_precent, priority, use_priority} = await request.json();
console.log(`Updating rule for with slug: ${slug}`);
return updateRule(name, description, payee, amount, transdescription, action, actionAmount, account_id, amount_is_precent, priority, use_priority, slug)
.then(() => new Response(`Rule with slug ${slug} updated successfully.`))
.catch(err => new Response(`Error updating rule: ${err.message}`, { status: 500 }));
}
export async function DELETE({ params }) {
const { slug } = params;
return deleteRule(slug)
.then(() => new Response(`Deleted rule with slug ${slug}.`))
.catch(err => new Response(`Error deleting rule: ${err.message}`, { status: 500 }));
}

View File

@ -1,10 +1,17 @@
import { fetchAccounts } from '$lib/simplefin'; import { fetchAccounts } from '$lib/simplefin';
import { updateAccounts } from '$lib/db' ; import { runRules, updateAccounts } from '$lib/db';
const url = "https://19443E0E8171E175EC5DA0C69B35DD50197F234B9A74C00D27FD606121257ECF:DAA3702E2100CFFD3B544251E6D755E86B1EDDFBFCC7F6FA9CE77AB3677E60DE@beta-bridge.simplefin.org/simplefin"; export async function POST({ request }) {
let body = null;
try {
let body = await request.json();
} catch (error) {}
const startDate = body?.startDate
? body
: Math.floor(new Date(new Date().getFullYear(), new Date().getMonth() - 1).getTime() / 1000);
export async function POST() { const res = await fetchAccounts(startDate);
const res = await fetchAccounts(url, new Date("2025-07-03")) await updateAccounts(res);
return new Response(await updateAccounts(res)); await runRules();
return new Response(`Accounts updated successfully`, { status: 200 });
} }

View File

@ -1,12 +1,13 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { getBudgetTransactions } from '$lib/db.js'; import { getBudget, getBudgetTransactions } from '$lib/db.js';
export async function load({ params }) { export async function load({ params }) {
const { slug } = params; const { slug } = params;
console.log(`Loading transactions for budget: ${slug}`); console.log(`Loading transactions for budget: ${slug}`);
const transactions = await getBudgetTransactions(slug); const transactions = await getBudgetTransactions(slug);
const budget = await getBudget(slug);
return { transactions, slug }; return { transactions, slug, budget };
} }

View File

@ -1,91 +1,193 @@
<script> <script>
import { EditSymbol } from '$lib/editSymbol.svelte';
import { TrashBin } from '$lib/trashbin.svelte';
let { data } = $props(); let { data } = $props();
let budget = $derived(data.budgets.find((b) => b.id == data.slug) || {}); let budget = $derived(data.budget);
let transactions = $derived(data.transactions || []); let transactions = $derived(data.transactions.transactions || []);
let toDeleteBudget = $state(null); let newData = $state({
amount: 0,
notes: ''
});
let toDelete = $state(null);
let toDeleteName = $state('');
async function deleteBudget() { async function saveTransaction() {
if (toDeleteBudget === budget.name) { let res = await fetch(`/api/budget/${budget.id}/transaction`, {
let res = await fetch(`/api/budget/${budget.id}`, { method: 'PATCH',
method: 'DELETE', headers: {
headers: { 'Content-Type': 'application/json'
'Content-Type': 'application/json' },
} body: JSON.stringify({
}); amount: $state.snapshot(newData.amount),
if (res.ok) { notes: $state.snapshot(newData.notes)
window.location.href = '/'; // Redirect to budgets list after deletion })
} else { });
console.error('Failed to delete budget'); }
}
} else {
alert('Please type the budget name correctly to confirm deletion.');
}
}
function edit(transaction) {
newData.amount = transaction.amount;
newData.notes = transaction.notes || '';
EditBudgetTransactionModal.showModal();
}
async function restoreBudget() {
let res = await fetch(`/api/budget/${budget.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ delete: false })
});
}
function deleteTransaction(transaction) {
toDelete = transaction;
DeleteTransactionModal.showModal();
}
</script> </script>
{#if budget.delete}
<div role="alert" class="alert alert-error" onclick={() => RestoreModal.showModal()}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>This budget has been deleted</span>
</div>
{/if}
<div class="flex mb-4"> <div class="flex mb-4">
<div class="w-32 flex-none justify-bottom"><h1 class="text-2xl font-bold">{budget.name}</h1></div> <div class="w-32 flex-none justify-bottom"><h1 class="text-2xl font-bold">{budget.name}</h1></div>
<div class="w-64 grow">{budget.amount}</div> <div class="w-64 grow">{budget.amount}</div>
<div class="w-14 flex-none text-right">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 30 30"
width="30px"
height="30px"
class="cursor-pointer hover:scale-110 transition"
onclick={()=> DeleteBudgetModal.showModal()}
>
<path fill="#FEFEFE" d="M 14.984375 2.4863281 A 1.0001 1.0001 0 0 0 14 3.5 L 14 4 L 8.5 4 A 1.0001 1.0001 0 0 0 7.4863281 5 L 6 5 A 1.0001 1.0001 0 1 0 6 7 L 24 7 A 1.0001 1.0001 0 1 0 24 5 L 22.513672 5 A 1.0001 1.0001 0 0 0 21.5 4 L 16 4 L 16 3.5 A 1.0001 1.0001 0 0 0 14.984375 2.4863281 z M 6 9 L 7.7929688 24.234375 C 7.9109687 25.241375 8.7633438 26 9.7773438 26 L 20.222656 26 C 21.236656 26 22.088031 25.241375 22.207031 24.234375 L 24 9 L 6 9 z"/>
</svg>
</div>
</div> </div>
<div class="">
<div class="mb-4"> <h2>Notes:</h2>
<p class="text-sm">Notes: {budget.notes}</p> <p>{budget.notes}</p>
</div> </div>
<table class="min-w-full border border-gray-300"> <table class="overflow-x-auto rounded-box border border-base-content/5 bg-base-100">
<thead> <thead>
<tr class=""> <tr class="">
<th class="px-4 py-2 border-b">Date</th> <th>Date</th>
<th class="px-4 py-2 border-b">Description</th> <th>Description</th>
<th class="px-4 py-2 border-b">Amount</th> <th>Amount</th>
</tr> <th>Notes</th>
</thead> <th></th>
<tbody> </tr>
{#each transactions as txn} </thead>
<tr> <tbody>
<td class="px-4 py-2 border-b">{txn.date}</td> {#each transactions as txn}
<td class="px-4 py-2 border-b">{txn.description}</td> <tr>
<td class="px-4 py-2 border-b">${txn.amount}</td> <td>{txn.date.toDateString()}</td>
</tr> <td>{txn.description}</td>
{/each} <td>${txn.budget_amount}</td>
{#if transactions.length === 0} <td>
<tr> {#if txn.notes}
<td class="px-4 py-2 border-b text-center" colspan="3">No transactions found.</td> <span>{txn.notes}</span>
</tr> {:else}
{/if} <span class="text-gray-500">No notes</span>
</tbody> {/if}
</td>
<td
><button class="btn btn-square btn-ghost" onclick={() => edit(txn)}
>{@render EditSymbol()}</button
></td
><td
><button class="btn btn-square btn-ghost" onclick={() => deleteTransaction(txn)}
>{@render TrashBin()}</button
></td
></tr
>
{/each}
{#if transactions.length === 0}
<tr>
<td colspan="3">No transactions found.</td>
</tr>
{/if}
</tbody>
</table> </table>
<dialog id="EditBudgetTransactionModal" class="modal">
<dialog id="DeleteBudgetModal" class="modal">
<div class="modal-box"> <div class="modal-box">
<form method="dialog"> <fieldset class="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button> <legend class="fieldset-legend">Edit</legend>
</form>
<p>Are you sure you want to delete <code>{budget.name}</code>? type its name in the box below if so.</p> <label class="label">Amount</label>
<div>
<input <input
bind:value={toDeleteBudget} bind:value={newData.amount}
type="text" type="text"
placeholder="Budget Name" placeholder="Amount"
class="input input-bordered w-full max-w-xs" class="input input-bordered w-full max-w-xs"
/> />
</div>
<button onclick={()=>deleteBudget()} class="btn btn-primary mt-4">Delete</button> <label class="label">Notes</label>
<textarea bind:value={newData.notes} class="textarea w-100" placeholder="Budget Notes"
></textarea>
<button onclick={() => saveTransaction()} class="btn btn-primary mt-4">Save</button>
</fieldset>
</div> </div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<dialog id="RestoreModal" class="modal">
<div class="modal-box">
<fieldset class="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4">
<legend class="fieldset-legend">Restore?</legend>
<button onclick={() => restoreBudget()} class="btn btn-primary mt-4">Restore</button>
</fieldset>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<dialog id="DeleteTransactionModal" class="modal">
<div class="modal-box">
<h1>Are you sure you want to delete {toDelete?.description}?</h1>
<p>Type it in the box to confirm</p>
<input
bind:value={toDeleteName}
type="text"
placeholder="Type the name to confirm"
class="input input-bordered w-full max-w-xs"
/>
<button
class="btn btn-error mt-4"
onclick={async () => {
if (toDelete.description === toDeleteName) {
let res = await fetch(`/api/budget/${toDelete.budget_transaction_id}/transaction/`, {
method: 'DELETE'
});
if (res.ok) {
console.log('Rule deleted successfully');
DeleteTransactionModal.close();
location.reload();
} else {
console.error('Failed to delete transaction');
}
} else {
console.error('Name does not match');
}
}}>Delete</button
>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog> </dialog>

View File

@ -0,0 +1,10 @@
import { error } from '@sveltejs/kit';
import { getRules } from '$lib/db.js';
export async function load({ params }) {
const rules = await getRules();
return { rules };
}

View File

@ -0,0 +1,318 @@
<script>
import { EditSymbol } from '$lib/editSymbol.svelte';
import { TrashBin } from '$lib/trashbin.svelte';
import { loadingModal } from '$lib/loadingModal.svelte';
let { data } = $props();
let rules = $derived(data.rules || []);
let budgets = $derived(data.budgets || []);
let accounts = $derived(data.accounts || []);
let loading = $state(false);
let adding = $state(false);
let toDelete = $state(null);
let toDeleteName = $state('');
const newRule = $state({
id: 0,
name: '',
priority: 0,
description: '',
payee: '',
amount: '',
transdescription: '',
action: '',
account_id: null,
actionAmount: 0,
amount_is_precent: false,
use_priority: true
});
async function doTheThing() {
if (adding) {
await addRule();
} else {
await updateRule();
}
}
function clear() {
newRule.name = '';
newRule.priority = 0;
newRule.description = '';
newRule.payee = '';
newRule.amount = '';
newRule.transdescription = '';
newRule.action = '';
newRule.account_id = null;
newRule.actionAmount = 0;
newRule.amount_is_precent = false;
newRule.use_priority = true;
}
async function addRule() {
let res = await fetch('/api/rules', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(newRule)
});
if (res.ok) {
// Optionally, you can refresh the rules list or show a success message
console.log('Rule added successfully');
clear();
AddRuleModal.close();
} else {
console.error('Failed to add rule');
}
}
async function updateRule() {
let res = await fetch(`/api/rules/${newRule.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(newRule)
});
if (res.ok) {
console.log('Rule updated successfully');
clear();
AddRuleModal.close();
} else {
console.error('Failed to update rule');
}
}
function editRule(rule) {
adding = false;
newRule.name = rule.name;
newRule.priority = rule.priority;
newRule.description = rule.description;
newRule.payee = rule.payee;
newRule.amount = rule.amount;
newRule.transdescription = rule.transdescription;
newRule.action = rule.action;
newRule.id = rule.id;
newRule.actionAmount = rule.action_amount;
newRule.account_id = rule.account_id;
newRule.amount_is_precent = rule.amount_is_precent;
newRule.use_priority = rule.use_priority;
AddRuleModal.showModal();
}
$inspect(newRule);
function deleteRule(rule) {
toDelete = rule;
DeleteModal.showModal();
}
function runRules() {
loading = true;
fetch('/api/rules', {
method: 'POST'
})
.then((response) => response.json())
.then((data) => {
console.log('Rules executed successfully:', data);
})
.catch((error) => {
console.error('Error executing rules:', error);
})
.finally(() => {
loading = false;
});
}
$inspect(rules);
</script>
{#if loading}
{@render loadingModal()}
{/if}
<div class="overflow-x-auto">
<table class="table">
<!-- head -->
<thead>
<tr>
<th>Priority</th>
<th>Use Priority</th>
<th>Name</th>
<th>Description</th>
<th>Payee</th>
<th>Amount</th>
<th>Transaction Description</th>
<th>Account</th>
<th>Action Amount</th>
<td>Precent?</td>
<th>Budget</th>
<th></th>
</tr>
</thead>
<tbody>
{#each rules as rule}
<tr>
<td>{rule.priority}</td>
<td>
{`${rule.use_priority ? 'Yes' : 'No'}`}
</td><td>{rule.name}</td>
<td>{rule.description}</td>
<td>{rule.payee}</td>
<td>{rule.amount}</td>
<td>{rule.transdescription}</td>
<td>{accounts?.find((e) => e.id == rule?.account_id)?.name}</td>
<td>{rule.action_amount}</td>
<td>{rule.amount_is_precent ? '%' : '$'}</td>
<td>{budgets?.find((e) => e.id == rule?.action)?.name}</td>
<td
><button class="btn btn-square btn-ghost" onclick={() => editRule(rule)}>
{@render EditSymbol()}</button
></td
>
<td
><button class="btn btn-square btn-ghost" onclick={() => deleteRule(rule)}>
{@render TrashBin()}</button
></td
>
</tr>
{/each}
<tr>
<td colspan="7"
><button
onclick={() => {
adding = true;
clear();
AddRuleModal.showModal();
}}
class="btn btn-neutral">Add</button
></td
></tr
>
</tbody>
</table>
</div>
<button onclick={() => runRules()} class="btn btn-primary">Run Rules</button>
<dialog id="AddRuleModal" class="modal">
<div class="modal-box">
<h1>{`${adding ? 'New' : 'Edit'}`} Rule</h1>
<fieldset class="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4">
<label class="label">Name</label>
<input
bind:value={newRule.name}
type="text"
placeholder="Name"
class="input input-bordered w-full max-w-xs"
/>
<label class="label">Priority</label>
<input
bind:value={newRule.priority}
type="number"
placeholder="priority"
class="input input-bordered w-full max-w-xs"
/>
<label class="label">Description</label>
<textarea bind:value={newRule.description} class="textarea w-100" placeholder="Description"
></textarea>
<label class="label">Payee Filter</label>
<input
bind:value={newRule.payee}
type="text"
placeholder="regex"
class="input input-bordered w-full max-w-xs"
/>
<label class="label">Amount</label>
<input
bind:value={newRule.amount}
type="text"
placeholder="regex"
class="input input-bordered w-full max-w-xs"
/>
<label class="label">Details Filter</label>
<input
bind:value={newRule.transdescription}
type="text"
placeholder="regex"
class="input input-bordered w-full max-w-xs"
/>
<label class="label">Account</label>
<select bind:value={newRule.account_id} class="select">
<option disabled selected>Pick a budget</option>
<option value={null}>None</option>
{#each accounts as account}
<option value={account.id.toString()}>{account.name}</option>
{/each}
</select>
<label class="label">Budget</label>
<select bind:value={newRule.action} class="select">
<option disabled selected>Pick a budget</option>
{#each budgets as budget}
<option value={budget.id.toString()}>{budget.name} - {budget.sum}</option>
{/each}
</select>
<label class="label">Action Amount</label>
<input
bind:value={newRule.actionAmount}
type="text"
placeholder="regex"
class="input input-bordered w-full max-w-xs"
/>
<label class="label">
<input type="checkbox" class="checkbox" bind:checked={newRule.amount_is_precent} />
Precentage?
</label>
<label class="label">
<input type="checkbox" class="checkbox" bind:checked={newRule.use_priority} />
Use Priority?
</label>
<button onclick={() => doTheThing()} class="btn btn-primary mt-4">Save</button>
</fieldset>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<dialog id="DeleteModal" class="modal">
<div class="modal-box">
<h1>Are you sure you want to delete {toDelete?.name}?</h1>
<p>Type it in the box to confirm</p>
<input
bind:value={toDeleteName}
type="text"
placeholder="Type the name to confirm"
class="input input-bordered w-full max-w-xs"
/>
<button
class="btn btn-error mt-4"
onclick={async () => {
if (toDelete.name === toDeleteName) {
let res = await fetch(`/api/rules/${toDelete.id}`, {
method: 'DELETE'
});
if (res.ok) {
console.log('Rule deleted successfully');
DeleteModal.close();
} else {
console.error('Failed to delete rule');
}
} else {
console.error('Name does not match');
}
}}>Delete</button
>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>

View File

@ -1,9 +1,11 @@
import { getHiddenAccounts, getDeletedBudgets } from "$lib/db"; import { getHiddenAccounts, getDeletedBudgets, getBudgets } from "$lib/db";
import { get } from "svelte/store";
export async function load({ params }) { export async function load({ params }) {
let accounts = await getHiddenAccounts(); let accounts = await getHiddenAccounts();
let budgets = await getDeletedBudgets(); let deletedBudgets = await getDeletedBudgets();
let budgets = await getBudgets();
return { accounts, budgets };
return { accounts, deletedBudgets, budgets };
} }

View File

@ -1,42 +1,153 @@
<script> <script>
let { data } = $props(); import { deleteBudgetForm } from './deleteBudget.svelte';
let accounts = $derived(data.accounts || []); import { loadingModal } from '$lib/loadingModal.svelte';
let budgets = $derived(data.budgets || []);
let { data } = $props();
let accounts = $derived(data.accounts || []);
let deletedBudgets = $derived(data.deletedBudgets || []);
let budgets = $derived(data.budgets || []);
let toDeleteBudget = $state({});
let loading = $state(false);
let newBudget = $state({
amount: 0,
notes: ''
});
async function saveBudget() {
loading = true;
let res = await fetch('/budget', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(newBudget)
});
if (res.ok) {
budgets.push({
id: res.text(), // Temporary ID, replace with actual ID from the server
...newBudget
});
newBudget = { name: '', amount: 0, notes: '' }; // Reset the form
// Optionally, you can refresh the budgets list or show a success message
window.location.href = '/settings'; // Redirect to budgets list after deletion
} else {
console.error('Failed to save budget');
}
loading = false;
}
async function update() { async function update() {
loading = true;
let res = await fetch('/api/simplefin/update', { let res = await fetch('/api/simplefin/update', {
method: 'POST' method: 'POST'
}); });
loading = false;
}
async function deleteBudget(name) {
loading = true;
if (name === toDeleteBudget.name) {
let res = await fetch(`/api/budget/${toDeleteBudget.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
});
if (res.ok) {
window.location.href = '/settings'; // Redirect to budgets list after deletion
} else {
console.error('Failed to delete budget');
}
} else {
alert('Please type the budget name correctly to confirm deletion.');
}
loading = false;
} }
</script> </script>
<button onclick={update}>Update</button> {#if loading}
{@render loadingModal()}
{/if}
<button class="btn btn-primary" onclick={update}>Update</button>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<h1 class="text-2xl font-bold">Settings</h1> <h1 class="text-2xl font-bold">Settings</h1>
<div class="mb-4"> <div class="mb-4">
<h2 class="text-xl font-semibold">Hidden Accounts</h2> <h2 class="text-xl font-semibold">Hidden Accounts</h2>
{#if accounts.length > 0} {#if accounts.length > 0}
<ul> <ul>
{#each accounts as account} {#each accounts as account}
<li><a href={`/account/${account.id}`}>{account.name}</a></li> <li><a href={`/account/${account.id}`}>{account.name}</a></li>
{/each} {/each}
</ul> </ul>
{:else} {:else}
<p>No hidden accounts.</p> <p>No hidden accounts.</p>
{/if} {/if}
</div> </div>
<div class="mb-4"> <div class="mb-4">
<h2 class="text-xl font-semibold">Deleted Budgets</h2> <h2 class="text-xl font-semibold">Budgets</h2>
{#if budgets.length > 0} <div>
<ul> {#if budgets.length > 0}
{#each budgets as budget} <ul>
<li><a href={`/budget/${budget.id}`}>{budget.name}</a> </li> {#each budgets as budget}
{/each} <li>
</ul> <button
{:else} class="btn btn-error"
<p>No deleted budgets.</p> onclick={() => {
{/if} toDeleteBudget = budget;
</div> DeleteBudgetModal.showModal();
</div> }}
>
{budget.name}
</button>
</li>
{/each}
</ul>
{:else}
<p>No budgets.</p>
{/if}
</div>
</div>
<div class="mb-4">
<h2 class="text-xl font-semibold">Deleted Budgets</h2>
{#if deletedBudgets.length > 0}
<ul>
{#each deletedBudgets as budget}
<li><a href={`/budget/${budget.id}`}>{budget.name}</a></li>
{/each}
</ul>
{:else}
<p>No deleted budgets.</p>
{/if}
</div>
</div>
<fieldset class="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4">
<legend class="fieldset-legend">Add a budget</legend>
<label class="label">Name</label>
<input
bind:value={newBudget.name}
type="text"
placeholder="Budget Name"
class="input input-bordered w-full max-w-xs"
/>
<label class="label">Notes</label>
<textarea bind:value={newBudget.notes} class="textarea w-100" placeholder="Budget Notes"
></textarea>
<button onclick={() => saveBudget()} class="btn btn-primary mt-4">Save</button>
</fieldset>
<dialog id="DeleteBudgetModal" class="modal">
<div class="modal-box">
{@render deleteBudgetForm(toDeleteBudget.name, deleteBudget)}
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>

View File

@ -0,0 +1,23 @@
<script module>
export { deleteBudgetForm };
let toDeleteBudget = $state('');
</script>
{#snippet deleteBudgetForm(name, deleteBudget)}
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<p>
Are you sure you want to delete <code>{name}</code>? type its name in the box below if so.
</p>
<div>
<input
bind:value={toDeleteBudget}
type="text"
placeholder="Budget Name"
class="input input-bordered w-full max-w-xs"
/>
</div>
<button onclick={() => deleteBudget(toDeleteBudget)} class="btn btn-primary mt-4">Delete</button>
{/snippet}

View File

@ -3,8 +3,7 @@ import {setTransactionNote} from '$lib/db';
export async function POST(request) { export async function POST(request) {
let body = await request.request.json(); let body = await request.request.json();
let res = await setTransactionNote(request.params.slug, body.notes); let res = await setTransactionNote(request.params.slug, body.notes);
console.log({slug: request.params.slug, notes: body.notes});
console.log(res);
return new Response(JSON.stringify({success: true}), { return new Response(JSON.stringify({success: true}), {
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'

4
wipedata.psql Normal file
View File

@ -0,0 +1,4 @@
\c budget budget sql.caseytimm.com
TRUNCATE budget_transaction;
UPDATE transaction
SET processed = false;