├── .dockerignore ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── docker-build.yml ├── .gitignore ├── API.md ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── assets ├── logo.png └── logo.svg ├── docker-compose.yml ├── nodemon.json ├── package-lock.json ├── package.json ├── public ├── assets │ └── styles.css ├── index.html ├── login.html ├── managers │ └── toast.js ├── script.js ├── service-worker.js └── styles.css ├── scripts ├── cors.js └── pwa-manifest-generator.js └── server.js /.dockerignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | npm-debug.log 4 | yarn-debug.log 5 | yarn-error.log 6 | 7 | # Environment 8 | .env 9 | .env.* 10 | 11 | # Git 12 | .git 13 | .gitignore 14 | .gitattributes 15 | 16 | # IDE 17 | .idea 18 | .vscode 19 | *.swp 20 | *.swo 21 | 22 | # OS 23 | .DS_Store 24 | Thumbs.db 25 | 26 | # Docker 27 | Dockerfile 28 | .dockerignore 29 | 30 | # Data 31 | data/ 32 | *.log 33 | data/transactions.json 34 | 35 | # Development config 36 | nodemon.json 37 | 38 | # Documentation 39 | README.md 40 | LICENSE 41 | *.md 42 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: Your Informative Title Here 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | 30 | **Smartphone (please complete the following information):** 31 | - Device: [e.g. iPhone6] 32 | - OS: [e.g. iOS8.1] 33 | - Browser [e.g. stock browser, safari] 34 | 35 | **Additional context** 36 | Add any other context about the problem here. 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - testing 8 | 9 | pull_request: 10 | branches: 11 | - main 12 | - testing 13 | 14 | env: 15 | DOCKER_IMAGE: dumbwareio/dumbbudget 16 | PLATFORMS: linux/amd64,linux/arm64 17 | 18 | jobs: 19 | build-and-push: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v3 24 | 25 | - name: Set up Docker Buildx 26 | uses: docker/setup-buildx-action@v2 27 | 28 | - name: Log in to Docker Hub 29 | uses: docker/login-action@v3 30 | with: 31 | username: ${{ secrets.DOCKER_USERNAME }} 32 | password: ${{ secrets.DOCKER_PASSWORD }} 33 | 34 | - name: Set Docker tags 35 | id: docker_meta 36 | run: | 37 | TAGS="${{ env.DOCKER_IMAGE }}:${{ github.sha }}" 38 | if [ "${{ github.ref }}" = "refs/heads/main" ]; then 39 | TAGS+=" ${{ env.DOCKER_IMAGE }}:latest" 40 | elif [ "${{ github.ref }}" = "refs/heads/testing" ]; then 41 | TAGS+=" ${{ env.DOCKER_IMAGE }}:testing" 42 | fi 43 | echo "DOCKER_TAGS=$TAGS" >> $GITHUB_ENV 44 | 45 | - name: Build and Push Multi-Platform Image 46 | run: | 47 | docker buildx create --use 48 | docker buildx build --platform ${{ env.PLATFORMS }} \ 49 | --tag ${{ env.DOCKER_IMAGE }}:${{ github.sha }} \ 50 | --tag ${{ env.DOCKER_IMAGE }}:latest \ 51 | --push . -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # Local data files 133 | data/transactions.json 134 | 135 | ## Development config 136 | # nodemon.json 137 | 138 | # Generated PWA Files 139 | /public/*manifest.json 140 | /public/logo.* 141 | /public/assets/logo.* 142 | /public/assets/*manifest.json -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | # DumbBudget API Documentation 2 | 3 | This document describes the API endpoints available for DumbCal integration with DumbBudget. 4 | 5 | ## Authentication 6 | 7 | All API requests must include the `DUMB_SECRET` in the request headers: 8 | 9 | ``` 10 | Authorization: Bearer YOUR_DUMB_SECRET 11 | ``` 12 | 13 | If the secret is invalid or missing, the API will return a 401 Unauthorized response. 14 | 15 | ## Endpoints 16 | 17 | ### GET /api/calendar/transactions 18 | 19 | Returns all transactions within a specified date range, including recurring transaction instances. 20 | 21 | **Query Parameters:** 22 | - `start_date`: (Required) Start date in ISO format (YYYY-MM-DD) 23 | - `end_date`: (Required) End date in ISO format (YYYY-MM-DD) 24 | 25 | **Example Request:** 26 | ``` 27 | GET /api/calendar/transactions?start_date=2024-03-01&end_date=2024-03-31 28 | Authorization: Bearer YOUR_DUMB_SECRET 29 | ``` 30 | 31 | **Response Format:** 32 | ```json 33 | { 34 | "transactions": [ 35 | { 36 | "type": "income"|"expense", 37 | "amount": number, 38 | "description": string, 39 | "date": string (ISO date), 40 | "category": string, 41 | "id": string, 42 | "recurring": { // Only present for recurring transactions 43 | "pattern": string, // Recurring pattern string 44 | "until": string|null // Optional ISO date string for end date 45 | }, 46 | "isRecurringInstance": boolean, // True for generated recurring instances 47 | "recurringParentId": string // Present only for recurring instances 48 | } 49 | ] 50 | } 51 | ``` 52 | 53 | **Recurring Pattern Format:** 54 | The `pattern` field in recurring transactions follows these formats: 55 | ``` 56 | Regular patterns: 57 | "every {number} {unit} [on {weekday}]" 58 | 59 | Monthly day patterns: 60 | "every {number}{suffix} of the month" 61 | 62 | Examples: 63 | - "every 1 day" 64 | - "every 2 day" 65 | - "every 1 week on monday" 66 | - "every 2 week on thursday" 67 | - "every 1 month" 68 | - "every 1 year" 69 | - "every 1st of the month" 70 | - "every 15th of the month" 71 | - "every 22nd of the month" 72 | ``` 73 | 74 | **Example Response:** 75 | ```json 76 | { 77 | "transactions": [ 78 | { 79 | "type": "expense", 80 | "amount": 50.00, 81 | "description": "Grocery shopping", 82 | "date": "2024-03-15", 83 | "category": "Food", 84 | "id": "abc123" 85 | }, 86 | { 87 | "type": "expense", 88 | "amount": 50.00, 89 | "description": "Grocery shopping", 90 | "date": "2024-03-22", 91 | "category": "Food", 92 | "id": "abc123-2024-03-22T00:00:00.000Z", 93 | "isRecurringInstance": true, 94 | "recurringParentId": "abc123" 95 | }, 96 | { 97 | "type": "income", 98 | "amount": 2000.00, 99 | "description": "Salary", 100 | "date": "2024-03-01", 101 | "category": "Salary", 102 | "id": "def456", 103 | "recurring": { 104 | "pattern": "every 1st of the month", 105 | "until": null 106 | } 107 | } 108 | ] 109 | } 110 | ``` 111 | 112 | **Error Responses:** 113 | - 400 Bad Request: Invalid date format or missing parameters 114 | - 401 Unauthorized: Invalid or missing DUMB_SECRET 115 | - 500 Internal Server Error: Server-side error 116 | 117 | ## Rate Limiting 118 | To prevent abuse, the API is rate-limited to 100 requests per hour per API key. 119 | 120 | ## Recurring Transactions 121 | When a transaction is recurring, the API will generate instances based on the pattern and return them in the response. Each instance will have: 122 | - A unique ID formed by combining the parent ID and instance date 123 | - The `isRecurringInstance` flag set to true 124 | - A reference to the parent transaction ID in the `recurringParentId` field 125 | - All other properties from the parent transaction, with the date adjusted according to the pattern 126 | 127 | The original transaction will also appear in the results if its date falls within the requested range. 128 | 129 | For weekly recurring transactions with a specified weekday: 130 | - The original transaction's date will be adjusted to the first occurrence of the specified weekday 131 | - For example, if you create a transaction on 2/1/2025 (Saturday) that recurs every 2 weeks on Monday: 132 | - The original transaction will be saved with date 2/3/2025 (first Monday) 133 | - Recurring instances will be generated for 2/17/2025, 3/3/2025, etc. 134 | 135 | For monthly recurring transactions on a specific day: 136 | - The original transaction's date will be adjusted to the first occurrence of the specified day 137 | - For example, if you create a transaction on 2/5/2025 that recurs every 15th of the month: 138 | - The original transaction will be saved with date 2/15/2025 (first occurrence) 139 | - Recurring instances will be generated for 3/15/2025, 4/15/2025, etc. 140 | 141 | The API handles these recurring patterns: 142 | - Daily: "every N day" 143 | - Weekly: "every N week on {weekday}" 144 | - Monthly: "every N month" 145 | - Yearly: "every N year" 146 | - Monthly day: "every Nth of the month" 147 | 148 | Where: 149 | - N is a positive integer 150 | - weekday is lowercase (monday, tuesday, etc.) 151 | - Patterns exactly match DumbCal's format for seamless integration -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | WORKDIR /app 4 | 5 | # Copy package files 6 | COPY package*.json ./ 7 | 8 | # Install dependencies 9 | RUN npm ci 10 | 11 | # Copy application files 12 | COPY . . 13 | 14 | # Create data directory 15 | RUN mkdir -p data 16 | 17 | # Expose port 18 | EXPOSE 3000 19 | 20 | # Start the application 21 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DumbBudget 2 | 3 | A simple, secure personal budgeting app with PIN protection. Track your income and expenses with a clean, modern interface. 4 | 5 | ![image](https://github.com/user-attachments/assets/7874b23a-159f-4c93-8e5d-521c18666547) 6 | 7 | 8 | ## Features 9 | 10 | - 🔒 PIN-protected access 11 | - 💰 Track income and expenses 12 | - 📊 Real-time balance calculations 13 | - 🏷️ Categorize transactions 14 | - 📅 Date range filtering 15 | - 🔄 Sort by date or amount 16 | - 📱 Responsive design 17 | - 🌓 Light/Dark theme 18 | - 📤 Export to CSV 19 | - 🔍 Filter transactions by type 20 | - 💱 Multi-currency support 21 | - 🌐 PWA Support 22 | 23 | ## Supported Currencies 24 | 25 | DumbBudget supports the following currencies: 26 | - USD (US Dollar) 🇺🇸 27 | - EUR (Euro) 🇪🇺 28 | - GBP (British Pound) 🇬🇧 29 | - JPY (Japanese Yen) 🇯🇵 30 | - AUD (Australian Dollar) 🇦🇺 31 | - CAD (Canadian Dollar) 🇨🇦 32 | - CHF (Swiss Franc) 🇨🇭 33 | - CNY (Chinese Yuan) 🇨🇳 34 | - HKD (Hong Kong Dollar) 🇭🇰 35 | - NZD (New Zealand Dollar) 🇳🇿 36 | - MXN (Mexican Peso) 🇲🇽 37 | - RUB (Russian Ruble) 🇷🇺 38 | - SGD (Singapore Dollar) 🇸🇬 39 | - KRW (South Korean Won) 🇰🇷 40 | - INR (Indian Rupee) 🇮🇳 41 | - BRL (Brazilian Real) 🇧🇷 42 | - ZAR (South African Rand) 🇿🇦 43 | - TRY (Turkish Lira) 🇹🇷 44 | - PLN (Polish Złoty) 🇵🇱 45 | - SEK (Swedish Krona) 🇸🇪 46 | - NOK (Norwegian Krone) 🇳🇴 47 | - DKK (Danish Krone) 🇩🇰 48 | - IDR (Indonesia Rupiah) 🇮🇩 49 | 50 | Set your preferred currency using the `CURRENCY` environment variable (defaults to USD if not set). 51 | 52 | ### Using Docker 53 | 54 | ```bash 55 | docker run -d \ 56 | -p 3000:3000 \ 57 | -v /path/to/your/data:/app/data \ 58 | -e DUMBBUDGET_PIN=12345 \ 59 | -e CURRENCY=USD \ 60 | -e BASE_URL=http://localhost:3000 \ 61 | -e SITE_TITLE='My Account' \ 62 | dumbwareio/dumbbudget:latest 63 | ``` 64 | 65 | ```yaml 66 | services: 67 | dumbbudget: 68 | image: dumbwareio/dumbbudget:latest 69 | container_name: dumbbudget 70 | restart: unless-stopped 71 | ports: 72 | - ${DUMBBUDGET_PORT:-3000}:3000 73 | volumes: 74 | - ${DUMBBUDGET_DATA_PATH:-./data}:/app/data 75 | environment: 76 | - DUMBBUDGET_PIN=${DUMBBUDGET_PIN:-} # PIN to access the site 77 | - BASE_URL=${DUMBBUDGET_BASE_URL:-http://localhost:3000} # URL to access the site 78 | - CURRENCY=${DUMBBUDGET_CURRENCY:-USD} # Supported Currency Code: https://github.com/DumbWareio/DumbBudget?tab=readme-ov-file#supported-currencies 79 | - SITE_TITLE=${DUMBBUDGET_SITE_TITLE:-DumbBudget} # Name to show on site 80 | - INSTANCE_NAME=${DUMBBUDGET_INSTANCE_NAME:-} # Name of instance/account 81 | # (OPTIONAL) 82 | # Restrict origins - ex: https://subdomain.domain.tld,https://auth.proxy.tld,http://internalip:port' (default is '*') 83 | # - ALLOWED_ORIGINS=${DUMBBUDGET_ALLOWED_ORIGINS:-http://localhost:3000} 84 | # healthcheck: 85 | # test: wget --spider -q http://127.0.0.1:3000 86 | # start_period: 20s 87 | # interval: 20s 88 | # timeout: 5s 89 | # retries: 3 90 | ``` 91 | 92 | > **Note**: Replace `/path/to/your/data` with the actual path where you want to store your transaction data on the host machine. 93 | 94 | ### Environment Variables 95 | 96 | | Variable | Description | Required | Default | Example | 97 | |----------|-------------|----------|---------|---------| 98 | | `DUMBBUDGET_PIN` | PIN code for accessing the application | Yes | - | `12345` | 99 | | `PORT` | Port number for the server | No | `3000` | `8080` | 100 | | `CURRENCY` | Currency code for transactions | No | `USD` | `EUR` | 101 | | `BASE_URL` | Base URL for the application | No | `http://localhost:PORT` | `https://budget.example.com` | 102 | | `SITE_TITLE` | Allows you to name each instance should you have multiple. | No | - | `My Account` | 103 | 104 | ## Development Setup 105 | 106 | 1. Clone the repository: 107 | ```bash 108 | git clone https://github.com/DumbWareio/DumbBudget.git 109 | cd DumbBudget 110 | ``` 111 | 112 | 2. Install dependencies: 113 | ```bash 114 | npm install 115 | ``` 116 | 117 | 3. Create a `.env` file: 118 | ```env 119 | DUMBBUDGET_PIN=12345 120 | PORT=3000 121 | NODE_ENV=development 122 | BASE_URL=http://localhost:3000 123 | CURRENCY=USD 124 | SITE_TITLE='DumbBudget' 125 | INSTANCE_NAME='My Account' 126 | ALLOWED_ORIGINS=* # Restrict origins - ex: https://subdomain.domain.tld,https://auth.proxy.tld,http://internalip:port' (default is '*') 127 | ``` 128 | 129 | 4. Start the development server: 130 | ```bash 131 | npm run dev 132 | ``` 133 | 134 | 5. Open http://localhost:3000 in your browser 135 | 136 | ## Building from Source 137 | 138 | ```bash 139 | # Build the Docker image 140 | docker build -t dumbwareio/dumbbudget:latest . 141 | 142 | # Create a directory for persistent data 143 | mkdir -p ~/dumbbudget-data 144 | 145 | # Run the container 146 | docker run -d \ 147 | -p 3000:3000 \ 148 | -v ~/dumbbudget-data:/app/data \ 149 | -e DUMBBUDGET_PIN=12345 \ 150 | -e BASE_URL=http://localhost:3000 \ 151 | -e SITE_TITLE='My Account' \ 152 | dumbwareio/dumbbudget:latest 153 | ``` 154 | 155 | ## Contributing 156 | 157 | 1. Fork the repository 158 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 159 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 160 | 4. Push to the branch (`git push origin feature/amazing-feature`) 161 | 5. Open a Pull Request 162 | 163 | ## Security 164 | 165 | DumbBudget includes several security features: 166 | - PIN protection for access 167 | - Rate limiting on PIN attempts 168 | - Temporary lockout after failed attempts 169 | - No sensitive data stored in browser storage 170 | - Secure session handling 171 | 172 | ## Support 173 | 174 | - Report bugs by opening an issue 175 | - Request features through issues 176 | - [Join our community discussions](https://discord.gg/zJutzxWyq2) 177 | 178 | ## Support the Project 179 | 180 | 181 | Buy Me A Coffee 182 | 183 | 184 | --- 185 | Made with ❤️ by [DumbWare.io](https://github.com/DumbWareio) 186 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | We are committed to maintaining the security of DumbBudget. 4 | 5 | ## Reporting a Vulnerability 6 | 7 | If you discover a security vulnerability in DumbBudget, please help us address it by following these steps: 8 | 9 | 1. **Do not open a public issue for security vulnerabilities.** 10 | - Instead, email us directly at [admin@dumbware.io](mailto:admin@dumbware.io). 11 | 12 | 2. Include the following details in your report: 13 | - A description of the vulnerability. 14 | - Steps to reproduce the issue (if applicable). 15 | - The potential impact of the vulnerability. 16 | - Any suggested fixes or patches (if available). 17 | 18 | 3. We will acknowledge your report within **48 hours** and provide updates as we work to resolve the issue. 19 | 20 | 4. Once the issue is resolved, we will publicly disclose the details in a responsible manner, including crediting you (if you wish). 21 | 22 | ## Security Best Practices 23 | 24 | To ensure the security of your DumbBudget installation, we recommend the following best practices: 25 | 26 | - Always use the latest version of the software. 27 | - Regularly review and update dependencies. 28 | - Protect sensitive environment variables and secrets (e.g., API keys). 29 | - Use HTTPS to secure communication between services. 30 | 31 | ## Contact 32 | 33 | For any questions related to security, please reach out to us at [admin@dumbware.io](mailto:admin@dumbware.io). 34 | 35 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DumbWareio/DumbBudget/2a2f36b3930ea6912fb84937dd230593a87d47e9/assets/logo.png -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 10 | 11 | 13 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | 2 | services: 3 | dumbbudget: 4 | image: dumbwareio/dumbbudget:latest 5 | container_name: dumbbudget 6 | restart: unless-stopped 7 | ports: 8 | - ${DUMBBUDGET_PORT:-3000}:3000 9 | volumes: 10 | - ${DUMBBUDGET_DATA_PATH:-./data}:/app/data 11 | environment: 12 | - DUMBBUDGET_PIN=${DUMBBUDGET_PIN:-} # PIN to access the site 13 | - BASE_URL=${DUMBBUDGET_BASE_URL:-http://localhost:3000} # URL to access the site 14 | - CURRENCY=${DUMBBUDGET_CURRENCY:-USD} # Supported Currency Code: https://github.com/DumbWareio/DumbBudget?tab=readme-ov-file#supported-currencies 15 | - SITE_TITLE=${DUMBBUDGET_SITE_TITLE:-DumbBudget} # Name to show on site 16 | - INSTANCE_NAME=${DUMBBUDGET_INSTANCE_NAME:-} # Name of instance/account 17 | # (OPTIONAL) 18 | # Restrict origins - ex: https://subdomain.domain.tld,https://auth.proxy.tld,http://internalip:port' (default is '*') 19 | # - ALLOWED_ORIGINS=${DUMBBUDGET_ALLOWED_ORIGINS:-http://localhost:3000} 20 | # healthcheck: 21 | # test: wget --spider -q http://127.0.0.1:3000 22 | # start_period: 20s 23 | # interval: 20s 24 | # timeout: 5s 25 | # retries: 3 -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": ["asset-manifest.json", "manifest.json"] 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dumbbudget", 3 | "version": "1.0.0", 4 | "description": "A stupid simple budget app", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server.js", 8 | "dev": "nodemon server.js" 9 | }, 10 | "dependencies": { 11 | "cookie-parser": "^1.4.7", 12 | "cors": "^2.8.5", 13 | "dotenv": "^16.3.1", 14 | "dumbdateparser": "^1.2.0", 15 | "express": "^4.18.2", 16 | "express-session": "^1.17.3", 17 | "helmet": "^7.1.0" 18 | }, 19 | "devDependencies": { 20 | "nodemon": "^3.0.2" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /public/assets/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* Light theme variables */ 3 | --primary: #2196F3; 4 | --primary-hover: #1976D2; 5 | --background: #f5f5f5; 6 | --container: white; 7 | --text: #333; 8 | --border: #ccc; 9 | --shadow: 0 2px 4px rgba(0,0,0,0.1); 10 | --transition: 0.2s ease; 11 | --success: #4CAF50; 12 | --danger: #f44336; 13 | --card-bg: #ffffff; 14 | --success-status-bg: rgba(37, 99, 235, 0.6); 15 | --danger-status-bg:rgba(220, 38, 38, 0.6); 16 | } 17 | 18 | [data-theme="dark"] { 19 | --background: #1a1a1a; 20 | --container: #2d2d2d; 21 | --text: white; 22 | --border: #404040; 23 | --shadow: 0 2px 4px rgba(0,0,0,0.2); 24 | --card-bg: #363636; 25 | --success-status-bg: rgba(96, 165, 250, 0.5); 26 | --danger-status-bg:rgba(220, 38, 38, 0.5); 27 | } 28 | 29 | /* Base styles */ 30 | * { 31 | margin: 0; 32 | padding: 0; 33 | box-sizing: border-box; 34 | } 35 | 36 | body { 37 | margin: 0; 38 | padding: 0; 39 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; 40 | background: var(--background); 41 | color: var(--text); 42 | transition: background var(--transition), color var(--transition); 43 | min-height: 100vh; 44 | display: flex; 45 | flex-direction: column; 46 | } 47 | 48 | /* Main content */ 49 | main { 50 | flex: 1; 51 | display: flex; 52 | align-items: center; 53 | justify-content: center; 54 | padding: 1rem; 55 | } 56 | 57 | /* Container styling */ 58 | .container { 59 | position: relative; 60 | background: var(--container); 61 | padding: 1.5rem 1rem 1rem; 62 | border-radius: 8px; 63 | box-shadow: var(--shadow); 64 | width: 100%; 65 | max-width: 70%; 66 | min-height: calc(100vh - 2rem); 67 | transition: background var(--transition), box-shadow var(--transition); 68 | margin: 0 auto; 69 | display: flex; 70 | flex-direction: column; 71 | } 72 | 73 | /* Form styling */ 74 | form { 75 | position: relative; 76 | background: var(--container); 77 | padding: 3rem 1.5rem 2rem; 78 | border-radius: 12px; 79 | box-shadow: var(--shadow); 80 | width: calc(100% - 2rem); 81 | max-width: 400px; 82 | transition: background var(--transition), box-shadow var(--transition); 83 | text-align: center; 84 | margin: 0 auto; 85 | transform: translateY(-10%); 86 | } 87 | 88 | /* Theme toggle */ 89 | #themeToggle { 90 | position: absolute; 91 | top: 0.75rem; 92 | right: 0.75rem; 93 | background: none; 94 | border: none; 95 | cursor: pointer; 96 | padding: 0.25rem; 97 | width: 32px; 98 | height: 32px; 99 | display: flex; 100 | align-items: center; 101 | justify-content: center; 102 | border-radius: 50%; 103 | transition: background-color var(--transition); 104 | } 105 | 106 | #themeToggle:hover { 107 | background: rgba(128, 128, 128, 0.1); 108 | } 109 | 110 | #themeToggle svg { 111 | width: 20px; 112 | height: 20px; 113 | stroke: var(--text); 114 | fill: none; 115 | stroke-width: 2; 116 | stroke-linecap: round; 117 | stroke-linejoin: round; 118 | transition: stroke var(--transition); 119 | } 120 | 121 | [data-theme="light"] .moon { 122 | display: block; 123 | } 124 | 125 | [data-theme="light"] .sun { 126 | display: none; 127 | } 128 | 129 | [data-theme="dark"] .moon { 130 | display: none; 131 | } 132 | 133 | [data-theme="dark"] .sun { 134 | display: block; 135 | } 136 | 137 | /* Headings */ 138 | h1 { 139 | font-size: 1.5rem; 140 | font-weight: 500; 141 | color: var(--text); 142 | text-align: center; 143 | padding-bottom: 1.5rem; 144 | } 145 | 146 | h2 { 147 | font-size: 0.875rem; 148 | margin-bottom: 1rem; 149 | } 150 | 151 | h3 { 152 | font-size: 0.875rem; 153 | margin-bottom: 0.125rem; 154 | line-height: 1.2; 155 | } 156 | 157 | /* PIN input styling */ 158 | .pin-input-container { 159 | display: flex; 160 | flex-wrap: wrap; 161 | gap: 0.75rem; 162 | justify-content: center; 163 | margin: 2rem 0; 164 | padding: 0 0.5rem; 165 | } 166 | 167 | .pin-input-container input.pin-input { 168 | width: 44px; 169 | height: 44px; 170 | text-align: center; 171 | font-size: 1.25rem; 172 | font-weight: 500; 173 | border: 2px solid var(--border); 174 | border-radius: 12px; 175 | background: var(--container); 176 | color: var(--text); 177 | transition: all var(--transition); 178 | -webkit-appearance: none; 179 | -moz-appearance: textfield; 180 | appearance: none; 181 | } 182 | 183 | .pin-input-container input.pin-input::-webkit-outer-spin-button, 184 | .pin-input-container input.pin-input::-webkit-inner-spin-button { 185 | -webkit-appearance: none; 186 | margin: 0; 187 | } 188 | 189 | .pin-input-container input.pin-input:focus { 190 | outline: none; 191 | border-color: var(--primary); 192 | box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.15); 193 | } 194 | 195 | .pin-input-container input.pin-input.has-value { 196 | border-color: var(--primary); 197 | background-color: var(--primary); 198 | color: white; 199 | } 200 | 201 | .pin-error { 202 | color: #ff4444; 203 | font-size: 0.9rem; 204 | margin-top: 0.5rem; 205 | text-align: center; 206 | display: none; 207 | opacity: 0; 208 | transform: translateY(-10px); 209 | transition: opacity var(--transition), transform var(--transition); 210 | } 211 | 212 | .pin-error[aria-hidden="false"] { 213 | display: block; 214 | opacity: 1; 215 | transform: translateY(0); 216 | } 217 | 218 | /* Responsive adjustments */ 219 | @media (max-width: 400px) { 220 | form, .container { 221 | padding: 3rem 0.75rem 1.5rem; 222 | width: calc(100% - 1rem); 223 | } 224 | 225 | .pin-input-container { 226 | gap: 0.4rem; 227 | padding: 0; 228 | } 229 | 230 | .pin-input-container input.pin-input { 231 | width: 36px; 232 | height: 36px; 233 | font-size: 1rem; 234 | border-radius: 8px; 235 | } 236 | 237 | h1 { 238 | font-size: 1.75rem; 239 | } 240 | } 241 | 242 | @media (max-width: 320px) { 243 | .pin-input-container input.pin-input { 244 | width: 32px; 245 | height: 32px; 246 | } 247 | } 248 | 249 | /* Header and Date Range Selector */ 250 | header { 251 | text-align: center; 252 | margin-bottom: 2rem; 253 | } 254 | 255 | .date-range-selector { 256 | margin: 1.5rem auto; 257 | padding: 1rem; 258 | background: var(--card-bg); 259 | border-radius: 8px; 260 | box-shadow: var(--shadow); 261 | width: 60%; 262 | } 263 | 264 | .date-input-group { 265 | display: flex; 266 | align-items: center; 267 | justify-content: center; 268 | gap: 0.25rem; 269 | } 270 | 271 | .date-input-group input[type="date"] { 272 | padding: 0.5rem; 273 | border: 1px solid var(--border); 274 | border-radius: 6px; 275 | background: var(--container); 276 | color: var(--text); 277 | font-size: 0.875rem; 278 | font-family: inherit; 279 | width: auto; 280 | min-width: 45%; 281 | text-align: center; 282 | } 283 | 284 | .date-input-group input[type="date"]::-webkit-calendar-picker-indicator { 285 | filter: invert(var(--is-dark, 0)); 286 | opacity: 0.5; 287 | cursor: pointer; 288 | } 289 | 290 | .date-input-group input[type="date"]::-webkit-calendar-picker-indicator:hover { 291 | opacity: 0.8; 292 | } 293 | 294 | .date-separator { 295 | color: var(--text); 296 | opacity: 0.7; 297 | font-size: 0.875rem; 298 | } 299 | 300 | @media (max-width: 670px) { 301 | .date-input-group { 302 | flex-direction: column; 303 | gap: 0.25rem; 304 | } 305 | 306 | .date-input-group input[type="date"] { 307 | width: 100%; 308 | } 309 | } 310 | 311 | /* Overview Section */ 312 | .overview { 313 | margin-bottom: 1rem; 314 | } 315 | 316 | .balance-card { 317 | background: var(--card-bg); 318 | padding: 0.5rem; 319 | border-radius: 8px; 320 | box-shadow: var(--shadow); 321 | text-align: center; 322 | margin-bottom: 0.5rem; 323 | } 324 | 325 | .summary-cards { 326 | display: grid; 327 | grid-template-columns: 1fr 1fr; 328 | gap: 0.5rem; 329 | margin-bottom: 1rem; 330 | } 331 | 332 | .card { 333 | background: var(--card-bg); 334 | padding: 0.5rem; 335 | border-radius: 8px; 336 | box-shadow: var(--shadow); 337 | text-align: center; 338 | } 339 | 340 | .card.income { 341 | border-left: 4px solid var(--success); 342 | } 343 | 344 | .card.expenses { 345 | border-left: 4px solid var(--danger); 346 | } 347 | 348 | .amount { 349 | font-size: 1.25rem; 350 | font-weight: 600; 351 | margin-top: 0; 352 | line-height: 1.2; 353 | } 354 | 355 | .amount.positive { 356 | color: var(--success); 357 | } 358 | 359 | .amount.negative { 360 | color: var(--danger); 361 | } 362 | 363 | /* Quick Add Form */ 364 | .quick-add { 365 | margin-bottom: 1rem; 366 | } 367 | 368 | #transactionForm { 369 | display: grid; 370 | gap: 0.5rem; 371 | padding: 0.75rem; 372 | background: var(--card-bg); 373 | border-radius: 8px; 374 | box-shadow: var(--shadow); 375 | max-width: 100%; 376 | transform: none; 377 | } 378 | 379 | #transactionForm select, 380 | #transactionForm input { 381 | padding: 0.5rem; 382 | border: 1px solid var(--border); 383 | border-radius: 6px; 384 | background: var(--container); 385 | color: var(--text); 386 | font-size: 0.875rem; 387 | width: 100%; 388 | } 389 | 390 | #transactionForm button { 391 | background: var(--primary); 392 | color: white; 393 | border: none; 394 | padding: 0.5rem; 395 | border-radius: 6px; 396 | cursor: pointer; 397 | font-size: 0.875rem; 398 | transition: background-color var(--transition); 399 | } 400 | 401 | #transactionForm button:hover { 402 | background: var(--primary-hover); 403 | } 404 | 405 | /* Transactions List */ 406 | .transactions { 407 | background: var(--card-bg); 408 | padding: 0.75rem; 409 | border-radius: 8px; 410 | box-shadow: var(--shadow); 411 | margin-bottom: 1rem; 412 | } 413 | 414 | .transactions-header { 415 | display: flex; 416 | align-items: center; 417 | justify-content: space-between; 418 | margin-bottom: 1rem; 419 | } 420 | 421 | .sort-controls { 422 | display: flex; 423 | align-items: center; 424 | gap: 0.5rem; 425 | } 426 | 427 | .sort-type-toggle { 428 | display: flex; 429 | gap: 0; 430 | background: var(--background); 431 | padding: 0.25rem; 432 | border-radius: 100px; 433 | } 434 | 435 | .sort-btn { 436 | background: none; 437 | border: none; 438 | padding: 0.5rem; 439 | cursor: pointer; 440 | border-radius: 100px; 441 | display: flex; 442 | align-items: center; 443 | justify-content: center; 444 | transition: all var(--transition); 445 | opacity: 0.7; 446 | min-width: 32px; 447 | min-height: 32px; 448 | } 449 | 450 | .currency-sort-symbol { 451 | font-size: 1.2rem; 452 | font-weight: 600; 453 | color: var(--text); 454 | line-height: 1; 455 | } 456 | 457 | .sort-btn svg { 458 | width: 16px; 459 | height: 16px; 460 | stroke: var(--text); 461 | } 462 | 463 | .sort-btn:hover { 464 | opacity: 1; 465 | background: rgba(128, 128, 128, 0.1); 466 | } 467 | 468 | .sort-btn.active { 469 | background: var(--container); 470 | opacity: 1; 471 | box-shadow: var(--shadow); 472 | } 473 | 474 | .sort-direction-btn { 475 | background: none; 476 | border: none; 477 | padding: 0.5rem; 478 | cursor: pointer; 479 | border-radius: 100px; 480 | display: flex; 481 | align-items: center; 482 | justify-content: center; 483 | transition: all var(--transition); 484 | opacity: 0.7; 485 | } 486 | 487 | .sort-direction-btn:hover { 488 | opacity: 1; 489 | background: rgba(128, 128, 128, 0.1); 490 | } 491 | 492 | .sort-direction-btn svg { 493 | width: 16px; 494 | height: 16px; 495 | stroke: var(--text); 496 | transition: transform var(--transition); 497 | } 498 | 499 | .sort-direction-btn.descending svg { 500 | transform: rotate(180deg); 501 | } 502 | 503 | #transactionsList { 504 | display: flex; 505 | flex-direction: column; 506 | gap: 0.5rem; 507 | } 508 | 509 | /* Update Transaction Item Styles */ 510 | .transaction-item { 511 | display: flex; 512 | align-items: center; 513 | justify-content: space-between; 514 | padding: 0.75rem; 515 | border-radius: 6px; 516 | background: var(--container); 517 | border: 1px solid var(--border); 518 | font-size: 0.875rem; 519 | cursor: pointer; 520 | transition: all var(--transition); 521 | } 522 | 523 | .transaction-item:hover { 524 | background: rgba(128, 128, 128, 0.05); 525 | } 526 | 527 | .transaction-content { 528 | display: flex; 529 | align-items: center; 530 | justify-content: space-between; 531 | flex: 1; 532 | margin-right: 0.5rem; 533 | } 534 | 535 | .delete-transaction { 536 | background: none; 537 | border: none; 538 | padding: 0.25rem; 539 | cursor: pointer; 540 | border-radius: 50%; 541 | display: flex; 542 | align-items: center; 543 | justify-content: center; 544 | opacity: 0.5; 545 | transition: all var(--transition); 546 | } 547 | 548 | .delete-transaction:hover { 549 | opacity: 1; 550 | background: rgba(244, 67, 54, 0.1); 551 | } 552 | 553 | .delete-transaction svg { 554 | width: 16px; 555 | height: 16px; 556 | stroke: var(--danger); 557 | } 558 | 559 | .transaction-item .details { 560 | display: flex; 561 | flex-direction: column; 562 | gap: 0.125rem; 563 | } 564 | 565 | .transaction-item .description { 566 | font-weight: 500; 567 | } 568 | 569 | .transaction-item .metadata { 570 | display: flex; 571 | align-items: center; 572 | gap: 0.5rem; 573 | font-size: 0.75rem; 574 | opacity: 0.7; 575 | } 576 | 577 | .transaction-item .category { 578 | color: var(--text); 579 | } 580 | 581 | .transaction-item .date { 582 | color: var(--text); 583 | } 584 | 585 | .transaction-item .metadata .category::after { 586 | content: "•"; 587 | margin-left: 0.5rem; 588 | } 589 | 590 | .transaction-item .transaction-amount { 591 | font-weight: 600; 592 | } 593 | 594 | .transaction-item .transaction-amount.income { 595 | color: var(--success); 596 | } 597 | 598 | .transaction-item .transaction-amount.expense { 599 | color: var(--danger); 600 | } 601 | 602 | .transaction-item .recurring-info { 603 | color: var(--primary); 604 | font-size: 0.75rem; 605 | opacity: 0.8; 606 | } 607 | 608 | .transaction-item .metadata .recurring-info::before { 609 | content: "•"; 610 | margin: 0 0.5rem; 611 | color: var(--text); 612 | opacity: 0.7; 613 | } 614 | 615 | /* Footer */ 616 | footer { 617 | display: flex; 618 | justify-content: center; 619 | align-items: center; 620 | margin-top: 2rem; 621 | padding: 1rem; 622 | width: 100%; 623 | } 624 | 625 | .export-buttons { 626 | display: flex; 627 | gap: 0.5rem; 628 | align-items: center; 629 | justify-content: center; 630 | background: var(--container); 631 | padding: 0.5rem; 632 | border-radius: 6px; 633 | border: 1px solid var(--border); 634 | margin: 0 auto; 635 | width: fit-content; 636 | } 637 | 638 | .export-btn { 639 | all: unset; 640 | background: transparent; 641 | border: 1px solid var(--border); 642 | padding: 0.5rem 1rem; 643 | cursor: pointer; 644 | color: var(--text); 645 | border-radius: 4px; 646 | transition: all 0.2s; 647 | font-size: 0.9rem; 648 | min-width: 60px; 649 | text-align: center; 650 | display: inline-flex; 651 | align-items: center; 652 | justify-content: center; 653 | height: 32px; 654 | box-sizing: border-box; 655 | } 656 | 657 | .export-btn:hover { 658 | background-color: var(--hover-color); 659 | border-color: var(--accent-color); 660 | } 661 | 662 | .export-btn:active { 663 | transform: translateY(1px); 664 | } 665 | 666 | /* Remove these specific styles */ 667 | #exportBtn { 668 | display: flex; 669 | align-items: center; 670 | justify-content: center; 671 | padding: 0.5rem; 672 | background: none; 673 | border: none; 674 | cursor: pointer; 675 | color: var(--text); 676 | border-radius: 4px; 677 | transition: background-color 0.2s; 678 | } 679 | 680 | #exportBtn:hover { 681 | background-color: rgba(128, 128, 128, 0.1); 682 | } 683 | 684 | #exportBtn svg { 685 | width: 24px; 686 | height: 24px; 687 | stroke: currentColor; 688 | } 689 | 690 | /* Responsive adjustments */ 691 | @media (max-width: 860px) { 692 | .container { 693 | padding: 1rem 0.75rem; 694 | max-width: 100%; 695 | } 696 | 697 | .summary-cards { 698 | grid-template-columns: 1fr; 699 | } 700 | 701 | .amount { 702 | font-size: 1.125rem; 703 | } 704 | 705 | .transaction-item { 706 | padding: 0.5rem; 707 | } 708 | 709 | .delete-transaction svg { 710 | width: 14px; 711 | height: 14px; 712 | } 713 | } 714 | 715 | /* Add Transaction Button */ 716 | .add-transaction-btn { 717 | width: auto; 718 | background: var(--primary); 719 | color: white; 720 | border: none; 721 | padding: 0.5rem 1.5rem; 722 | border-radius: 6px; 723 | cursor: pointer; 724 | font-size: 0.875rem; 725 | margin: 0 auto 1rem; 726 | display: block; 727 | transition: background-color var(--transition); 728 | } 729 | 730 | .add-transaction-btn:hover { 731 | background: var(--primary-hover); 732 | } 733 | 734 | @media (max-width: 600px) { 735 | .add-transaction-btn { 736 | width: auto; 737 | padding: 0.5rem 2rem; 738 | } 739 | } 740 | 741 | /* Modal Styles */ 742 | .modal { 743 | display: none; 744 | position: fixed; 745 | top: 0; 746 | left: 0; 747 | width: 100%; 748 | height: 100%; 749 | background: rgba(0, 0, 0, 0.5); 750 | z-index: 1000; 751 | align-items: center; 752 | justify-content: center; 753 | } 754 | 755 | .modal.active { 756 | display: flex; 757 | } 758 | 759 | .modal-content { 760 | background: var(--container); 761 | padding: 1rem; 762 | border-radius: 8px; 763 | box-shadow: var(--shadow); 764 | width: 90%; 765 | max-width: 400px; 766 | position: relative; 767 | } 768 | 769 | .modal-header { 770 | display: flex; 771 | flex-direction: column; 772 | align-items: center; 773 | margin-bottom: 1.5rem; 774 | position: relative; 775 | } 776 | 777 | .close-modal { 778 | position: absolute; 779 | right: -0.5rem; 780 | top: -0.5rem; 781 | background: none; 782 | border: none; 783 | color: var(--text); 784 | font-size: 1.25rem; 785 | cursor: pointer; 786 | padding: 0.25rem; 787 | line-height: 1; 788 | opacity: 0.7; 789 | transition: opacity var(--transition); 790 | } 791 | 792 | .close-modal:hover { 793 | opacity: 1; 794 | } 795 | 796 | .transaction-type-toggle { 797 | display: flex; 798 | gap: 0; 799 | width: 100%; 800 | max-width: 280px; 801 | background: var(--background); 802 | padding: 0.25rem; 803 | border-radius: 100px; 804 | } 805 | 806 | .toggle-btn { 807 | flex: 1; 808 | padding: 0.5rem 1rem; 809 | border: none; 810 | background: transparent; 811 | color: var(--text); 812 | font-size: 0.875rem; 813 | cursor: pointer; 814 | transition: all var(--transition); 815 | border-radius: 100px; 816 | opacity: 0.7; 817 | font-weight: 500; 818 | } 819 | 820 | .toggle-btn:hover { 821 | opacity: 1; 822 | } 823 | 824 | .toggle-btn.active { 825 | background: var(--container); 826 | color: var(--primary); 827 | opacity: 1; 828 | box-shadow: var(--shadow); 829 | } 830 | 831 | #transactionForm { 832 | display: grid; 833 | gap: 0.75rem; 834 | } 835 | 836 | #transactionForm input, 837 | #transactionForm select { 838 | padding: 0.5rem; 839 | border: 1px solid var(--border); 840 | border-radius: 6px; 841 | background: var(--container); 842 | color: var(--text); 843 | font-size: 0.875rem; 844 | width: 100%; 845 | } 846 | 847 | #transactionForm button { 848 | background: var(--primary); 849 | color: white; 850 | border: none; 851 | padding: 0.5rem; 852 | border-radius: 6px; 853 | cursor: pointer; 854 | font-size: 0.875rem; 855 | transition: background-color var(--transition); 856 | } 857 | 858 | #transactionForm button:hover { 859 | background: var(--primary-hover); 860 | } 861 | 862 | /* Amount Input Styling */ 863 | .amount-input-wrapper { 864 | position: relative; 865 | display: flex; 866 | align-items: center; 867 | } 868 | 869 | .currency-symbol { 870 | position: absolute; 871 | left: 0.5rem; 872 | color: var(--text); 873 | opacity: 0.7; 874 | font-size: 0.875rem; 875 | pointer-events: none; 876 | } 877 | 878 | .amount-input-wrapper input { 879 | padding-left: 1.25rem !important; 880 | } 881 | 882 | /* Remove spinner arrows */ 883 | .amount-input-wrapper input::-webkit-outer-spin-button, 884 | .amount-input-wrapper input::-webkit-inner-spin-button { 885 | -webkit-appearance: none; 886 | margin: 0; 887 | } 888 | 889 | .amount-input-wrapper input[type=number] { 890 | -moz-appearance: textfield; 891 | appearance: none; 892 | } 893 | 894 | #transactionForm input[type="date"] { 895 | font-family: inherit; 896 | color: var(--text); 897 | opacity: 0.9; 898 | } 899 | 900 | #transactionForm input[type="date"]::-webkit-calendar-picker-indicator { 901 | filter: invert(var(--is-dark, 0)); 902 | opacity: 0.5; 903 | cursor: pointer; 904 | } 905 | 906 | #transactionForm input[type="date"]::-webkit-calendar-picker-indicator:hover { 907 | opacity: 0.8; 908 | } 909 | 910 | /* Card Headers and Filter Buttons */ 911 | .card-header { 912 | display: flex; 913 | align-items: center; 914 | justify-content: center; 915 | gap: 0.5rem; 916 | margin-bottom: 0.125rem; 917 | } 918 | 919 | .filter-btn { 920 | background: none; 921 | border: none; 922 | padding: 0.25rem; 923 | cursor: pointer; 924 | border-radius: 50%; 925 | display: flex; 926 | align-items: center; 927 | justify-content: center; 928 | transition: all var(--transition); 929 | opacity: 0.5; 930 | } 931 | 932 | .filter-btn:hover { 933 | opacity: 0.8; 934 | background: rgba(128, 128, 128, 0.1); 935 | } 936 | 937 | .filter-btn.active { 938 | opacity: 1; 939 | background: rgba(128, 128, 128, 0.1); 940 | } 941 | 942 | .filter-btn svg { 943 | width: 14px; 944 | height: 14px; 945 | stroke: var(--text); 946 | } 947 | 948 | .card.income .filter-btn.active svg { 949 | stroke: var(--success); 950 | } 951 | 952 | .card.expenses .filter-btn.active svg { 953 | stroke: var(--danger); 954 | } 955 | 956 | /* Recurring transaction controls */ 957 | .recurring-controls { 958 | margin-top: 0.75rem; 959 | padding-top: 0.75rem; 960 | border-top: 1px solid var(--border); 961 | } 962 | 963 | .recurring-checkbox-wrapper { 964 | display: flex; 965 | align-items: center; 966 | gap: 0.5rem; 967 | margin-bottom: 0.5rem; 968 | } 969 | 970 | .recurring-checkbox-wrapper label { 971 | color: var(--text); 972 | font-size: 0.875rem; 973 | } 974 | 975 | .recurring-options { 976 | display: grid; 977 | gap: 0.75rem; 978 | } 979 | 980 | .interval-wrapper { 981 | display: flex; 982 | align-items: center; 983 | gap: 0.5rem; 984 | } 985 | 986 | .interval-wrapper input[type="number"] { 987 | width: 60px; 988 | padding: 0.5rem; 989 | border: 1px solid var(--border); 990 | border-radius: 6px; 991 | background: var(--container); 992 | color: var(--text); 993 | } 994 | 995 | .interval-wrapper select, 996 | #recurring-weekday { 997 | padding: 0.5rem; 998 | border: 1px solid var(--border); 999 | border-radius: 6px; 1000 | background: var(--container); 1001 | color: var(--text); 1002 | font-size: 0.875rem; 1003 | } 1004 | 1005 | /* Style for recurring transaction instances in the list */ 1006 | .transaction-item.recurring-instance { 1007 | border-left: 3px solid var(--primary); 1008 | } 1009 | 1010 | .transaction-item.recurring-instance::before { 1011 | content: "↻"; 1012 | margin-right: 0.5rem; 1013 | color: var(--primary); 1014 | } 1015 | 1016 | /* Custom Category Field Styles */ 1017 | #customCategoryField { 1018 | margin-top: 10px; 1019 | display: flex; 1020 | gap: 8px; 1021 | align-items: center; 1022 | } 1023 | 1024 | #customCategory { 1025 | flex: 1; 1026 | padding: 8px 12px; 1027 | border: 1px solid var(--border); 1028 | border-radius: 4px; 1029 | background: var(--container); 1030 | color: var(--text); 1031 | } 1032 | 1033 | #customCategoryField button { 1034 | padding: 8px 12px; 1035 | border: none; 1036 | border-radius: 4px; 1037 | cursor: pointer; 1038 | transition: var(--transition); 1039 | font-size: 14px; 1040 | } 1041 | 1042 | #saveCategory { 1043 | background: var(--success); 1044 | color: white; 1045 | } 1046 | 1047 | #saveCategory:hover { 1048 | background: color-mix(in srgb, var(--success) 85%, black); 1049 | } 1050 | 1051 | #cancelCategory { 1052 | background: var(--danger); 1053 | color: white; 1054 | } 1055 | 1056 | #cancelCategory:hover { 1057 | background: color-mix(in srgb, var(--danger) 85%, black); 1058 | } 1059 | 1060 | .toast-container { 1061 | position: fixed; 1062 | bottom: 1rem; 1063 | left: 50%; 1064 | transform: translateX(-50%); 1065 | padding: 0.5rem 1rem; 1066 | display: flex; 1067 | flex-direction: column; 1068 | gap: 10px; 1069 | z-index: 2000; 1070 | } 1071 | 1072 | .toast { 1073 | color: #ffffff; 1074 | padding: 0.5rem 1rem; 1075 | border-radius: 20px; 1076 | opacity: 0; 1077 | transition: opacity 0.3s ease-in-out; 1078 | max-width: 300px; 1079 | box-sizing: border-box; 1080 | word-wrap: break-word; 1081 | font-size: 0.875rem; 1082 | cursor: pointer; 1083 | text-align: center; 1084 | } 1085 | 1086 | .toast.show { 1087 | opacity: 1; 1088 | } 1089 | 1090 | .toast.success { 1091 | background-color: var(--success-status-bg); 1092 | } 1093 | 1094 | .toast.error { 1095 | background-color: var(--danger-status-bg); 1096 | } 1097 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Loading... 7 | 8 | 9 | 21 | 22 | 23 | 24 | 25 |
26 |
27 | 43 | 44 |
45 |

