8 Commits

Author SHA1 Message Date
Peter Smit
df0a32617d Fix decimal error in expense form in a hacky way 2025-09-14 19:37:02 +02:00
Weblate (bot)
eb78848601 Translations update from Hosted Weblate (#407)
All checks were successful
CI / checks (push) Successful in 50s
* Add currency and exchange rate with Frankfurter per expense

* Remove as unknown as

* Translated using Weblate (German)

Currently translated at 99.6% (273 of 274 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/de/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (274 of 274 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/nl/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (283 of 283 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/nl/

* Translated using Weblate (German)

Currently translated at 97.1% (275 of 283 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/de/

* Translated using Weblate (German)

Currently translated at 97.1% (275 of 283 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/de/

* Translated using Weblate (French)

Currently translated at 95.0% (269 of 283 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/fr/

* Translated using Weblate (German)

Currently translated at 100.0% (283 of 283 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/de/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (283 of 283 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/pt_BR/

* Translated using Weblate (French)

Currently translated at 96.4% (273 of 283 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/fr/

* Translated using Weblate (French)

Currently translated at 91.4% (277 of 303 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/fr/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (303 of 303 strings)

Translation: Spliit/Spliit
Translate-URL: https://hosted.weblate.org/projects/spliit/spliit/nl/

---------

Co-authored-by: Steven Sengchanh <91092101+whimcomp@users.noreply.github.com>
Co-authored-by: Peter Smit <petersmit27@gmail.com>
Co-authored-by: Marcel Herhold <herhold.marcel@gmail.com>
Co-authored-by: Julian van Santen <julian@julianvansanten.nl>
Co-authored-by: Femke <femkeweijsenfeld2003@gmail.com>
Co-authored-by: Rico Stendel <rico@stendel.family>
Co-authored-by: renardyre <renardyre@gmail.com>
Co-authored-by: Antonin <atooo57@gmail.com>
2025-09-14 17:35:26 +02:00
Peter Smit
a9f008683f Remove as unknown as
All checks were successful
CI / checks (push) Successful in 55s
(cherry picked from commit 4e7733286a)
2025-09-13 17:20:55 +02:00
Peter Smit
52a2b552cb Merge branch 'currency-conversion' of github.com:whimcomp/spliit into whimcomp-currency-conversion
# Conflicts:
#	src/app/groups/[groupId]/expenses/expense-form.tsx
2025-09-13 17:20:39 +02:00
Peter Smit
0e77a666f4 Fix prettier issues
All checks were successful
CI / checks (push) Successful in 55s
2025-09-13 11:44:07 +02:00
Peter Smit
c49d0ea220 Always round minor units to an integer 2025-09-13 11:41:33 +02:00
Peter Smit
05a793ee39 Remove unneeded as unknown ases 2025-09-13 11:32:39 +02:00
Steven Sengchanh
d641540b65 Add currency and exchange rate with Frankfurter per expense 2025-04-21 01:31:14 +02:00
18 changed files with 912 additions and 225 deletions

View File

@@ -38,12 +38,15 @@
"earlierThisYear": "Dieses Jahr",
"lastYear": "Letztes Jahr",
"older": "Älter"
}
},
"export": "Exportieren"
},
"ExpenseCard": {
"paidBy": "Gezahlt von <strong>{paidBy}</strong> für <paidFor></paidFor>",
"receivedBy": "Empfangen von <strong>{paidBy}</strong> für <paidFor></paidFor>",
"yourBalance": "Deine Bilanz:"
"yourBalance": "Deine Bilanz:",
"everyone": "jeder",
"notInvolved": "Du bist nicht involviert"
},
"Groups": {
"myGroups": "Meine Gruppen",
@@ -117,6 +120,12 @@
"create": "Erstellen",
"creating": "Erstellt…",
"cancel": "Abbrechen"
},
"CurrencyCodeField": {
"label": "Hauptwährung",
"createDescription": "Alle Beträge und Salden werden in dieser Währung angegeben.",
"customOption": "benutzerdefiniert",
"editDescription": "Alle Beträge und Salden werden in dieser Währung angegeben. Bei Änderung dieser, werden bereits eingegebene Ausgaben NICHT umgerechnet, es sei denn, die Währung hat andere \"kleinere Einheiten\" als die aktuelle (z. B. Wechsel von US-Dollar zu Japanischem Yen)"
}
},
"ExpenseForm": {
@@ -240,7 +249,7 @@
"triggerTitle": "Ausgabe von Rechnungsbeleg erstellen",
"title": "Von Rechnungsbeleg erstellen",
"description": "Ausgabeninformationen von einem Foto einer Rechnung lesen.",
"body": "Lade ein Foto der Rechnung hoch und wir versuchen die Ausgabeinformationen zu extrahieren",
"body": "Lade ein Foto der Rechnung hoch und wir versuchen die Ausgabeinformationen zu extrahieren.",
"selectImage": "Bild wählen…",
"titleLabel": "Titel:",
"categoryLabel": "Kategorie:",
@@ -295,7 +304,7 @@
"Groups": {
"today": "Heute",
"yesterday": "Gestern",
"earlierThisWeek": "Diese Woche",
"earlierThisWeek": "Anfang dieser Woche",
"lastWeek": "Letze Woche",
"earlierThisMonth": "Diesen Monat",
"lastMonth": "Letzen Monat",
@@ -328,7 +337,7 @@
"invalidNumber": "Zahl nicht valide.",
"amountRequired": "Du musst einen Betrag angeben.",
"amountNotZero": "Der Betrag darf nicht 0 sein.",
"amountTenMillion": "Der Betrag muss kleiner als 10.000.000 sein",
"amountTenMillion": "Der Betrag muss kleiner als 10.000.000 sein.",
"paidByRequired": "Du musst ein Mitglied auswählen.",
"paidForMin1": "Die Ausgabe muss mindestens für ein Mitglied bezahlt werden.",
"noZeroShares": "Alle Anteile müssen größer als 0 sein.",
@@ -403,5 +412,18 @@
"TV/Phone/Internet": "TV/Internet/Telefonie",
"Water": "Wasser"
}
},
"Currencies": {
"search": "Währung suchen...",
"noCurrency": "Keine Währungen gefunden.",
"other": {
"heading": "Andere Währungen"
},
"custom": {
"heading": "Benutzerdefinierte"
},
"common": {
"heading": "Geläufigste"
}
}
}

View File

@@ -141,6 +141,10 @@
"label": "Income date",
"description": "Enter the date the income was received."
},
"currencyField": {
"label": "Currency of income",
"description": "The currency in which the income was received."
},
"categoryFieldDescription": "Select the income category.",
"paidByField": {
"label": "Received by",
@@ -165,6 +169,10 @@
"label": "Expense date",
"description": "Enter the date the expense was paid."
},
"currencyField": {
"label": "Currency of expense",
"description": "The currency in which the expense was paid."
},
"categoryFieldDescription": "Select the expense category.",
"paidByField": {
"label": "Paid by",
@@ -190,6 +198,27 @@
"amountField": {
"label": "Amount"
},
"conversionUnavailable": "To set a different currency per expense and convert amounts, select a non-custom currency for the group.",
"originalAmountField": {
"label": "Amount to convert"
},
"conversionRateField": {
"useApi": "Use rates from Frankfurter",
"useCustom": "Use custom rate",
"label": "Exchange rate"
},
"conversionRateState": {
"loading": "Getting exchange rates…",
"success": "Obtained rates:",
"error": "Oops, we could not get the most recent rates.",
"staleRate": "Using rate:",
"noRate": "Enter a custom rate below.",
"currencyNotFound": "Oops, Frankfurter does not have the rate for this currency at this day.",
"noDate": "Enter the expense date to get a conversion rate.",
"dateMismatch": "Rates from date: {date}",
"refresh": "Refresh",
"customRate": "Using custom rate"
},
"isReimbursementField": {
"label": "This is a reimbursement"
},
@@ -331,6 +360,7 @@
"amountRequired": "You must enter an amount.",
"amountNotZero": "The amount must not be zero.",
"amountTenMillion": "The amount must be lower than 10,000,000.",
"ratePositive": "The rate must be strictly greater than zero.",
"paidByRequired": "You must select a participant.",
"paidForMin1": "The expense must be paid for at least one participant.",
"noZeroShares": "All shares must be higher than 0.",

View File

@@ -1,6 +1,6 @@
{
"Homepage": {
"title": "Partagez <strong>vos dépenses</strong> avec <strong>vos amis</strong> & <strong>votre famille :)</strong>",
"title": "Partagez <strong>vos dépenses</strong> avec <strong>vos amis & votre famille</strong>",
"description": "Bienvenue sur votre instance <strong>Spliit</strong> !",
"button": {
"groups": "Accéder aux groupes",
@@ -38,12 +38,15 @@
"earlierThisYear": "Plus tôt cette année",
"lastYear": "L'année dernière",
"older": "Plus ancien"
}
},
"export": "Exporter"
},
"ExpenseCard": {
"paidBy": "Payé par <strong>{paidBy}</strong> pour <paidFor></paidFor>",
"receivedBy": "Reçu par <strong>{paidBy}</strong> pour <paidFor></paidFor>",
"yourBalance": "Votre solde :"
"yourBalance": "Votre solde :",
"everyone": "tout le monde",
"notInvolved": "Vous n'êtes pas concerné"
},
"Groups": {
"myGroups": "Mes groupes",
@@ -99,9 +102,9 @@
"protectedParticipant": "Ce participant fait partie des dépenses et ne peut pas être supprimé.",
"new": "Nouveau",
"add": "Ajouter un participant",
"John": "John",
"Jane": "Jane",
"Jack": "Jack"
"John": "Jean",
"Jane": "Jeanne",
"Jack": "Jacques"
},
"Settings": {
"title": "Paramètres locaux",
@@ -117,6 +120,12 @@
"create": "Créer",
"creating": "Création…",
"cancel": "Annuler"
},
"CurrencyCodeField": {
"label": "Devise principale",
"createDescription": "Tous les montants et soldes seront dans cette devise.",
"editDescription": "Tous les montants et soldes seront exprimés dans cette devise. La modification de cette option n'entraînera PAS la conversion des dépenses déjà saisies, sauf si la devise a des « unités mineures » différentes de celles de la devise actuelle (par exemple, passage du dollar américain au yen japonais).",
"customOption": "Personalisée"
}
},
"ExpenseForm": {
@@ -150,7 +159,11 @@
"description": "Sélectionnez pour qui le revenu a été reçu."
},
"splitModeDescription": "Sélectionnez comment diviser le revenu.",
"attachDescription": "Voir et joindre des reçus au revenu."
"attachDescription": "Voir et joindre des reçus au revenu.",
"currencyField": {
"label": "Devise de la recette",
"description": "La devise dans laquelle le bénéfice a été perçu."
}
},
"Expense": {
"create": "Créer une dépense",
@@ -167,7 +180,8 @@
"categoryFieldDescription": "Sélectionnez la catégorie de dépense.",
"paidByField": {
"label": "Payé par",
"description": "Sélectionnez le participant qui a réglé la dépense."
"description": "Sélectionnez le participant qui a réglé la dépense.",
"placeholder": "Sélectionner un participant"
},
"recurrenceRule": {
"label": "Récurrence de la dépense",
@@ -182,7 +196,11 @@
"description": "Sélectionnez les participants concernés"
},
"splitModeDescription": "Sélectionnez comment diviser la dépense.",
"attachDescription": "Voir et joindre des reçus à la dépense."
"attachDescription": "Voir et joindre des reçus à la dépense.",
"currencyField": {
"label": "Devise de la dépense",
"description": "La devise dans laquelle la dépense a été payée."
}
},
"amountField": {
"label": "Montant"
@@ -221,7 +239,21 @@
"save": "Sauvegarder",
"saving": "Sauvegarde…",
"cancel": "Annuler",
"reimbursement": "Remboursement"
"reimbursement": "Remboursement",
"conversionUnavailable": "Pour définir une devise différente pour chaque dépense et convertir les montants, sélectionnez une devise non personnalisée pour le groupe.",
"originalAmountField": {
"label": "Montant à convertir"
},
"conversionRateField": {
"useCustom": "Utiliser un taux personnalisé",
"label": "Taux de change"
},
"conversionRateState": {
"loading": "Obtention des taux de change…",
"success": "Taux obtenus:",
"error": "Oups, nous n'avons pas pu obtenir les taux de change les plus récents.",
"refresh": "Actualiser"
}
},
"ExpenseDocumentsInput": {
"TooBigToast": {
@@ -348,7 +380,7 @@
"Games": "Jeux",
"Movies": "Films",
"Music": "Musique",
"Sports": "Sports"
"Sports": "Sport"
},
"Food and Drink": {
"heading": "Nourriture et boissons",
@@ -377,7 +409,8 @@
"Gifts": "Cadeaux",
"Insurance": "Assurance",
"Medical Expenses": "Dépenses médicales",
"Taxes": "Impôts"
"Taxes": "Impôts",
"Donation": "Don"
},
"Transportation": {
"heading": "Transport",
@@ -401,5 +434,18 @@
"TV/Phone/Internet": "TV/Téléphone/Internet",
"Water": "Eau"
}
},
"Currencies": {
"search": "Chercher une devise...",
"noCurrency": "Aucune devise trouvée.",
"custom": {
"heading": "Personnalisée"
},
"common": {
"heading": "Les plus courantes"
},
"other": {
"heading": "Autres devises"
}
}
}

View File

@@ -45,7 +45,8 @@
"paidBy": "Betaald door <strong>{paidBy}</strong> voor <paidFor></paidFor>",
"everyone": "iedereen",
"receivedBy": "Ontvangen door <strong>{paidBy}</strong> voor <paidFor></paidFor>",
"yourBalance": "Jouw balans:"
"yourBalance": "Jouw balans:",
"notInvolved": "Je bent hier niet bij betrokken"
},
"Groups": {
"myGroups": "Mijn groepen",
@@ -119,6 +120,12 @@
"create": "Groep maken",
"creating": "Aan het maken…",
"cancel": "Annuleren"
},
"CurrencyCodeField": {
"label": "Hoofdvaluta",
"createDescription": "Alle hoeveelheden en saldi worden in deze valuta weergegeven.",
"editDescription": "Alle bedragen en saldi worden in deze valuta weergegeven. Als je dit wijzigt, worden reeds ingevoerde uitgaven NIET omgerekend, behalve wanneer de valuta andere \"kleinste eenheden\" heeft dan de huidige (bijvoorbeeld bij een wijziging van Amerikaanse dollar naar Japanse yen)",
"customOption": "Aangepast"
}
},
"ExpenseForm": {
@@ -152,7 +159,11 @@
"description": "Selecteer voor wie het inkomen is ontvangen."
},
"splitModeDescription": "Selecteer hoe het inkomen verdeeld moet worden.",
"attachDescription": "Bekijk en voeg bijlagen toe aan het inkomen."
"attachDescription": "Bekijk en voeg bijlagen toe aan het inkomen.",
"currencyField": {
"label": "Munteenheid van inkomen",
"description": "De munteenheid waar het inkomen in is ontvangen."
}
},
"Expense": {
"create": "Maak uitgave",
@@ -169,7 +180,8 @@
"categoryFieldDescription": "Selecteer de uitgavecategorie.",
"paidByField": {
"label": "Betaald door",
"description": "Selecteer de deelnemer die de uitgave heeft gedaan."
"description": "Selecteer de deelnemer die de uitgave heeft gedaan.",
"placeholder": "Selecteer een deelnemer"
},
"recurrenceRule": {
"label": "Terugkerende uitgave",
@@ -184,7 +196,11 @@
"description": "Selecteer voor wie de uitgave is gedaan."
},
"splitModeDescription": "Selecteer hoe de uitgave verdeeld moet worden.",
"attachDescription": "Bekijk en voeg bijlagen toe aan de uitgave."
"attachDescription": "Bekijk en voeg bijlagen toe aan de uitgave.",
"currencyField": {
"label": "Munteenheid van uitgave",
"description": "De munteenheid waar de uitgave in is betaald."
}
},
"amountField": {
"label": "Bedrag"
@@ -201,7 +217,7 @@
"selectNone": "Selecteer niemand",
"selectAll": "Selecteer iedereen",
"shares": "deel/delen",
"advancedOptions": "Geavanceerde split-opties",
"advancedOptions": "Andere split-opties",
"SplitModeField": {
"label": "Split soort",
"evenly": "Gelijk verdeeld",
@@ -213,7 +229,7 @@
"DeletePopup": {
"label": "Verwijderen",
"title": "Deze uitgave verwijderen?",
"description": "Wil je deze uitgave echt verwijderen?",
"description": "Wil je deze uitgave echt verwijderen? Dit kan niet ongedaan worden.",
"yes": "Ja",
"cancel": "Annuleer"
},
@@ -223,7 +239,28 @@
"save": "Opslaan",
"saving": "Aan het opslaan…",
"cancel": "Annuleren",
"reimbursement": "Terugbetaling"
"reimbursement": "Terugbetaling",
"conversionUnavailable": "Om een andere munteenheid in te stellen voor een uitgave en bedragen om te rekenen, kies een standaard munteenheid voor de groep.",
"originalAmountField": {
"label": "Om te rekenen bedrag"
},
"conversionRateField": {
"useApi": "Gebruik koersen van Frankfurter",
"useCustom": "Gebruik aangepaste koers",
"label": "Wisselkoers"
},
"conversionRateState": {
"loading": "Wisselkoers aan het ophalen…",
"success": "Verkregen wisselkoers:",
"error": "Oeps, we konden de nieuwste wisselkoersen niet verkrijgen.",
"staleRate": "Gebruikte wisselkoers:",
"noRate": "Voer hieronder een aangepaste koers in.",
"currencyNotFound": "Oeps, Frankfurter heeft geen koersen voor deze munteenheid op deze dag.",
"noDate": "Voer de datum van de uitgave in om een wisselkoers te krijgen.",
"dateMismatch": "Wisselkoers van: {date}",
"refresh": "Ververs",
"customRate": "Aangepaste wisselkoers wordt gebruikt"
}
},
"ExpenseDocumentsInput": {
"TooBigToast": {
@@ -334,7 +371,8 @@
"paidForMin1": "De uitgave moet voor ten minste één deelnemer zijn gedaan.",
"noZeroShares": "Een deel mag niet 0 zijn.",
"amountSum": "Het totaalbedrag moet gelijk zijn aan het uitgavebedrag.",
"percentageSum": "Het totaalpercentage moet gelijk zijn aan 100%."
"percentageSum": "Het totaalpercentage moet gelijk zijn aan 100%.",
"ratePositive": "De koers moet groter dan nul zijn."
},
"Categories": {
"search": "Categorie zoeken…",
@@ -404,5 +442,18 @@
"TV/Phone/Internet": "Internet/TV/Telefoon",
"Water": "Water"
}
},
"Currencies": {
"noCurrency": "Geen valuta gevonden.",
"custom": {
"heading": "Aangepast"
},
"common": {
"heading": "Meest voorkomend"
},
"other": {
"heading": "Andere valuta"
},
"search": "Valuta zoeken..."
}
}

