7 Commits

Author SHA1 Message Date
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 908 additions and 223 deletions

View File

@@ -38,12 +38,15 @@
"earlierThisYear": "Dieses Jahr", "earlierThisYear": "Dieses Jahr",
"lastYear": "Letztes Jahr", "lastYear": "Letztes Jahr",
"older": "Älter" "older": "Älter"
} },
"export": "Exportieren"
}, },
"ExpenseCard": { "ExpenseCard": {
"paidBy": "Gezahlt von <strong>{paidBy}</strong> für <paidFor></paidFor>", "paidBy": "Gezahlt von <strong>{paidBy}</strong> für <paidFor></paidFor>",
"receivedBy": "Empfangen 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": { "Groups": {
"myGroups": "Meine Gruppen", "myGroups": "Meine Gruppen",
@@ -117,6 +120,12 @@
"create": "Erstellen", "create": "Erstellen",
"creating": "Erstellt…", "creating": "Erstellt…",
"cancel": "Abbrechen" "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": { "ExpenseForm": {
@@ -240,7 +249,7 @@
"triggerTitle": "Ausgabe von Rechnungsbeleg erstellen", "triggerTitle": "Ausgabe von Rechnungsbeleg erstellen",
"title": "Von Rechnungsbeleg erstellen", "title": "Von Rechnungsbeleg erstellen",
"description": "Ausgabeninformationen von einem Foto einer Rechnung lesen.", "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…", "selectImage": "Bild wählen…",
"titleLabel": "Titel:", "titleLabel": "Titel:",
"categoryLabel": "Kategorie:", "categoryLabel": "Kategorie:",
@@ -295,7 +304,7 @@
"Groups": { "Groups": {
"today": "Heute", "today": "Heute",
"yesterday": "Gestern", "yesterday": "Gestern",
"earlierThisWeek": "Diese Woche", "earlierThisWeek": "Anfang dieser Woche",
"lastWeek": "Letze Woche", "lastWeek": "Letze Woche",
"earlierThisMonth": "Diesen Monat", "earlierThisMonth": "Diesen Monat",
"lastMonth": "Letzen Monat", "lastMonth": "Letzen Monat",
@@ -328,7 +337,7 @@
"invalidNumber": "Zahl nicht valide.", "invalidNumber": "Zahl nicht valide.",
"amountRequired": "Du musst einen Betrag angeben.", "amountRequired": "Du musst einen Betrag angeben.",
"amountNotZero": "Der Betrag darf nicht 0 sein.", "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.", "paidByRequired": "Du musst ein Mitglied auswählen.",
"paidForMin1": "Die Ausgabe muss mindestens für ein Mitglied bezahlt werden.", "paidForMin1": "Die Ausgabe muss mindestens für ein Mitglied bezahlt werden.",
"noZeroShares": "Alle Anteile müssen größer als 0 sein.", "noZeroShares": "Alle Anteile müssen größer als 0 sein.",
@@ -403,5 +412,18 @@
"TV/Phone/Internet": "TV/Internet/Telefonie", "TV/Phone/Internet": "TV/Internet/Telefonie",
"Water": "Wasser" "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", "label": "Income date",
"description": "Enter the date the income was received." "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.", "categoryFieldDescription": "Select the income category.",
"paidByField": { "paidByField": {
"label": "Received by", "label": "Received by",
@@ -165,6 +169,10 @@
"label": "Expense date", "label": "Expense date",
"description": "Enter the date the expense was paid." "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.", "categoryFieldDescription": "Select the expense category.",
"paidByField": { "paidByField": {
"label": "Paid by", "label": "Paid by",
@@ -190,6 +198,27 @@
"amountField": { "amountField": {
"label": "Amount" "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": { "isReimbursementField": {
"label": "This is a reimbursement" "label": "This is a reimbursement"
}, },
@@ -331,6 +360,7 @@
"amountRequired": "You must enter an amount.", "amountRequired": "You must enter an amount.",
"amountNotZero": "The amount must not be zero.", "amountNotZero": "The amount must not be zero.",
"amountTenMillion": "The amount must be lower than 10,000,000.", "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.", "paidByRequired": "You must select a participant.",
"paidForMin1": "The expense must be paid for at least one participant.", "paidForMin1": "The expense must be paid for at least one participant.",
"noZeroShares": "All shares must be higher than 0.", "noZeroShares": "All shares must be higher than 0.",

View File

@@ -1,6 +1,6 @@
{ {
"Homepage": { "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> !", "description": "Bienvenue sur votre instance <strong>Spliit</strong> !",
"button": { "button": {
"groups": "Accéder aux groupes", "groups": "Accéder aux groupes",
@@ -38,12 +38,15 @@
"earlierThisYear": "Plus tôt cette année", "earlierThisYear": "Plus tôt cette année",
"lastYear": "L'année dernière", "lastYear": "L'année dernière",
"older": "Plus ancien" "older": "Plus ancien"
} },
"export": "Exporter"
}, },
"ExpenseCard": { "ExpenseCard": {
"paidBy": "Payé par <strong>{paidBy}</strong> pour <paidFor></paidFor>", "paidBy": "Payé par <strong>{paidBy}</strong> pour <paidFor></paidFor>",
"receivedBy": "Reçu 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": { "Groups": {
"myGroups": "Mes groupes", "myGroups": "Mes groupes",
@@ -99,9 +102,9 @@
"protectedParticipant": "Ce participant fait partie des dépenses et ne peut pas être supprimé.", "protectedParticipant": "Ce participant fait partie des dépenses et ne peut pas être supprimé.",
"new": "Nouveau", "new": "Nouveau",
"add": "Ajouter un participant", "add": "Ajouter un participant",
"John": "John", "John": "Jean",
"Jane": "Jane", "Jane": "Jeanne",
"Jack": "Jack" "Jack": "Jacques"
}, },
"Settings": { "Settings": {
"title": "Paramètres locaux", "title": "Paramètres locaux",
@@ -117,6 +120,12 @@
"create": "Créer", "create": "Créer",
"creating": "Création…", "creating": "Création…",
"cancel": "Annuler" "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": { "ExpenseForm": {
@@ -150,7 +159,11 @@
"description": "Sélectionnez pour qui le revenu a été reçu." "description": "Sélectionnez pour qui le revenu a été reçu."
}, },
"splitModeDescription": "Sélectionnez comment diviser le revenu.", "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": { "Expense": {
"create": "Créer une dépense", "create": "Créer une dépense",
@@ -167,7 +180,8 @@
"categoryFieldDescription": "Sélectionnez la catégorie de dépense.", "categoryFieldDescription": "Sélectionnez la catégorie de dépense.",
"paidByField": { "paidByField": {
"label": "Payé par", "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": { "recurrenceRule": {
"label": "Récurrence de la dépense", "label": "Récurrence de la dépense",
@@ -182,7 +196,11 @@
"description": "Sélectionnez les participants concernés" "description": "Sélectionnez les participants concernés"
}, },
"splitModeDescription": "Sélectionnez comment diviser la dépense.", "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": { "amountField": {
"label": "Montant" "label": "Montant"
@@ -221,7 +239,21 @@
"save": "Sauvegarder", "save": "Sauvegarder",
"saving": "Sauvegarde…", "saving": "Sauvegarde…",
"cancel": "Annuler", "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": { "ExpenseDocumentsInput": {
"TooBigToast": { "TooBigToast": {
@@ -348,7 +380,7 @@
"Games": "Jeux", "Games": "Jeux",
"Movies": "Films", "Movies": "Films",
"Music": "Musique", "Music": "Musique",
"Sports": "Sports" "Sports": "Sport"
}, },
"Food and Drink": { "Food and Drink": {
"heading": "Nourriture et boissons", "heading": "Nourriture et boissons",
@@ -377,7 +409,8 @@
"Gifts": "Cadeaux", "Gifts": "Cadeaux",
"Insurance": "Assurance", "Insurance": "Assurance",
"Medical Expenses": "Dépenses médicales", "Medical Expenses": "Dépenses médicales",
"Taxes": "Impôts" "Taxes": "Impôts",
"Donation": "Don"
}, },
"Transportation": { "Transportation": {
"heading": "Transport", "heading": "Transport",
@@ -401,5 +434,18 @@
"TV/Phone/Internet": "TV/Téléphone/Internet", "TV/Phone/Internet": "TV/Téléphone/Internet",
"Water": "Eau" "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>", "paidBy": "Betaald door <strong>{paidBy}</strong> voor <paidFor></paidFor>",
"everyone": "iedereen", "everyone": "iedereen",
"receivedBy": "Ontvangen door <strong>{paidBy}</strong> voor <paidFor></paidFor>", "receivedBy": "Ontvangen door <strong>{paidBy}</strong> voor <paidFor></paidFor>",
"yourBalance": "Jouw balans:" "yourBalance": "Jouw balans:",
"notInvolved": "Je bent hier niet bij betrokken"
}, },
"Groups": { "Groups": {
"myGroups": "Mijn groepen", "myGroups": "Mijn groepen",
@@ -119,6 +120,12 @@
"create": "Groep maken", "create": "Groep maken",
"creating": "Aan het maken…", "creating": "Aan het maken…",
"cancel": "Annuleren" "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": { "ExpenseForm": {
@@ -152,7 +159,11 @@
"description": "Selecteer voor wie het inkomen is ontvangen." "description": "Selecteer voor wie het inkomen is ontvangen."
}, },
"splitModeDescription": "Selecteer hoe het inkomen verdeeld moet worden.", "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": { "Expense": {
"create": "Maak uitgave", "create": "Maak uitgave",
@@ -169,7 +180,8 @@
"categoryFieldDescription": "Selecteer de uitgavecategorie.", "categoryFieldDescription": "Selecteer de uitgavecategorie.",
"paidByField": { "paidByField": {
"label": "Betaald door", "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": { "recurrenceRule": {
"label": "Terugkerende uitgave", "label": "Terugkerende uitgave",
@@ -184,7 +196,11 @@
"description": "Selecteer voor wie de uitgave is gedaan." "description": "Selecteer voor wie de uitgave is gedaan."
}, },
"splitModeDescription": "Selecteer hoe de uitgave verdeeld moet worden.", "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": { "amountField": {
"label": "Bedrag" "label": "Bedrag"
@@ -201,7 +217,7 @@
"selectNone": "Selecteer niemand", "selectNone": "Selecteer niemand",
"selectAll": "Selecteer iedereen", "selectAll": "Selecteer iedereen",
"shares": "deel/delen", "shares": "deel/delen",
"advancedOptions": "Geavanceerde split-opties", "advancedOptions": "Andere split-opties",
"SplitModeField": { "SplitModeField": {
"label": "Split soort", "label": "Split soort",
"evenly": "Gelijk verdeeld", "evenly": "Gelijk verdeeld",
@@ -213,7 +229,7 @@
"DeletePopup": { "DeletePopup": {
"label": "Verwijderen", "label": "Verwijderen",
"title": "Deze uitgave 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", "yes": "Ja",
"cancel": "Annuleer" "cancel": "Annuleer"
}, },
@@ -223,7 +239,28 @@
"save": "Opslaan", "save": "Opslaan",
"saving": "Aan het opslaan…", "saving": "Aan het opslaan…",
"cancel": "Annuleren", "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": { "ExpenseDocumentsInput": {
"TooBigToast": { "TooBigToast": {
@@ -334,7 +371,8 @@
"paidForMin1": "De uitgave moet voor ten minste één deelnemer zijn gedaan.", "paidForMin1": "De uitgave moet voor ten minste één deelnemer zijn gedaan.",
"noZeroShares": "Een deel mag niet 0 zijn.", "noZeroShares": "Een deel mag niet 0 zijn.",
"amountSum": "Het totaalbedrag moet gelijk zijn aan het uitgavebedrag.", "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": { "Categories": {
"search": "Categorie zoeken…", "search": "Categorie zoeken…",
@@ -404,5 +442,18 @@
"TV/Phone/Internet": "Internet/TV/Telefoon", "TV/Phone/Internet": "Internet/TV/Telefoon",
"Water": "Water" "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": { "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>!", "description": "Bem-vindo à sua nova instalação do <strong>Spliit</strong>!",
"button": { "button": {
"groups": "Ir para grupos", "groups": "Ir para grupos",
@@ -38,12 +38,15 @@
"earlierThisYear": "Anteriores neste ano", "earlierThisYear": "Anteriores neste ano",
"lastYear": "Ano passado", "lastYear": "Ano passado",
"older": "Mais antigas" "older": "Mais antigas"
} },
"export": "Exportar"
}, },
"ExpenseCard": { "ExpenseCard": {
"paidBy": "Pago por <strong>{paidBy}</strong> para <paidFor></paidFor>", "paidBy": "Pago por <strong>{paidBy}</strong> para <paidFor></paidFor>",
"receivedBy": "Recebido 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": { "Groups": {
"myGroups": "Meus grupos", "myGroups": "Meus grupos",
@@ -117,6 +120,12 @@
"create": "Criar", "create": "Criar",
"creating": "Criando…", "creating": "Criando…",
"cancel": "Cancelar" "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": { "ExpenseForm": {
@@ -159,14 +168,23 @@
"categoryFieldDescription": "Selecione a categoria da despesa.", "categoryFieldDescription": "Selecione a categoria da despesa.",
"paidByField": { "paidByField": {
"label": "Pago por", "label": "Pago por",
"description": "Selecione o participante que pagou a despesa." "description": "Selecione o participante que pagou a despesa.",
"placeholder": "Selecione um participante"
}, },
"paidFor": { "paidFor": {
"title": "Pago para", "title": "Pago para",
"description": "Selecione para quem a despesa foi paga." "description": "Selecione para quem a despesa foi paga."
}, },
"splitModeDescription": "Selecione como dividir a despesa.", "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": { "amountField": {
"label": "Valor" "label": "Valor"
@@ -361,7 +379,8 @@
"Gifts": "Presentes", "Gifts": "Presentes",
"Insurance": "Seguro", "Insurance": "Seguro",
"Medical Expenses": "Despesas médicas", "Medical Expenses": "Despesas médicas",
"Taxes": "Impostos" "Taxes": "Impostos",
"Donation": "Doação"
}, },
"Transportation": { "Transportation": {
"heading": "Transporte", "heading": "Transporte",
@@ -385,5 +404,18 @@
"TV/Phone/Internet": "TV/Telefone/Internet", "TV/Phone/Internet": "TV/Telefone/Internet",
"Water": "Água" "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", "server-only": "^0.0.1",
"sharp": "^0.33.2", "sharp": "^0.33.2",
"superjson": "^2.2.1", "superjson": "^2.2.1",
"swr": "^2.3.3",
"tailwind-merge": "^1.14.0", "tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"ts-pattern": "^5.0.6", "ts-pattern": "^5.0.6",
@@ -11040,7 +11041,6 @@
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -16850,6 +16850,19 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/symbol-tree": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "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": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@@ -64,6 +64,7 @@
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sharp": "^0.33.2", "sharp": "^0.33.2",
"superjson": "^2.2.1", "superjson": "^2.2.1",
"swr": "^2.3.3",
"tailwind-merge": "^1.14.0", "tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"ts-pattern": "^5.0.6", "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 name String
information String? @db.Text information String? @db.Text
currency String @default("$") currency String @default("$")
currencyCode String? currencyCode String?
participants Participant[] participants Participant[]
expenses Expense[] expenses Expense[]
activities Activity[] activities Activity[]
@@ -40,25 +40,28 @@ model Category {
} }
model Expense { model Expense {
id String @id id String @id
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date
title String title String
category Category? @relation(fields: [categoryId], references: [id]) category Category? @relation(fields: [categoryId], references: [id])
categoryId Int @default(0) categoryId Int @default(0)
amount Int amount Int
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade) originalAmount Int?
paidById String originalCurrency String?
paidFor ExpensePaidFor[] conversionRate Decimal?
groupId String paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
isReimbursement Boolean @default(false) paidById String
splitMode SplitMode @default(EVENLY) paidFor ExpensePaidFor[]
createdAt DateTime @default(now()) groupId String
documents ExpenseDocument[] isReimbursement Boolean @default(false)
notes String? splitMode SplitMode @default(EVENLY)
createdAt DateTime @default(now())
documents ExpenseDocument[]
notes String?
recurrenceRule RecurrenceRule? @default(NONE) recurrenceRule RecurrenceRule? @default(NONE)
recurringExpenseLink RecurringExpenseLink? recurringExpenseLink RecurringExpenseLink?
recurringExpenseLinkId String? recurringExpenseLinkId String?
} }
@@ -79,16 +82,16 @@ enum SplitMode {
} }
model RecurringExpenseLink { model RecurringExpenseLink {
id String @id id String @id
groupId String groupId String
currentFrameExpense Expense @relation(fields: [currentFrameExpenseId], references: [id], onDelete: Cascade) currentFrameExpense Expense @relation(fields: [currentFrameExpenseId], references: [id], onDelete: Cascade)
currentFrameExpenseId String @unique currentFrameExpenseId String @unique
// Note: We do not want to link to the next expense because once it is created, it should be // 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 // 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. // and any prior related recurring expenses, they'll need to delete them one by one.
nextExpenseCreatedAt DateTime? nextExpenseCreatedAt DateTime?
nextExpenseDate DateTime nextExpenseDate DateTime
@@index([groupId]) @@index([groupId])
@@index([groupId, nextExpenseCreatedAt, nextExpenseDate(sort: Desc)]) @@index([groupId, nextExpenseCreatedAt, nextExpenseDate(sort: Desc)])

View File

@@ -1,4 +1,5 @@
import { CategorySelector } from '@/components/category-selector' import { CategorySelector } from '@/components/category-selector'
import { CurrencySelector } from '@/components/currency-selector'
import { ExpenseDocumentsInput } from '@/components/expense-documents-input' import { ExpenseDocumentsInput } from '@/components/expense-documents-input'
import { SubmitButton } from '@/components/submit-button' import { SubmitButton } from '@/components/submit-button'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -32,9 +33,11 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Locale } from '@/i18n'
import { randomId } from '@/lib/api' import { randomId } from '@/lib/api'
import { defaultCurrencyList, getCurrency } from '@/lib/currency'
import { RuntimeFeatureFlags } from '@/lib/featureFlags' import { RuntimeFeatureFlags } from '@/lib/featureFlags'
import { useActiveUser } from '@/lib/hooks' import { useActiveUser, useCurrencyRate } from '@/lib/hooks'
import { import {
ExpenseFormValues, ExpenseFormValues,
SplittingOptions, SplittingOptions,
@@ -51,7 +54,7 @@ import {
import { AppRouterOutput } from '@/trpc/routers/_app' import { AppRouterOutput } from '@/trpc/routers/_app'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { RecurrenceRule } from '@prisma/client' import { RecurrenceRule } from '@prisma/client'
import { Save } from 'lucide-react' import { ChevronRight, Save } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl' import { useLocale, useTranslations } from 'next-intl'
import Link from 'next/link' import Link from 'next/link'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'
@@ -78,7 +81,7 @@ const getDefaultSplittingOptions = (
splitMode: 'EVENLY' as const, splitMode: 'EVENLY' as const,
paidFor: group.participants.map(({ id }) => ({ paidFor: group.participants.map(({ id }) => ({
participant: id, participant: id,
shares: '1' as unknown as number, shares: 1,
})), })),
} }
@@ -110,7 +113,7 @@ const getDefaultSplittingOptions = (
splitMode: parsedDefaultSplitMode.splitMode, splitMode: parsedDefaultSplitMode.splitMode,
paidFor: parsedDefaultSplitMode.paidFor.map((paidFor) => ({ paidFor: parsedDefaultSplitMode.paidFor.map((paidFor) => ({
participant: paidFor.participant, 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') { if (expenseFormValues.splitMode === 'EVENLY') {
return expenseFormValues.paidFor.map(({ participant }) => ({ return expenseFormValues.paidFor.map(({ participant }) => ({
participant, participant,
shares: '100' as unknown as number, shares: 100,
})) }))
} else if (expenseFormValues.splitMode === 'BY_AMOUNT') { } else if (expenseFormValues.splitMode === 'BY_AMOUNT') {
return null return null
@@ -161,7 +164,7 @@ export function ExpenseForm({
runtimeFeatureFlags: RuntimeFeatureFlags runtimeFeatureFlags: RuntimeFeatureFlags
}) { }) {
const t = useTranslations('ExpenseForm') const t = useTranslations('ExpenseForm')
const locale = useLocale() const locale = useLocale() as Locale
const isCreate = expense === undefined const isCreate = expense === undefined
const searchParams = useSearchParams() const searchParams = useSearchParams()
@@ -186,18 +189,18 @@ export function ExpenseForm({
? { ? {
title: expense.title, title: expense.title,
expenseDate: expense.expenseDate ?? new Date(), expenseDate: expense.expenseDate ?? new Date(),
amount: String( amount: amountAsDecimal(expense.amount, groupCurrency),
amountAsDecimal(expense.amount, groupCurrency), originalCurrency: expense.originalCurrency ?? group.currencyCode,
) as unknown as number, // hack originalAmount: expense.originalAmount ?? undefined,
conversionRate: expense.conversionRate?.toNumber(),
category: expense.categoryId, category: expense.categoryId,
paidBy: expense.paidById, paidBy: expense.paidById,
paidFor: expense.paidFor.map(({ participantId, shares }) => ({ paidFor: expense.paidFor.map(({ participantId, shares }) => ({
participant: participantId, participant: participantId,
shares: String( shares:
expense.splitMode === 'BY_AMOUNT' expense.splitMode === 'BY_AMOUNT'
? amountAsDecimal(shares, groupCurrency) ? amountAsDecimal(shares, groupCurrency)
: shares / 100, : shares / 100,
) as unknown as number,
})), })),
splitMode: expense.splitMode, splitMode: expense.splitMode,
saveDefaultSplittingOptions: false, saveDefaultSplittingOptions: false,
@@ -210,19 +213,20 @@ export function ExpenseForm({
? { ? {
title: t('reimbursement'), title: t('reimbursement'),
expenseDate: new Date(), expenseDate: new Date(),
amount: String( amount: amountAsDecimal(
amountAsDecimal( Number(searchParams.get('amount')) || 0,
Number(searchParams.get('amount')) || 0, groupCurrency,
groupCurrency, ),
), originalCurrency: group.currencyCode,
) as unknown as number, // hack originalAmount: undefined,
conversionRate: undefined,
category: 1, // category with Id 1 is Payment category: 1, // category with Id 1 is Payment
paidBy: searchParams.get('from') ?? undefined, paidBy: searchParams.get('from') ?? undefined,
paidFor: [ paidFor: [
searchParams.get('to') searchParams.get('to')
? { ? {
participant: searchParams.get('to')!, participant: searchParams.get('to')!,
shares: '1' as unknown as number, shares: 1,
} }
: undefined, : undefined,
], ],
@@ -238,7 +242,10 @@ export function ExpenseForm({
expenseDate: searchParams.get('date') expenseDate: searchParams.get('date')
? new Date(searchParams.get('date') as string) ? new Date(searchParams.get('date') as string)
: new Date(), : 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') category: searchParams.get('categoryId')
? Number(searchParams.get('categoryId')) ? Number(searchParams.get('categoryId'))
: 0, // category with Id 0 is General : 0, // category with Id 0 is General
@@ -277,6 +284,12 @@ export function ExpenseForm({
? amountAsMinorUnits(shares, groupCurrency) ? amountAsMinorUnits(shares, groupCurrency)
: shares, : shares,
})) }))
// Currency should be blank if same as group currency
if (!conversionRequired) {
delete values.originalAmount
delete values.originalCurrency
}
return onSubmit(values, activeUserId ?? undefined) return onSubmit(values, activeUserId ?? undefined)
} }
@@ -287,6 +300,23 @@ export function ExpenseForm({
const sExpense = isIncome ? 'Income' : 'Expense' 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(() => { useEffect(() => {
setManuallyEditedParticipants(new Set()) setManuallyEditedParticipants(new Set())
}, [form.watch('splitMode'), form.watch('amount')]) }, [form.watch('splitMode'), form.watch('amount')])
@@ -329,11 +359,9 @@ export function ExpenseForm({
if (!editedParticipants.includes(participant.participant)) { if (!editedParticipants.includes(participant.participant)) {
return { return {
...participant, ...participant,
shares: String( shares: Number(
Number( amountPerRemaining.toFixed(groupCurrency.decimal_digits),
amountPerRemaining.toFixed(groupCurrency.decimal_digits), ),
),
) as unknown as number,
} }
} }
return participant return participant
@@ -347,6 +375,71 @@ export function ExpenseForm({
form.watch('splitMode'), 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 ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(submit)}> <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 <FormField
control={form.control} control={form.control}
name="amount" name="amount"
render={({ field: { onChange, ...field } }) => ( render={({ field: { onChange, ...field } }) => (
<FormItem className="sm:order-3"> <FormItem className="sm:order-5">
<FormLabel>{t('amountField.label')}</FormLabel> <FormLabel>{t('amountField.label')}</FormLabel>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span>{group.currency}</span> <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 <FormField
control={form.control} control={form.control}
name="paidBy" name="paidBy"
@@ -592,7 +827,7 @@ export function ExpenseForm({
participant: p.id, participant: p.id,
shares: shares:
paidFor.find((pfor) => pfor.participant === p.id) paidFor.find((pfor) => pfor.participant === p.id)
?.shares ?? ('1' as unknown as number), ?.shares ?? 1,
})) }))
form.setValue('paidFor', newPaidFor, { form.setValue('paidFor', newPaidFor, {
shouldDirty: true, shouldDirty: true,
@@ -630,7 +865,7 @@ export function ExpenseForm({
data-id={`${id}/${form.getValues().splitMode}/${ data-id={`${id}/${form.getValues().splitMode}/${
group.currency 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"> <FormItem className="flex-1 flex flex-row items-start space-x-3 space-y-0">
<FormControl> <FormControl>
@@ -651,7 +886,7 @@ export function ExpenseForm({
...field.value, ...field.value,
{ {
participant: id, participant: id,
shares: '1' as unknown as number, shares: 1,
}, },
], ],
options, options,
@@ -714,106 +949,209 @@ export function ExpenseForm({
)} )}
</FormLabel> </FormLabel>
</FormItem> </FormItem>
{form.getValues().splitMode !== 'EVENLY' && ( <div className="flex">
<FormField {form.getValues().splitMode === 'BY_AMOUNT' &&
name={`paidFor[${field.value.findIndex( !!conversionRequired && (
({ participant }) => participant === id, <FormField
)}].shares`} name={`paidFor[${field.value.findIndex(
render={() => { ({ participant }) => participant === id,
const sharesLabel = ( )}].originalAmount`}
<span render={() => {
className={cn('text-sm', { const sharesLabel = (
'text-muted': !field.value?.some( <span
({ participant }) => className={cn('text-sm', {
participant === id, 'text-muted': !field.value?.some(
), ({ participant }) =>
})} participant === id,
> ),
{match(form.getValues().splitMode) })}
.with('BY_SHARES', () => ( >
<>{t('shares')}</> {originalCurrency.symbol}
)) </span>
.with('BY_PERCENTAGE', () => <>%</>) )
.with('BY_AMOUNT', () => ( return (
<>{group.currency}</> <div>
)) <div className="flex gap-1 items-center">
.otherwise(() => ( {sharesLabel}
<></> <FormControl>
))} <Input
</span> key={String(
) !field.value?.some(
return ( ({ participant }) =>
<div> participant === id,
<div className="flex gap-1 items-center"> ),
{form.getValues().splitMode === )}
'BY_AMOUNT' && sharesLabel} className="text-base w-[80px] -my-2"
<FormControl> type="text"
<Input inputMode="decimal"
key={String( disabled={
!field.value?.some( !field.value?.some(
({ participant }) => ({ participant }) =>
participant === id, participant === id,
), )
)} }
className="text-base w-[80px] -my-2" value={
type="text" field.value.find(
disabled={ ({ participant }) =>
!field.value?.some( participant === id,
({ participant }) => )?.originalAmount ?? ''
participant === id, }
) onChange={(event) => {
} const originalAmount = Number(
value={ event.target.value,
field.value?.find( )
({ participant }) => let convertedAmount = ''
participant === id, if (
)?.shares !Number.isNaN(
} originalAmount,
onChange={(event) => { ) &&
field.onChange( exchangeRate.data
field.value.map((p) => ) {
p.participant === id convertedAmount = (
? { originalAmount *
participant: id, exchangeRate.data
shares: ).toFixed(
enforceCurrencyPattern( groupCurrency.decimal_digits,
event.target.value, )
), }
} field.onChange(
: p, 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( className="text-base w-[80px] -my-2"
(prev) => new Set(prev).add(id), type="text"
) disabled={
}} !field.value?.some(
inputMode={ ({ participant }) =>
form.getValues().splitMode === participant === id,
'BY_AMOUNT' )
? 'decimal' }
: 'numeric' value={
} field.value?.find(
step={ ({ participant }) =>
form.getValues().splitMode === participant === id,
'BY_AMOUNT' )?.shares
? 0.01 }
: 1 onChange={(event) => {
} field.onChange(
/> field.value.map((p) =>
</FormControl> p.participant === id
{[ ? {
'BY_SHARES', participant: id,
'BY_PERCENTAGE', shares:
].includes( enforceCurrencyPattern(
form.getValues().splitMode, event.target
) && sharesLabel} .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> </div>
<FormMessage className="float-right" /> )
</div> }}
) />
}} )}
/> </div>
)}
</div> </div>
) )
}} }}

View File

@@ -1,3 +1,4 @@
import { getCurrency } from '@/lib/currency'
import { formatAmountAsDecimal, getCurrencyFromGroup } from '@/lib/utils' import { formatAmountAsDecimal, getCurrencyFromGroup } from '@/lib/utils'
import { Parser } from '@json2csv/plainjs' import { Parser } from '@json2csv/plainjs'
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client'
@@ -38,6 +39,9 @@ export async function GET(
title: true, title: true,
category: { select: { name: true } }, category: { select: { name: true } },
amount: true, amount: true,
originalAmount: true,
originalCurrency: true,
conversionRate: true,
paidById: true, paidById: true,
paidFor: { select: { participantId: true, shares: true } }, paidFor: { select: { participantId: true, shares: true } },
isReimbursement: true, isReimbursement: true,
@@ -54,30 +58,29 @@ export async function GET(
/* /*
CSV Structure: CSV Columns:
--------------------------------------------------------------
| Date | Description | Category | Currency | Cost
--------------------------------------------------------------
| Is Reimbursement | Split mode | UserA | UserB
--------------------------------------------------------------
Columns:
- Date: The date of the expense. - Date: The date of the expense.
- Description: A brief description of the expense. - Description: A brief description of the expense.
- Category: The category of the expense (e.g., Food, Travel, etc.). - Category: The category of the expense (e.g., Food, Travel, etc.).
- Currency: The currency in which the expense is recorded. - Currency: The currency in which the expense is recorded.
- Cost: The amount spent. - 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. - 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). - 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). - UserA, UserB: User-specific data or balances (e.g., amount owed or contributed by each user).
Example Row: Example Table:
------------------------------------------------------------------------------------------ +------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
| 2025-01-06 | Dinner with team | Food | ₹ | 5000 | No | Evenly | John | Jane | 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 = [ const fields = [
{ label: 'Date', value: 'date' }, { label: 'Date', value: 'date' },
@@ -85,6 +88,9 @@ export async function GET(
{ label: 'Category', value: 'categoryName' }, { label: 'Category', value: 'categoryName' },
{ label: 'Currency', value: 'currency' }, { label: 'Currency', value: 'currency' },
{ label: 'Cost', value: 'amount' }, { 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: 'Is Reimbursement', value: 'isReimbursement' },
{ label: 'Split mode', value: 'splitMode' }, { label: 'Split mode', value: 'splitMode' },
...group.participants.map((participant) => ({ ...group.participants.map((participant) => ({
@@ -101,6 +107,16 @@ export async function GET(
categoryName: expense.category?.name || '', categoryName: expense.category?.name || '',
currency: group.currencyCode ?? group.currency, currency: group.currencyCode ?? group.currency,
amount: formatAmountAsDecimal(expense.amount, 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', isReimbursement: expense.isReimbursement ? 'Yes' : 'No',
splitMode: splitModeLabel[expense.splitMode], splitMode: splitModeLabel[expense.splitMode],
...Object.fromEntries( ...Object.fromEntries(

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
import dayjs from 'dayjs'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import useSWR, { Fetcher } from 'swr'
export function useMediaQuery(query: string): boolean { export function useMediaQuery(query: string): boolean {
const getMatches = (query: string): boolean => { const getMatches = (query: string): boolean => {
@@ -64,3 +66,62 @@ export function useActiveUser(groupId?: string) {
return activeUser 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> 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 export const expenseFormSchema = z
.object({ .object({
expenseDate: z.coerce.date(), expenseDate: z.coerce.date(),
@@ -55,11 +68,27 @@ export const expenseFormSchema = z
) )
.refine((amount) => amount != 0, 'amountNotZero') .refine((amount) => amount != 0, 'amountNotZero')
.refine((amount) => amount <= 10_000_000_00, 'amountTenMillion'), .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' }), paidBy: z.string({ required_error: 'paidByRequired' }),
paidFor: z paidFor: z
.array( .array(
z.object({ z.object({
participant: z.string(), participant: z.string(),
originalAmount: z.string().optional(), // For converting shares by amounts in original currency, not saved.
shares: z.union([ shares: z.union([
z.number(), z.number(),
z.string().transform((value, ctx) => { z.string().transform((value, ctx) => {
@@ -163,17 +192,18 @@ export const expenseFormSchema = z
// Format the share split as a number (if from form submission) // Format the share split as a number (if from form submission)
return { return {
...expense, ...expense,
paidFor: expense.paidFor.map(({ participant, shares }) => { paidFor: expense.paidFor.map((paidFor) => {
const shares = paidFor.shares
if (typeof shares === 'string' && expense.splitMode !== 'BY_AMOUNT') { if (typeof shares === 'string' && expense.splitMode !== 'BY_AMOUNT') {
// For splitting not by amount, preserve the previous behaviour of multiplying the share by 100 // For splitting not by amount, preserve the previous behaviour of multiplying the share by 100
return { return {
participant, ...paidFor,
shares: Math.round(Number(shares) * 100), shares: Math.round(Number(shares) * 100),
} }
} }
// Otherwise, no need as the number will have been formatted according to currency. // Otherwise, no need as the number will have been formatted according to currency.
return { return {
participant, ...paidFor,
shares: Number(shares), shares: Number(shares),
} }
}), }),

View File

@@ -99,10 +99,10 @@ export function amountAsDecimal(
* - €1.5 = 150 "minor units" of euros (cents) * - €1.5 = 150 "minor units" of euros (cents)
* - JPY 1000 = 1000 "minor units" of yen (the yen does not have minor units in practice) * - 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) { 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 '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 type { QueryClient } from '@tanstack/react-query'
import { QueryClientProvider } from '@tanstack/react-query' import { QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client' import { httpBatchLink } from '@trpc/client'
@@ -8,6 +9,15 @@ import superjson from 'superjson'
import { makeQueryClient } from './query-client' import { makeQueryClient } from './query-client'
import type { AppRouter } from './routers/_app' 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>() export const trpc = createTRPCReact<AppRouter>()
let clientQueryClientSingleton: QueryClient let clientQueryClientSingleton: QueryClient

View File

@@ -1,7 +1,17 @@
import { Prisma } from '@prisma/client'
import { initTRPC } from '@trpc/server' import { initTRPC } from '@trpc/server'
import { cache } from 'react' import { cache } from 'react'
import superjson from 'superjson' 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 () => { export const createTRPCContext = cache(async () => {
/** /**
* @see: https://trpc.io/docs/server/context * @see: https://trpc.io/docs/server/context