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()}
+
- `;
+ `);
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.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
// --------------------------------------------------------