View File

@@ -1,6 +1,6 @@
{
"Homepage": {
"title": "Compartilhe <strong>Despesas</strong> com <strong>Amigos e Família</strong>",
"title": "Compartilhe <strong>Despesas</strong> com <strong>Amigos e Família</strong>",
"description": "Bem-vindo à sua nova instalação do <strong>Spliit</strong>!",
"button": {
"groups": "Ir para grupos",
@@ -38,12 +38,15 @@
"earlierThisYear": "Anteriores neste ano",
"lastYear": "Ano passado",
"older": "Mais antigas"
}
},
"export": "Exportar"
},
"ExpenseCard": {
"paidBy": "Pago por <strong>{paidBy}</strong> para <paidFor></paidFor>",
"receivedBy": "Recebido por <strong>{paidBy}</strong> para <paidFor></paidFor>",
"yourBalance": "Seu saldo:"
"yourBalance": "Seu saldo:",
"everyone": "Todos",
"notInvolved": "Você não está envolvido"
},
"Groups": {
"myGroups": "Meus grupos",
@@ -117,6 +120,12 @@
"create": "Criar",
"creating": "Criando…",
"cancel": "Cancelar"
},
"CurrencyCodeField": {
"label": "Moeda principal",
"createDescription": "Todos os valores e saldos estarão nesta moeda.",
"editDescription": "Todos os valores e saldos estarão nesta moeda. A sua alteração NÃO irá converter despesas já registradas, exceto quando a moeda possuir \"unidades menores\" que a atual (ex. Alterar de Dólar Americano para Yen Japonês)",
"customOption": "Customizado"
}
},
"ExpenseForm": {
@@ -159,14 +168,23 @@
"categoryFieldDescription": "Selecione a categoria da despesa.",
"paidByField": {
"label": "Pago por",
"description": "Selecione o participante que pagou a despesa."
"description": "Selecione o participante que pagou a despesa.",
"placeholder": "Selecione um participante"
},
"paidFor": {
"title": "Pago para",
"description": "Selecione para quem a despesa foi paga."
},
"splitModeDescription": "Selecione como dividir a despesa.",
"attachDescription": "Veja e anexe recibos à despesa."
"attachDescription": "Veja e anexe recibos à despesa.",
"recurrenceRule": {
"label": "Recorrência da Despesa",
"description": "Selecione a frequência de recorrência da despesa.",
"none": "Nenhuma",
"daily": "Diariamente",
"weekly": "Semanalmente",
"monthly": "Mensalmente"
}
},
"amountField": {
"label": "Valor"
@@ -361,7 +379,8 @@
"Gifts": "Presentes",
"Insurance": "Seguro",
"Medical Expenses": "Despesas médicas",
"Taxes": "Impostos"
"Taxes": "Impostos",
"Donation": "Doação"
},
"Transportation": {
"heading": "Transporte",
@@ -385,5 +404,18 @@
"TV/Phone/Internet": "TV/Telefone/Internet",
"Water": "Água"
}
},
"Currencies": {
"search": "Pesquisar moeda...",
"noCurrency": "Nenhuma moeda encontrada.",
"custom": {
"heading": "Customizado"
},
"common": {
"heading": "Mais comum"
},
"other": {
"heading": "Outras moedas"
}
}
}

