feat(costs): let a trip keep its costs as totals only (#1714)

Travellers who track what a trip costs but never split it saw an
Unfinished warning on every expense and a settle-up they had no use for.
A trip now has a costs mode (appended migration, trips.costs_mode, split
by default); totals leaves payers, shares and settling up out of Costs on
desktop and phone through the same gate as a trip of one, and saves new
expenses without a payer. PUT /api/trips/:id and update_trip take it on
budget_edit. The split chips move into their own component.
This commit is contained in:
Maurice
2026-11-13 09:58:17 +01:00
parent c9c419b4ad
commit a70eea5bf2
62 changed files with 778 additions and 104 deletions
+3
View File
@@ -202,6 +202,9 @@ const budget: TranslationStrings = {
'أعطِ كل شخص عددًا من الحصص، مثل عدد الليالي التي أقامها؛ ويُقسَّم المبلغ الإجمالي بهذه النسبة.',
'costs.splitSharesOf': 'حصص {name}',
'costs.splitSharesNone': 'أعطِ حصة لشخص واحد على الأقل',
'costs.mode.label': 'تقسيم التكاليف بين المسافرين',
'costs.mode.hintSplit': 'من دفع ومن يتشارك كل مصروف، والتسوية في النهاية.',
'costs.mode.hintTotals': 'الإجماليات فقط: بلا دافعين ولا حصص ولا تسوية. ما قُسِّم من قبل يبقى محفوظًا.',
'costs.splitHint.ticket': 'اكتب ما ورد في الإيصال وحدّد من أخذ كل عنصر.',
'costs.filter.allCategories': 'كل الفئات',
'costs.filter.allDays': 'كل الأيام',
+3
View File
@@ -164,6 +164,9 @@ const budget: TranslationStrings = {
'Hər kəsə pay sayı verin, məsələn qaldığı gecələrin sayı; ümumi məbləğ bu nisbətdə bölünür.',
'costs.splitSharesOf': '{name} üçün paylar',
'costs.splitSharesNone': 'Ən azı bir nəfərə pay verin',
'costs.mode.label': 'Xərcləri səyahətçilər arasında bölmək',
'costs.mode.hintSplit': 'Hər xərci kimin ödədiyi və kimlərin bölüşdüyü, sonda isə hesablaşma.',
'costs.mode.hintTotals': 'Yalnız yekunlar: ödəyən, pay və hesablaşma yoxdur. Əvvəl bölünənlər saxlanılır.',
'costs.splitHint.ticket': 'Qəbzdə olanları sadalayın və hər elementi kimin götürdüyünü işarələyin.',
'costs.pickSomeone': 'Bölüşdürmək üçün ən azı bir nəfər seçin.',
'costs.splitSummary': '{count} nəfər arasında bölünüb · hərəsinə {amount}',
+3
View File
@@ -174,6 +174,9 @@ const budget: TranslationStrings = {
'Dê a cada pessoa um número de cotas, como as noites em que ficou; o total é dividido nessa proporção.',
'costs.splitSharesOf': 'Cotas de {name}',
'costs.splitSharesNone': 'Dê uma cota a pelo menos uma pessoa',
'costs.mode.label': 'Dividir os custos entre os viajantes',
'costs.mode.hintSplit': 'Quem pagou e quem divide cada despesa, e o acerto no final.',
'costs.mode.hintTotals': 'Só os totais: sem pagadores, partes ou acerto. O que foi dividido continua salvo.',
'costs.splitHint.ticket': 'Liste o que estava no comprovante e marque quem consumiu cada item.',
'costs.filter.allCategories': 'Todas as categorias',
'costs.filter.allDays': 'Todos os dias',
+3
View File
@@ -139,6 +139,9 @@ const budget: TranslationStrings = {
'Dona a cada persona un nombre de parts, com ara les nits que s’hi ha allotjat; el total es reparteix en aquesta proporció.',
'costs.splitSharesOf': 'Parts de {name}',
'costs.splitSharesNone': 'Dona una part a almenys una persona',
'costs.mode.label': 'Repartir les despeses entre els viatgers',
'costs.mode.hintSplit': 'Qui ha pagat i qui comparteix cada despesa, i el saldo final.',
'costs.mode.hintTotals': 'Només els totals: sense pagadors, parts ni saldo. El que s’havia repartit es manté desat.',
'costs.splitHint.ticket': 'Llista el que hi havia al rebut i marca qui ha pres cada article.',
'costs.pickSomeone': 'Tria almenys una persona amb qui dividir-ho.',
'costs.splitSummary': 'Dividit entre {count} persones · {amount} cadascú',
+3
View File
@@ -181,6 +181,9 @@ const budget: TranslationStrings = {
'Dejte každé osobě počet podílů, třeba podle počtu nocí; celková částka se rozdělí v tomto poměru.',
'costs.splitSharesOf': 'Podíly pro {name}',
'costs.splitSharesNone': 'Dejte podíl alespoň jedné osobě',
'costs.mode.label': 'Dělit náklady mezi cestující',
'costs.mode.hintSplit': 'Kdo platil a kdo se na každém výdaji podílí, a vyrovnání na konci.',
'costs.mode.hintTotals': 'Jen součty: bez plátců, podílů a vyrovnání. Co už bylo rozděleno, zůstává uložené.',
'costs.splitHint.ticket': 'Vypište, co bylo na účtence, a zaškrtněte, kdo měl kterou položku.',
'costs.filter.allCategories': 'Všechny kategorie',
'costs.filter.allDays': 'Všechny dny',
+4
View File
@@ -178,6 +178,10 @@ const budget: TranslationStrings = {
'Gib jeder Person eine Zahl von Anteilen, etwa die Nächte, die sie geblieben ist; der Betrag wird in diesem Verhältnis aufgeteilt.',
'costs.splitSharesOf': 'Anteile von {name}',
'costs.splitSharesNone': 'Gib mindestens einer Person einen Anteil',
'costs.mode.label': 'Kosten unter den Reisenden aufteilen',
'costs.mode.hintSplit': 'Wer bezahlt hat und wer sich jede Ausgabe teilt, und am Ende das Ausgleichen.',
'costs.mode.hintTotals':
'Nur Summen: keine Zahler, Anteile oder Ausgleich. Was schon aufgeteilt war, bleibt gespeichert.',
'costs.splitHint.ticket': 'Liste auf, was auf dem Beleg steht, und hak ab, wer was hatte.',
'costs.ticketItemName': 'Artikelname',
'costs.ticketSplitting': 'Aufteilen auf:',
+3
View File
@@ -148,6 +148,9 @@ const budget: TranslationStrings = {
'Give each person a number of shares, such as the nights they stayed; the total is split in that ratio.',
'costs.splitSharesOf': 'Shares for {name}',
'costs.splitSharesNone': 'Give at least one person a share',
'costs.mode.label': 'Split costs between travelers',
'costs.mode.hintSplit': 'Who paid and who shares each expense, and settling up at the end.',
'costs.mode.hintTotals': 'Totals only: no payers, shares or settling up. What was split stays saved.',
'costs.splitHint.ticket': 'List what was on the receipt and tick who had each item.',
'costs.pickSomeone': 'Pick at least one person to split with.',
'costs.splitSummary': 'Split {count} ways · {amount} each',
+4
View File
@@ -174,6 +174,10 @@ const budget: TranslationStrings = {
'Da a cada persona un número de partes, como las noches que se alojó; el total se reparte en esa proporción.',
'costs.splitSharesOf': 'Partes de {name}',
'costs.splitSharesNone': 'Da una parte al menos a una persona',
'costs.mode.label': 'Repartir los gastos entre los viajeros',
'costs.mode.hintSplit': 'Quién pagó y quién comparte cada gasto, y el ajuste de cuentas al final.',
'costs.mode.hintTotals':
'Solo totales: sin pagadores, partes ni ajuste de cuentas. Lo que ya estaba repartido se conserva.',
'costs.splitHint.ticket': 'Enumera lo que había en el recibo y marca quién tomó cada artículo.',
'costs.filter.allCategories': 'Todas las categorías',
'costs.filter.allDays': 'Todos los días',
+3
View File
@@ -148,6 +148,9 @@ const budget: TranslationStrings = {
'Anna igale inimesele osade arv, näiteks ööbitud ööde arv; summa jagatakse selles vahekorras.',
'costs.splitSharesOf': '{name} osad',
'costs.splitSharesNone': 'Anna vähemalt ühele inimesele osa',
'costs.mode.label': 'Jaga kulud reisijate vahel',
'costs.mode.hintSplit': 'Kes maksis ja kes iga kulu jagab, ning lõpus arveldamine.',
'costs.mode.hintTotals': 'Ainult summad: maksjaid, osasid ega arveldamist pole. Varem jagatu jääb salvestatuks.',
'costs.splitHint.ticket': 'Loetle, mis tšekil oli, ja märgi iga kirje juurde, kellele see kuulus.',
'costs.pickSomeone': 'Vali jagamiseks vähemalt üks inimene.',
'costs.splitSummary': 'Jaotatud {count} osaks · igaühele {amount}',
+4
View File
@@ -175,6 +175,10 @@ const budget: TranslationStrings = {
'Donnez à chaque personne un nombre de parts, par exemple ses nuits sur place ; le total est réparti dans cette proportion.',
'costs.splitSharesOf': 'Parts de {name}',
'costs.splitSharesNone': 'Donnez une part à au moins une personne',
'costs.mode.label': 'Partager les dépenses entre les voyageurs',
'costs.mode.hintSplit': 'Qui a payé et qui partage chaque dépense, puis le règlement à la fin.',
'costs.mode.hintTotals':
'Totaux seulement : ni payeurs, ni parts, ni règlement. Ce qui était partagé reste enregistré.',
'costs.splitHint.ticket': 'Listez ce qui figurait sur le reçu et cochez qui a pris chaque article.',
'costs.filter.allCategories': 'Toutes les catégories',
'costs.filter.allDays': 'Tous les jours',
+3
View File
@@ -176,6 +176,9 @@ const budget: TranslationStrings = {
'Δώστε σε κάθε άτομο έναν αριθμό μεριδίων, όπως τις νύχτες που έμεινε· το σύνολο μοιράζεται με αυτή την αναλογία.',
'costs.splitSharesOf': 'Μερίδια για {name}',
'costs.splitSharesNone': 'Δώστε μερίδιο σε τουλάχιστον ένα άτομο',
'costs.mode.label': 'Μοιρασιά των εξόδων μεταξύ των ταξιδιωτών',
'costs.mode.hintSplit': 'Ποιος πλήρωσε και ποιοι μοιράζονται κάθε έξοδο, και η εκκαθάριση στο τέλος.',
'costs.mode.hintTotals': 'Μόνο σύνολα: χωρίς πληρωτές, μερίδια ή εκκαθάριση. Ό,τι είχε μοιραστεί μένει αποθηκευμένο.',
'costs.splitHint.ticket': 'Καταγράψτε τι είχε η απόδειξη και σημειώστε ποιος πήρε κάθε είδος.',
'costs.filter.allCategories': 'Όλες οι κατηγορίες',
'costs.filter.allDays': 'Όλες οι ημέρες',
+3
View File
@@ -152,6 +152,9 @@ const budget: TranslationStrings = {
'costs.splitHint.shares': 'תנו לכל אדם מספר חלקים, למשל מספר הלילות שנשאר; הסכום הכולל מתחלק ביחס הזה.',
'costs.splitSharesOf': 'החלקים של {name}',
'costs.splitSharesNone': 'תנו חלק לאדם אחד לפחות',
'costs.mode.label': 'חלוקת ההוצאות בין המטיילים',
'costs.mode.hintSplit': 'מי שילם ומי מתחלק בכל הוצאה, והתחשבנות בסוף.',
'costs.mode.hintTotals': 'סכומים בלבד: בלי משלמים, חלקים או התחשבנות. מה שחולק כבר נשאר שמור.',
'costs.splitHint.ticket': 'רשמו מה היה בקבלה וסמנו מי לקח כל פריט.',
'costs.pickSomeone': 'בחרו לפחות אדם אחד לחלוקה.',
'costs.splitSummary': 'חלוקה ל-{count} · {amount} לכל אחד',
+4
View File
@@ -176,6 +176,10 @@ const budget: TranslationStrings = {
'Adj mindenkinek egy részszámot, például az eltöltött éjszakák számát; a végösszeg ebben az arányban oszlik meg.',
'costs.splitSharesOf': '{name} részei',
'costs.splitSharesNone': 'Adj részt legalább egy személynek',
'costs.mode.label': 'Költségek elosztása az utazók között',
'costs.mode.hintSplit': 'Ki fizetett és kik osztoznak az egyes kiadásokon, a végén pedig az elszámolás.',
'costs.mode.hintTotals':
'Csak összesítés: nincs fizető, részesedés vagy elszámolás. Ami már fel volt osztva, megmarad.',
'costs.splitHint.ticket': 'Sorold fel, mi szerepelt a nyugtán, és minden tételnél jelöld be, kié volt.',
'costs.filter.allCategories': 'Összes kategória',
'costs.filter.allDays': 'Összes nap',
+4
View File
@@ -167,6 +167,10 @@ const budget: TranslationStrings = {
'Beri setiap orang sejumlah bagian, misalnya jumlah malam menginap; total dibagi menurut perbandingan itu.',
'costs.splitSharesOf': 'Bagian untuk {name}',
'costs.splitSharesNone': 'Beri bagian kepada setidaknya satu orang',
'costs.mode.label': 'Bagi biaya di antara para pelancong',
'costs.mode.hintSplit':
'Siapa yang membayar dan siapa yang ikut menanggung tiap pengeluaran, lalu pelunasan di akhir.',
'costs.mode.hintTotals': 'Hanya total: tanpa pembayar, bagian, atau pelunasan. Yang sudah dibagi tetap tersimpan.',
'costs.splitHint.ticket': 'Tuliskan isi kuitansi dan centang siapa yang mengambil tiap item.',
'costs.filter.allCategories': 'Semua kategori',
'costs.filter.allDays': 'Semua hari',
+3
View File
@@ -174,6 +174,9 @@ const budget: TranslationStrings = {
'Assegna a ogni persona un numero di quote, ad esempio le notti in cui è rimasta; il totale viene diviso in questa proporzione.',
'costs.splitSharesOf': 'Quote di {name}',
'costs.splitSharesNone': 'Assegna una quota ad almeno una persona',
'costs.mode.label': 'Dividere le spese tra i viaggiatori',
'costs.mode.hintSplit': 'Chi ha pagato e chi condivide ogni spesa, e il saldo alla fine.',
'costs.mode.hintTotals': 'Solo totali: niente pagatori, quote o saldi. Ciò che era già diviso resta salvato.',
'costs.splitHint.ticket': "Elenca cosa c'era sulla ricevuta e spunta chi ha preso ciascun articolo.",
'costs.filter.allCategories': 'Tutte le categorie',
'costs.filter.allDays': 'Tutti i giorni',
+3
View File
@@ -166,6 +166,9 @@ const budget: TranslationStrings = {
'costs.splitHint.shares': '泊数など、各メンバーに口数を指定してください。合計金額はその比率で分けられます。',
'costs.splitSharesOf': '{name}の口数',
'costs.splitSharesNone': '少なくとも1人に口数を指定してください',
'costs.mode.label': '旅行者の間で費用を分ける',
'costs.mode.hintSplit': '誰が支払い、誰が各支出を分担するか、そして最後に精算します。',
'costs.mode.hintTotals': '合計のみ:支払者・分担・精算はありません。分担済みの内容は保存されたままです。',
'costs.splitHint.ticket': 'レシートの品目を書き出し、それぞれ誰の分かにチェックを入れます。',
'costs.filter.allCategories': 'すべてのカテゴリー',
'costs.filter.allDays': 'すべての日',
+3
View File
@@ -166,6 +166,9 @@ const budget: TranslationStrings = {
'costs.splitHint.shares': '머문 박 수처럼 각자에게 지분 수를 정하세요. 총액이 그 비율로 나뉩니다.',
'costs.splitSharesOf': '{name}의 지분',
'costs.splitSharesNone': '최소 한 명에게 지분을 정하세요',
'costs.mode.label': '여행자끼리 비용 나누기',
'costs.mode.hintSplit': '누가 냈고 누가 각 지출을 나누는지, 그리고 마지막 정산까지.',
'costs.mode.hintTotals': '합계만: 결제자, 분담, 정산이 없습니다. 이미 나눈 내용은 그대로 저장됩니다.',
'costs.splitHint.ticket': '영수증에 있던 항목을 적고, 항목마다 누구 몫인지 체크하세요.',
'costs.filter.allCategories': '모든 카테고리',
'costs.filter.allDays': '모든 날짜',
+4
View File
@@ -175,6 +175,10 @@ const budget: TranslationStrings = {
'Geef iedereen een aantal delen, zoals het aantal overnachtingen; het totaal wordt in die verhouding verdeeld.',
'costs.splitSharesOf': 'Delen voor {name}',
'costs.splitSharesNone': 'Geef ten minste één persoon een deel',
'costs.mode.label': 'Kosten verdelen tussen de reizigers',
'costs.mode.hintSplit': 'Wie betaalde en wie elke uitgave deelt, en aan het eind de verrekening.',
'costs.mode.hintTotals':
'Alleen totalen: geen betalers, aandelen of verrekening. Wat al verdeeld was, blijft bewaard.',
'costs.splitHint.ticket': 'Zet op een rij wat er op de bon stond en vink aan wie welk item had.',
'costs.filter.allCategories': 'Alle categorieën',
'costs.filter.allDays': 'Alle dagen',
+4
View File
@@ -188,6 +188,10 @@ const budget: TranslationStrings = {
'Przypisz każdej osobie liczbę udziałów, np. liczbę nocy; kwota zostanie podzielona w tej proporcji.',
'costs.splitSharesOf': 'Udziały: {name}',
'costs.splitSharesNone': 'Przypisz udział co najmniej jednej osobie',
'costs.mode.label': 'Dziel koszty między podróżnych',
'costs.mode.hintSplit': 'Kto zapłacił i kto dzieli każdy wydatek, a na koniec rozliczenie.',
'costs.mode.hintTotals':
'Tylko sumy: bez płacących, udziałów i rozliczeń. To, co już podzielono, pozostaje zapisane.',
'costs.splitHint.ticket': 'Wypisz, co było na paragonie, i zaznacz, kto miał którą pozycję.',
'costs.filter.allCategories': 'Wszystkie kategorie',
'costs.filter.allDays': 'Wszystkie dni',
+4
View File
@@ -187,6 +187,10 @@ const budget: TranslationStrings = {
'costs.splitHint.shares': 'Укажите каждому число долей, например число ночей; сумма делится в этой пропорции.',
'costs.splitSharesOf': 'Доли: {name}',
'costs.splitSharesNone': 'Укажите долю хотя бы одному человеку',
'costs.mode.label': 'Делить расходы между путешественниками',
'costs.mode.hintSplit': 'Кто заплатил и кто делит каждый расход, а в конце взаиморасчёт.',
'costs.mode.hintTotals':
'Только итоги: без плательщиков, долей и взаиморасчётов. Уже разделённое остаётся сохранённым.',
'costs.splitHint.ticket': 'Перечислите, что было в чеке, и отметьте, кто что брал.',
'costs.filter.allCategories': 'Все категории',
'costs.filter.allDays': 'Все дни',
+3
View File
@@ -153,6 +153,9 @@ const budget: TranslationStrings = {
'Dajte každej osobe počet podielov, napríklad podľa počtu nocí; celková suma sa rozdelí v tomto pomere.',
'costs.splitSharesOf': 'Podiely pre {name}',
'costs.splitSharesNone': 'Dajte podiel aspoň jednej osobe',
'costs.mode.label': 'Deliť náklady medzi cestujúcich',
'costs.mode.hintSplit': 'Kto platil a kto sa na každom výdavku podieľa, a na konci vyrovnanie.',
'costs.mode.hintTotals': 'Len súčty: bez platiteľov, podielov a vyrovnania. Čo už bolo rozdelené, zostáva uložené.',
'costs.splitHint.ticket': 'Vypíšte, čo bolo na účtenke, a označte, kto mal ktorú položku.',
'costs.pickSomeone': 'Vyberte aspoň jednu osobu na rozdelenie.',
'costs.splitSummary': 'Rozdelené na {count} dielov · {amount} na osobu',
+3
View File
@@ -174,6 +174,9 @@ const budget: TranslationStrings = {
'Ge varje person ett antal andelar, till exempel antal nätter; summan delas i den proportionen.',
'costs.splitSharesOf': 'Andelar för {name}',
'costs.splitSharesNone': 'Ge minst en person en andel',
'costs.mode.label': 'Dela kostnaderna mellan resenärerna',
'costs.mode.hintSplit': 'Vem som betalade och vilka som delar varje utgift, och avräkning på slutet.',
'costs.mode.hintTotals': 'Bara summor: inga betalare, andelar eller avräkning. Det som redan delats sparas.',
'costs.splitHint.ticket': 'Lista det som stod på kvittot och bocka för vem som hade varje artikel.',
'costs.filter.allCategories': 'Alla kategorier',
'costs.filter.allDays': 'Alla dagar',
+3
View File
@@ -138,6 +138,9 @@ const budget: TranslationStrings = {
'costs.splitHint.shares': 'กำหนดจำนวนส่วนแบ่งให้แต่ละคน เช่น จำนวนคืนที่พัก แล้วยอดรวมจะถูกแบ่งตามสัดส่วนนั้น',
'costs.splitSharesOf': 'ส่วนแบ่งของ {name}',
'costs.splitSharesNone': 'กำหนดส่วนแบ่งให้อย่างน้อยหนึ่งคน',
'costs.mode.label': 'แบ่งค่าใช้จ่ายระหว่างผู้ร่วมเดินทาง',
'costs.mode.hintSplit': 'ใครจ่ายและใครร่วมรับผิดชอบค่าใช้จ่ายแต่ละรายการ แล้วเคลียร์ยอดตอนท้าย',
'costs.mode.hintTotals': 'เฉพาะยอดรวม: ไม่มีผู้จ่าย ส่วนแบ่ง หรือการเคลียร์ยอด สิ่งที่แบ่งไว้แล้วยังคงบันทึกอยู่',
'costs.splitHint.ticket': 'ใส่รายการที่อยู่ในใบเสร็จ แล้วติ๊กว่าใครสั่งรายการไหน',
'costs.pickSomeone': 'เลือกอย่างน้อยหนึ่งคนที่จะแยกด้วย',
'costs.splitSummary': '{count} คน · คนละ {amount}',
+3
View File
@@ -174,6 +174,9 @@ const budget: TranslationStrings = {
'costs.splitHint.shares': 'Herkese bir pay sayısı verin, örneğin kaldığı gece sayısı; toplam bu oranda bölünür.',
'costs.splitSharesOf': '{name} payları',
'costs.splitSharesNone': 'En az bir kişiye pay verin',
'costs.mode.label': 'Masrafları yolcular arasında böl',
'costs.mode.hintSplit': 'Her harcamayı kimin ödediği ve kimlerin paylaştığı, sonunda da hesaplaşma.',
'costs.mode.hintTotals': 'Yalnızca toplamlar: ödeyen, pay veya hesaplaşma yok. Önceden bölünenler kayıtlı kalır.',
'costs.splitHint.ticket': 'Fişte neler olduğunu listeleyin ve her kalemi kimin aldığını işaretleyin.',
'costs.filter.allCategories': 'Tüm kategoriler',
'costs.filter.allDays': 'Tüm günler',
+3
View File
@@ -188,6 +188,9 @@ const budget: TranslationStrings = {
'Вкажіть кожному кількість часток, наприклад кількість ночей; сума ділиться в цій пропорції.',
'costs.splitSharesOf': 'Частки: {name}',
'costs.splitSharesNone': 'Вкажіть частку хоча б одній людині',
'costs.mode.label': 'Ділити витрати між мандрівниками',
'costs.mode.hintSplit': 'Хто заплатив і хто ділить кожну витрату, а наприкінці розрахунок.',
'costs.mode.hintTotals': 'Лише підсумки: без платників, часток і розрахунків. Уже поділене залишається збереженим.',
'costs.splitHint.ticket': 'Перелічіть, що було в чеку, і позначте, хто брав кожну позицію.',
'costs.filter.allCategories': 'Усі категорії',
'costs.filter.allDays': 'Усі дні',
+4
View File
@@ -166,6 +166,10 @@ const budget: TranslationStrings = {
'costs.splitHint.shares': 'Đặt cho mỗi người một số phần, ví dụ số đêm đã ở; tổng tiền được chia theo tỷ lệ đó.',
'costs.splitSharesOf': 'Số phần của {name}',
'costs.splitSharesNone': 'Hãy đặt phần cho ít nhất một người',
'costs.mode.label': 'Chia chi phí giữa các thành viên',
'costs.mode.hintSplit': 'Ai đã trả và ai cùng chia từng khoản chi, rồi thanh toán bù trừ ở cuối.',
'costs.mode.hintTotals':
'Chỉ tổng cộng: không có người trả, phần chia hay thanh toán bù trừ. Những gì đã chia vẫn được lưu.',
'costs.splitHint.ticket': 'Liệt kê các món trên hóa đơn và đánh dấu ai đã dùng từng món.',
'costs.filter.allCategories': 'Tất cả danh mục',
'costs.filter.allDays': 'Tất cả các ngày',
+3
View File
@@ -165,6 +165,9 @@ const budget: TranslationStrings = {
'costs.splitHint.shares': '為每個人設定份數,例如住了幾晚;總額按此比例分攤。',
'costs.splitSharesOf': '{name} 的份數',
'costs.splitSharesNone': '請至少為一人設定份數',
'costs.mode.label': '在旅伴之間分攤費用',
'costs.mode.hintSplit': '記錄誰付款、誰分攤每筆支出,最後結算。',
'costs.mode.hintTotals': '僅統計總額:沒有付款人、分攤或結算。已分攤的內容會保留。',
'costs.splitHint.ticket': '列出收據上的項目,並勾選每一項是誰的。',
'costs.filter.allCategories': '所有類別',
'costs.filter.allDays': '所有日期',
+3
View File
@@ -165,6 +165,9 @@ const budget: TranslationStrings = {
'costs.splitHint.shares': '为每个人设定份数,例如住了几晚;总额按此比例分摊。',
'costs.splitSharesOf': '{name} 的份数',
'costs.splitSharesNone': '请至少为一人设定份数',
'costs.mode.label': '在旅伴之间分摊费用',
'costs.mode.hintSplit': '记录谁付款、谁分摊每笔支出,最后结算。',
'costs.mode.hintTotals': '仅统计总额:没有付款人、分摊或结算。已分摊的内容会保留。',
'costs.splitHint.ticket': '列出收据上的项目,并勾选每一项是谁的。',
'costs.filter.allCategories': '所有类别',
'costs.filter.allDays': '所有日期',
+18
View File
@@ -5,6 +5,7 @@ import {
tripUpdateRequestSchema,
tripAddMemberRequestSchema,
activeTripResponseSchema,
tripSchema,
} from './trip.schema';
import { describe, it, expect } from 'vitest';
@@ -55,6 +56,23 @@ describe('tripUpdateRequestSchema', () => {
);
expect(tripUpdateRequestSchema.safeParse({ cover_image: 'x'.repeat(2049) }).success).toBe(false);
});
it('takes the costs mode, split or totals and nothing else (#1714)', () => {
expect(tripUpdateRequestSchema.safeParse({ costs_mode: 'totals' }).success).toBe(true);
expect(tripUpdateRequestSchema.safeParse({ costs_mode: 'split' }).success).toBe(true);
expect(tripUpdateRequestSchema.safeParse({ costs_mode: 'none' }).success).toBe(false);
expect(tripUpdateRequestSchema.safeParse({ costs_mode: null }).success).toBe(false);
});
});
describe('tripSchema', () => {
const trip = { id: 1, user_id: 1, title: 'Japan', currency: 'EUR', is_archived: 0, reminder_days: 3 };
it('reads the costs mode, and a trip cached without one still parses (#1714)', () => {
expect(tripSchema.parse({ ...trip, costs_mode: 'totals' }).costs_mode).toBe('totals');
expect(tripSchema.safeParse(trip).success).toBe(true);
expect(tripSchema.safeParse({ ...trip, costs_mode: 'shares' }).success).toBe(false);
});
});
describe('tripAddMemberRequestSchema', () => {
+14
View File
@@ -32,6 +32,16 @@ export function tripSpanDays(startDate: string, endDate: string): number {
return Math.floor((utcDay(endDate) - utcDay(startDate)) / 86400000) + 1;
}
/**
* How a trip keeps its costs (#1714): `split` tracks who paid and who shares
* each expense and settles up between the travellers, `totals` keeps only what
* the trip costs. Who paid and the shares stay stored either way; `totals` only
* leaves them out of Costs. Trips start out as `split`.
*/
export const TRIP_COSTS_MODES = ['split', 'totals'] as const;
export const tripCostsModeSchema = z.enum(TRIP_COSTS_MODES);
export type TripCostsMode = z.infer<typeof tripCostsModeSchema>;
/**
* Trip entity as returned by the trip list / get / create / update endpoints
* (server/src/services/tripService.ts -> TRIP_SELECT). Columns of the `trips`
@@ -49,6 +59,8 @@ export const tripSchema = z.object({
cover_image: z.string().nullable().optional(),
is_archived: z.number(),
reminder_days: z.number(),
// Optional: a trip cached before the column existed has none, and reads as `split`.
costs_mode: tripCostsModeSchema.optional(),
created_at: z.string().optional(),
updated_at: z.string().optional(),
// computed in TRIP_SELECT (list/get)
@@ -121,6 +133,8 @@ export const tripUpdateRequestSchema = z.object({
// Bounded like a place's image url: a replaced cover is queued for release,
// and every upload path the old value spells out is one queued object.
cover_image: z.string().max(2048).nullable().optional(),
/** Costs as totals only or split between the travellers; needs `budget_edit`, not `trip_edit`. */
costs_mode: tripCostsModeSchema.optional(),
});
export type TripUpdateRequest = z.infer<typeof tripUpdateRequestSchema>;