feat(costs): Splitwise-like cost splitting

Add per-payer and per-member custom split amounts with Equally, Custom and
Ticket split modes on top of the existing equal split, keep legacy "paid by"
expenses working, and document the modes in the Budget Tracking wiki page.
This commit is contained in:
Zorth Thorch
2026-06-29 19:25:15 +02:00
committed by Maurice
parent 3701ab6cad
commit e34f40b686
30 changed files with 619 additions and 150 deletions
+8
View File
@@ -24,6 +24,7 @@ export const budgetItemMemberSchema = z.object({
avatar_url: z.string().nullable().optional(),
avatar: z.string().nullable().optional(),
budget_item_id: z.number().optional(),
amount: z.number().nullable().optional(),
});
export type BudgetItemMember = z.infer<typeof budgetItemMemberSchema>;
@@ -126,6 +127,11 @@ const payerInputSchema = z.object({
amount: z.number(),
});
const memberInputSchema = z.object({
user_id: z.number(),
amount: z.number().nullable().optional(),
});
export const budgetCreateItemRequestSchema = z.object({
name: z.string().min(1),
category: z.string().optional(),
@@ -137,6 +143,7 @@ export const budgetCreateItemRequestSchema = z.object({
payers: z.array(payerInputSchema).optional(),
// Equal-split participants. When omitted, the item has no split (planning-only).
member_ids: z.array(z.number()).optional(),
members: z.array(memberInputSchema).optional(),
persons: z.number().nullable().optional(),
days: z.number().nullable().optional(),
note: z.string().nullable().optional(),
@@ -156,6 +163,7 @@ export const budgetUpdateItemRequestSchema = z.object({
exchange_rate: z.number().optional(),
payers: z.array(payerInputSchema).optional(),
member_ids: z.array(z.number()).optional(),
members: z.array(memberInputSchema).optional(),
persons: z.number().nullable().optional(),
days: z.number().nullable().optional(),
note: z.string().nullable().optional(),
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'في الإجمالي فقط — لم تتم التسوية بعد',
'costs.tapToInclude': 'اضغط للتضمين',
'costs.amount': 'المبلغ',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Apenas no total — ainda não acertado',
'costs.tapToInclude': 'Toque para incluir',
'costs.amount': 'Valor',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Jen v součtu — zatím nevyrovnáno',
'costs.tapToInclude': 'Klepnutím zahrnout',
'costs.amount': 'Částka',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Nur Gesamtsumme — noch nicht abgerechnet',
'costs.tapToInclude': 'Zum Einbeziehen tippen',
'costs.amount': 'Betrag',
'costs.split': "Aufteilen",
'costs.splitEqually': "Gleichmäßig",
'costs.splitCustom': "Individuell",
'costs.splitTicket': "Beleg",
};
export default budget;
+4
View File
@@ -91,6 +91,10 @@ const budget: TranslationStrings = {
'costs.category': 'Category',
'costs.whoPaid': 'Who paid?',
'costs.splitBetween': 'Split equally between',
'costs.split': 'Split',
'costs.splitEqually': 'Equally',
'costs.splitCustom': 'Custom',
'costs.splitTicket': 'Ticket',
'costs.pickSomeone': 'Pick at least one person to split with.',
'costs.splitSummary': 'Split {count} ways · {amount} each',
'costs.cat.accommodation': 'Accommodation',
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Solo en el total — aún sin saldar',
'costs.tapToInclude': 'Toca para incluir',
'costs.amount': 'Importe',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Total seulement — pas encore réglé',
'costs.tapToInclude': 'Toucher pour inclure',
'costs.amount': 'Montant',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -117,6 +117,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Μόνο στο σύνολο — δεν έχει διακανονιστεί',
'costs.tapToInclude': 'Πατήστε για προσθήκη',
'costs.amount': 'Ποσό',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Csak az összegben — még nincs rendezve',
'costs.tapToInclude': 'Koppintson a hozzáadáshoz',
'costs.amount': 'Összeg',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Hanya total — belum diselesaikan',
'costs.tapToInclude': 'Ketuk untuk menyertakan',
'costs.amount': 'Jumlah',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Solo nel totale — non ancora saldato',
'costs.tapToInclude': 'Tocca per includere',
'costs.amount': 'Importo',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': '合計のみ — 未精算',
'costs.tapToInclude': 'タップして追加',
'costs.amount': '金額',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': '합계에만 반영 — 아직 미정산',
'costs.tapToInclude': '탭하여 포함',
'costs.amount': '금액',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Alleen in totaal — nog niet verrekend',
'costs.tapToInclude': 'Tik om toe te voegen',
'costs.amount': 'Bedrag',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Tylko w sumie — jeszcze nierozliczone',
'costs.tapToInclude': 'Dotknij, aby dodać',
'costs.amount': 'Kwota',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Только в сумме — ещё не урегулировано',
'costs.tapToInclude': 'Нажмите, чтобы добавить',
'costs.amount': 'Сумма',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Endast totalt ännu inte reglerat',
'costs.tapToInclude': 'Tryck för att inkludera',
'costs.amount': 'Summa',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Yalnızca toplamda — henüz ödenmedi',
'costs.tapToInclude': 'Eklemek için dokun',
'costs.amount': 'Tutar',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': 'Лише в сумі — ще не врегульовано',
'costs.tapToInclude': 'Натисніть, щоб додати',
'costs.amount': 'Сума',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -115,6 +115,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': '僅計入總額 — 尚未結算',
'costs.tapToInclude': '點按以加入',
'costs.amount': '金額',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;
+4 -1
View File
@@ -115,6 +115,9 @@ const budget: TranslationStrings = {
'costs.unfinishedHint': '仅计入总额 — 尚未结算',
'costs.tapToInclude': '点按以加入',
'costs.amount': '金额',
'costs.split': "Split",
'costs.splitEqually': "Equally",
'costs.splitCustom': "Custom",
'costs.splitTicket': "Ticket",
};
export default budget;