24
package-lock.json generated
View File

@@ -56,6 +56,7 @@
"server-only": "^0.0.1",
"sharp": "^0.33.2",
"superjson": "^2.2.1",
"swr": "^2.3.3",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"ts-pattern": "^5.0.6",
@@ -11040,7 +11041,6 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -16850,6 +16850,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/swr": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.3.tgz",
"integrity": "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@@ -17369,6 +17382,15 @@
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@@ -64,6 +64,7 @@
"server-only": "^0.0.1",
"sharp": "^0.33.2",
"superjson": "^2.2.1",
"swr": "^2.3.3",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"ts-pattern": "^5.0.6",

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Expense" ADD COLUMN "conversionRate" DECIMAL(65,30),
ADD COLUMN "originalAmount" INTEGER,
ADD COLUMN "originalCurrency" TEXT;

View File

@@ -16,7 +16,7 @@ model Group {
name String
information String? @db.Text
currency String @default("$")
currencyCode String?
currencyCode String?
participants Participant[]
expenses Expense[]
activities Activity[]
@@ -40,25 +40,28 @@ model Category {
}
model Expense {
id String @id
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date
title String
category Category? @relation(fields: [categoryId], references: [id])
categoryId Int @default(0)
amount Int
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
paidById String
paidFor ExpensePaidFor[]
groupId String
isReimbursement Boolean @default(false)
splitMode SplitMode @default(EVENLY)
createdAt DateTime @default(now())
documents ExpenseDocument[]
notes String?
id String @id
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date
title String
category Category? @relation(fields: [categoryId], references: [id])
categoryId Int @default(0)
amount Int
originalAmount Int?
originalCurrency String?
conversionRate Decimal?
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
paidById String
paidFor ExpensePaidFor[]
groupId String
isReimbursement Boolean @default(false)
splitMode SplitMode @default(EVENLY)
createdAt DateTime @default(now())
documents ExpenseDocument[]
notes String?
recurrenceRule RecurrenceRule? @default(NONE)
recurringExpenseLink RecurringExpenseLink?
recurrenceRule RecurrenceRule? @default(NONE)
recurringExpenseLink RecurringExpenseLink?
recurringExpenseLinkId String?
}
@@ -79,16 +82,16 @@ enum SplitMode {
}
model RecurringExpenseLink {
id String @id
groupId String
currentFrameExpense Expense @relation(fields: [currentFrameExpenseId], references: [id], onDelete: Cascade)
currentFrameExpenseId String @unique
id String @id
groupId String
currentFrameExpense Expense @relation(fields: [currentFrameExpenseId], references: [id], onDelete: Cascade)
currentFrameExpenseId String @unique
// Note: We do not want to link to the next expense because once it is created, it should be
// treated as it's own independent entity. This means that if a user wants to delete an Expense
// and any prior related recurring expenses, they'll need to delete them one by one.
nextExpenseCreatedAt DateTime?
nextExpenseDate DateTime
nextExpenseDate DateTime
@@index([groupId])
@@index([groupId, nextExpenseCreatedAt, nextExpenseDate(sort: Desc)])

View File

@@ -1,4 +1,5 @@
import { CategorySelector } from '@/components/category-selector'
import { CurrencySelector } from '@/components/currency-selector'
import { ExpenseDocumentsInput } from '@/components/expense-documents-input'
import { SubmitButton } from '@/components/submit-button'
import { Button } from '@/components/ui/button'
@@ -32,9 +33,11 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Locale } from '@/i18n'
import { randomId } from '@/lib/api'
import { defaultCurrencyList, getCurrency } from '@/lib/currency'
import { RuntimeFeatureFlags } from '@/lib/featureFlags'
import { useActiveUser } from '@/lib/hooks'
import { useActiveUser, useCurrencyRate } from '@/lib/hooks'
import {
ExpenseFormValues,
SplittingOptions,
@@ -51,7 +54,7 @@ import {
import { AppRouterOutput } from '@/trpc/routers/_app'
import { zodResolver } from '@hookform/resolvers/zod'
import { RecurrenceRule } from '@prisma/client'
import { Save } from 'lucide-react'
import { ChevronRight, Save } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
@@ -78,7 +81,7 @@ const getDefaultSplittingOptions = (
splitMode: 'EVENLY' as const,
paidFor: group.participants.map(({ id }) => ({
participant: id,
shares: '1' as unknown as number,
shares: 1,
})),
}
@@ -110,7 +113,7 @@ const getDefaultSplittingOptions = (
splitMode: parsedDefaultSplitMode.splitMode,
paidFor: parsedDefaultSplitMode.paidFor.map((paidFor) => ({
participant: paidFor.participant,
shares: String(paidFor.shares / 100) as unknown as number,
shares: paidFor.shares / 100,
})),
}
}
@@ -124,7 +127,7 @@ async function persistDefaultSplittingOptions(
if (expenseFormValues.splitMode === 'EVENLY') {
return expenseFormValues.paidFor.map(({ participant }) => ({
participant,
shares: '100' as unknown as number,
shares: 100,
}))
} else if (expenseFormValues.splitMode === 'BY_AMOUNT') {
return null
@@ -161,7 +164,7 @@ export function ExpenseForm({
runtimeFeatureFlags: RuntimeFeatureFlags
}) {
const t = useTranslations('ExpenseForm')
const locale = useLocale()
const locale = useLocale() as Locale
const isCreate = expense === undefined
const searchParams = useSearchParams()
@@ -186,18 +189,18 @@ export function ExpenseForm({
? {
title: expense.title,
expenseDate: expense.expenseDate ?? new Date(),
amount: String(
amountAsDecimal(expense.amount, groupCurrency),
) as unknown as number, // hack
amount: amountAsDecimal(expense.amount, groupCurrency),
originalCurrency: expense.originalCurrency ?? group.currencyCode,
originalAmount: expense.originalAmount ?? undefined,
conversionRate: expense.conversionRate?.toNumber(),
category: expense.categoryId,
paidBy: expense.paidById,
paidFor: expense.paidFor.map(({ participantId, shares }) => ({
participant: participantId,
shares: String(
shares:
expense.splitMode === 'BY_AMOUNT'
? amountAsDecimal(shares, groupCurrency)
: shares / 100,
) as unknown as number,
})),
splitMode: expense.splitMode,
saveDefaultSplittingOptions: false,
@@ -210,19 +213,20 @@ export function ExpenseForm({
? {
title: t('reimbursement'),
expenseDate: new Date(),
amount: String(
amountAsDecimal(
Number(searchParams.get('amount')) || 0,
groupCurrency,
),
) as unknown as number, // hack
amount: amountAsDecimal(
Number(searchParams.get('amount')) || 0,
groupCurrency,
),
originalCurrency: group.currencyCode,
originalAmount: undefined,
conversionRate: undefined,
category: 1, // category with Id 1 is Payment
paidBy: searchParams.get('from') ?? undefined,
paidFor: [
searchParams.get('to')
? {
participant: searchParams.get('to')!,
shares: '1' as unknown as number,
shares: 1,
}
: undefined,
],
@@ -238,7 +242,10 @@ export function ExpenseForm({
expenseDate: searchParams.get('date')
? new Date(searchParams.get('date') as string)
: new Date(),
amount: (searchParams.get('amount') || 0) as unknown as number, // hack,
amount: Number(searchParams.get('amount')) || 0,
originalCurrency: group.currencyCode ?? undefined,
originalAmount: undefined,
conversionRate: undefined,
category: searchParams.get('categoryId')
? Number(searchParams.get('categoryId'))
: 0, // category with Id 0 is General
@@ -277,6 +284,12 @@ export function ExpenseForm({
? amountAsMinorUnits(shares, groupCurrency)
: shares,
}))
// Currency should be blank if same as group currency
if (!conversionRequired) {
delete values.originalAmount
delete values.originalCurrency
}
return onSubmit(values, activeUserId ?? undefined)
}
@@ -287,6 +300,23 @@ export function ExpenseForm({
const sExpense = isIncome ? 'Income' : 'Expense'
const originalCurrency = getCurrency(
form.getValues('originalCurrency'),
locale,
'Custom',
)
const exchangeRate = useCurrencyRate(
form.watch('expenseDate'),
form.watch('originalCurrency') ?? '',
groupCurrency.code,
)
const conversionRequired =
group.currencyCode &&
group.currencyCode.length &&
originalCurrency.code.length &&
originalCurrency.code !== group.currencyCode
useEffect(() => {
setManuallyEditedParticipants(new Set())
}, [form.watch('splitMode'), form.watch('amount')])
@@ -329,11 +359,9 @@ export function ExpenseForm({
if (!editedParticipants.includes(participant.participant)) {
return {
...participant,
shares: String(
Number(
amountPerRemaining.toFixed(groupCurrency.decimal_digits),
),
) as unknown as number,
shares: Number(
amountPerRemaining.toFixed(groupCurrency.decimal_digits),
),
}
}
return participant
@@ -347,6 +375,71 @@ export function ExpenseForm({
form.watch('splitMode'),
])
const [usingCustomConversionRate, setUsingCustomConversionRate] = useState(
!!form.formState.defaultValues?.conversionRate,
)
useEffect(() => {
if (!usingCustomConversionRate && exchangeRate.data) {
form.setValue('conversionRate', exchangeRate.data)
}
}, [exchangeRate.data, usingCustomConversionRate])
useEffect(() => {
if (!form.getFieldState('originalAmount').isTouched) return
const originalAmount = form.getValues('originalAmount') ?? 0
const conversionRate = form.getValues('conversionRate')
if (conversionRate && originalAmount) {
const rate = Number(conversionRate)
const convertedAmount = originalAmount * rate
if (!Number.isNaN(convertedAmount)) {
const v = enforceCurrencyPattern(
convertedAmount.toFixed(groupCurrency.decimal_digits),
)
const income = Number(v) < 0
setIsIncome(income)
if (income) form.setValue('isReimbursement', false)
form.setValue('amount', Number(v))
}
}
}, [
form.watch('originalAmount'),
form.watch('conversionRate'),
form.getFieldState('originalAmount').isTouched,
])
let conversionRateMessage = ''
if (exchangeRate.isLoading) {
conversionRateMessage = t('conversionRateState.loading')
} else {
let ratesDisplay = ''
if (exchangeRate.data) {
// non breaking spaces so the rate text is not split with line feeds
ratesDisplay = `${form.getValues('originalCurrency')}\xa01\xa0=\xa0${
group.currencyCode
}\xa0${exchangeRate.data}`
}
if (exchangeRate.error) {
if (exchangeRate.error instanceof RangeError && exchangeRate.data)
conversionRateMessage = t('conversionRateState.dateMismatch', {
date: exchangeRate.error.message,
})
else {
conversionRateMessage = t('conversionRateState.error')
}
conversionRateMessage +=
' ' +
(ratesDisplay.length
? `${t('conversionRateState.staleRate')} ${ratesDisplay}`
: t('conversionRateState.noRate'))
} else {
conversionRateMessage = ratesDisplay.length
? `${t('conversionRateState.success')} ${ratesDisplay}`
: t('conversionRateState.currencyNotFound')
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(submit)}>
@@ -413,11 +506,175 @@ export function ExpenseForm({
)}
/>
<FormField
name="originalCurrency"
render={({ field: { onChange, ...field } }) => (
<FormItem className="sm:order-3">
<FormLabel>{t(`${sExpense}.currencyField.label`)}</FormLabel>
<FormControl>
{group.currencyCode ? (
<CurrencySelector
currencies={defaultCurrencyList(locale, '')}
defaultValue={form.watch(field.name) ?? ''}
isLoading={false}
onValueChange={(v) => onChange(v)}
/>
) : (
<Input
className="text-base"
disabled={true}
{...field}
placeholder={group.currency}
/>
)}
</FormControl>
<FormDescription>
{t(`${sExpense}.currencyField.description`)}{' '}
{!group.currencyCode && t('conversionUnavailable')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div
className={`sm:order-4 ${
!conversionRequired ? 'max-sm:hidden sm:invisible' : ''
} col-span-2 md:col-span-1 space-y-2`}
>
<FormField
control={form.control}
name="originalAmount"
render={({ field: { onChange, ...field } }) => (
<FormItem>
<FormLabel>{t('originalAmountField.label')}</FormLabel>
<div className="flex items-baseline gap-2">
<span>{originalCurrency.symbol}</span>
<FormControl>
<Input
className="text-base max-w-[120px]"
type="text"
inputMode="decimal"
placeholder="0.00"
onChange={(event) => {
const v = enforceCurrencyPattern(event.target.value)
onChange(v)
}}
{...field}
onFocus={(e) => {
const target = e.currentTarget
setTimeout(() => target.select(), 1)
}}
/>
</FormControl>
</div>
<FormDescription>
{isNaN(form.getValues('expenseDate').getTime()) ? (
t('conversionRateState.noDate')
) : form.getValues('expenseDate') &&
!usingCustomConversionRate ? (
<>
{conversionRateMessage}
{!exchangeRate.isLoading && (
<Button
className="h-auto py-0"
variant="link"
onClick={() => exchangeRate.refresh()}
>
{t('conversionRateState.refresh')}
</Button>
)}
</>
) : (
t('conversionRateState.customRate')
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Collapsible
open={usingCustomConversionRate}
onOpenChange={setUsingCustomConversionRate}
>
<CollapsibleTrigger asChild>
<Button variant="link" className="-mx-4">
{usingCustomConversionRate
? t('conversionRateField.useApi')
: t('conversionRateField.useCustom')}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<FormField
control={form.control}
name="conversionRate"
render={({ field: { onChange, ...field } }) => (
<FormItem
className={`sm:order-4 ${
!conversionRequired
? 'max-sm:hidden sm:invisible'
: ''
}`}
>
<FormLabel>{t('conversionRateField.label')}</FormLabel>
<div className="flex items-baseline gap-2">
<span>
{originalCurrency.symbol} 1 = {group.currency}
</span>
<FormControl>
<Input
className="text-base max-w-[120px]"
type="text"
inputMode="decimal"
placeholder="0.00"
onChange={(event) => {
const v = enforceCurrencyPattern(
event.target.value,
)
onChange(v)
}}
{...field}
onFocus={(e) => {
const target = e.currentTarget
setTimeout(() => target.select(), 1)
}}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
</div>
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem className="order-3 sm:order-2">
<FormLabel>{t('categoryField.label')}</FormLabel>
<CategorySelector
categories={categories}
defaultValue={
form.watch(field.name) // may be overwritten externally
}
onValueChange={field.onChange}
isLoading={isCategoryLoading}
/>
<FormDescription>
{t(`${sExpense}.categoryFieldDescription`)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="amount"
render={({ field: { onChange, ...field } }) => (
<FormItem className="sm:order-3">
<FormItem className="sm:order-5">
<FormLabel>{t('amountField.label')}</FormLabel>
<div className="flex items-baseline gap-2">
<span>{group.currency}</span>
@@ -470,28 +727,6 @@ export function ExpenseForm({
)}
/>
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem className="order-3 sm:order-2">
<FormLabel>{t('categoryField.label')}</FormLabel>
<CategorySelector
categories={categories}
defaultValue={
form.watch(field.name) // may be overwritten externally
}
onValueChange={field.onChange}
isLoading={isCategoryLoading}
/>
<FormDescription>
{t(`${sExpense}.categoryFieldDescription`)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="paidBy"
@@ -592,7 +827,7 @@ export function ExpenseForm({
participant: p.id,
shares:
paidFor.find((pfor) => pfor.participant === p.id)
?.shares ?? ('1' as unknown as number),
?.shares ?? 1,
}))
form.setValue('paidFor', newPaidFor, {
shouldDirty: true,
@@ -630,7 +865,7 @@ export function ExpenseForm({
data-id={`${id}/${form.getValues().splitMode}/${
group.currency
}`}
className="flex items-center border-t last-of-type:border-b last-of-type:!mb-4 -mx-6 px-6 py-3"
className="flex flex-wrap gap-y-4 items-center border-t last-of-type:border-b last-of-type:!mb-4 -mx-6 px-6 py-3"
>
<FormItem className="flex-1 flex flex-row items-start space-x-3 space-y-0">
<FormControl>
@@ -651,7 +886,7 @@ export function ExpenseForm({
...field.value,
{
participant: id,
shares: '1' as unknown as number,
shares: 1,
},
],
options,
@@ -714,106 +949,209 @@ export function ExpenseForm({
)}
</FormLabel>
</FormItem>
{form.getValues().splitMode !== 'EVENLY' && (
<FormField
name={`paidFor[${field.value.findIndex(
({ participant }) => participant === id,
)}].shares`}
render={() => {
const sharesLabel = (
<span
className={cn('text-sm', {
'text-muted': !field.value?.some(
({ participant }) =>
participant === id,
),
})}
>
{match(form.getValues().splitMode)
.with('BY_SHARES', () => (
<>{t('shares')}</>
))
.with('BY_PERCENTAGE', () => <>%</>)
.with('BY_AMOUNT', () => (
<>{group.currency}</>
))
.otherwise(() => (
<></>
))}
</span>
)
return (
<div>
<div className="flex gap-1 items-center">
{form.getValues().splitMode ===
'BY_AMOUNT' && sharesLabel}
<FormControl>
<Input
key={String(
!field.value?.some(
({ participant }) =>
participant === id,
),
)}
className="text-base w-[80px] -my-2"
type="text"
disabled={
!field.value?.some(
({ participant }) =>
participant === id,
)
}
value={
field.value?.find(
({ participant }) =>
participant === id,
)?.shares
}
onChange={(event) => {
field.onChange(
field.value.map((p) =>
p.participant === id
? {
participant: id,
shares:
enforceCurrencyPattern(
event.target.value,
),
}
: p,
<div className="flex">
{form.getValues().splitMode === 'BY_AMOUNT' &&
!!conversionRequired && (
<FormField
name={`paidFor[${field.value.findIndex(
({ participant }) => participant === id,
)}].originalAmount`}
render={() => {
const sharesLabel = (
<span
className={cn('text-sm', {
'text-muted': !field.value?.some(
({ participant }) =>
participant === id,
),
})}
>
{originalCurrency.symbol}
</span>
)
return (
<div>
<div className="flex gap-1 items-center">
{sharesLabel}
<FormControl>
<Input
key={String(
!field.value?.some(
({ participant }) =>
participant === id,
),
)}
className="text-base w-[80px] -my-2"
type="text"
inputMode="decimal"
disabled={
!field.value?.some(
({ participant }) =>
participant === id,
)
}
value={
field.value.find(
({ participant }) =>
participant === id,
)?.originalAmount ?? ''
}
onChange={(event) => {
const originalAmount = Number(
event.target.value,
)
let convertedAmount = ''
if (
!Number.isNaN(
originalAmount,
) &&
exchangeRate.data
) {
convertedAmount = (
originalAmount *
exchangeRate.data
).toFixed(
groupCurrency.decimal_digits,
)
}
field.onChange(
field.value.map((p) =>
p.participant === id
? {
participant: id,
originalAmount:
event.target
.value,
shares:
enforceCurrencyPattern(
convertedAmount,
),
}
: p,
),
)
setManuallyEditedParticipants(
(prev) =>
new Set(prev).add(id),
)
}}
step={
10 **
-originalCurrency.decimal_digits
}
/>
</FormControl>
<ChevronRight className="h-4 w-4 mx-1 opacity-50" />
</div>
</div>
)
}}
/>
)}
{form.getValues().splitMode !== 'EVENLY' && (
<FormField
name={`paidFor[${field.value.findIndex(
({ participant }) => participant === id,
)}].shares`}
render={() => {
const sharesLabel = (
<span
className={cn('text-sm', {
'text-muted': !field.value?.some(
({ participant }) =>
participant === id,
),
})}
>
{match(form.getValues().splitMode)
.with('BY_SHARES', () => (
<>{t('shares')}</>
))
.with('BY_PERCENTAGE', () => <>%</>)
.with('BY_AMOUNT', () => (
<>{group.currency}</>
))
.otherwise(() => (
<></>
))}
</span>
)
return (
<div>
<div className="flex gap-1 items-center">
{form.getValues().splitMode ===
'BY_AMOUNT' && sharesLabel}
<FormControl>
<Input
key={String(
!field.value?.some(
({ participant }) =>
participant === id,
),
)
setManuallyEditedParticipants(
(prev) => new Set(prev).add(id),
)
}}
inputMode={
form.getValues().splitMode ===
'BY_AMOUNT'
? 'decimal'
: 'numeric'
}
step={
form.getValues().splitMode ===
'BY_AMOUNT'
? 0.01
: 1
}
/>
</FormControl>
{[
'BY_SHARES',
'BY_PERCENTAGE',
].includes(
form.getValues().splitMode,
) && sharesLabel}
)}
className="text-base w-[80px] -my-2"
type="text"
disabled={
!field.value?.some(
({ participant }) =>
participant === id,
)
}
value={
field.value?.find(
({ participant }) =>
participant === id,
)?.shares
}
onChange={(event) => {
field.onChange(
field.value.map((p) =>
p.participant === id
? {
participant: id,
shares:
enforceCurrencyPattern(
event.target
.value,
),
}
: p,
),
)
setManuallyEditedParticipants(
(prev) =>
new Set(prev).add(id),
)
}}
inputMode={
form.getValues().splitMode ===
'BY_AMOUNT'
? 'decimal'
: 'numeric'
}
step={
form.getValues().splitMode ===
'BY_AMOUNT'
? 10 **
-groupCurrency.decimal_digits
: 1
}
/>
</FormControl>
{[
'BY_SHARES',
'BY_PERCENTAGE',
].includes(
form.getValues().splitMode,
) && sharesLabel}
</div>
<FormMessage className="float-right" />
</div>
<FormMessage className="float-right" />
</div>
)
}}
/>
)}
)
}}
/>
)}
</div>
</div>
)
}}

View File

@@ -1,3 +1,4 @@
import { getCurrency } from '@/lib/currency'
import { formatAmountAsDecimal, getCurrencyFromGroup } from '@/lib/utils'
import { Parser } from '@json2csv/plainjs'
import { PrismaClient } from '@prisma/client'
@@ -38,6 +39,9 @@ export async function GET(
title: true,
category: { select: { name: true } },
amount: true,
originalAmount: true,
originalCurrency: true,
conversionRate: true,
paidById: true,
paidFor: { select: { participantId: true, shares: true } },
isReimbursement: true,
@@ -54,30 +58,29 @@ export async function GET(
/*
CSV Structure:
--------------------------------------------------------------
| Date | Description | Category | Currency | Cost
--------------------------------------------------------------
| Is Reimbursement | Split mode | UserA | UserB
--------------------------------------------------------------
Columns:
CSV Columns:
- Date: The date of the expense.
- Description: A brief description of the expense.
- Category: The category of the expense (e.g., Food, Travel, etc.).
- Currency: The currency in which the expense is recorded.
- Cost: The amount spent.
- Original cost: The amount spent in the original currency.
- Original currency: The currency the amount was originally spent in.
- Conversion rate: The rate used to convert the amount.
- Is Reimbursement: Whether the expense is a reimbursement or not.
- Split mode: The method used to split the expense (e.g., Evenly, By shares, By percentage, By amount).
- UserA, UserB: User-specific data or balances (e.g., amount owed or contributed by each user).
Example Row:
------------------------------------------------------------------------------------------
| 2025-01-06 | Dinner with team | Food | ₹ | 5000 | No | Evenly | John | Jane
------------------------------------------------------------------------------------------
Example Table:
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
| Date | Description | Category | Currency | Cost | Original cost | Original currency | Conversion rate | Is reinbursement | Split mode | User A | User B |
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
| 2025-01-06 | Dinner with team | Food | INR | 5000 | | | | No | Evenly | 2500 | -2500 |
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
| 2025-02-07 | Plane tickets | Travel | INR | 97264.09 | 1000 | EUR | 97.2641 | No | Unevenly - By amount | -80000 | -17264.09 |
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
*/
*/
const fields = [
{ label: 'Date', value: 'date' },
@@ -85,6 +88,9 @@ export async function GET(
{ label: 'Category', value: 'categoryName' },
{ label: 'Currency', value: 'currency' },
{ label: 'Cost', value: 'amount' },
{ label: 'Original cost', value: 'originalAmount' },
{ label: 'Original currency', value: 'originalCurrency' },
{ label: 'Conversion rate', value: 'conversionRate' },
{ label: 'Is Reimbursement', value: 'isReimbursement' },
{ label: 'Split mode', value: 'splitMode' },
...group.participants.map((participant) => ({
@@ -101,6 +107,16 @@ export async function GET(
categoryName: expense.category?.name || '',
currency: group.currencyCode ?? group.currency,
amount: formatAmountAsDecimal(expense.amount, currency),
originalAmount: expense.originalAmount
? formatAmountAsDecimal(
expense.originalAmount,
getCurrency(expense.originalCurrency),
)
: null,
originalCurrency: expense.originalCurrency,
conversionRate: expense.conversionRate
? expense.conversionRate.toString()
: null,
isReimbursement: expense.isReimbursement ? 'Yes' : 'No',
splitMode: splitModeLabel[expense.splitMode],
...Object.fromEntries(

View File

@@ -20,6 +20,9 @@ export async function GET(
title: true,
category: { select: { grouping: true, name: true } },
amount: true,
originalAmount: true,
originalCurrency: true,
conversionRate: true,
paidById: true,
paidFor: { select: { participantId: true, shares: true } },
isReimbursement: true,

View File

@@ -71,6 +71,9 @@ export async function createExpense(
expenseDate: expenseFormValues.expenseDate,
categoryId: expenseFormValues.category,
amount: expenseFormValues.amount,
originalAmount: expenseFormValues.originalAmount,
originalCurrency: expenseFormValues.originalCurrency,
conversionRate: expenseFormValues.conversionRate,
title: expenseFormValues.title,
paidById: expenseFormValues.paidBy,
splitMode: expenseFormValues.splitMode,
@@ -206,6 +209,9 @@ export async function updateExpense(
data: {
expenseDate: expenseFormValues.expenseDate,
amount: expenseFormValues.amount,
originalAmount: expenseFormValues.originalAmount,
originalCurrency: expenseFormValues.originalCurrency,
conversionRate: expenseFormValues.conversionRate,
title: expenseFormValues.title,
categoryId: expenseFormValues.category,
paidById: expenseFormValues.paidBy,

View File

@@ -1,4 +1,6 @@
import dayjs from 'dayjs'
import { useEffect, useState } from 'react'
import useSWR, { Fetcher } from 'swr'
export function useMediaQuery(query: string): boolean {
const getMatches = (query: string): boolean => {
@@ -64,3 +66,62 @@ export function useActiveUser(groupId?: string) {
return activeUser
}
interface FrankfurterAPIResponse {
base: string
date: string
rates: Record<string, number>
}
const fetcher: Fetcher<FrankfurterAPIResponse> = (url: string) =>
fetch(url).then(async (res) => {
if (!res.ok)
throw new TypeError('Unsuccessful response from API', { cause: res })
return res.json() as Promise<FrankfurterAPIResponse>
})
export function useCurrencyRate(
date: Date,
baseCurrency: string,
targetCurrency: string,
) {
const dateString = dayjs(date).format('YYYY-MM-DD')
// Only send request if both currency codes are given and not the same
const url =
!isNaN(date.getTime()) &&
!!baseCurrency.length &&
!!targetCurrency.length &&
baseCurrency !== targetCurrency &&
`https://api.frankfurter.app/${dateString}?base=${baseCurrency}`
const { data, error, isLoading, mutate } = useSWR<FrankfurterAPIResponse>(
url,
fetcher,
{ shouldRetryOnError: false, revalidateOnFocus: false },
)
if (data) {
let exchangeRate = undefined
let sentError = error
if (!error && data.date !== dateString) {
// this happens if for example, the requested date is in the future.
sentError = new RangeError(data.date)
}
if (data.rates[targetCurrency]) {
exchangeRate = data.rates[targetCurrency]
}
return {
data: exchangeRate,
error: sentError,
isLoading,
refresh: mutate,
}
}
return {
data,
error,
isLoading,
refresh: mutate,
}
}

View File

@@ -32,6 +32,19 @@ export const groupFormSchema = z
export type GroupFormValues = z.infer<typeof groupFormSchema>
const inputCoercedToNumber = z.union([
z.number(),
z.string().transform((value, ctx) => {
const valueAsNumber = Number(value)
if (Number.isNaN(valueAsNumber))
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'invalidNumber',
})
return valueAsNumber
}),
])
export const expenseFormSchema = z
.object({
expenseDate: z.coerce.date(),
@@ -55,11 +68,27 @@ export const expenseFormSchema = z
)
.refine((amount) => amount != 0, 'amountNotZero')
.refine((amount) => amount <= 10_000_000_00, 'amountTenMillion'),
originalAmount: z
.union([
z.literal('').transform(() => undefined),
inputCoercedToNumber
.refine((amount) => amount != 0, 'amountNotZero')
.refine((amount) => amount <= 10_000_000_00, 'amountTenMillion'),
])
.optional(),
originalCurrency: z.union([z.string().length(3).nullish(), z.literal('')]),
conversionRate: z
.union([
z.literal('').transform(() => undefined),
inputCoercedToNumber.refine((amount) => amount > 0, 'ratePositive'),
])
.optional(),
paidBy: z.string({ required_error: 'paidByRequired' }),
paidFor: z
.array(
z.object({
participant: z.string(),
originalAmount: z.string().optional(), // For converting shares by amounts in original currency, not saved.
shares: z.union([
z.number(),
z.string().transform((value, ctx) => {
@@ -119,10 +148,12 @@ export const expenseFormSchema = z
break // noop
case 'BY_AMOUNT': {
const sum = expense.paidFor.reduce(
(sum, { shares }) => sum + Number(shares),
// Total hack, but multiplying by 1000 avoids floating point rounding issues
// The ideal solution is using the group's currency decimal digits to determine the multiplier, but I can't seem to access that here
(sum, { shares }) => sum + Math.round(Number(shares) * 1000),
0,
)
if (sum !== expense.amount) {
if (sum !== Math.round(expense.amount * 1000)) {
const detail =
sum < expense.amount
? `${((expense.amount - sum) / 100).toFixed(2)} missing`
@@ -163,17 +194,18 @@ export const expenseFormSchema = z
// Format the share split as a number (if from form submission)
return {
...expense,
paidFor: expense.paidFor.map(({ participant, shares }) => {
paidFor: expense.paidFor.map((paidFor) => {
const shares = paidFor.shares
if (typeof shares === 'string' && expense.splitMode !== 'BY_AMOUNT') {
// For splitting not by amount, preserve the previous behaviour of multiplying the share by 100
return {
participant,
...paidFor,
shares: Math.round(Number(shares) * 100),
}
}
// Otherwise, no need as the number will have been formatted according to currency.
return {
participant,
...paidFor,
shares: Number(shares),
}
}),

View File

@@ -99,10 +99,10 @@ export function amountAsDecimal(
* - €1.5 = 150 "minor units" of euros (cents)
* - JPY 1000 = 1000 "minor units" of yen (the yen does not have minor units in practice)
*
* @param amount The amount in decimal major units
* @param amount The amount in decimal major units (always an integer)
*/
export function amountAsMinorUnits(amount: number, currency: Currency) {
return amount * 10 ** currency.decimal_digits
return Math.round(amount * 10 ** currency.decimal_digits)
}
/**

View File

@@ -1,4 +1,5 @@
'use client' // <-- to make sure we can mount the Provider from a server component
import { Prisma } from '@prisma/client'
import type { QueryClient } from '@tanstack/react-query'
import { QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
@@ -8,6 +9,15 @@ import superjson from 'superjson'
import { makeQueryClient } from './query-client'
import type { AppRouter } from './routers/_app'
superjson.registerCustom<Prisma.Decimal, string>(
{
isApplicable: (v): v is Prisma.Decimal => Prisma.Decimal.isDecimal(v),
serialize: (v) => v.toJSON(),
deserialize: (v) => new Prisma.Decimal(v),
},
'decimal.js',
)
export const trpc = createTRPCReact<AppRouter>()
let clientQueryClientSingleton: QueryClient

View File

@@ -1,7 +1,17 @@
import { Prisma } from '@prisma/client'
import { initTRPC } from '@trpc/server'
import { cache } from 'react'
import superjson from 'superjson'
superjson.registerCustom<Prisma.Decimal, string>(
{
isApplicable: (v): v is Prisma.Decimal => Prisma.Decimal.isDecimal(v),
serialize: (v) => v.toJSON(),
deserialize: (v) => new Prisma.Decimal(v),
},
'decimal.js',
)
export const createTRPCContext = cache(async () => {
/**
* @see: https://trpc.io/docs/server/context