DumbBudget

46 |
47 | 48 |
49 |
50 |

Balance

51 |
$0.00
52 |
53 |
54 |
55 |
56 |

Income

57 | 62 |
63 |
$0.00
64 |
65 |
66 |
67 |

Expenses

68 | 73 |
74 |
$0.00
75 |
76 |
77 |
78 | 79 | 80 | 81 |
82 |
83 | 84 | to 85 | 86 |
87 |
88 | 89 |
90 |
91 |

Transactions

92 |
93 |
94 | 102 | 105 |
106 | 112 |
113 |
114 |
115 | 116 |
117 |
118 | 119 |
120 |
121 | 122 | 123 |
124 |
125 |
126 |
127 |
128 | 129 | 130 | 164 | 165 | 166 | 167 | 168 | 169 | -------------------------------------------------------------------------------- /public/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Loading... 7 | 8 | 20 | 21 | 22 | 23 | 24 |
25 |
26 | 42 |

DumbBudget

43 |

Enter PIN

44 |
45 | 46 |
47 |
48 | 49 | 50 | -------------------------------------------------------------------------------- /public/managers/toast.js: -------------------------------------------------------------------------------- 1 | export class ToastManager { 2 | constructor(containerElement) { 3 | this.container = containerElement; 4 | this.isError = 'error'; 5 | this.isSuccess = 'success'; 6 | } 7 | 8 | show(message, type = 'success', isStatic = false, timeoutMs = 2000) { 9 | const toast = document.createElement('div'); 10 | toast.classList.add('toast'); 11 | toast.textContent = message; 12 | 13 | if (type === this.isSuccess) toast.classList.add('success'); 14 | else toast.classList.add('error'); 15 | 16 | this.container.appendChild(toast); 17 | 18 | setTimeout(() => { 19 | toast.addEventListener('click', () => this.hide(toast)); 20 | toast.classList.add('show'); 21 | }, 10); 22 | 23 | if (!isStatic) { 24 | setTimeout(() => { 25 | toast.classList.remove('show'); 26 | setTimeout(() => { 27 | this.hide(toast); 28 | }, 300); // Match transition duration 29 | }, timeoutMs); 30 | } 31 | } 32 | 33 | hide(toast) { 34 | toast.classList.remove('show'); 35 | setTimeout(() => { 36 | this.container.removeChild(toast); 37 | }, 300); 38 | } 39 | 40 | clear() { 41 | // use to clear static toast messages 42 | while (this.container.firstChild) { 43 | this.container.removeChild(this.container.firstChild); 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /public/script.js: -------------------------------------------------------------------------------- 1 | import { ToastManager } from "./managers/toast"; 2 | const toastManager = new ToastManager(document.getElementById('toast-container')); 3 | 4 | // Global variables 5 | let currentTransactionType = 'income'; // Default transaction type 6 | let currentFilter = null; // null = show all, 'income' = only income, 'expense' = only expenses 7 | let editingTransactionId = null; 8 | let currentSortField = 'date'; 9 | let currentSortDirection = 'desc'; 10 | 11 | // Theme toggle functionality 12 | function getBaseUrl() { 13 | // First try to get it from the server-provided meta tag 14 | const metaBaseUrl = document.querySelector('meta[name="base-url"]')?.content; 15 | if (metaBaseUrl) return metaBaseUrl; 16 | 17 | // Fallback to window.location.origin 18 | return window.location.origin; 19 | } 20 | 21 | function initThemeToggle() { 22 | const themeToggle = document.getElementById('themeToggle'); 23 | const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); 24 | 25 | // Set initial theme based on system preference 26 | if (localStorage.getItem('theme') === null) { 27 | document.documentElement.setAttribute('data-theme', prefersDark.matches ? 'dark' : 'light'); 28 | } else { 29 | document.documentElement.setAttribute('data-theme', localStorage.getItem('theme')); 30 | } 31 | 32 | themeToggle.addEventListener('click', () => { 33 | const currentTheme = document.documentElement.getAttribute('data-theme'); 34 | const newTheme = currentTheme === 'light' ? 'dark' : 'light'; 35 | 36 | document.documentElement.setAttribute('data-theme', newTheme); 37 | document.documentElement.style.setProperty('--is-dark', newTheme === 'dark' ? '1' : '0'); 38 | localStorage.setItem('theme', newTheme); 39 | }); 40 | } 41 | 42 | // Debug logging 43 | function debugLog(...args) { 44 | if (window.appConfig?.debug) { 45 | console.log('[DEBUG]', ...args); 46 | } 47 | } 48 | 49 | // Helper function to join paths with base path 50 | function joinPath(path) { 51 | const basePath = window.appConfig?.basePath || ''; 52 | debugLog('joinPath input:', path); 53 | debugLog('basePath:', basePath); 54 | 55 | // If path starts with http(s), return as is 56 | if (path.match(/^https?:\/\//)) { 57 | debugLog('Absolute URL detected, returning as is:', path); 58 | return path; 59 | } 60 | 61 | // Remove any leading slash from path and trailing slash from basePath 62 | const cleanPath = path.replace(/^\/+/, ''); 63 | const cleanBase = basePath.replace(/\/+$/, ''); 64 | 65 | // Join with single slash 66 | const result = cleanBase ? `${cleanBase}/${cleanPath}` : cleanPath; 67 | debugLog('joinPath result:', result); 68 | return result; 69 | } 70 | 71 | // Fetch config with instance name 72 | async function updateInstanceName() { 73 | try { 74 | const res = await fetch(joinPath('api/config'), fetchConfig); 75 | const data = await res.json(); 76 | document.title = data.instanceName; 77 | document.getElementById('instance-name').textContent = data.instanceName; 78 | } catch (error) { 79 | console.error('Error fetching instance name, falling back to default. Error: ', error); 80 | document.title = 'DumbBudget'; 81 | document.getElementById('instance-name').textContent = 'DumbBudget'; 82 | } 83 | } 84 | 85 | // PIN input functionality 86 | function setupPinInputs() { 87 | const form = document.getElementById('pinForm'); 88 | if (!form) return; // Only run on login page 89 | 90 | debugLog('Setting up PIN inputs'); 91 | // Fetch PIN length from server 92 | 93 | fetch(joinPath('pin-length')) 94 | 95 | .then(response => response.json()) 96 | .then(data => { 97 | const pinLength = data.length; 98 | debugLog('PIN length:', pinLength); 99 | const container = document.querySelector('.pin-input-container'); 100 | 101 | // Create PIN input fields 102 | for (let i = 0; i < pinLength; i++) { 103 | const input = document.createElement('input'); 104 | input.type = 'password'; 105 | input.maxLength = 1; 106 | input.className = 'pin-input'; 107 | input.setAttribute('inputmode', 'numeric'); 108 | input.pattern = '[0-9]*'; 109 | input.setAttribute('autocomplete', 'off'); 110 | container.appendChild(input); 111 | } 112 | 113 | // Handle input behavior 114 | const inputs = container.querySelectorAll('.pin-input'); 115 | 116 | // Focus first input immediately 117 | if (inputs.length > 0) { 118 | inputs[0].focus(); 119 | } 120 | 121 | inputs.forEach((input, index) => { 122 | input.addEventListener('input', (e) => { 123 | // Only allow numbers 124 | e.target.value = e.target.value.replace(/[^0-9]/g, ''); 125 | 126 | if (e.target.value) { 127 | e.target.classList.add('has-value'); 128 | if (index < inputs.length - 1) { 129 | inputs[index + 1].focus(); 130 | } else { 131 | // Last digit entered, submit the form 132 | const pin = Array.from(inputs).map(input => input.value).join(''); 133 | submitPin(pin, inputs); 134 | } 135 | } else { 136 | e.target.classList.remove('has-value'); 137 | } 138 | }); 139 | 140 | input.addEventListener('keydown', (e) => { 141 | if (e.key === 'Backspace' && !e.target.value && index > 0) { 142 | inputs[index - 1].focus(); 143 | } 144 | }); 145 | 146 | // Prevent paste of multiple characters 147 | input.addEventListener('paste', (e) => { 148 | e.preventDefault(); 149 | const pastedData = e.clipboardData.getData('text'); 150 | const numbers = pastedData.match(/\d/g); 151 | 152 | if (numbers) { 153 | numbers.forEach((num, i) => { 154 | if (inputs[index + i]) { 155 | inputs[index + i].value = num; 156 | inputs[index + i].classList.add('has-value'); 157 | if (index + i + 1 < inputs.length) { 158 | inputs[index + i + 1].focus(); 159 | } else { 160 | // If paste fills all inputs, submit the form 161 | const pin = Array.from(inputs).map(input => input.value).join(''); 162 | submitPin(pin, inputs); 163 | } 164 | } 165 | }); 166 | } 167 | }); 168 | }); 169 | }); 170 | } 171 | 172 | // Handle PIN submission with debug logging 173 | function submitPin(pin, inputs) { 174 | debugLog('Submitting PIN'); 175 | const errorElement = document.querySelector('.pin-error'); 176 | 177 | 178 | fetch(joinPath('verify-pin'), { 179 | 180 | method: 'POST', 181 | headers: { 182 | 'Content-Type': 'application/json' 183 | }, 184 | body: JSON.stringify({ pin }) 185 | }) 186 | .then(async response => { 187 | const data = await response.json(); 188 | debugLog('PIN verification response:', response.status); 189 | 190 | if (response.ok) { 191 | debugLog('PIN verified, redirecting to home'); 192 | window.location.pathname = joinPath('/'); 193 | } else if (response.status === 429) { 194 | debugLog('Account locked out'); 195 | // Handle lockout 196 | errorElement.textContent = data.error; 197 | errorElement.setAttribute('aria-hidden', 'false'); 198 | inputs.forEach(input => { 199 | input.value = ''; 200 | input.classList.remove('has-value'); 201 | input.disabled = true; 202 | }); 203 | } else { 204 | // Handle invalid PIN 205 | const message = data.attemptsLeft > 0 206 | ? `Incorrect PIN. ${data.attemptsLeft} attempts remaining.` 207 | : 'Incorrect PIN. Last attempt before lockout.'; 208 | 209 | errorElement.textContent = message; 210 | errorElement.setAttribute('aria-hidden', 'false'); 211 | inputs.forEach(input => { 212 | input.value = ''; 213 | input.classList.remove('has-value'); 214 | }); 215 | inputs[0].focus(); 216 | } 217 | }) 218 | .catch(error => { 219 | console.error('Error:', error); 220 | debugLog('PIN verification error:', error); 221 | errorElement.textContent = 'An error occurred. Please try again.'; 222 | errorElement.setAttribute('aria-hidden', 'false'); 223 | }); 224 | } 225 | 226 | // Supported currencies list 227 | const SUPPORTED_CURRENCIES = { 228 | USD: { locale: 'en-US', symbol: '$' }, 229 | EUR: { locale: 'de-DE', symbol: '€' }, 230 | GBP: { locale: 'en-GB', symbol: '£' }, 231 | JPY: { locale: 'ja-JP', symbol: '¥' }, 232 | AUD: { locale: 'en-AU', symbol: 'A$' }, 233 | CAD: { locale: 'en-CA', symbol: 'C$' }, 234 | CHF: { locale: 'de-CH', symbol: 'CHF' }, 235 | CNY: { locale: 'zh-CN', symbol: '¥' }, 236 | HKD: { locale: 'zh-HK', symbol: 'HK$' }, 237 | NZD: { locale: 'en-NZ', symbol: 'NZ$' }, 238 | MXN: { locale: 'es-MX', symbol: '$' }, 239 | RUB: { locale: 'ru-RU', symbol: '₽' }, 240 | SGD: { locale: 'en-SG', symbol: 'S$' }, 241 | KRW: { locale: 'ko-KR', symbol: '₩' }, 242 | INR: { locale: 'en-IN', symbol: '₹' }, 243 | BRL: { locale: 'pt-BR', symbol: 'R$' }, 244 | ZAR: { locale: 'en-ZA', symbol: 'R' }, 245 | TRY: { locale: 'tr-TR', symbol: '₺' }, 246 | PLN: { locale: 'pl-PL', symbol: 'zł' }, 247 | SEK: { locale: 'sv-SE', symbol: 'kr' }, 248 | NOK: { locale: 'nb-NO', symbol: 'kr' }, 249 | DKK: { locale: 'da-DK', symbol: 'kr' }, 250 | IDR: { locale: 'id-ID', symbol: 'Rp' }, 251 | PHP: { locale: 'fil-PH', symbol: '₱' } 252 | }; 253 | 254 | let currentCurrency = 'USD'; // Default currency 255 | 256 | // Fetch current currency from server 257 | async function fetchCurrentCurrency() { 258 | try { 259 | debugLog('Fetching current currency'); 260 | const response = await fetch(joinPath('api/settings/currency'), fetchConfig); 261 | await handleFetchResponse(response); 262 | const data = await response.json(); 263 | currentCurrency = data.currency; 264 | debugLog('Current currency set to:', currentCurrency); 265 | } catch (error) { 266 | console.error('Error fetching currency:', error); 267 | debugLog('Falling back to USD'); 268 | // Fallback to USD if there's an error 269 | currentCurrency = 'USD'; 270 | } 271 | } 272 | 273 | // Update the formatCurrency function to use the current currency 274 | const formatCurrency = (amount) => { 275 | const currencyInfo = SUPPORTED_CURRENCIES[currentCurrency] || SUPPORTED_CURRENCIES.USD; 276 | return new Intl.NumberFormat(currencyInfo.locale, { 277 | style: 'currency', 278 | currency: currentCurrency 279 | }).format(amount); 280 | }; 281 | 282 | let currentDate = new Date(); 283 | 284 | // Shared fetch configuration with debug logging 285 | const fetchConfig = { 286 | credentials: 'include', 287 | headers: { 288 | 'Content-Type': 'application/json' 289 | } 290 | }; 291 | 292 | // Handle session errors - only for main app, not login 293 | async function handleFetchResponse(response) { 294 | debugLog('Fetch response:', response.status, response.url); 295 | 296 | // If we're already on the login page, don't redirect 297 | if (window.location.pathname.includes('login')) { 298 | return response; 299 | } 300 | 301 | // Handle unauthorized responses 302 | if (response.status === 401) { 303 | debugLog('Unauthorized, redirecting to login'); 304 | window.location.href = joinPath('login'); 305 | return null; 306 | } 307 | 308 | // Handle other error responses 309 | if (!response.ok) { 310 | throw new Error(`HTTP error! status: ${response.status}`); 311 | } 312 | 313 | // Check content type 314 | const contentType = response.headers.get('content-type'); 315 | if (!contentType || (!contentType.includes('application/json') && contentType.split(';')[0].trim() !== 'text/csv')) { 316 | debugLog('Response is not JSON or CSV, session likely expired'); 317 | window.location.href = joinPath('login'); 318 | return null; 319 | } 320 | 321 | return response; 322 | } 323 | 324 | // Update loadTransactions function 325 | async function loadTransactions() { 326 | try { 327 | const startDate = document.getElementById('startDate').value; 328 | const endDate = document.getElementById('endDate').value; 329 | const response = await fetch(joinPath(`api/transactions/range?start=${startDate}&end=${endDate}`), fetchConfig); 330 | await handleFetchResponse(response); 331 | const transactions = await response.json(); 332 | 333 | const transactionsList = document.getElementById('transactionsList'); 334 | let filteredTransactions = currentFilter 335 | ? transactions.filter(t => t.type === currentFilter) 336 | : transactions; 337 | 338 | // Sort transactions 339 | filteredTransactions.sort((a, b) => { 340 | if (currentSortField === 'date') { 341 | // Use string comparison for dates to avoid timezone issues 342 | return currentSortDirection === 'asc' 343 | ? a.date.localeCompare(b.date) 344 | : b.date.localeCompare(a.date); 345 | } else { 346 | return currentSortDirection === 'asc' ? a.amount - b.amount : b.amount - a.amount; 347 | } 348 | }); 349 | 350 | transactionsList.innerHTML = filteredTransactions.map(transaction => { 351 | // Split the date string and format as M/D/YYYY without timezone conversion 352 | const [year, month, day] = transaction.date.split('-'); 353 | const formattedDate = `${parseInt(month)}/${parseInt(day)}/${year}`; 354 | 355 | const isRecurring = transaction.isRecurringInstance || transaction.recurring; 356 | 357 | return ` 358 |
359 |
360 |
361 |
${transaction.description}
362 | 367 |
368 |
369 | ${transaction.type === 'expense' ? '-' : ''}${formatCurrency(transaction.amount)} 370 |
371 |
372 | 379 |
380 | `}).join(''); 381 | 382 | // Add click handlers for editing and deleting 383 | transactionsList.querySelectorAll('.transaction-item').forEach(item => { 384 | const deleteBtn = item.querySelector('.delete-transaction'); 385 | const content = item.querySelector('.transaction-content'); 386 | const isRecurring = item.classList.contains('recurring-instance'); 387 | 388 | // Edit handler for all transactions 389 | content.addEventListener('click', () => { 390 | const id = item.dataset.id; 391 | const type = item.dataset.type; 392 | const isRecurring = item.classList.contains('recurring-instance'); 393 | 394 | // For recurring instances, get the parent transaction 395 | let transaction = filteredTransactions.find(t => t.id === id); 396 | if (isRecurring) { 397 | const parentId = id.match(/^[^-]+-[^-]+-[^-]+-[^-]+-[^-]+/)[0]; 398 | transaction = filteredTransactions.find(t => t.id === parentId) || transaction; 399 | } 400 | 401 | editTransaction(id, transaction, isRecurring); 402 | }); 403 | 404 | // Delete handler 405 | deleteBtn.addEventListener('click', async (e) => { 406 | e.stopPropagation(); 407 | const id = item.dataset.id; 408 | const isRecurring = item.classList.contains('recurring-instance'); 409 | 410 | // For recurring instances, get the parent ID (the UUID part before the timestamp) 411 | const transactionId = isRecurring ? id.match(/^[^-]+-[^-]+-[^-]+-[^-]+-[^-]+/)[0] : id; 412 | 413 | const message = isRecurring ? 414 | 'Are you sure you want to delete this recurring transaction? This will delete ALL instances of this transaction.' : 415 | 'Are you sure you want to delete this transaction?'; 416 | 417 | if (confirm(message)) { 418 | try { 419 | debugLog('Deleting transaction with ID:', transactionId); 420 | const response = await fetch(joinPath(`api/transactions/${transactionId}`), { 421 | ...fetchConfig, 422 | method: 'DELETE' 423 | }); 424 | await handleFetchResponse(response); 425 | await loadTransactions(); 426 | await updateTotals(); 427 | toastManager.show('Transaction deleted!', 'error'); 428 | } catch (error) { 429 | console.error('Error deleting transaction:', error); 430 | toastManager.show('Failed to delete transaction. Please try again.', 'error'); 431 | } 432 | } 433 | }); 434 | }); 435 | } catch (error) { 436 | console.error('Error loading transactions:', error); 437 | } 438 | } 439 | 440 | // Update editTransaction function 441 | function editTransaction(id, transaction, isRecurringInstance) { 442 | // For recurring instances, always use the base transaction ID 443 | if (isRecurringInstance) { 444 | // Extract the base transaction ID (everything before the date) 445 | editingTransactionId = id.split('-202')[0]; // This will get the UUID part before the date 446 | 447 | // Find the original transaction to get its start date 448 | const startDate = transaction.recurring?.startDate || transaction.date; 449 | transaction = { ...transaction, date: startDate }; 450 | } else { 451 | editingTransactionId = id; 452 | } 453 | 454 | const modal = document.getElementById('transactionModal'); 455 | const form = document.getElementById('transactionForm'); 456 | const toggleBtns = document.querySelectorAll('.toggle-btn'); 457 | const categoryField = document.getElementById('categoryField'); 458 | const recurringCheckbox = document.getElementById('recurring-checkbox'); 459 | const recurringOptions = document.getElementById('recurring-options'); 460 | const recurringWeekday = document.getElementById('recurring-weekday'); 461 | const recurringInterval = document.getElementById('recurring-interval'); 462 | const recurringUnit = document.getElementById('recurring-unit'); 463 | const dayOfMonthSelect = document.getElementById('day-of-month-select'); 464 | 465 | // Set form values 466 | document.getElementById('amount').value = transaction.amount; 467 | document.getElementById('description').value = transaction.description; 468 | document.getElementById('transactionDate').value = transaction.date; 469 | 470 | // Update the currentTransactionType to match the transaction being edited 471 | currentTransactionType = transaction.type; 472 | 473 | // Set transaction type 474 | toggleBtns.forEach(btn => { 475 | btn.classList.toggle('active', btn.dataset.type === transaction.type); 476 | }); 477 | 478 | // Show/hide and set category for expenses 479 | if (transaction.type === 'expense') { 480 | categoryField.style.display = 'block'; 481 | document.getElementById('category').value = transaction.category; 482 | } else { 483 | categoryField.style.display = 'none'; 484 | } 485 | 486 | // Set recurring options if this is a recurring transaction 487 | if (transaction.recurring) { 488 | recurringCheckbox.checked = true; 489 | recurringOptions.style.display = 'block'; 490 | 491 | // Parse the recurring pattern 492 | const pattern = transaction.recurring.pattern; 493 | const monthlyDayMatch = pattern.match(/every (\d+)(?:st|nd|rd|th) of the month/); 494 | const regularMatch = pattern.match(/every (\d+) (day|week|month|year)(?:\s+on\s+(\w+))?/); 495 | 496 | if (monthlyDayMatch) { 497 | recurringUnit.value = 'day of month'; 498 | dayOfMonthSelect.value = monthlyDayMatch[1]; 499 | dayOfMonthSelect.style.display = 'inline-block'; 500 | recurringInterval.style.display = 'none'; 501 | recurringWeekday.style.display = 'none'; 502 | } else if (regularMatch) { 503 | const [, interval, unit, weekday] = regularMatch; 504 | recurringInterval.value = interval; 505 | recurringUnit.value = unit; 506 | recurringInterval.style.display = 'inline-block'; 507 | dayOfMonthSelect.style.display = 'none'; 508 | 509 | if (unit === 'week' && weekday) { 510 | recurringWeekday.style.display = 'inline-block'; 511 | recurringWeekday.value = weekday; 512 | } else { 513 | recurringWeekday.style.display = 'none'; 514 | } 515 | } 516 | } else { 517 | recurringCheckbox.checked = false; 518 | recurringOptions.style.display = 'none'; 519 | recurringWeekday.style.display = 'none'; 520 | recurringInterval.style.display = 'inline-block'; 521 | dayOfMonthSelect.style.display = 'none'; 522 | } 523 | 524 | // Update form submit button text 525 | const submitBtn = form.querySelector('button[type="submit"]'); 526 | submitBtn.textContent = 'Update'; 527 | 528 | // Show modal 529 | modal.classList.add('active'); 530 | } 531 | 532 | async function updateTotals() { 533 | try { 534 | const startDate = document.getElementById('startDate').value; 535 | const endDate = document.getElementById('endDate').value; 536 | 537 | const response = await fetch(joinPath(`api/totals/range?start=${startDate}&end=${endDate}`), fetchConfig); 538 | await handleFetchResponse(response); 539 | const totals = await response.json(); 540 | document.getElementById('totalIncome').textContent = formatCurrency(totals.income); 541 | document.getElementById('totalExpenses').textContent = formatCurrency(totals.expenses); 542 | const balanceElement = document.getElementById('totalBalance'); 543 | balanceElement.textContent = formatCurrency(totals.balance); 544 | 545 | // Add appropriate class based on balance value 546 | balanceElement.classList.remove('positive', 'negative'); 547 | if (totals.balance > 0) { 548 | balanceElement.classList.add('positive'); 549 | } else if (totals.balance < 0) { 550 | balanceElement.classList.add('negative'); 551 | } 552 | } catch (error) { 553 | console.error('Error updating totals:', error); 554 | } 555 | } 556 | 557 | // Custom Categories Management 558 | function loadCustomCategories() { 559 | const customCategories = JSON.parse(localStorage.getItem('customCategories') || '[]'); 560 | const categorySelect = document.getElementById('category'); 561 | const addNewOption = categorySelect.querySelector('option[value="add_new"]'); 562 | 563 | // Remove existing custom categories 564 | Array.from(categorySelect.options).forEach(option => { 565 | if (option.dataset.custom === 'true') { 566 | categorySelect.removeChild(option); 567 | } 568 | }); 569 | 570 | // Add custom categories before the "Add Category" option 571 | customCategories.forEach(category => { 572 | const option = document.createElement('option'); 573 | option.value = category; 574 | option.textContent = category; 575 | option.dataset.custom = 'true'; 576 | categorySelect.insertBefore(option, addNewOption); 577 | }); 578 | } 579 | 580 | function saveCustomCategory(category) { 581 | try { 582 | const customCategories = JSON.parse(localStorage.getItem('customCategories') || '[]'); 583 | if (!customCategories.includes(category)) { 584 | customCategories.push(category); 585 | localStorage.setItem('customCategories', JSON.stringify(customCategories)); 586 | toastManager.show(`New category (${category}) added!`, 'success'); 587 | } 588 | loadCustomCategories(); 589 | } 590 | catch (error) { 591 | console.error('Error saving custom category:', error); 592 | toastManager.show('Failed to save custom category. Please try again.', 'error'); 593 | } 594 | } 595 | 596 | function initCategoryHandling() { 597 | const categorySelect = document.getElementById('category'); 598 | const customCategoryField = document.getElementById('customCategoryField'); 599 | const customCategoryInput = document.getElementById('customCategory'); 600 | const saveCategoryBtn = document.getElementById('saveCategory'); 601 | const cancelCategoryBtn = document.getElementById('cancelCategory'); 602 | 603 | // Load custom categories on page load 604 | loadCustomCategories(); 605 | 606 | categorySelect.addEventListener('change', (e) => { 607 | if (e.target.value === 'add_new') { 608 | customCategoryField.style.display = 'block'; 609 | categorySelect.style.display = 'none'; 610 | customCategoryInput.focus(); 611 | } 612 | }); 613 | 614 | saveCategoryBtn.addEventListener('click', () => { 615 | const newCategory = customCategoryInput.value.trim(); 616 | if (newCategory) { 617 | saveCustomCategory(newCategory); 618 | customCategoryField.style.display = 'none'; 619 | categorySelect.style.display = 'block'; 620 | categorySelect.value = newCategory; 621 | customCategoryInput.value = ''; 622 | } 623 | }); 624 | 625 | cancelCategoryBtn.addEventListener('click', () => { 626 | customCategoryField.style.display = 'none'; 627 | categorySelect.style.display = 'block'; 628 | categorySelect.value = 'Other'; 629 | customCategoryInput.value = ''; 630 | }); 631 | 632 | // Handle Enter key in custom category input 633 | customCategoryInput.addEventListener('keypress', (e) => { 634 | if (e.key === 'Enter') { 635 | e.preventDefault(); 636 | saveCategoryBtn.click(); 637 | } 638 | }); 639 | } 640 | 641 | // Update the initModalHandling function to include category handling 642 | function initModalHandling() { 643 | const modal = document.getElementById('transactionModal'); 644 | // Only initialize if we're on the main page 645 | if (!modal) return; 646 | 647 | const addTransactionBtn = document.getElementById('addTransactionBtn'); 648 | const closeModalBtn = document.querySelector('.close-modal'); 649 | const transactionForm = document.getElementById('transactionForm'); 650 | const categoryField = document.getElementById('categoryField'); 651 | const toggleBtns = document.querySelectorAll('.toggle-btn'); 652 | const amountInput = document.getElementById('amount'); 653 | 654 | // Initialize category handling 655 | initCategoryHandling(); 656 | 657 | // Create and add recurring controls 658 | const recurringControls = createRecurringControls(); 659 | transactionForm.appendChild(recurringControls); 660 | recurringControls.style.display = 'block'; 661 | 662 | // Update amount input placeholder with current currency symbol 663 | function updateAmountPlaceholder() { 664 | const currencyInfo = SUPPORTED_CURRENCIES[currentCurrency] || SUPPORTED_CURRENCIES.USD; 665 | amountInput.placeholder = `Amount (${currencyInfo.symbol})`; 666 | } 667 | 668 | // Open modal 669 | addTransactionBtn.addEventListener('click', () => { 670 | modal.classList.add('active'); 671 | // Reset form 672 | transactionForm.reset(); 673 | // Reset toggle buttons 674 | toggleBtns.forEach(btn => { 675 | btn.classList.toggle('active', btn.dataset.type === 'income'); 676 | }); 677 | // Hide category field for income by default 678 | categoryField.style.display = 'none'; 679 | currentTransactionType = 'income'; 680 | 681 | // Reset recurring options 682 | document.getElementById('recurring-checkbox').checked = false; 683 | document.getElementById('recurring-options').style.display = 'none'; 684 | document.getElementById('recurring-weekday').style.display = 'none'; 685 | 686 | // Set today's date as default 687 | const today = new Date().toISOString().split('T')[0]; 688 | document.getElementById('transactionDate').value = today; 689 | 690 | // Update amount placeholder with current currency 691 | updateAmountPlaceholder(); 692 | }); 693 | 694 | // Close modal 695 | const closeModal = () => { 696 | modal.classList.remove('active'); 697 | editingTransactionId = null; 698 | const submitBtn = transactionForm.querySelector('button[type="submit"]'); 699 | submitBtn.textContent = 'Add'; 700 | }; 701 | 702 | closeModalBtn.addEventListener('click', closeModal); 703 | 704 | // Close modal when clicking outside 705 | modal.addEventListener('click', (e) => { 706 | if (e.target === modal) { 707 | closeModal(); 708 | } 709 | }); 710 | 711 | // Transaction type toggle 712 | toggleBtns.forEach(btn => { 713 | btn.addEventListener('click', () => { 714 | // Remove active class from all buttons 715 | toggleBtns.forEach(b => b.classList.remove('active')); 716 | // Add active class to clicked button 717 | btn.classList.add('active'); 718 | 719 | currentTransactionType = btn.dataset.type; 720 | 721 | // Show/hide category field based on transaction type 722 | categoryField.style.display = currentTransactionType === 'expense' ? 'block' : 'none'; 723 | }); 724 | }); 725 | 726 | // Update form submission 727 | transactionForm.addEventListener('submit', async (e) => { 728 | e.preventDefault(); 729 | 730 | const formData = { 731 | type: currentTransactionType, 732 | amount: parseFloat(document.getElementById('amount').value), 733 | description: document.getElementById('description').value, 734 | category: currentTransactionType === 'expense' ? document.getElementById('category').value : null, 735 | date: document.getElementById('transactionDate').value, 736 | recurring: buildRecurringPattern() 737 | }; 738 | 739 | try { 740 | const url = editingTransactionId 741 | ? joinPath(`api/transactions/${editingTransactionId}`) 742 | : joinPath('api/transactions'); 743 | 744 | const method = editingTransactionId ? 'PUT' : 'POST'; 745 | 746 | const response = await fetch(url, { 747 | ...fetchConfig, 748 | method, 749 | body: JSON.stringify(formData) 750 | }); 751 | 752 | await handleFetchResponse(response); 753 | 754 | // Reset editing state 755 | editingTransactionId = null; 756 | 757 | // Update submit button text 758 | const submitBtn = transactionForm.querySelector('button[type="submit"]'); 759 | submitBtn.textContent = 'Add'; 760 | 761 | // Close modal and reset form 762 | closeModal(); 763 | transactionForm.reset(); 764 | 765 | // Refresh transactions list and totals 766 | await loadTransactions(); 767 | await updateTotals(); 768 | 769 | const transactionTypeMessage = currentTransactionType === 'income' ? 'Income' : 'Expense'; 770 | toastManager.show(`${transactionTypeMessage} saved!`, 'success'); 771 | } catch (error) { 772 | console.error('Error saving transaction:', error); 773 | toastManager.show('Failed to save transaction. Please try again.', 'error'); 774 | } 775 | }); 776 | } 777 | 778 | // Add recurring transaction UI elements 779 | function createRecurringControls() { 780 | const container = document.createElement('div'); 781 | container.className = 'recurring-controls'; 782 | 783 | const checkboxWrapper = document.createElement('div'); 784 | checkboxWrapper.className = 'recurring-checkbox-wrapper'; 785 | checkboxWrapper.style.display = 'flex'; 786 | checkboxWrapper.style.alignItems = 'center'; 787 | checkboxWrapper.style.gap = '0.5rem'; 788 | checkboxWrapper.style.marginBottom = '1rem'; 789 | checkboxWrapper.style.width = 'fit-content'; 790 | checkboxWrapper.style.minWidth = '100px'; 791 | 792 | const checkbox = document.createElement('input'); 793 | checkbox.type = 'checkbox'; 794 | checkbox.id = 'recurring-checkbox'; 795 | checkbox.style.margin = '0'; 796 | 797 | const label = document.createElement('label'); 798 | label.htmlFor = 'recurring-checkbox'; 799 | label.textContent = 'Recurring'; 800 | label.style.margin = '0'; 801 | label.style.padding = '0'; 802 | label.style.cursor = 'pointer'; 803 | label.style.userSelect = 'none'; 804 | 805 | checkboxWrapper.appendChild(checkbox); 806 | checkboxWrapper.appendChild(label); 807 | 808 | const optionsDiv = document.createElement('div'); 809 | optionsDiv.id = 'recurring-options'; 810 | optionsDiv.style.display = 'none'; 811 | optionsDiv.className = 'recurring-options'; 812 | 813 | // Interval and unit wrapper 814 | const intervalWrapper = document.createElement('div'); 815 | intervalWrapper.className = 'interval-wrapper'; 816 | 817 | // Interval input 818 | const intervalInput = document.createElement('input'); 819 | intervalInput.type = 'number'; 820 | intervalInput.id = 'recurring-interval'; 821 | intervalInput.min = '1'; 822 | intervalInput.defaultValue = '1'; 823 | intervalInput.value = '1'; 824 | 825 | // Day of month select 826 | const dayOfMonthSelect = document.createElement('select'); 827 | dayOfMonthSelect.id = 'day-of-month-select'; 828 | dayOfMonthSelect.style.display = 'none'; 829 | // Add options for days 1-31 830 | for (let i = 1; i <= 31; i++) { 831 | const option = document.createElement('option'); 832 | option.value = i; 833 | option.textContent = `${i}${getDaySuffix(i)}`; 834 | dayOfMonthSelect.appendChild(option); 835 | } 836 | 837 | // Unit select 838 | const unitSelect = document.createElement('select'); 839 | unitSelect.id = 'recurring-unit'; 840 | const units = ['day', 'week', 'month', 'year', 'day of month']; 841 | units.forEach(unit => { 842 | const option = document.createElement('option'); 843 | option.value = unit; 844 | option.textContent = unit === 'day of month' ? 'day of month' : unit + (unit === 'day' ? '' : 's'); 845 | unitSelect.appendChild(option); 846 | }); 847 | 848 | intervalWrapper.appendChild(intervalInput); 849 | intervalWrapper.appendChild(dayOfMonthSelect); 850 | intervalWrapper.appendChild(unitSelect); 851 | 852 | // Weekday select (for weekly recurrence) 853 | const weekdaySelect = document.createElement('select'); 854 | weekdaySelect.id = 'recurring-weekday'; 855 | weekdaySelect.style.display = 'none'; 856 | const weekdays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; 857 | weekdays.forEach(day => { 858 | const option = document.createElement('option'); 859 | option.value = day; 860 | option.textContent = day.charAt(0).toUpperCase() + day.slice(1); 861 | weekdaySelect.appendChild(option); 862 | }); 863 | 864 | // Event listeners 865 | checkbox.addEventListener('change', () => { 866 | optionsDiv.style.display = checkbox.checked ? 'block' : 'none'; 867 | }); 868 | 869 | unitSelect.addEventListener('change', () => { 870 | weekdaySelect.style.display = unitSelect.value === 'week' ? 'inline-block' : 'none'; 871 | intervalInput.style.display = unitSelect.value === 'day of month' ? 'none' : 'inline-block'; 872 | dayOfMonthSelect.style.display = unitSelect.value === 'day of month' ? 'inline-block' : 'none'; 873 | }); 874 | 875 | // Assemble the controls 876 | optionsDiv.appendChild(intervalWrapper); 877 | optionsDiv.appendChild(weekdaySelect); 878 | 879 | container.appendChild(checkboxWrapper); 880 | container.appendChild(optionsDiv); 881 | 882 | return container; 883 | } 884 | 885 | // Function to build the recurring pattern string 886 | function buildRecurringPattern() { 887 | const checkbox = document.getElementById('recurring-checkbox'); 888 | if (!checkbox.checked) return null; 889 | 890 | const unit = document.getElementById('recurring-unit').value; 891 | 892 | if (unit === 'day of month') { 893 | const dayNum = document.getElementById('day-of-month-select').value; 894 | const suffix = getDaySuffix(dayNum); 895 | return { 896 | pattern: `every ${dayNum}${suffix} of the month`, 897 | until: null 898 | }; 899 | } 900 | 901 | const interval = document.getElementById('recurring-interval').value; 902 | const weekday = document.getElementById('recurring-weekday').value; 903 | 904 | let pattern = `every ${interval} ${unit}`; 905 | if (unit === 'week' && weekday) { 906 | pattern += ` on ${weekday}`; 907 | } 908 | 909 | return { 910 | pattern, 911 | until: null 912 | }; 913 | } 914 | 915 | // Helper function to get the correct suffix for a day number 916 | function getDaySuffix(day) { 917 | if (day >= 11 && day <= 13) return 'th'; 918 | 919 | switch (day % 10) { 920 | case 1: return 'st'; 921 | case 2: return 'nd'; 922 | case 3: return 'rd'; 923 | default: return 'th'; 924 | } 925 | } 926 | 927 | // Update the initMainPage function to fetch currency first 928 | async function initMainPage() { 929 | await fetchCurrentCurrency(); 930 | const mainContainer = document.getElementById('transactionModal'); 931 | if (!mainContainer) return; // Only run on main page 932 | 933 | // Update currency symbols 934 | const currencyInfo = SUPPORTED_CURRENCIES[currentCurrency] || SUPPORTED_CURRENCIES.USD; 935 | document.querySelector('.currency-sort-symbol').textContent = currencyInfo.symbol; 936 | document.querySelector('.currency-symbol').textContent = currencyInfo.symbol; 937 | 938 | // Update amount placeholder when currency changes 939 | const amountInput = document.getElementById('amount'); 940 | if (amountInput) { 941 | amountInput.placeholder = `Amount (${currencyInfo.symbol})`; 942 | } 943 | 944 | const startDateInput = document.getElementById('startDate'); 945 | const endDateInput = document.getElementById('endDate'); 946 | 947 | // Set initial date range to current month 948 | const now = new Date(); 949 | const firstDay = new Date(now.getFullYear(), now.getMonth(), 1); 950 | const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0); 951 | 952 | startDateInput.value = firstDay.toISOString().split('T')[0]; 953 | endDateInput.value = lastDay.toISOString().split('T')[0]; 954 | 955 | // Add event listeners for date changes 956 | startDateInput.addEventListener('change', () => { 957 | if (startDateInput.value > endDateInput.value) { 958 | endDateInput.value = startDateInput.value; 959 | } 960 | loadTransactions(); 961 | updateTotals(); 962 | }); 963 | 964 | endDateInput.addEventListener('change', () => { 965 | if (endDateInput.value < startDateInput.value) { 966 | startDateInput.value = endDateInput.value; 967 | } 968 | loadTransactions(); 969 | updateTotals(); 970 | }); 971 | 972 | // Export to CSV 973 | document.getElementById('exportBtn').addEventListener('click', async () => { 974 | try { 975 | const startDate = document.getElementById('startDate').value; 976 | const endDate = document.getElementById('endDate').value; 977 | const response = await fetch(joinPath(`api/export/range?start=${startDate}&end=${endDate}`), { 978 | ...fetchConfig, 979 | method: 'GET' 980 | }); 981 | 982 | // Use the same response handler as other requests 983 | const handledResponse = await handleFetchResponse(response); 984 | if (!handledResponse) return; 985 | 986 | const blob = await handledResponse.blob(); 987 | const url = window.URL.createObjectURL(blob); 988 | const a = document.createElement('a'); 989 | a.href = url; 990 | a.download = `transactions-${startDate}-to-${endDate}.csv`; 991 | document.body.appendChild(a); 992 | a.click(); 993 | window.URL.revokeObjectURL(url); 994 | document.body.removeChild(a); 995 | } catch (error) { 996 | console.error('Error exporting transactions:', error); 997 | alert('Failed to export transactions. Please try again.'); 998 | } 999 | }); 1000 | 1001 | // Export to PDF 1002 | document.getElementById('exportPdfBtn').addEventListener('click', async () => { 1003 | try { 1004 | const startDate = document.getElementById('startDate').value; 1005 | const endDate = document.getElementById('endDate').value; 1006 | 1007 | // Get instance name from server 1008 | const configResponse = await fetch(joinPath('api/config'), fetchConfig); 1009 | await handleFetchResponse(configResponse); 1010 | const config = await configResponse.json(); 1011 | const instanceName = config.instanceName || 'DumbBudget'; 1012 | 1013 | // Get the current totals 1014 | const response = await fetch(joinPath(`api/totals/range?start=${startDate}&end=${endDate}`), fetchConfig); 1015 | await handleFetchResponse(response); 1016 | const totals = await response.json(); 1017 | 1018 | // Get transactions 1019 | const transactionsResponse = await fetch(joinPath(`api/transactions/range?start=${startDate}&end=${endDate}`), fetchConfig); 1020 | await handleFetchResponse(transactionsResponse); 1021 | const transactions = await transactionsResponse.json(); 1022 | 1023 | // Create PDF 1024 | const { jsPDF } = window.jspdf; 1025 | const doc = new jsPDF(); 1026 | 1027 | // Set font 1028 | doc.setFont('helvetica'); 1029 | 1030 | // Add title 1031 | doc.setFontSize(20); 1032 | doc.text(instanceName, 20, 20); 1033 | 1034 | // Add date range 1035 | doc.setFontSize(12); 1036 | doc.text(`Date Range: ${startDate} to ${endDate}`, 20, 30); 1037 | 1038 | // Add totals section 1039 | doc.setFontSize(14); 1040 | doc.text('Summary', 20, 45); 1041 | doc.setFontSize(12); 1042 | doc.text(`Total Income: ${formatCurrency(totals.income)}`, 20, 55); 1043 | doc.text(`Total Expenses: ${formatCurrency(totals.expenses)}`, 20, 65); 1044 | doc.text(`Balance: ${formatCurrency(totals.balance)}`, 20, 75); 1045 | 1046 | // Add transactions table 1047 | const tableData = transactions.map(t => [ 1048 | t.date, 1049 | t.description, 1050 | t.category || '-', 1051 | formatCurrency(t.type === 'expense' ? -t.amount : t.amount), 1052 | t.type 1053 | ]); 1054 | 1055 | doc.autoTable({ 1056 | startY: 85, 1057 | head: [['Date', 'Description', 'Category', 'Amount', 'Type']], 1058 | body: tableData, 1059 | theme: 'grid', 1060 | headStyles: { fillColor: [66, 66, 66] }, 1061 | styles: { fontSize: 10 }, 1062 | columnStyles: { 1063 | 0: { cellWidth: 30 }, // Date 1064 | 1: { cellWidth: 60 }, // Description 1065 | 2: { cellWidth: 30 }, // Category 1066 | 3: { cellWidth: 30 }, // Amount 1067 | 4: { cellWidth: 20 } // Type 1068 | } 1069 | }); 1070 | 1071 | // Save the PDF 1072 | doc.save(`transactions-${startDate}-to-${endDate}.pdf`); 1073 | 1074 | } catch (error) { 1075 | console.error('Error exporting to PDF:', error); 1076 | toastManager.show('Failed to export to PDF. Please try again.', 'error'); 1077 | } 1078 | }); 1079 | 1080 | // Add filter button handlers 1081 | const filterButtons = document.querySelectorAll('.filter-btn'); 1082 | filterButtons.forEach(btn => { 1083 | btn.addEventListener('click', () => { 1084 | const filterType = btn.dataset.filter; 1085 | 1086 | // Remove active class from all buttons 1087 | filterButtons.forEach(b => b.classList.remove('active')); 1088 | 1089 | if (currentFilter === filterType) { 1090 | // If clicking the active filter, clear it 1091 | currentFilter = null; 1092 | } else { 1093 | // Set new filter and activate button 1094 | currentFilter = filterType; 1095 | btn.classList.add('active'); 1096 | } 1097 | 1098 | loadTransactions(); 1099 | }); 1100 | }); 1101 | 1102 | // Initialize sort controls 1103 | const sortButtons = document.querySelectorAll('.sort-btn'); 1104 | const sortDirection = document.getElementById('sortDirection'); 1105 | 1106 | sortButtons.forEach(btn => { 1107 | btn.addEventListener('click', () => { 1108 | // Remove active class from all buttons 1109 | sortButtons.forEach(b => b.classList.remove('active')); 1110 | // Add active class to clicked button 1111 | btn.classList.add('active'); 1112 | 1113 | currentSortField = btn.dataset.sort; 1114 | loadTransactions(); 1115 | }); 1116 | }); 1117 | 1118 | sortDirection.addEventListener('click', () => { 1119 | currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc'; 1120 | sortDirection.classList.toggle('descending', currentSortDirection === 'desc'); 1121 | loadTransactions(); 1122 | }); 1123 | 1124 | // Set initial sort direction indicator 1125 | sortDirection.classList.toggle('descending', currentSortDirection === 'desc'); 1126 | 1127 | // Initial load 1128 | loadTransactions(); 1129 | updateTotals(); 1130 | } 1131 | 1132 | const registerServiceWorker = () => { 1133 | // Register PWA Service Worker 1134 | if ("serviceWorker" in navigator) { 1135 | navigator.serviceWorker.register("/service-worker.js") 1136 | .then((reg) => console.log("Service Worker registered:", reg.scope)) 1137 | .catch((err) => console.log("Service Worker registration failed:", err)); 1138 | } 1139 | } 1140 | 1141 | // Initialize functionality 1142 | 1143 | initThemeToggle(); 1144 | 1145 | // Check which page we're on 1146 | const isLoginPage = window.location.pathname.includes('login'); 1147 | 1148 | if (isLoginPage) { 1149 | // Only initialize PIN inputs on login page 1150 | setupPinInputs(); 1151 | } else { 1152 | // Only initialize main page functionality when not on login 1153 | initModalHandling(); 1154 | initMainPage(); 1155 | } 1156 | 1157 | registerServiceWorker(); 1158 | 1159 | -------------------------------------------------------------------------------- /public/service-worker.js: -------------------------------------------------------------------------------- 1 | const CACHE_NAME = "DUMBBUDGET_PWA_CACHE_V1"; 2 | const ASSETS_TO_CACHE = []; 3 | 4 | const preload = async () => { 5 | console.log("Installing web app"); 6 | return await caches.open(CACHE_NAME) 7 | .then(async (cache) => { 8 | console.log("caching index and important routes"); 9 | const response = await fetch("/asset-manifest.json"); 10 | const assets = await response.json(); 11 | ASSETS_TO_CACHE.push(...assets); 12 | console.log("Assets Cached:", ASSETS_TO_CACHE); 13 | return cache.addAll(ASSETS_TO_CACHE); 14 | }); 15 | } 16 | 17 | // Fetch asset manifest dynamically 18 | globalThis.addEventListener("install", (event) => { 19 | event.waitUntil(preload()); 20 | }); 21 | 22 | globalThis.addEventListener("activate", (event) => { 23 | event.waitUntil(clients.claim()); 24 | }); 25 | 26 | globalThis.addEventListener("fetch", (event) => { 27 | event.respondWith( 28 | caches.match(event.request).then((cachedResponse) => { 29 | return cachedResponse || fetch(event.request); 30 | }) 31 | ); 32 | }); -------------------------------------------------------------------------------- /public/styles.css: -------------------------------------------------------------------------------- 1 | footer { 2 | display: flex; 3 | justify-content: center; 4 | margin-top: 2rem; 5 | padding: 1rem; 6 | text-align: center; 7 | } 8 | 9 | .export-buttons { 10 | display: inline-flex; 11 | gap: 0.5rem; 12 | align-items: center; 13 | justify-content: center; 14 | background: var(--background); 15 | padding: 0.5rem; 16 | border-radius: 6px; 17 | border: 1px solid var(--border); 18 | margin: 0 auto; 19 | } 20 | 21 | .export-btn { 22 | background: transparent; 23 | border: 1px solid var(--border); 24 | padding: 0.5rem 1rem; 25 | cursor: pointer; 26 | color: var(--text); 27 | border-radius: 4px; 28 | transition: all 0.2s; 29 | font-size: 0.9rem; 30 | min-width: 60px; 31 | text-align: center; 32 | display: inline-flex; 33 | align-items: center; 34 | justify-content: center; 35 | height: 32px; 36 | box-sizing: border-box; 37 | margin: 0; 38 | } 39 | 40 | .export-btn:hover { 41 | background-color: var(--hover-color); 42 | border-color: var(--accent-color); 43 | } 44 | 45 | .export-btn:active { 46 | transform: translateY(1px); 47 | } -------------------------------------------------------------------------------- /scripts/cors.js: -------------------------------------------------------------------------------- 1 | const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS || '*'; 2 | const NODE_ENV = process.env.NODE_ENV || 'production'; 3 | let allowedOrigins = []; 4 | 5 | function setupOrigins(baseUrl) { 6 | if (NODE_ENV === 'development' || ALLOWED_ORIGINS === '*') allowedOrigins = '*'; 7 | else if (ALLOWED_ORIGINS && typeof ALLOWED_ORIGINS === 'string') { 8 | try { 9 | allowedOrigins.push(baseUrl); 10 | const allowed = ALLOWED_ORIGINS.split(',').map(origin => origin.trim()); 11 | allowed.forEach(origin => { 12 | const normalizedOrigin = normalizeOrigin(origin); 13 | if (normalizedOrigin !== baseUrl) allowedOrigins.push(normalizedOrigin); 14 | }); 15 | } 16 | catch (error) { 17 | console.error(`Error setting up ALLOWED_ORIGINS: ${ALLOWED_ORIGINS}:`, error); 18 | } 19 | } 20 | console.log("ALLOWED ORIGINS:", allowedOrigins); 21 | return allowedOrigins; 22 | } 23 | 24 | function normalizeOrigin(origin) { 25 | if (origin) { 26 | try { 27 | console.log("Validating Origin:", origin); 28 | const normalizedOrigin = new URL(origin).origin; 29 | console.log("Normalized Url:", normalizedOrigin); 30 | return normalizedOrigin; 31 | } catch (error) { 32 | console.error("Error parsing referer URL:", error); 33 | throw new Error("Error parsing referer URL:", error); 34 | } 35 | } 36 | } 37 | 38 | function validateOrigin(origin) { 39 | if (NODE_ENV === 'development' || allowedOrigins === '*') return true; 40 | 41 | try { 42 | if (origin) origin = normalizeOrigin(origin); 43 | else { 44 | console.warn("No origin to validate."); 45 | return false; 46 | } 47 | 48 | if (allowedOrigins.includes(origin)) return true; 49 | else { 50 | console.warn("Blocked request from origin:", { origin }); 51 | return false; 52 | } 53 | } 54 | catch (error) { 55 | console.error(error); 56 | } 57 | } 58 | 59 | function originValidationMiddleware(req, res, next) { 60 | let origin = `${req.protocol}://${req.headers.host}`; 61 | const isOriginValid = validateOrigin(origin); 62 | 63 | if (isOriginValid) { 64 | return next(); 65 | } else { 66 | console.warn("Blocked request from origin:", { origin }); 67 | res.status(403).json({ error: 'Forbidden' }); 68 | } 69 | } 70 | 71 | 72 | function getCorsOptions(baseUrl) { 73 | const allowedOrigins = setupOrigins(baseUrl); 74 | const corsOptions = { 75 | origin: allowedOrigins, 76 | credentials: true, 77 | methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], 78 | allowedHeaders: ['Content-Type', 'Authorization'], 79 | }; 80 | 81 | return corsOptions; 82 | } 83 | 84 | module.exports = { getCorsOptions, originValidationMiddleware }; -------------------------------------------------------------------------------- /scripts/pwa-manifest-generator.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const ROOT_DIR = path.resolve(__dirname, '..'); // Assuming this script is in a 'scripts' dir 4 | const PUBLIC_DIR = path.join(ROOT_DIR, "public"); 5 | const ROOT_ASSETS_DIR = path.join(ROOT_DIR, 'assets'); 6 | const PUBLIC_ASSETS_DIR = path.join(PUBLIC_DIR, 'assets'); 7 | 8 | function getFiles(dir, basePath = "/") { 9 | let fileList = []; 10 | const files = fs.readdirSync(dir); 11 | 12 | files.forEach((file) => { 13 | const filePath = path.join(dir, file); 14 | const fileUrl = path.join(basePath, file).replace(/\\/g, "/"); 15 | 16 | if (fs.statSync(filePath).isDirectory()) { 17 | fileList = fileList.concat(getFiles(filePath, fileUrl)); 18 | } else { 19 | fileList.push(fileUrl); 20 | } 21 | }); 22 | 23 | return fileList; 24 | } 25 | 26 | function generateAssetManifest() { 27 | const assets = getFiles(PUBLIC_DIR); 28 | fs.writeFileSync(path.join(PUBLIC_ASSETS_DIR, "asset-manifest.json"), JSON.stringify(assets, null, 2)); 29 | console.log("Asset manifest generated!", assets); 30 | } 31 | 32 | function copyRootAssets() { 33 | if (!fs.existsSync(ROOT_ASSETS_DIR)) { 34 | console.log("Assets directory does not exist."); 35 | return; 36 | } 37 | 38 | const logoFiles = fs.readdirSync(ROOT_ASSETS_DIR).filter(file => { 39 | return file.toLowerCase().endsWith('.png') || file.toLowerCase().endsWith('.jpg') || file.toLowerCase().endsWith('.jpeg') || file.toLowerCase().endsWith('.svg'); // Filter for image files 40 | }); 41 | 42 | if (logoFiles.length === 0) { 43 | console.log("No logo files found in assets directory."); 44 | return; 45 | } 46 | 47 | logoFiles.forEach(logoFile => { 48 | const sourcePath = path.join(ROOT_ASSETS_DIR, logoFile); 49 | const destinationPath = path.join(PUBLIC_ASSETS_DIR, logoFile); 50 | 51 | fs.copyFileSync(sourcePath, destinationPath); 52 | console.log(`Copied ${logoFile} to public directory.`); 53 | }); 54 | } 55 | 56 | function generatePWAManifest(siteTitle) { 57 | copyRootAssets(); 58 | generateAssetManifest(); // fetched later in service-worker 59 | 60 | const pwaManifest = { 61 | name: siteTitle, 62 | short_name: siteTitle, 63 | description: "A stupid simple budget app", 64 | start_url: "/", 65 | display: "standalone", 66 | background_color: "#ffffff", 67 | theme_color: "#000000", 68 | icons: [ 69 | { 70 | src: "assets/logo.png", 71 | type: "image/png", 72 | sizes: "192x192" 73 | }, 74 | { 75 | src: "assets/logo.png", 76 | type: "image/png", 77 | sizes: "512x512" 78 | } 79 | ], 80 | orientation: "any" 81 | }; 82 | 83 | fs.writeFileSync(path.join(PUBLIC_ASSETS_DIR, "manifest.json"), JSON.stringify(pwaManifest, null, 2)); 84 | console.log("PWA manifest generated!", pwaManifest); 85 | } 86 | 87 | module.exports = { generatePWAManifest }; -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const dotenv = require('dotenv').config(); 2 | const express = require('express'); 3 | const session = require('express-session'); 4 | const helmet = require('helmet'); 5 | const crypto = require('crypto'); 6 | const path = require('path'); 7 | const cookieParser = require('cookie-parser'); 8 | const fs = require('fs').promises; 9 | const cors = require('cors'); 10 | const { getCorsOptions, originValidationMiddleware } = require('./scripts/cors'); 11 | const { generatePWAManifest } = require('./scripts/pwa-manifest-generator'); 12 | 13 | const app = express(); 14 | const PORT = process.env.PORT || 3000; 15 | const NODE_ENV = process.env.NODE_ENV || 'production'; 16 | const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`; 17 | const SITE_TITLE = process.env.SITE_TITLE || 'DumbBudget'; 18 | const INSTANCE_NAME = process.env.INSTANCE_NAME || ''; 19 | const SITE_INSTANCE_TITLE = INSTANCE_NAME ? `${SITE_TITLE} - ${INSTANCE_NAME}` : SITE_TITLE; 20 | const PUBLIC_DIR = path.join(__dirname, 'public'); 21 | const ASSETS_DIR = path.join(PUBLIC_DIR, 'assets'); 22 | 23 | // Get the project name from package.json to use for the PIN environment variable 24 | const projectName = require('./package.json').name.toUpperCase().replace(/-/g, '_'); 25 | const PIN = process.env[`${projectName}_PIN`]; 26 | 27 | // Ensure data directory exists 28 | const DATA_DIR = path.join(__dirname, 'data'); 29 | const TRANSACTIONS_FILE = path.join(DATA_DIR, 'transactions.json'); 30 | 31 | // Debug logging setup 32 | const DEBUG = process.env.DEBUG === 'TRUE'; 33 | function debugLog(...args) { 34 | if (DEBUG) { 35 | console.log('[DEBUG]', ...args); 36 | } 37 | } 38 | 39 | // Add logging to BASE_PATH extraction 40 | const BASE_PATH = (() => { 41 | if (!process.env.BASE_URL) { 42 | debugLog('No BASE_URL set, using empty base path'); 43 | return ''; 44 | } 45 | try { 46 | const url = new URL(process.env.BASE_URL); 47 | const path = url.pathname.replace(/\/$/, ''); // Remove trailing slash 48 | debugLog('Extracted base path:', path); 49 | return path; 50 | } catch { 51 | // If BASE_URL is just a path (e.g. /budget) 52 | const path = process.env.BASE_URL.replace(/\/$/, ''); 53 | debugLog('Using BASE_URL as path:', path); 54 | return path; 55 | } 56 | })(); 57 | 58 | async function ensureDataDir() { 59 | try { 60 | await fs.access(DATA_DIR); 61 | } catch { 62 | await fs.mkdir(DATA_DIR); 63 | } 64 | } 65 | 66 | async function loadTransactions() { 67 | try { 68 | await fs.access(TRANSACTIONS_FILE); 69 | const data = await fs.readFile(TRANSACTIONS_FILE, 'utf8'); 70 | return JSON.parse(data); 71 | } catch (error) { 72 | // If file doesn't exist or is invalid, return empty structure 73 | return { 74 | [new Date().toISOString().slice(0, 7)]: { 75 | income: [], 76 | expenses: [] 77 | } 78 | }; 79 | } 80 | } 81 | 82 | async function saveTransactions(transactions) { 83 | // Ensure data directory exists before saving 84 | await ensureDataDir(); 85 | await fs.writeFile(TRANSACTIONS_FILE, JSON.stringify(transactions, null, 2)); 86 | } 87 | 88 | // Initialize data directory 89 | ensureDataDir().catch(console.error); 90 | 91 | // Log whether PIN protection is enabled 92 | if (!PIN || PIN.trim() === '') { 93 | console.log('PIN protection is disabled'); 94 | } else { 95 | console.log('PIN protection is enabled'); 96 | } 97 | 98 | // Brute force protection 99 | const loginAttempts = new Map(); 100 | const MAX_ATTEMPTS = 5; 101 | const LOCKOUT_TIME = 15 * 60 * 1000; // 15 minutes in milliseconds 102 | 103 | function resetAttempts(ip) { 104 | loginAttempts.delete(ip); 105 | } 106 | 107 | function isLockedOut(ip) { 108 | const attempts = loginAttempts.get(ip); 109 | if (!attempts) return false; 110 | 111 | if (attempts.count >= MAX_ATTEMPTS) { 112 | const timeElapsed = Date.now() - attempts.lastAttempt; 113 | if (timeElapsed < LOCKOUT_TIME) { 114 | return true; 115 | } 116 | resetAttempts(ip); 117 | } 118 | return false; 119 | } 120 | 121 | function recordAttempt(ip) { 122 | const attempts = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 }; 123 | attempts.count += 1; 124 | attempts.lastAttempt = Date.now(); 125 | loginAttempts.set(ip, attempts); 126 | } 127 | 128 | generatePWAManifest(SITE_INSTANCE_TITLE); 129 | 130 | // Middleware 131 | // Trust proxy - required for secure cookies behind a reverse proxy 132 | app.set('trust proxy', 1); 133 | 134 | // Cors Setup 135 | const corsOptions = getCorsOptions(BASE_URL); 136 | app.use(cors(corsOptions)); 137 | 138 | // Security middleware - minimal configuration like DumbDrop 139 | app.use(helmet({ 140 | contentSecurityPolicy: false, 141 | crossOriginEmbedderPolicy: false, 142 | crossOriginOpenerPolicy: false, 143 | crossOriginResourcePolicy: false, 144 | originAgentCluster: false, 145 | dnsPrefetchControl: false, 146 | frameguard: false, 147 | hsts: false, 148 | ieNoOpen: false, 149 | noSniff: false, 150 | permittedCrossDomainPolicies: false, 151 | referrerPolicy: false, 152 | xssFilter: false 153 | })); 154 | 155 | app.use(express.json()); 156 | app.use(cookieParser()); 157 | 158 | // Session configuration - simplified like DumbDrop 159 | app.use(session({ 160 | secret: crypto.randomBytes(32).toString('hex'), 161 | resave: false, 162 | saveUninitialized: true, 163 | cookie: { 164 | secure: false, 165 | httpOnly: true, 166 | sameSite: 'lax' 167 | } 168 | })); 169 | 170 | // Constant-time PIN comparison to prevent timing attacks 171 | function verifyPin(storedPin, providedPin) { 172 | if (!storedPin || !providedPin) return false; 173 | if (storedPin.length !== providedPin.length) return false; 174 | 175 | try { 176 | return crypto.timingSafeEqual( 177 | Buffer.from(storedPin), 178 | Buffer.from(providedPin) 179 | ); 180 | } catch { 181 | return false; 182 | } 183 | } 184 | 185 | // Add logging to authentication middleware 186 | const authMiddleware = (req, res, next) => { 187 | debugLog('Auth middleware for path:', req.path); 188 | // If no PIN is set, bypass authentication 189 | if (!PIN || PIN.trim() === '') { 190 | debugLog('PIN protection disabled, bypassing auth'); 191 | return next(); 192 | } 193 | 194 | // Check if user is authenticated via session 195 | if (!req.session.authenticated) { 196 | debugLog('User not authenticated, redirecting to login'); 197 | return res.redirect(BASE_PATH + '/login'); 198 | } 199 | debugLog('User authenticated, proceeding'); 200 | next(); 201 | }; 202 | 203 | // Mount all routes under BASE_PATH 204 | app.use(BASE_PATH, express.static('public', { index: false })); 205 | 206 | // Routes 207 | app.get(BASE_PATH + '/', [originValidationMiddleware, authMiddleware], (req, res) => { 208 | res.sendFile(path.join(__dirname, 'public', 'index.html')); 209 | }); 210 | 211 | // Serve the pwa/asset manifest 212 | app.get('/asset-manifest.json', (req, res) => { 213 | // generated in pwa-manifest-generator and fetched from service-worker.js 214 | res.sendFile(path.join(ASSETS_DIR, 'asset-manifest.json')); 215 | }); 216 | app.get('/manifest.json', (req, res) => { 217 | res.sendFile(path.join(ASSETS_DIR, 'manifest.json')); 218 | }); 219 | 220 | app.get('/managers/toast', (req, res) => { 221 | res.sendFile(path.join(PUBLIC_DIR, 'managers', 'toast.js')); 222 | }); 223 | 224 | 225 | app.get(BASE_PATH + '/login', (req, res) => { 226 | if (!PIN || PIN.trim() === '') { 227 | return res.redirect(BASE_PATH + '/'); 228 | } 229 | if (req.session.authenticated) { 230 | return res.redirect(BASE_PATH + '/'); 231 | } 232 | res.sendFile(path.join(__dirname, 'public', 'login.html')); 233 | }); 234 | 235 | app.get(BASE_PATH + '/api/config', (req, res) => { 236 | const instanceName = SITE_INSTANCE_TITLE; 237 | res.json({ instanceName: instanceName }); 238 | }); 239 | 240 | app.get(BASE_PATH + '/pin-length', (req, res) => { 241 | // If no PIN is set, return 0 length 242 | if (!PIN || PIN.trim() === '') { 243 | return res.json({ length: 0 }); 244 | } 245 | res.json({ length: PIN.length }); 246 | }); 247 | 248 | app.post(BASE_PATH + '/verify-pin', (req, res) => { 249 | // If no PIN is set, authentication is successful 250 | if (!PIN || PIN.trim() === '') { 251 | req.session.authenticated = true; 252 | return res.status(200).json({ success: true }); 253 | } 254 | 255 | const ip = req.ip; 256 | 257 | // Check if IP is locked out 258 | if (isLockedOut(ip)) { 259 | const attempts = loginAttempts.get(ip); 260 | const timeLeft = Math.ceil((LOCKOUT_TIME - (Date.now() - attempts.lastAttempt)) / 1000 / 60); 261 | return res.status(429).json({ 262 | error: `Too many attempts. Please try again in ${timeLeft} minutes.` 263 | }); 264 | } 265 | 266 | const { pin } = req.body; 267 | 268 | if (!pin || typeof pin !== 'string') { 269 | return res.status(400).json({ error: 'Invalid PIN format' }); 270 | } 271 | 272 | // Add artificial delay to further prevent timing attacks 273 | const delay = crypto.randomInt(50, 150); 274 | setTimeout(() => { 275 | if (verifyPin(PIN, pin)) { 276 | // Reset attempts on successful login 277 | resetAttempts(ip); 278 | 279 | // Set authentication in session 280 | req.session.authenticated = true; 281 | 282 | // Set secure cookie 283 | res.cookie(`${projectName}_PIN`, pin, { 284 | httpOnly: true, 285 | secure: req.secure || (BASE_URL.startsWith('https') && NODE_ENV === 'production'), 286 | sameSite: 'strict', 287 | maxAge: 24 * 60 * 60 * 1000 // 24 hours 288 | }); 289 | 290 | res.status(200).json({ success: true }); 291 | } else { 292 | // Record failed attempt 293 | recordAttempt(ip); 294 | 295 | const attempts = loginAttempts.get(ip); 296 | const attemptsLeft = MAX_ATTEMPTS - attempts.count; 297 | 298 | res.status(401).json({ 299 | error: 'Invalid PIN', 300 | attemptsLeft: Math.max(0, attemptsLeft) 301 | }); 302 | } 303 | }, delay); 304 | }); 305 | 306 | // Cleanup old lockouts periodically 307 | setInterval(() => { 308 | const now = Date.now(); 309 | for (const [ip, attempts] of loginAttempts.entries()) { 310 | if (now - attempts.lastAttempt >= LOCKOUT_TIME) { 311 | loginAttempts.delete(ip); 312 | } 313 | } 314 | }, 60000); // Clean up every minute 315 | 316 | // Helper function to get transactions within date range 317 | async function getTransactionsInRange(startDate, endDate) { 318 | const transactions = await loadTransactions(); 319 | const allTransactions = []; 320 | const recurringTransactions = []; 321 | 322 | // Collect all transactions within the date range 323 | Object.values(transactions).forEach(month => { 324 | // Safely handle income transactions 325 | if (month && Array.isArray(month.income)) { 326 | month.income.forEach(t => { 327 | if (t.recurring?.pattern) { 328 | // For recurring transactions, only add to recurring array 329 | recurringTransactions.push({ ...t, type: 'income' }); 330 | } else if (t.date >= startDate && t.date <= endDate) { 331 | // For non-recurring, add to all transactions if in range 332 | allTransactions.push({ ...t, type: 'income' }); 333 | } 334 | }); 335 | } 336 | 337 | // Safely handle expense transactions 338 | if (month && Array.isArray(month.expenses)) { 339 | month.expenses.forEach(t => { 340 | if (t.recurring?.pattern) { 341 | // For recurring transactions, only add to recurring array 342 | recurringTransactions.push({ ...t, type: 'expense' }); 343 | } else if (t.date >= startDate && t.date <= endDate) { 344 | // For non-recurring, add to all transactions if in range 345 | allTransactions.push({ ...t, type: 'expense' }); 346 | } 347 | }); 348 | } 349 | }); 350 | 351 | // Generate recurring instances 352 | const recurringInstances = []; 353 | for (const transaction of recurringTransactions) { 354 | recurringInstances.push(...generateRecurringInstances(transaction, startDate, endDate)); 355 | } 356 | 357 | // Combine all transactions and instances 358 | return [...allTransactions, ...recurringInstances] 359 | .sort((a, b) => new Date(b.date) - new Date(a.date)); 360 | } 361 | 362 | // API Routes - all under BASE_PATH 363 | app.post(BASE_PATH + '/api/transactions', authMiddleware, async (req, res) => { 364 | try { 365 | const { type, amount, description, category, date, recurring } = req.body; 366 | 367 | // Basic validation 368 | if (!type || !amount || !description || !date) { 369 | return res.status(400).json({ error: 'Missing required fields' }); 370 | } 371 | if (type !== 'income' && type !== 'expense') { 372 | return res.status(400).json({ error: 'Invalid transaction type' }); 373 | } 374 | if (type === 'expense' && !category) { 375 | return res.status(400).json({ error: 'Category required for expenses' }); 376 | } 377 | 378 | // Validate recurring pattern if present 379 | if (recurring?.pattern) { 380 | const isValidRegular = /every (\d+) (day|week|month|year)s?(?: on (\w+))?/.test(recurring.pattern); 381 | const isValidMonthDay = /every (\d+)(?:st|nd|rd|th) of the month/.test(recurring.pattern); 382 | 383 | if (!isValidRegular && !isValidMonthDay) { 384 | return res.status(400).json({ error: 'Invalid recurring pattern format' }); 385 | } 386 | if (recurring.until && isNaN(new Date(recurring.until).getTime())) { 387 | return res.status(400).json({ error: 'Invalid until date format' }); 388 | } 389 | } 390 | 391 | // For recurring transactions with a weekday pattern, adjust the date to the first occurrence 392 | let adjustedDate = date; 393 | if (recurring?.pattern) { 394 | const pattern = parseRecurringPattern(recurring.pattern); 395 | if (pattern.unit === 'week' && pattern.dayOfWeek) { 396 | const [year, month, day] = date.split('-'); 397 | const selectedDate = new Date(year, month - 1, day); 398 | const weekdays = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; 399 | const targetDay = weekdays.indexOf(pattern.dayOfWeek); 400 | const currentDay = selectedDate.getDay(); 401 | 402 | // Calculate days to add to reach the target weekday 403 | let daysToAdd = targetDay - currentDay; 404 | if (daysToAdd < 0) { 405 | daysToAdd += 7; // Move to next week if target day has passed 406 | } 407 | 408 | // Adjust the date 409 | selectedDate.setDate(selectedDate.getDate() + daysToAdd); 410 | adjustedDate = selectedDate.toISOString().split('T')[0]; 411 | } else if (pattern.unit === 'monthday') { 412 | // For day-of-month pattern, adjust to the first occurrence 413 | const [year, month] = date.split('-'); 414 | const selectedDate = new Date(year, month - 1, pattern.dayOfMonth); 415 | 416 | // If the selected day has passed in the current month, move to next month 417 | if (selectedDate < new Date(date)) { 418 | selectedDate.setMonth(selectedDate.getMonth() + 1); 419 | } 420 | 421 | adjustedDate = selectedDate.toISOString().split('T')[0]; 422 | } 423 | } 424 | 425 | const transactions = await loadTransactions() || {}; 426 | const [year, month] = adjustedDate.split('-'); 427 | const key = `${year}-${month}`; 428 | 429 | // Initialize month structure if it doesn't exist 430 | if (!transactions[key]) { 431 | transactions[key] = { 432 | income: [], 433 | expenses: [] 434 | }; 435 | } 436 | 437 | // Ensure arrays exist 438 | if (!Array.isArray(transactions[key].income)) { 439 | transactions[key].income = []; 440 | } 441 | if (!Array.isArray(transactions[key].expenses)) { 442 | transactions[key].expenses = []; 443 | } 444 | 445 | // Add transaction 446 | const newTransaction = { 447 | id: crypto.randomUUID(), 448 | amount: parseFloat(amount), 449 | description, 450 | date: adjustedDate 451 | }; 452 | 453 | // Add recurring information if present 454 | if (recurring?.pattern) { 455 | newTransaction.recurring = { 456 | pattern: recurring.pattern, 457 | until: recurring.until || null 458 | }; 459 | } 460 | 461 | if (type === 'expense') { 462 | newTransaction.category = category; 463 | transactions[key].expenses.push(newTransaction); 464 | } else { 465 | transactions[key].income.push(newTransaction); 466 | } 467 | 468 | await saveTransactions(transactions); 469 | res.status(201).json(newTransaction); 470 | } catch (error) { 471 | console.error('Error adding transaction:', error); 472 | res.status(500).json({ error: 'Failed to add transaction' }); 473 | } 474 | }); 475 | 476 | app.get(BASE_PATH + '/api/transactions/:year/:month', authMiddleware, async (req, res) => { 477 | try { 478 | const { year, month } = req.params; 479 | const key = `${year}-${month.padStart(2, '0')}`; 480 | const transactions = await loadTransactions(); 481 | 482 | const monthData = transactions[key] || { income: [], expenses: [] }; 483 | 484 | // Combine and sort transactions by date 485 | const allTransactions = [ 486 | ...monthData.income.map(t => ({ ...t, type: 'income' })), 487 | ...monthData.expenses.map(t => ({ ...t, type: 'expense' })) 488 | ].sort((a, b) => new Date(b.date) - new Date(a.date)); 489 | 490 | res.json(allTransactions); 491 | } catch (error) { 492 | console.error('Error fetching transactions:', error); 493 | res.status(500).json({ error: 'Failed to fetch transactions' }); 494 | } 495 | }); 496 | 497 | app.get(BASE_PATH + '/api/totals/:year/:month', authMiddleware, async (req, res) => { 498 | try { 499 | const { year, month } = req.params; 500 | const key = `${year}-${month.padStart(2, '0')}`; 501 | const transactions = await loadTransactions(); 502 | 503 | const monthData = transactions[key] || { income: [], expenses: [] }; 504 | 505 | const totals = { 506 | income: monthData.income.reduce((sum, t) => sum + t.amount, 0), 507 | expenses: monthData.expenses.reduce((sum, t) => sum + t.amount, 0), 508 | balance: 0 509 | }; 510 | 511 | totals.balance = totals.income - totals.expenses; 512 | 513 | res.json(totals); 514 | } catch (error) { 515 | console.error('Error calculating totals:', error); 516 | res.status(500).json({ error: 'Failed to calculate totals' }); 517 | } 518 | }); 519 | 520 | // Helper function to parse recurring pattern 521 | function parseRecurringPattern(pattern) { 522 | // Try matching the existing pattern first 523 | const weeklyMatches = pattern.match(/every (\d+) (day|week|month|year)s?(?: on (\w+))?/); 524 | 525 | // Try matching the "Nth of month" pattern 526 | const monthlyDayMatches = pattern.match(/every (\d+)(?:st|nd|rd|th) of the month/); 527 | 528 | if (weeklyMatches) { 529 | const [_, interval, unit, dayOfWeek] = weeklyMatches; 530 | return { 531 | interval: parseInt(interval), 532 | unit: unit.toLowerCase(), 533 | dayOfWeek: dayOfWeek ? dayOfWeek.toLowerCase() : null 534 | }; 535 | } else if (monthlyDayMatches) { 536 | const [_, dayOfMonth] = monthlyDayMatches; 537 | return { 538 | interval: 1, 539 | unit: 'monthday', 540 | dayOfMonth: parseInt(dayOfMonth) 541 | }; 542 | } 543 | 544 | throw new Error('Invalid recurring pattern'); 545 | } 546 | 547 | // Helper function to generate recurring instances 548 | function generateRecurringInstances(transaction, startDate, endDate) { 549 | if (!transaction.recurring?.pattern) return []; 550 | 551 | const instances = []; 552 | const pattern = parseRecurringPattern(transaction.recurring.pattern); 553 | 554 | // Convert dates to Date objects for easier manipulation 555 | const [tYear, tMonth, tDay] = transaction.date.split('-'); 556 | let currentDate = new Date(tYear, tMonth - 1, tDay); 557 | 558 | const [sYear, sMonth, sDay] = startDate.split('-'); 559 | const rangeStart = new Date(sYear, sMonth - 1, sDay); 560 | 561 | const [eYear, eMonth, eDay] = endDate.split('-'); 562 | const rangeEnd = new Date(eYear, eMonth - 1, eDay); 563 | 564 | const until = transaction.recurring.until 565 | ? (() => { 566 | const [uYear, uMonth, uDay] = transaction.recurring.until.split('-'); 567 | return new Date(uYear, uMonth - 1, uDay); 568 | })() 569 | : rangeEnd; 570 | 571 | // Handle "Nth of month" pattern 572 | if (pattern.unit === 'monthday') { 573 | // Set the initial date to the first occurrence 574 | currentDate = new Date(currentDate.getFullYear(), currentDate.getMonth(), pattern.dayOfMonth); 575 | if (currentDate < new Date(tYear, tMonth - 1, tDay)) { 576 | currentDate.setMonth(currentDate.getMonth() + 1); 577 | } 578 | } 579 | // For weekly patterns, ensure we start on the correct day of the week 580 | else if (pattern.unit === 'week' && pattern.dayOfWeek) { 581 | const weekdays = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; 582 | const targetDay = weekdays.indexOf(pattern.dayOfWeek); 583 | const currentDay = currentDate.getDay(); 584 | 585 | // Calculate days to add to reach the target weekday 586 | let daysToAdd = targetDay - currentDay; 587 | if (daysToAdd < 0) { 588 | daysToAdd += 7; // Move to next week if target day has passed 589 | } 590 | 591 | // Adjust the start date to the first occurrence 592 | currentDate.setDate(currentDate.getDate() + daysToAdd); 593 | 594 | // For intervals greater than 1, we need to ensure we're starting on the correct week 595 | if (pattern.interval > 1) { 596 | // Calculate weeks since the start date 597 | const weeksSinceStart = Math.floor((currentDate - rangeStart) / (7 * 24 * 60 * 60 * 1000)); 598 | const remainingWeeks = weeksSinceStart % pattern.interval; 599 | if (remainingWeeks !== 0) { 600 | // Move forward to the next valid week 601 | currentDate.setDate(currentDate.getDate() + (pattern.interval - remainingWeeks) * 7); 602 | } 603 | } 604 | } 605 | 606 | // Track dates we've already added to prevent duplicates 607 | const addedDates = new Set(); 608 | 609 | // Generate instances 610 | while (currentDate <= until) { 611 | const dateStr = currentDate.toISOString().split('T')[0]; 612 | 613 | // Only add if it falls within range and isn't a duplicate 614 | if (currentDate >= rangeStart && 615 | currentDate <= rangeEnd && 616 | !addedDates.has(dateStr)) { 617 | 618 | instances.push({ 619 | ...transaction, 620 | id: `${transaction.id}-${dateStr}`, 621 | date: dateStr, 622 | isRecurringInstance: true, 623 | recurringParentId: transaction.id 624 | }); 625 | 626 | addedDates.add(dateStr); 627 | } 628 | 629 | // Advance to next occurrence based on interval and unit 630 | switch (pattern.unit) { 631 | case 'day': 632 | currentDate.setDate(currentDate.getDate() + pattern.interval); 633 | break; 634 | case 'week': 635 | currentDate.setDate(currentDate.getDate() + (pattern.interval * 7)); 636 | break; 637 | case 'month': 638 | // Don't modify the current date yet 639 | // Check if we need to handle end-of-month special case 640 | const originalDay = parseInt(transaction.date.split('-')[2]); 641 | const daysInCurrentMonth = new Date( 642 | currentDate.getFullYear(), 643 | currentDate.getMonth() + 1, 644 | 0 645 | ).getDate(); 646 | 647 | // Check if the original transaction was on the last day of its month 648 | const isLastDayOfMonth = originalDay >= daysInCurrentMonth; 649 | 650 | // Get the number of days in the target month (after adding interval) 651 | const daysInTargetMonth = new Date( 652 | currentDate.getFullYear(), 653 | currentDate.getMonth() + pattern.interval + 1, 654 | 0 655 | ).getDate(); 656 | 657 | // First, create a new date for next month with a safe day value (1) 658 | const nextMonth = new Date(currentDate); 659 | nextMonth.setDate(1); // Set to first of month to avoid rollover 660 | nextMonth.setMonth(nextMonth.getMonth() + pattern.interval); 661 | 662 | if (isLastDayOfMonth) { 663 | // If original was last day of month, set to last day of target month 664 | nextMonth.setDate(daysInTargetMonth); 665 | } else if (originalDay > daysInTargetMonth) { 666 | // If original day doesn't exist in target month, use last day 667 | nextMonth.setDate(daysInTargetMonth); 668 | } else { 669 | // Otherwise use the same day of month 670 | nextMonth.setDate(originalDay); 671 | } 672 | 673 | // Update current date with our safely constructed date 674 | currentDate = nextMonth; 675 | break; 676 | case 'year': 677 | currentDate.setFullYear(currentDate.getFullYear() + pattern.interval); 678 | break; 679 | case 'monthday': 680 | // For monthday pattern, move to next month then set the day 681 | currentDate.setMonth(currentDate.getMonth() + 1); 682 | 683 | // Get the last day of the target month 684 | const lastDayOfMonth = new Date( 685 | currentDate.getFullYear(), 686 | currentDate.getMonth() + 1, 687 | 0 688 | ).getDate(); 689 | 690 | // If the desired day exceeds the last day, use the last day instead 691 | if (pattern.dayOfMonth > lastDayOfMonth) { 692 | currentDate.setDate(lastDayOfMonth); 693 | } else { 694 | currentDate.setDate(pattern.dayOfMonth); 695 | } 696 | break; 697 | } 698 | } 699 | 700 | return instances; 701 | } 702 | 703 | // Update the range endpoint to include recurring instances 704 | app.get(BASE_PATH + '/api/transactions/range', authMiddleware, async (req, res) => { 705 | try { 706 | const { start, end } = req.query; 707 | if (!start || !end) { 708 | return res.status(400).json({ error: 'Start and end dates are required' }); 709 | } 710 | 711 | // Get transactions with recurring instances already included 712 | const transactions = await getTransactionsInRange(start, end); 713 | 714 | // Sort by date 715 | transactions.sort((a, b) => new Date(b.date) - new Date(a.date)); 716 | 717 | res.json(transactions); 718 | } catch (error) { 719 | console.error('Error fetching transactions:', error); 720 | res.status(500).json({ error: 'Failed to fetch transactions' }); 721 | } 722 | }); 723 | 724 | app.get(BASE_PATH + '/api/totals/range', authMiddleware, async (req, res) => { 725 | try { 726 | const { start, end } = req.query; 727 | if (!start || !end) { 728 | return res.status(400).json({ error: 'Start and end dates are required' }); 729 | } 730 | 731 | const transactions = await getTransactionsInRange(start, end); 732 | 733 | const totals = { 734 | income: transactions 735 | .filter(t => t.type === 'income') 736 | .reduce((sum, t) => sum + t.amount, 0), 737 | expenses: transactions 738 | .filter(t => t.type === 'expense') 739 | .reduce((sum, t) => sum + t.amount, 0), 740 | balance: 0 741 | }; 742 | 743 | totals.balance = totals.income - totals.expenses; 744 | 745 | res.json(totals); 746 | } catch (error) { 747 | console.error('Error calculating totals:', error); 748 | res.status(500).json({ error: 'Failed to calculate totals' }); 749 | } 750 | }); 751 | 752 | app.get(BASE_PATH + '/api/export/:year/:month', authMiddleware, async (req, res) => { 753 | try { 754 | const { year, month } = req.params; 755 | const key = `${year}-${month.padStart(2, '0')}`; 756 | const transactions = await loadTransactions(); 757 | 758 | const monthData = transactions[key] || { income: [], expenses: [] }; 759 | 760 | // Combine all transactions 761 | const allTransactions = [ 762 | ...monthData.income.map(t => ({ ...t, type: 'income' })), 763 | ...monthData.expenses.map(t => ({ ...t, type: 'expense' })) 764 | ].sort((a, b) => new Date(b.date) - new Date(a.date)); 765 | 766 | // Convert to CSV 767 | const csvRows = ['Date,Type,Category,Description,Amount']; 768 | allTransactions.forEach(t => { 769 | csvRows.push(`${t.date},${t.type},${t.category || ''},${t.description},${t.amount}`); 770 | }); 771 | 772 | res.setHeader('Content-Type', 'text/csv'); 773 | res.setHeader('Content-Disposition', `attachment; filename=transactions-${key}.csv`); 774 | res.send(csvRows.join('\n')); 775 | } catch (error) { 776 | console.error('Error exporting transactions:', error); 777 | res.status(500).json({ error: 'Failed to export transactions' }); 778 | } 779 | }); 780 | 781 | app.get(BASE_PATH + '/api/export/range', authMiddleware, async (req, res) => { 782 | try { 783 | const { start, end } = req.query; 784 | if (!start || !end) { 785 | return res.status(400).json({ error: 'Start and end dates are required' }); 786 | } 787 | 788 | const transactions = await getTransactionsInRange(start, end); 789 | 790 | // Convert to CSV with specified format 791 | const csvRows = ['Category,Date,Description,Value']; 792 | transactions.forEach(t => { 793 | const category = t.type === 'income' ? 'Income' : t.category; 794 | const value = t.type === 'income' ? t.amount : -t.amount; 795 | // Escape description to handle commas and quotes 796 | const escapedDescription = t.description.replace(/"/g, '""'); 797 | const formattedDescription = escapedDescription.includes(',') ? `"${escapedDescription}"` : escapedDescription; 798 | 799 | csvRows.push(`${category},${t.date},${formattedDescription},${value}`); 800 | }); 801 | 802 | res.setHeader('Content-Type', 'text/csv'); 803 | res.setHeader('Content-Disposition', `attachment; filename=transactions-${start}-to-${end}.csv`); 804 | res.send(csvRows.join('\n')); 805 | } catch (error) { 806 | console.error('Error exporting transactions:', error); 807 | res.status(500).json({ error: 'Failed to export transactions' }); 808 | } 809 | }); 810 | 811 | app.put(BASE_PATH + '/api/transactions/:id', authMiddleware, async (req, res) => { 812 | try { 813 | const { id } = req.params; 814 | const { type, amount, description, category, date, recurring } = req.body; 815 | 816 | // Basic validation 817 | if (!type || !amount || !description || !date) { 818 | return res.status(400).json({ error: 'Missing required fields' }); 819 | } 820 | if (type !== 'income' && type !== 'expense') { 821 | return res.status(400).json({ error: 'Invalid transaction type' }); 822 | } 823 | if (type === 'expense' && !category) { 824 | return res.status(400).json({ error: 'Category required for expenses' }); 825 | } 826 | 827 | const transactions = await loadTransactions(); 828 | let found = false; 829 | 830 | // Find and update the transaction 831 | for (const key of Object.keys(transactions)) { 832 | const monthData = transactions[key]; 833 | 834 | // Check in income array 835 | const incomeIndex = monthData.income.findIndex(t => t.id === id); 836 | if (incomeIndex !== -1) { 837 | // If type changed, move to expenses 838 | if (type === 'expense') { 839 | const transaction = monthData.income.splice(incomeIndex, 1)[0]; 840 | transaction.category = category; 841 | monthData.expenses.push({ 842 | ...transaction, 843 | amount: parseFloat(amount), 844 | description, 845 | date, 846 | recurring: recurring || null 847 | }); 848 | } else { 849 | monthData.income[incomeIndex] = { 850 | ...monthData.income[incomeIndex], 851 | amount: parseFloat(amount), 852 | description, 853 | date, 854 | recurring: recurring || null 855 | }; 856 | } 857 | found = true; 858 | break; 859 | } 860 | 861 | // Check in expenses array 862 | const expenseIndex = monthData.expenses.findIndex(t => t.id === id); 863 | if (expenseIndex !== -1) { 864 | // If type changed, move to income 865 | if (type === 'income') { 866 | const transaction = monthData.expenses.splice(expenseIndex, 1)[0]; 867 | delete transaction.category; 868 | monthData.income.push({ 869 | ...transaction, 870 | amount: parseFloat(amount), 871 | description, 872 | date, 873 | recurring: recurring || null 874 | }); 875 | } else { 876 | monthData.expenses[expenseIndex] = { 877 | ...monthData.expenses[expenseIndex], 878 | amount: parseFloat(amount), 879 | description, 880 | category, 881 | date, 882 | recurring: recurring || null 883 | }; 884 | } 885 | found = true; 886 | break; 887 | } 888 | } 889 | 890 | if (!found) { 891 | return res.status(404).json({ error: 'Transaction not found' }); 892 | } 893 | 894 | await saveTransactions(transactions); 895 | res.json({ success: true }); 896 | } catch (error) { 897 | console.error('Error updating transaction:', error); 898 | res.status(500).json({ error: 'Failed to update transaction' }); 899 | } 900 | }); 901 | 902 | app.delete(BASE_PATH + '/api/transactions/:id', authMiddleware, async (req, res) => { 903 | try { 904 | const { id } = req.params; 905 | const transactions = await loadTransactions(); 906 | let found = false; 907 | 908 | // Check if this is a recurring instance 909 | const isRecurringInstance = id.includes('-'); 910 | const parentId = isRecurringInstance ? id.split('-')[0] : id; 911 | 912 | // Find and delete the transaction 913 | for (const key of Object.keys(transactions)) { 914 | const monthData = transactions[key]; 915 | 916 | // Check in income array 917 | const incomeIndex = monthData.income.findIndex(t => 918 | t.id === parentId || t.id === id 919 | ); 920 | if (incomeIndex !== -1) { 921 | monthData.income.splice(incomeIndex, 1); 922 | found = true; 923 | break; 924 | } 925 | 926 | // Check in expenses array 927 | const expenseIndex = monthData.expenses.findIndex(t => 928 | t.id === parentId || t.id === id 929 | ); 930 | if (expenseIndex !== -1) { 931 | monthData.expenses.splice(expenseIndex, 1); 932 | found = true; 933 | break; 934 | } 935 | } 936 | 937 | if (!found) { 938 | return res.status(404).json({ error: 'Transaction not found' }); 939 | } 940 | 941 | await saveTransactions(transactions); 942 | res.json({ success: true }); 943 | } catch (error) { 944 | console.error('Error deleting transaction:', error); 945 | res.status(500).json({ error: 'Failed to delete transaction' }); 946 | } 947 | }); 948 | 949 | // Supported currencies list - must match client-side list 950 | const SUPPORTED_CURRENCIES = [ 951 | 'USD', 'EUR', 'GBP', 'JPY', 'AUD', 952 | 'CAD', 'CHF', 'CNY', 'HKD', 'NZD', 953 | 'MXN', 'RUB', 'SGD', 'KRW', 'INR', 954 | 'BRL', 'ZAR', 'TRY', 'PLN', 'SEK', 955 | 'NOK', 'DKK', 'IDR', 'PHP' 956 | ]; 957 | 958 | // Get current currency setting 959 | app.get(BASE_PATH + '/api/settings/currency', authMiddleware, (req, res) => { 960 | const currency = process.env.CURRENCY || 'USD'; 961 | if (!SUPPORTED_CURRENCIES.includes(currency)) { 962 | return res.status(200).json({ currency: 'USD' }); 963 | } 964 | res.status(200).json({ currency }); 965 | }); 966 | 967 | // Get list of supported currencies 968 | app.get(BASE_PATH + '/api/settings/supported-currencies', authMiddleware, (req, res) => { 969 | res.status(200).json({ currencies: SUPPORTED_CURRENCIES }); 970 | }); 971 | 972 | // Add logging to config endpoint 973 | app.get(BASE_PATH + '/config.js', (req, res) => { 974 | debugLog('Serving config.js with BASE_PATH:', BASE_PATH); 975 | res.type('application/javascript'); 976 | res.send(`window.appConfig = { 977 | debug: ${DEBUG}, 978 | basePath: '${BASE_PATH}', 979 | title: '${SITE_INSTANCE_TITLE}' 980 | };`); 981 | }); 982 | 983 | // API Authentication middleware for DumbCal 984 | const apiAuthMiddleware = (req, res, next) => { 985 | const authHeader = req.headers.authorization; 986 | 987 | if (!authHeader || !authHeader.startsWith('Bearer ')) { 988 | return res.status(401).json({ error: 'Missing authorization header' }); 989 | } 990 | 991 | const token = authHeader.split(' ')[1]; 992 | if (token !== process.env.DUMB_SECRET) { 993 | return res.status(401).json({ error: 'Invalid authorization token' }); 994 | } 995 | 996 | next(); 997 | }; 998 | 999 | // Calendar API endpoint 1000 | app.get(BASE_PATH + '/api/calendar/transactions', apiAuthMiddleware, async (req, res) => { 1001 | try { 1002 | const { start_date, end_date } = req.query; 1003 | 1004 | // Validate date parameters 1005 | if (!start_date || !end_date) { 1006 | return res.status(400).json({ error: 'Missing start_date or end_date parameter' }); 1007 | } 1008 | 1009 | // Validate date format 1010 | const startDate = new Date(start_date); 1011 | const endDate = new Date(end_date); 1012 | 1013 | if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { 1014 | return res.status(400).json({ error: 'Invalid date format. Use YYYY-MM-DD' }); 1015 | } 1016 | 1017 | // Load transactions 1018 | const allTransactions = await loadTransactions(); 1019 | 1020 | // Filter transactions within date range 1021 | const filteredTransactions = []; 1022 | 1023 | for (const [month, data] of Object.entries(allTransactions)) { 1024 | const monthDate = new Date(month + '-01'); 1025 | 1026 | if (monthDate >= startDate && monthDate <= endDate) { 1027 | // Add income transactions 1028 | data.income.forEach(transaction => { 1029 | filteredTransactions.push({ 1030 | type: 'income', 1031 | ...transaction, 1032 | amount: parseFloat(transaction.amount) 1033 | }); 1034 | }); 1035 | 1036 | // Add expense transactions 1037 | data.expenses.forEach(transaction => { 1038 | filteredTransactions.push({ 1039 | type: 'expense', 1040 | ...transaction, 1041 | amount: parseFloat(transaction.amount) 1042 | }); 1043 | }); 1044 | } 1045 | } 1046 | 1047 | res.json({ transactions: filteredTransactions }); 1048 | } catch (error) { 1049 | console.error('Calendar API error:', error); 1050 | res.status(500).json({ error: 'Internal server error' }); 1051 | } 1052 | }); 1053 | 1054 | // Add logging to server startup 1055 | app.listen(PORT, () => { 1056 | console.log(`Server running on ${BASE_URL}`); 1057 | debugLog('Debug mode enabled'); 1058 | debugLog('Base path:', BASE_PATH); 1059 | }); 1060 | --------------------------------------------------------------------------------