From ce107c80a43ad9afc300b3853012697f5e63d473 Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Thu, 30 Apr 2026 23:12:38 -0300 Subject: [PATCH] Add budget loan tracking --- public/locales/ar.json | 35 +++- public/locales/de.json | 33 +++- public/locales/el.json | 35 +++- public/locales/en.json | 33 +++- public/locales/es.json | 35 +++- public/locales/fr.json | 35 +++- public/locales/hi.json | 35 +++- public/locales/it.json | 35 +++- public/locales/ja.json | 35 +++- public/locales/pt.json | 35 +++- public/locales/ru.json | 35 +++- public/locales/sv.json | 35 +++- public/locales/tr.json | 35 +++- public/locales/uk.json | 35 +++- public/locales/zh.json | 35 +++- public/pages/budget.js | 241 ++++++++++++++++++++++++++- public/styles/budget.css | 171 ++++++++++++++++++++ server/db-schema-test.js | 69 ++++++++ server/db.js | 41 +++++ server/routes/budget.js | 295 +++++++++++++++++++++++++++++++++- test-notes-contacts-budget.js | 34 ++++ 21 files changed, 1338 insertions(+), 34 deletions(-) diff --git a/public/locales/ar.json b/public/locales/ar.json index e247dd4..9684c58 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Name of the new subcategory:", "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", - "emptyAction": "إضافة إدخال" + "emptyAction": "إضافة إدخال", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "الإعدادات", @@ -1005,4 +1036,4 @@ "shortcuts": { "goKitchen": "المطبخ" } -} \ No newline at end of file +} diff --git a/public/locales/de.json b/public/locales/de.json index aec8962..4500f45 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -590,7 +590,38 @@ "newCategoryPrompt": "Name der neuen Kategorie:", "newSubcategoryPrompt": "Name der neuen Unterkategorie:", "categoryAddedToast": "Kategorie hinzugefügt.", - "subcategoryAddedToast": "Unterkategorie hinzugefügt." + "subcategoryAddedToast": "Unterkategorie hinzugefügt.", + "loansTitle": "Darlehen", + "loansSummary": "{{count}} aktiv · {{amount}} offen", + "newLoan": "Neues Darlehen", + "createLoan": "Darlehen erstellen", + "editLoan": "Darlehen bearbeiten", + "deleteLoan": "Darlehen löschen", + "deleteLoanConfirm": "Darlehen \"{{title}}\" löschen? Bereits im Budget verbuchte Zahlungen werden ebenfalls entfernt.", + "loanRemainingAmount": "Offen", + "loanRemainingInstallments": "Raten offen", + "loanPaidAmount": "Bezahlt", + "loansEmpty": "Keine aktiven Darlehen.", + "loanInstallmentMeta": "{{paid}} von {{total}} Raten bezahlt", + "loanRemainingOf": "von {{total}}", + "loanNextDue": "Nächste: {{month}}", + "loanPaidStatus": "Bezahlt", + "markLoanPaid": "Bezahlt", + "loanBorrowerLabel": "Person *", + "loanBorrowerPlaceholder": "z. B. Lais", + "loanTitleLabel": "Darlehenstitel", + "loanTitlePlaceholder": "z. B. Persönliches Darlehen", + "loanAmountLabel": "Gesamtbetrag *", + "loanInstallmentsLabel": "Raten *", + "loanStartMonthLabel": "Erster Fälligkeitsmonat *", + "loanNotesLabel": "Notizen", + "loanBorrowerRequired": "Person ist erforderlich", + "loanInstallmentsRequired": "Anzahl der Raten eingeben", + "loanStartMonthRequired": "Ersten Fälligkeitsmonat eingeben", + "loanAddedToast": "Darlehen hinzugefügt", + "loanSavedToast": "Darlehen gespeichert", + "loanDeletedToast": "Darlehen gelöscht", + "loanPaymentAddedToast": "Zahlung erfasst" }, "settings": { "title": "Einstellungen", diff --git a/public/locales/el.json b/public/locales/el.json index e3230bb..268662d 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Name of the new subcategory:", "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", - "emptyAction": "Προσθήκη εγγραφής" + "emptyAction": "Προσθήκη εγγραφής", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "Ρυθμίσεις", @@ -1005,4 +1036,4 @@ "shortcuts": { "goKitchen": "Κουζίνα" } -} \ No newline at end of file +} diff --git a/public/locales/en.json b/public/locales/en.json index 21462d9..460ba14 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Name of the new subcategory:", "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", - "emptyAction": "Add entry" + "emptyAction": "Add entry", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "Settings", diff --git a/public/locales/es.json b/public/locales/es.json index 6c4ca09..aa7206d 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Nombre de la nueva subcategoría:", "categoryAddedToast": "Categoría añadida.", "subcategoryAddedToast": "Subcategoría añadida.", - "emptyAction": "Agregar entrada" + "emptyAction": "Agregar entrada", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "Ajustes", @@ -1005,4 +1036,4 @@ "shortcuts": { "goKitchen": "Cocina" } -} \ No newline at end of file +} diff --git a/public/locales/fr.json b/public/locales/fr.json index d804651..767d26d 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Nom de la nouvelle sous-catégorie :", "categoryAddedToast": "Catégorie ajoutée.", "subcategoryAddedToast": "Sous-catégorie ajoutée.", - "emptyAction": "Ajouter une entrée" + "emptyAction": "Ajouter une entrée", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "Paramètres", @@ -1005,4 +1036,4 @@ "shortcuts": { "goKitchen": "Cuisine" } -} \ No newline at end of file +} diff --git a/public/locales/hi.json b/public/locales/hi.json index 028f1a1..e173bac 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Name of the new subcategory:", "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", - "emptyAction": "प्रविष्टि जोड़ें" + "emptyAction": "प्रविष्टि जोड़ें", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "सेटिंग्स", @@ -1005,4 +1036,4 @@ "shortcuts": { "goKitchen": "रसोई" } -} \ No newline at end of file +} diff --git a/public/locales/it.json b/public/locales/it.json index f8dceed..b6351bc 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Nome della nuova sottocategoria:", "categoryAddedToast": "Categoria aggiunta.", "subcategoryAddedToast": "Sottocategoria aggiunta.", - "emptyAction": "Aggiungi voce" + "emptyAction": "Aggiungi voce", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "Impostazioni", @@ -1005,4 +1036,4 @@ "shortcuts": { "goKitchen": "Cucina" } -} \ No newline at end of file +} diff --git a/public/locales/ja.json b/public/locales/ja.json index fa047db..e365f1b 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Name of the new subcategory:", "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", - "emptyAction": "エントリを追加" + "emptyAction": "エントリを追加", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "設定", @@ -1005,4 +1036,4 @@ "shortcuts": { "goKitchen": "キッチン" } -} \ No newline at end of file +} diff --git a/public/locales/pt.json b/public/locales/pt.json index 32b1e7f..7f5557d 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Nome da nova subcategoria:", "categoryAddedToast": "Categoria adicionada.", "subcategoryAddedToast": "Subcategoria adicionada.", - "emptyAction": "Adicionar entrada" + "emptyAction": "Adicionar entrada", + "loansTitle": "Empréstimos", + "loansSummary": "{{count}} ativos · {{amount}} restantes", + "newLoan": "Novo empréstimo", + "createLoan": "Criar empréstimo", + "editLoan": "Editar empréstimo", + "deleteLoan": "Excluir empréstimo", + "deleteLoanConfirm": "Excluir empréstimo \"{{title}}\"? Pagamentos já lançados no orçamento também serão removidos.", + "loanRemainingAmount": "Restante", + "loanRemainingInstallments": "Parcelas restantes", + "loanPaidAmount": "Pago", + "loansEmpty": "Nenhum empréstimo ativo.", + "loanInstallmentMeta": "{{paid}} de {{total}} parcelas pagas", + "loanRemainingOf": "de {{total}}", + "loanNextDue": "Próxima: {{month}}", + "loanPaidStatus": "Pago", + "markLoanPaid": "Dar baixa", + "loanBorrowerLabel": "Pessoa *", + "loanBorrowerPlaceholder": "Ex.: Lais", + "loanTitleLabel": "Título do empréstimo", + "loanTitlePlaceholder": "Ex.: Empréstimo pessoal", + "loanAmountLabel": "Valor total *", + "loanInstallmentsLabel": "Parcelas *", + "loanStartMonthLabel": "Primeiro mês de vencimento *", + "loanNotesLabel": "Observações", + "loanBorrowerRequired": "Informe a pessoa", + "loanInstallmentsRequired": "Informe a quantidade de parcelas", + "loanStartMonthRequired": "Informe o primeiro mês de vencimento", + "loanAddedToast": "Empréstimo adicionado", + "loanSavedToast": "Empréstimo salvo", + "loanDeletedToast": "Empréstimo excluído", + "loanPaymentAddedToast": "Pagamento registrado" }, "settings": { "title": "Configurações", @@ -1006,4 +1037,4 @@ "shortcuts": { "goKitchen": "Cozinha" } -} \ No newline at end of file +} diff --git a/public/locales/ru.json b/public/locales/ru.json index 8de0150..1b405a3 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Name of the new subcategory:", "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", - "emptyAction": "Добавить запись" + "emptyAction": "Добавить запись", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "Настройки", @@ -1005,4 +1036,4 @@ "shortcuts": { "goKitchen": "Кухня" } -} \ No newline at end of file +} diff --git a/public/locales/sv.json b/public/locales/sv.json index 37a2198..a6830c9 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Name of the new subcategory:", "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", - "emptyAction": "Lägg till post" + "emptyAction": "Lägg till post", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "Inställningar", @@ -1005,4 +1036,4 @@ "shortcuts": { "goKitchen": "Kök" } -} \ No newline at end of file +} diff --git a/public/locales/tr.json b/public/locales/tr.json index e0e9dbd..a0829fa 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Name of the new subcategory:", "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", - "emptyAction": "Giriş ekle" + "emptyAction": "Giriş ekle", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "Ayarlar", @@ -1005,4 +1036,4 @@ "shortcuts": { "goKitchen": "Mutfak" } -} \ No newline at end of file +} diff --git a/public/locales/uk.json b/public/locales/uk.json index 7d97282..f615f46 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Name of the new subcategory:", "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", - "emptyAction": "Додати запис" + "emptyAction": "Додати запис", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "Налаштування", @@ -1013,4 +1044,4 @@ "shortcuts": { "goKitchen": "Кухня" } -} \ No newline at end of file +} diff --git a/public/locales/zh.json b/public/locales/zh.json index babffb6..4288973 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -565,7 +565,38 @@ "newSubcategoryPrompt": "Name of the new subcategory:", "categoryAddedToast": "Category added.", "subcategoryAddedToast": "Subcategory added.", - "emptyAction": "添加记录" + "emptyAction": "添加记录", + "loansTitle": "Loans", + "loansSummary": "{{count}} active · {{amount}} remaining", + "newLoan": "New loan", + "createLoan": "Create loan", + "editLoan": "Edit loan", + "deleteLoan": "Delete loan", + "deleteLoanConfirm": "Delete loan \"{{title}}\"? Payments already posted to the budget will also be removed.", + "loanRemainingAmount": "Remaining", + "loanRemainingInstallments": "Installments left", + "loanPaidAmount": "Paid", + "loansEmpty": "No active loans.", + "loanInstallmentMeta": "{{paid}} of {{total}} installments paid", + "loanRemainingOf": "of {{total}}", + "loanNextDue": "Next: {{month}}", + "loanPaidStatus": "Paid", + "markLoanPaid": "Mark paid", + "loanBorrowerLabel": "Borrower *", + "loanBorrowerPlaceholder": "e.g. Lais", + "loanTitleLabel": "Loan title", + "loanTitlePlaceholder": "e.g. Personal loan", + "loanAmountLabel": "Total amount *", + "loanInstallmentsLabel": "Installments *", + "loanStartMonthLabel": "First due month *", + "loanNotesLabel": "Notes", + "loanBorrowerRequired": "Borrower is required", + "loanInstallmentsRequired": "Enter the number of installments", + "loanStartMonthRequired": "Enter the first due month", + "loanAddedToast": "Loan added", + "loanSavedToast": "Loan saved", + "loanDeletedToast": "Loan deleted", + "loanPaymentAddedToast": "Payment recorded" }, "settings": { "title": "设置", @@ -1005,4 +1036,4 @@ "shortcuts": { "goKitchen": "厨房" } -} \ No newline at end of file +} diff --git a/public/pages/budget.js b/public/pages/budget.js index 1335266..10c18a5 100644 --- a/public/pages/budget.js +++ b/public/pages/budget.js @@ -124,6 +124,7 @@ let state = { entries: [], summary: null, prevSummary: null, // Vormonat für Monatsvergleich + loans: { loans: [], summary: { active_count: 0, remaining_amount: 0, remaining_installments: 0 } }, currency: 'EUR', meta: { expenseCategories: [], incomeCategories: [], expenseSubcategories: {} }, }; @@ -148,6 +149,11 @@ function addMonths(ym, n) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; } +function setHtml(element, html) { + element.replaceChildren(); + element.insertAdjacentHTML('afterbegin', html); +} + // -------------------------------------------------------- // API // -------------------------------------------------------- @@ -155,21 +161,24 @@ function addMonths(ym, n) { async function loadMonth(month) { const prevMonth = addMonths(month, -1); try { - const [entriesRes, summaryRes, prevSummaryRes] = await Promise.all([ + const [entriesRes, summaryRes, prevSummaryRes, loansRes] = await Promise.all([ api.get(`/budget?month=${month}`), api.get(`/budget/summary?month=${month}`), api.get(`/budget/summary?month=${prevMonth}`), + api.get('/budget/loans'), ]); state.month = month; state.entries = entriesRes.data; state.summary = summaryRes.data; state.prevSummary = prevSummaryRes.data; + state.loans = loansRes.data; } catch (err) { console.error('[Budget] loadMonth Fehler:', err); state.month = month; state.entries = []; state.summary = { income: 0, expenses: 0, balance: 0, byCategory: [] }; state.prevSummary = null; + state.loans = { loans: [], summary: { active_count: 0, remaining_amount: 0, remaining_installments: 0 } }; window.oikos?.showToast(t('budget.loadError'), 'danger'); } } @@ -206,7 +215,7 @@ export async function render(container, { user }) { state.currency = prefsRes.data?.currency ?? 'EUR'; } catch (_) { /* Fallback auf EUR */ } - container.innerHTML = ` + setHtml(container, `

${t('budget.title')}

@@ -229,7 +238,7 @@ export async function render(container, { user }) {
- `; + `); if (window.lucide) lucide.createIcons(); @@ -286,7 +295,7 @@ function renderBody() { const balanceClass = s.balance >= 0 ? 'budget-summary-card--balance-positive' : 'budget-summary-card--balance-negative'; const prevLabel = p ? formatMonthLabel(p.month).split(' ')[0].slice(0, 3) : ''; - body.innerHTML = ` + setHtml(body, `
@@ -315,6 +324,8 @@ function renderBody() {
` : ''} + ${renderLoansDashboard()} +
@@ -329,12 +340,29 @@ function renderBody() { ${renderEntries()}
- `; + `); if (window.lucide) lucide.createIcons(); _container.querySelector('#empty-cta-budget')?.addEventListener('click', () => { document.querySelector('.page-fab')?.click(); }); + _container.querySelector('#budget-add-loan')?.addEventListener('click', () => openLoanModal()); + _container.querySelectorAll('[data-action="loan-pay"]').forEach((btn) => { + btn.addEventListener('click', async () => { + await markLoanPayment(parseInt(btn.dataset.id, 10)); + }); + }); + _container.querySelectorAll('[data-action="loan-edit"]').forEach((btn) => { + btn.addEventListener('click', () => { + const loan = state.loans.loans.find((item) => item.id === parseInt(btn.dataset.id, 10)); + if (loan) openLoanModal(loan); + }); + }); + _container.querySelectorAll('[data-action="loan-delete"]').forEach((btn) => { + btn.addEventListener('click', async () => { + await deleteLoan(parseInt(btn.dataset.id, 10)); + }); + }); stagger(_container.querySelector('#budget-list')?.querySelectorAll('.budget-entry') ?? []); _container.querySelector('#budget-list')?.addEventListener('click', async (e) => { @@ -415,6 +443,90 @@ function renderEntries() { }).join(''); } +function renderLoansDashboard() { + const loans = state.loans?.loans ?? []; + const summary = state.loans?.summary ?? {}; + const activeLoans = loans.filter((loan) => loan.status === 'active'); + + return ` +
+
+
+
${t('budget.loansTitle')}
+
${t('budget.loansSummary', { + count: summary.active_count ?? 0, + amount: formatAmount(summary.remaining_amount ?? 0), + })}
+
+ +
+
+
+ ${t('budget.loanRemainingAmount')} + ${formatAmount(summary.remaining_amount ?? 0)} +
+
+ ${t('budget.loanRemainingInstallments')} + ${summary.remaining_installments ?? 0} +
+
+ ${t('budget.loanPaidAmount')} + ${formatAmount(summary.paid_amount ?? 0)} +
+
+ ${activeLoans.length ? ` +
+ ${activeLoans.map(renderLoanCard).join('')} +
+ ` : ` +
${t('budget.loansEmpty')}
+ `} +
+ `; +} + +function renderLoanCard(loan) { + const paidPct = Math.min(100, Math.round((loan.paid_amount / loan.total_amount) * 100)); + const nextDue = loan.next_due_month ? formatMonthLabel(loan.next_due_month) : t('budget.loanPaidStatus'); + const payDisabled = loan.remaining_installments <= 0 ? 'disabled' : ''; + + return ` +
+
+
${esc(loan.title)}
+
${esc(loan.borrower)} · ${t('budget.loanInstallmentMeta', { + paid: loan.paid_installments, + total: loan.installment_count, + })}
+
+
+ ${formatAmount(loan.remaining_amount)} + ${t('budget.loanRemainingOf', { total: formatAmount(loan.total_amount) })} +
+
+ +
+ +
+ `; +} + /** * Rendert eine Trend-Zeile im Vergleich zum Vormonat. * Alle drei Metriken (income, expenses, balance) nutzen dieselbe Logik: @@ -670,6 +782,125 @@ function openBudgetModal({ mode, entry = null }) { }); } +function openLoanModal(loan = null) { + const isEdit = Boolean(loan); + const todayMonth = new Date().toISOString().slice(0, 7); + const content = ` +
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ `; + + openSharedModal({ + title: isEdit ? t('budget.editLoan') : t('budget.newLoan'), + content, + size: 'sm', + onSave(panel) { + panel.querySelector('#lm-cancel').addEventListener('click', closeModal); + panel.querySelector('#lm-save').addEventListener('click', async () => { + const saveBtn = panel.querySelector('#lm-save'); + const borrower = panel.querySelector('#lm-borrower').value.trim(); + const title = panel.querySelector('#lm-title').value.trim() || borrower; + const total_amount = parseFloat(panel.querySelector('#lm-amount').value); + const installment_count = parseInt(panel.querySelector('#lm-installments').value, 10); + const start_month = panel.querySelector('#lm-start').value; + const notes = panel.querySelector('#lm-notes').value.trim(); + + if (!borrower) { window.oikos?.showToast(t('budget.loanBorrowerRequired'), 'error'); return; } + if (isNaN(total_amount) || total_amount <= 0) { window.oikos?.showToast(t('budget.validAmountRequired'), 'error'); return; } + if (!Number.isInteger(installment_count) || installment_count < 1) { window.oikos?.showToast(t('budget.loanInstallmentsRequired'), 'error'); return; } + if (!/^\d{4}-\d{2}$/.test(start_month)) { window.oikos?.showToast(t('budget.loanStartMonthRequired'), 'error'); return; } + + saveBtn.disabled = true; + saveBtn.textContent = '...'; + try { + const body = { borrower, title, total_amount, installment_count, start_month, notes }; + if (isEdit) { + await api.put(`/budget/loans/${loan.id}`, body); + } else { + await api.post('/budget/loans', body); + } + await loadMonth(state.month); + closeModal({ force: true }); + renderBody(); + window.oikos?.showToast(isEdit ? t('budget.loanSavedToast') : t('budget.loanAddedToast'), 'success'); + } catch (err) { + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); + saveBtn.disabled = false; + saveBtn.textContent = isEdit ? t('common.save') : t('budget.createLoan'); + } + }); + }, + }); +} + +async function markLoanPayment(id) { + const loan = state.loans.loans.find((item) => item.id === id); + if (!loan?.next_installment_number) return; + const today = new Date().toISOString().slice(0, 10); + try { + await api.post(`/budget/loans/${id}/payments`, { + installment_number: loan.next_installment_number, + amount: loan.next_installment_number === loan.installment_count + ? loan.remaining_amount + : Math.min(loan.installment_amount, loan.remaining_amount), + paid_date: today, + }); + await loadMonth(state.month); + renderBody(); + window.oikos?.showToast(t('budget.loanPaymentAddedToast'), 'success'); + } catch (err) { + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); + } +} + +async function deleteLoan(id) { + const loan = state.loans.loans.find((item) => item.id === id); + if (!loan) return; + if (!window.confirm(t('budget.deleteLoanConfirm', { title: loan.title }))) return; + try { + await api.delete(`/budget/loans/${id}`); + await loadMonth(state.month); + renderBody(); + window.oikos?.showToast(t('budget.loanDeletedToast'), 'success'); + } catch (err) { + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); + } +} + // -------------------------------------------------------- // Eintrag löschen // -------------------------------------------------------- diff --git a/public/styles/budget.css b/public/styles/budget.css index d9c0e3e..b73cf47 100644 --- a/public/styles/budget.css +++ b/public/styles/budget.css @@ -176,6 +176,177 @@ flex-shrink: 0; } +/* -------------------------------------------------------- + * Empréstimos + * -------------------------------------------------------- */ +.budget-loans { + margin: 0 var(--space-4) var(--space-3); + padding: var(--space-3); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + flex-shrink: 0; +} + +.budget-loans__header, +.budget-loan-card__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.budget-loans__eyebrow { + font-size: var(--text-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.budget-loans__summary { + font-size: var(--text-sm); + color: var(--color-text-secondary); + margin-top: 2px; +} + +.budget-loans__add { + flex-shrink: 0; +} + +.budget-loans__stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-2); + margin-top: var(--space-3); +} + +.budget-loans__stats > div { + min-width: 0; + padding: var(--space-2); + border-radius: var(--radius-sm); + background: var(--color-surface-2); +} + +.budget-loans__stats span, +.budget-loan-card__amounts span, +.budget-loan-card__meta, +.budget-loan-card__footer > span { + display: block; + font-size: var(--text-xs); + color: var(--color-text-secondary); +} + +.budget-loans__stats strong, +.budget-loan-card__amounts strong { + display: block; + font-size: var(--text-base); + color: var(--color-text-primary); + margin-top: 2px; + white-space: nowrap; +} + +.budget-loans__list { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-2); + margin-top: var(--space-3); + max-height: 260px; + overflow-y: auto; +} + +.budget-loans__empty { + color: var(--color-text-secondary); + font-size: var(--text-sm); + padding: var(--space-3) 0 0; +} + +.budget-loan-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--space-2) var(--space-3); + padding: var(--space-3); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-sm); +} + +.budget-loan-card__main { + min-width: 0; +} + +.budget-loan-card__title { + font-size: var(--text-base); + font-weight: var(--font-weight-semibold); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.budget-loan-card__amounts { + text-align: right; +} + +.budget-loan-card__progress { + grid-column: 1 / -1; + height: 6px; + overflow: hidden; + border-radius: var(--radius-full); + background: var(--color-surface-2); +} + +.budget-loan-card__progress span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--color-success); +} + +.budget-loan-card__footer { + grid-column: 1 / -1; +} + +.budget-loan-card__actions { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.budget-page .form-grid-2 { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); +} + +@media (max-width: 560px) { + .budget-loans__header, + .budget-loan-card__footer { + align-items: stretch; + flex-direction: column; + } + + .budget-loans__stats { + grid-template-columns: 1fr; + } + + .budget-loan-card { + grid-template-columns: 1fr; + } + + .budget-loan-card__amounts { + text-align: left; + } + + .budget-loan-card__actions { + justify-content: flex-end; + flex-wrap: wrap; + } + + .budget-page .form-grid-2 { + grid-template-columns: 1fr; + } +} + /* -------------------------------------------------------- * Transaktions-Liste * -------------------------------------------------------- */ diff --git a/server/db-schema-test.js b/server/db-schema-test.js index e3fb25e..cec0981 100644 --- a/server/db-schema-test.js +++ b/server/db-schema-test.js @@ -159,6 +159,31 @@ const MIGRATIONS_SQL = { created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), UNIQUE(category_key, name) ); + CREATE TABLE IF NOT EXISTS budget_loans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + borrower TEXT NOT NULL, + total_amount REAL NOT NULL CHECK(total_amount > 0), + installment_count INTEGER NOT NULL CHECK(installment_count > 0), + start_month TEXT NOT NULL, + notes TEXT, + status TEXT NOT NULL DEFAULT 'active' + CHECK(status IN ('active', 'paid')), + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + CREATE TABLE IF NOT EXISTS budget_loan_payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + loan_id INTEGER NOT NULL REFERENCES budget_loans(id) ON DELETE CASCADE, + installment_number INTEGER NOT NULL CHECK(installment_number > 0), + amount REAL NOT NULL CHECK(amount > 0), + paid_date TEXT NOT NULL, + budget_entry_id INTEGER REFERENCES budget_entries(id) ON DELETE SET NULL, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + UNIQUE(loan_id, installment_number) + ); CREATE TABLE IF NOT EXISTS api_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, @@ -203,6 +228,9 @@ const MIGRATIONS_SQL = { CREATE TRIGGER IF NOT EXISTS trg_budget_entries_updated_at AFTER UPDATE ON budget_entries FOR EACH ROW BEGIN UPDATE budget_entries SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END; + CREATE TRIGGER IF NOT EXISTS trg_budget_loans_updated_at + AFTER UPDATE ON budget_loans FOR EACH ROW + BEGIN UPDATE budget_loans SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END; CREATE INDEX IF NOT EXISTS idx_tasks_assigned_to ON tasks(assigned_to); CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date); CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); @@ -214,6 +242,10 @@ const MIGRATIONS_SQL = { CREATE INDEX IF NOT EXISTS idx_notes_pinned ON notes(pinned); CREATE INDEX IF NOT EXISTS idx_budget_date ON budget_entries(date); CREATE INDEX IF NOT EXISTS idx_budget_created_by ON budget_entries(created_by); + CREATE INDEX IF NOT EXISTS idx_budget_loans_status ON budget_loans(status); + CREATE INDEX IF NOT EXISTS idx_budget_loans_start_month ON budget_loans(start_month); + CREATE INDEX IF NOT EXISTS idx_budget_loan_payments_loan ON budget_loan_payments(loan_id); + CREATE INDEX IF NOT EXISTS idx_budget_loan_payments_paid_date ON budget_loan_payments(paid_date); CREATE INDEX IF NOT EXISTS idx_birthdays_name ON birthdays(name); CREATE INDEX IF NOT EXISTS idx_birthdays_birth_date ON birthdays(birth_date); CREATE INDEX IF NOT EXISTS idx_birthdays_created_by ON birthdays(created_by); @@ -425,6 +457,43 @@ const MIGRATIONS_SQL = { ALTER TABLE calendar_events ADD COLUMN attachment_size INTEGER; ALTER TABLE calendar_events ADD COLUMN attachment_data TEXT; `, + 21: ` + CREATE TABLE IF NOT EXISTS budget_loans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + borrower TEXT NOT NULL, + total_amount REAL NOT NULL CHECK(total_amount > 0), + installment_count INTEGER NOT NULL CHECK(installment_count > 0), + start_month TEXT NOT NULL, + notes TEXT, + status TEXT NOT NULL DEFAULT 'active' + CHECK(status IN ('active', 'paid')), + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + CREATE TABLE IF NOT EXISTS budget_loan_payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + loan_id INTEGER NOT NULL REFERENCES budget_loans(id) ON DELETE CASCADE, + installment_number INTEGER NOT NULL CHECK(installment_number > 0), + amount REAL NOT NULL CHECK(amount > 0), + paid_date TEXT NOT NULL, + budget_entry_id INTEGER REFERENCES budget_entries(id) ON DELETE SET NULL, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + UNIQUE(loan_id, installment_number) + ); + + CREATE TRIGGER IF NOT EXISTS trg_budget_loans_updated_at + AFTER UPDATE ON budget_loans FOR EACH ROW + BEGIN UPDATE budget_loans SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END; + + CREATE INDEX IF NOT EXISTS idx_budget_loans_status ON budget_loans(status); + CREATE INDEX IF NOT EXISTS idx_budget_loans_start_month ON budget_loans(start_month); + CREATE INDEX IF NOT EXISTS idx_budget_loan_payments_loan ON budget_loan_payments(loan_id); + CREATE INDEX IF NOT EXISTS idx_budget_loan_payments_paid_date ON budget_loan_payments(paid_date); + `, }; export { MIGRATIONS_SQL }; diff --git a/server/db.js b/server/db.js index d9adb6c..235005a 100644 --- a/server/db.js +++ b/server/db.js @@ -873,6 +873,47 @@ const MIGRATIONS = [ ALTER TABLE calendar_events ADD COLUMN attachment_data TEXT; `, }, + { + version: 28, + description: 'Budget loans and installment payments', + up: ` + CREATE TABLE IF NOT EXISTS budget_loans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + borrower TEXT NOT NULL, + total_amount REAL NOT NULL CHECK(total_amount > 0), + installment_count INTEGER NOT NULL CHECK(installment_count > 0), + start_month TEXT NOT NULL, + notes TEXT, + status TEXT NOT NULL DEFAULT 'active' + CHECK(status IN ('active', 'paid')), + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + CREATE TABLE IF NOT EXISTS budget_loan_payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + loan_id INTEGER NOT NULL REFERENCES budget_loans(id) ON DELETE CASCADE, + installment_number INTEGER NOT NULL CHECK(installment_number > 0), + amount REAL NOT NULL CHECK(amount > 0), + paid_date TEXT NOT NULL, + budget_entry_id INTEGER REFERENCES budget_entries(id) ON DELETE SET NULL, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + UNIQUE(loan_id, installment_number) + ); + + CREATE TRIGGER IF NOT EXISTS trg_budget_loans_updated_at + AFTER UPDATE ON budget_loans FOR EACH ROW + BEGIN UPDATE budget_loans SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END; + + CREATE INDEX IF NOT EXISTS idx_budget_loans_status ON budget_loans(status); + CREATE INDEX IF NOT EXISTS idx_budget_loans_start_month ON budget_loans(start_month); + CREATE INDEX IF NOT EXISTS idx_budget_loan_payments_loan ON budget_loan_payments(loan_id); + CREATE INDEX IF NOT EXISTS idx_budget_loan_payments_paid_date ON budget_loan_payments(paid_date); + `, + }, ]; /** diff --git a/server/routes/budget.js b/server/routes/budget.js index 36c7386..00e123c 100644 --- a/server/routes/budget.js +++ b/server/routes/budget.js @@ -9,7 +9,7 @@ import express from 'express'; import { readFileSync } from 'node:fs'; import path from 'path'; import * as db from '../db.js'; -import { str, oneOf, date as validateDate, num, rrule, collectErrors, MAX_TITLE, MAX_SHORT, MONTH_RE } from '../middleware/validate.js'; +import { str, oneOf, date as validateDate, month as validateMonth, num, rrule, collectErrors, MAX_TITLE, MAX_SHORT, MONTH_RE } from '../middleware/validate.js'; const log = createLogger('Budget'); @@ -228,6 +228,65 @@ function validateSubcategory(category, subcategory) { return row ? subcategory : null; } +function addMonths(ym, n) { + const [y, m] = ym.split('-').map(Number); + const d = new Date(y, m - 1 + n, 1); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; +} + +function cents(value) { + return Math.round(Number(value || 0) * 100) / 100; +} + +function loanSummaryRow(loan) { + const payments = db.get().prepare(` + SELECT p.*, u.display_name AS creator_name + FROM budget_loan_payments p + LEFT JOIN users u ON u.id = p.created_by + WHERE p.loan_id = ? + ORDER BY p.installment_number ASC + `).all(loan.id); + const paidAmount = cents(payments.reduce((sum, p) => sum + Number(p.amount || 0), 0)); + const paidInstallments = payments.length; + const remainingAmount = Math.max(0, cents(loan.total_amount - paidAmount)); + const remainingInstallments = Math.max(0, loan.installment_count - paidInstallments); + const installmentAmount = cents(loan.total_amount / loan.installment_count); + + return { + ...loan, + total_amount: cents(loan.total_amount), + installment_amount: installmentAmount, + paid_amount: paidAmount, + paid_installments: paidInstallments, + remaining_amount: remainingAmount, + remaining_installments: remainingInstallments, + next_installment_number: remainingInstallments > 0 ? paidInstallments + 1 : null, + next_due_month: remainingInstallments > 0 ? addMonths(loan.start_month, paidInstallments) : null, + payments, + }; +} + +function loadLoan(id) { + const loan = db.get().prepare(` + SELECT l.*, u.display_name AS creator_name + FROM budget_loans l + LEFT JOIN users u ON u.id = l.created_by + WHERE l.id = ? + `).get(id); + return loan ? loanSummaryRow(loan) : null; +} + +function refreshLoanStatus(loanId) { + const loan = loadLoan(loanId); + if (!loan) return null; + const status = loan.remaining_installments === 0 || loan.remaining_amount <= 0.005 ? 'paid' : 'active'; + if (status !== loan.status) { + db.get().prepare('UPDATE budget_loans SET status = ? WHERE id = ?').run(status, loanId); + return loadLoan(loanId); + } + return loan; +} + // -------------------------------------------------------- // Statische Routen vor /:id // -------------------------------------------------------- @@ -391,6 +450,240 @@ router.get('/categories/:categoryKey/subcategories', (req, res) => { } }); +router.get('/loans', (req, res) => { + try { + const loans = db.get().prepare(` + SELECT l.*, u.display_name AS creator_name + FROM budget_loans l + LEFT JOIN users u ON u.id = l.created_by + ORDER BY CASE l.status WHEN 'active' THEN 0 ELSE 1 END, + l.start_month ASC, + l.created_at DESC + `).all().map(loanSummaryRow); + const active = loans.filter((loan) => loan.status === 'active'); + const totals = loans.reduce((acc, loan) => { + acc.total_amount += loan.total_amount; + acc.paid_amount += loan.paid_amount; + acc.remaining_amount += loan.remaining_amount; + acc.remaining_installments += loan.remaining_installments; + return acc; + }, { total_amount: 0, paid_amount: 0, remaining_amount: 0, remaining_installments: 0 }); + + res.json({ + data: { + loans, + summary: { + active_count: active.length, + total_count: loans.length, + total_amount: cents(totals.total_amount), + paid_amount: cents(totals.paid_amount), + remaining_amount: cents(totals.remaining_amount), + remaining_installments: totals.remaining_installments, + }, + }, + }); + } catch (err) { + log.error('GET /loans error:', err); + res.status(500).json({ error: 'Internal error', code: 500 }); + } +}); + +router.post('/loans', (req, res) => { + try { + const vTitle = str(req.body.title || req.body.borrower, 'Title', { max: MAX_TITLE }); + const vBorrower = str(req.body.borrower, 'Borrower', { max: MAX_SHORT }); + const vAmount = num(req.body.total_amount, 'Amount', { required: true }); + const vStartMonth = validateMonth(req.body.start_month, 'Start month'); + const vNotes = str(req.body.notes, 'Notes', { max: 1000, required: false }); + const installmentCount = parseInt(req.body.installment_count, 10); + const errors = collectErrors([vTitle, vBorrower, vAmount, vStartMonth, vNotes]); + if (!Number.isInteger(installmentCount) || installmentCount < 1 || installmentCount > 240) { + errors.push('Installment count must be between 1 and 240.'); + } + if (vAmount.value !== null && vAmount.value <= 0) errors.push('Amount must be greater than zero.'); + if (!vStartMonth.value) errors.push('Start month is required.'); + if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + + const result = db.get().prepare(` + INSERT INTO budget_loans (title, borrower, total_amount, installment_count, start_month, notes, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + vTitle.value, + vBorrower.value, + cents(vAmount.value), + installmentCount, + vStartMonth.value, + vNotes.value, + req.session.userId + ); + + res.status(201).json({ data: loadLoan(result.lastInsertRowid) }); + } catch (err) { + log.error('POST /loans error:', err); + res.status(500).json({ error: 'Internal error', code: 500 }); + } +}); + +router.put('/loans/:id', (req, res) => { + try { + const id = parseInt(req.params.id, 10); + const loan = db.get().prepare('SELECT * FROM budget_loans WHERE id = ?').get(id); + if (!loan) return res.status(404).json({ error: 'Loan not found.', code: 404 }); + + const checks = []; + if (req.body.title !== undefined) checks.push(str(req.body.title, 'Title', { max: MAX_TITLE })); + if (req.body.borrower !== undefined) checks.push(str(req.body.borrower, 'Borrower', { max: MAX_SHORT })); + if (req.body.total_amount !== undefined) checks.push(num(req.body.total_amount, 'Amount')); + if (req.body.start_month !== undefined) checks.push(validateMonth(req.body.start_month, 'Start month')); + if (req.body.notes !== undefined) checks.push(str(req.body.notes, 'Notes', { max: 1000, required: false })); + const errors = collectErrors(checks); + const installmentCount = req.body.installment_count === undefined ? null : parseInt(req.body.installment_count, 10); + if (req.body.installment_count !== undefined && (!Number.isInteger(installmentCount) || installmentCount < 1 || installmentCount > 240)) { + errors.push('Installment count must be between 1 and 240.'); + } + const paidCount = db.get().prepare('SELECT COUNT(*) AS c FROM budget_loan_payments WHERE loan_id = ?').get(id).c; + if (installmentCount !== null && installmentCount < paidCount) { + errors.push('Installment count cannot be lower than paid installments.'); + } + if (req.body.total_amount !== undefined && Number(req.body.total_amount) <= 0) errors.push('Amount must be greater than zero.'); + if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + + db.get().prepare(` + UPDATE budget_loans + SET title = COALESCE(?, title), + borrower = COALESCE(?, borrower), + total_amount = COALESCE(?, total_amount), + installment_count = COALESCE(?, installment_count), + start_month = COALESCE(?, start_month), + notes = ? + WHERE id = ? + `).run( + req.body.title?.trim() ?? null, + req.body.borrower?.trim() ?? null, + req.body.total_amount !== undefined ? cents(req.body.total_amount) : null, + installmentCount, + req.body.start_month ?? null, + req.body.notes !== undefined ? (req.body.notes?.trim() || null) : loan.notes, + id + ); + + res.json({ data: refreshLoanStatus(id) }); + } catch (err) { + log.error('PUT /loans/:id error:', err); + res.status(500).json({ error: 'Internal error', code: 500 }); + } +}); + +router.post('/loans/:id/payments', (req, res) => { + try { + const id = parseInt(req.params.id, 10); + const loan = loadLoan(id); + if (!loan) return res.status(404).json({ error: 'Loan not found.', code: 404 }); + if (loan.remaining_installments <= 0) return res.status(409).json({ error: 'Loan is already paid.', code: 409 }); + + const installmentNumber = req.body.installment_number === undefined + ? loan.next_installment_number + : parseInt(req.body.installment_number, 10); + const defaultAmount = installmentNumber === loan.installment_count + ? loan.remaining_amount + : Math.min(loan.installment_amount, loan.remaining_amount); + const vAmount = num(req.body.amount ?? defaultAmount, 'Amount', { required: true }); + const vDate = validateDate(req.body.paid_date, 'Paid date', true); + const errors = collectErrors([vAmount, vDate]); + if (!Number.isInteger(installmentNumber) || installmentNumber < 1 || installmentNumber > loan.installment_count) { + errors.push('Installment number is invalid.'); + } + if (vAmount.value !== null && vAmount.value <= 0) errors.push('Amount must be greater than zero.'); + if (vAmount.value !== null && vAmount.value - loan.remaining_amount > 0.005) { + errors.push('Amount cannot be greater than the remaining loan amount.'); + } + if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + + const existing = db.get().prepare(` + SELECT 1 FROM budget_loan_payments WHERE loan_id = ? AND installment_number = ? + `).get(id, installmentNumber); + if (existing) return res.status(409).json({ error: 'Installment already paid.', code: 409 }); + + const paymentAmount = cents(vAmount.value); + const tx = db.get().transaction(() => { + const budgetResult = db.get().prepare(` + INSERT INTO budget_entries (title, amount, category, subcategory, date, is_recurring, created_by) + VALUES (?, ?, ?, '', ?, 0, ?) + `).run( + `Loan repayment: ${loan.borrower}`, + paymentAmount, + 'Geschenke & Transfers', + vDate.value, + req.session.userId + ); + const paymentResult = db.get().prepare(` + INSERT INTO budget_loan_payments + (loan_id, installment_number, amount, paid_date, budget_entry_id, created_by) + VALUES (?, ?, ?, ?, ?, ?) + `).run(id, installmentNumber, paymentAmount, vDate.value, budgetResult.lastInsertRowid, req.session.userId); + return paymentResult.lastInsertRowid; + }); + + const paymentId = tx(); + res.status(201).json({ + data: { + payment: db.get().prepare('SELECT * FROM budget_loan_payments WHERE id = ?').get(paymentId), + loan: refreshLoanStatus(id), + }, + }); + } catch (err) { + log.error('POST /loans/:id/payments error:', err); + res.status(500).json({ error: 'Internal error', code: 500 }); + } +}); + +router.delete('/loans/:id/payments/:paymentId', (req, res) => { + try { + const id = parseInt(req.params.id, 10); + const paymentId = parseInt(req.params.paymentId, 10); + const payment = db.get().prepare(` + SELECT * FROM budget_loan_payments WHERE id = ? AND loan_id = ? + `).get(paymentId, id); + if (!payment) return res.status(404).json({ error: 'Payment not found.', code: 404 }); + + const tx = db.get().transaction(() => { + db.get().prepare('DELETE FROM budget_loan_payments WHERE id = ?').run(paymentId); + if (payment.budget_entry_id) { + db.get().prepare('DELETE FROM budget_entries WHERE id = ?').run(payment.budget_entry_id); + } + }); + tx(); + refreshLoanStatus(id); + res.status(204).end(); + } catch (err) { + log.error('DELETE /loans/:id/payments/:paymentId error:', err); + res.status(500).json({ error: 'Internal error', code: 500 }); + } +}); + +router.delete('/loans/:id', (req, res) => { + try { + const id = parseInt(req.params.id, 10); + const loan = db.get().prepare('SELECT * FROM budget_loans WHERE id = ?').get(id); + if (!loan) return res.status(404).json({ error: 'Loan not found.', code: 404 }); + + const payments = db.get().prepare('SELECT budget_entry_id FROM budget_loan_payments WHERE loan_id = ?').all(id); + const tx = db.get().transaction(() => { + db.get().prepare('DELETE FROM budget_loans WHERE id = ?').run(id); + for (const payment of payments) { + if (payment.budget_entry_id) { + db.get().prepare('DELETE FROM budget_entries WHERE id = ?').run(payment.budget_entry_id); + } + } + }); + tx(); + res.status(204).end(); + } catch (err) { + log.error('DELETE /loans/:id error:', err); + res.status(500).json({ error: 'Internal error', code: 500 }); + } +}); + router.post('/categories', (req, res) => { try { const vName = str(req.body.name, 'Name', { max: MAX_SHORT }); diff --git a/test-notes-contacts-budget.js b/test-notes-contacts-budget.js index a2cb3e1..a6803f4 100644 --- a/test-notes-contacts-budget.js +++ b/test-notes-contacts-budget.js @@ -307,6 +307,40 @@ test('Index idx_budget_date genutzt', () => { assert(usesIndex, JSON.stringify(plan)); }); +test('Empréstimo com parcelas calcula restante', () => { + const loan = db.prepare(` + INSERT INTO budget_loans (title, borrower, total_amount, installment_count, start_month, created_by) + VALUES ('Empréstimo Lais', 'Lais', 1000, 5, '2026-03', ?) + `).run(uid); + const loanId = loan.lastInsertRowid; + + const entry = db.prepare(` + INSERT INTO budget_entries (title, amount, category, date, created_by) + VALUES ('Loan repayment: Lais', 200, 'Geschenke & Transfers', '2026-03-05', ?) + `).run(uid); + db.prepare(` + INSERT INTO budget_loan_payments + (loan_id, installment_number, amount, paid_date, budget_entry_id, created_by) + VALUES (?, 1, 200, '2026-03-05', ?, ?) + `).run(loanId, entry.lastInsertRowid, uid); + + const totals = db.prepare(` + SELECT l.total_amount, + l.installment_count, + COUNT(p.id) AS paid_installments, + COALESCE(SUM(p.amount), 0) AS paid_amount + FROM budget_loans l + LEFT JOIN budget_loan_payments p ON p.loan_id = l.id + WHERE l.id = ? + GROUP BY l.id + `).get(loanId); + + assert(totals.paid_installments === 1, `Parcelas pagas: ${totals.paid_installments}`); + assert(Math.abs(totals.paid_amount - 200) < 0.01, `Pago: ${totals.paid_amount}`); + assert(Math.abs((totals.total_amount - totals.paid_amount) - 800) < 0.01, 'Restante deve ser 800'); + assert(totals.installment_count - totals.paid_installments === 4, 'Devem restar 4 parcelas'); +}); + // -------------------------------------------------------- // Ergebnis // --------------------------------------------------------