Skip to content

Commit 41164f7

Browse files
committed
Amelioration table de payments robustes supression modification etc
1 parent 67c52bd commit 41164f7

21 files changed

Lines changed: 729 additions & 81 deletions

File tree

app/app/expense/[id].tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export default function ExpenseDetailScreen() {
5252
mutationFn: () => expensesApi.delete(id),
5353
onSuccess: () => {
5454
qc.invalidateQueries({ queryKey: ['group'] });
55+
// Le nombre de depenses affiche dans la liste des groupes change aussi.
56+
qc.invalidateQueries({ queryKey: ['groups'] });
5557
router.back();
5658
},
5759
});

app/src/i18n/locales/de.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,21 @@
382382
"pending_badge": "⏳ {{n}} zu bestätigen",
383383
"all_settled": "🎉 Alles beglichen — nichts mehr offen.",
384384
"chain_hint": "Dieser Saldo entsteht aus einer Verrechnung über mehrere Personen: keine gemeinsame Ausgabe verbindet euch, die Rückzahlung wird deshalb separat erfasst.",
385-
"guest_auto": "{{name}} hat kein Konto: diese Seite wird automatisch bestätigt."
385+
"guest_auto": "{{name}} hat kein Konto: diese Seite wird automatisch bestätigt.",
386+
"calc_show": "🔎 Rechnung anzeigen",
387+
"calc_hide": "Rechnung ausblenden",
388+
"calc_title": "Woher diese Beträge kommen",
389+
"calc_paid": "hat ausgelegt",
390+
"calc_share": "sein Anteil",
391+
"calc_settled_own": "bereits beglichene Anteile",
392+
"calc_settled_as_payer": "beglichene Anteile, die er ausgelegt hatte",
393+
"calc_paid_back": "gezahlte Rückzahlungen",
394+
"calc_received": "erhaltene Rückzahlungen",
395+
"calc_net": "Position",
396+
"calc_total": "Summe der Positionen",
397+
"calc_total_ok": "✓ Plus und Minus heben sich exakt auf — nichts ist verloren gegangen",
398+
"calc_total_bad": "⚠ Abweichung von {{amount}} — bitte melden, das sollte nie passieren",
399+
"calc_explain": "Jede Position wird anschließend zugeordnet: wer am meisten schuldet, zahlt an den mit dem größten Guthaben, dann an den nächsten, bis alles auf null steht. Daraus entstehen die Überweisungen oben, mit möglichst wenigen Schritten."
386400
},
387401
"home": {
388402
"greeting": "Hallo, {{name}} 👋"

app/src/i18n/locales/en.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,21 @@
382382
"pending_badge": "⏳ {{n}} to confirm",
383383
"all_settled": "🎉 All settled — nothing left to pay back.",
384384
"chain_hint": "This balance comes from netting across several people: no expense links you directly, so the reimbursement is recorded on its own.",
385-
"guest_auto": "{{name}} has no account: their side is confirmed automatically."
385+
"guest_auto": "{{name}} has no account: their side is confirmed automatically.",
386+
"calc_show": "🔎 Show the maths",
387+
"calc_hide": "Hide the maths",
388+
"calc_title": "Where these amounts come from",
389+
"calc_paid": "paid out",
390+
"calc_share": "their share",
391+
"calc_settled_own": "their shares already settled",
392+
"calc_settled_as_payer": "settled shares they had fronted",
393+
"calc_paid_back": "reimbursements paid",
394+
"calc_received": "reimbursements received",
395+
"calc_net": "position",
396+
"calc_total": "Sum of positions",
397+
"calc_total_ok": "✓ the + and − cancel out exactly — nothing went missing",
398+
"calc_total_bad": "⚠ off by {{amount}} — please report it, this should never happen",
399+
"calc_explain": "Each position is then matched up: whoever owes most pays whoever is owed most, then the next, until everything is at zero. That is what produces the transfers above, in as few moves as possible."
386400
},
387401
"home": {
388402
"greeting": "Hi, {{name}} 👋"

app/src/i18n/locales/es.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,21 @@
382382
"pending_badge": "⏳ {{n}} por confirmar",
383383
"all_settled": "🎉 Todo saldado — no queda nada por devolver.",
384384
"chain_hint": "Este saldo viene de una compensación entre varias personas: ningún gasto os une directamente, así que el reembolso se anota aparte.",
385-
"guest_auto": "{{name}} no tiene cuenta: su lado se valida automáticamente."
385+
"guest_auto": "{{name}} no tiene cuenta: su lado se valida automáticamente.",
386+
"calc_show": "🔎 Ver el cálculo",
387+
"calc_hide": "Ocultar el cálculo",
388+
"calc_title": "De dónde salen estos importes",
389+
"calc_paid": "ha adelantado",
390+
"calc_share": "su parte",
391+
"calc_settled_own": "sus partes ya saldadas",
392+
"calc_settled_as_payer": "partes saldadas que había adelantado",
393+
"calc_paid_back": "reembolsos pagados",
394+
"calc_received": "reembolsos recibidos",
395+
"calc_net": "posición",
396+
"calc_total": "Suma de las posiciones",
397+
"calc_total_ok": "✓ los + y los − se anulan exactamente — no se ha perdido nada",
398+
"calc_total_bad": "⚠ desfase de {{amount}} — comunícalo, esto no debería ocurrir",
399+
"calc_explain": "Cada posición se empareja después: quien más debe paga a quien más se le debe, luego al siguiente, hasta que todo queda a cero. Eso produce las transferencias de arriba, en el mínimo de operaciones."
386400
},
387401
"home": {
388402
"greeting": "Hola, {{name}} 👋"

app/src/i18n/locales/fr.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,21 @@
382382
"pending_badge": "⏳ {{n}} à confirmer",
383383
"all_settled": "🎉 Tout est soldé — plus rien à rembourser.",
384384
"chain_hint": "Ce solde vient d'une compensation entre plusieurs personnes : aucune dépense ne vous lie directement, le remboursement se note donc à part.",
385-
"guest_auto": "{{name}} n'a pas de compte : son accord est validé d'office."
385+
"guest_auto": "{{name}} n'a pas de compte : son accord est validé d'office.",
386+
"calc_show": "🔎 Voir le calcul",
387+
"calc_hide": "Masquer le calcul",
388+
"calc_title": "D'où viennent ces montants",
389+
"calc_paid": "a avancé",
390+
"calc_share": "sa part",
391+
"calc_settled_own": "ses parts déjà réglées",
392+
"calc_settled_as_payer": "parts réglées qu'il avait avancées",
393+
"calc_paid_back": "remboursements versés",
394+
"calc_received": "remboursements reçus",
395+
"calc_net": "position",
396+
"calc_total": "Somme des positions",
397+
"calc_total_ok": "✓ les + et les − s'annulent exactement — rien ne s'est perdu",
398+
"calc_total_bad": "⚠ écart de {{amount}} — signale-le, ce ne devrait jamais arriver",
399+
"calc_explain": "Chaque position est ensuite appariée : le plus endetté verse au plus créditeur, puis au suivant, jusqu'à ce que tout soit à zéro. C'est ce qui donne les virements ci-dessus, en un minimum d'opérations."
386400
},
387401
"home": {
388402
"greeting": "Bonjour, {{name}} 👋"

app/src/i18n/locales/it.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,21 @@
382382
"pending_badge": "⏳ {{n}} da confermare",
383383
"all_settled": "🎉 Tutto saldato — non resta nulla da rimborsare.",
384384
"chain_hint": "Questo saldo nasce da una compensazione tra più persone: nessuna spesa vi lega direttamente, quindi il rimborso si annota a parte.",
385-
"guest_auto": "{{name}} non ha un account: il suo lato è validato d'ufficio."
385+
"guest_auto": "{{name}} non ha un account: il suo lato è validato d'ufficio.",
386+
"calc_show": "🔎 Vedi il calcolo",
387+
"calc_hide": "Nascondi il calcolo",
388+
"calc_title": "Da dove vengono questi importi",
389+
"calc_paid": "ha anticipato",
390+
"calc_share": "la sua parte",
391+
"calc_settled_own": "le sue parti già saldate",
392+
"calc_settled_as_payer": "parti saldate che aveva anticipato",
393+
"calc_paid_back": "rimborsi versati",
394+
"calc_received": "rimborsi ricevuti",
395+
"calc_net": "posizione",
396+
"calc_total": "Somma delle posizioni",
397+
"calc_total_ok": "✓ i + e i − si annullano esattamente — non si è perso nulla",
398+
"calc_total_bad": "⚠ scarto di {{amount}} — segnalalo, non dovrebbe mai succedere",
399+
"calc_explain": "Ogni posizione viene poi abbinata: chi deve di più paga chi è più in credito, poi il successivo, finché tutto è a zero. È ciò che produce i bonifici qui sopra, nel minor numero di operazioni."
386400
},
387401
"home": {
388402
"greeting": "Ciao, {{name}} 👋"

app/src/screens/expenses/AddExpenseScreen.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,8 @@ export default function AddExpenseScreen() {
229229
mutationFn: expensesApi.create,
230230
onSuccess: () => {
231231
qc.invalidateQueries({ queryKey: ['group', groupId] });
232+
// Le nombre de dépenses affiché dans la liste des groupes change aussi.
233+
qc.invalidateQueries({ queryKey: ['groups'] });
232234
router.replace(`/group/${groupId}`);
233235
},
234236
onError: (e: any) =>

app/src/screens/groups/GroupDetailScreen.tsx

Lines changed: 136 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export default function GroupDetailScreen() {
5353

5454
const [expandedBalance, setExpandedBalance] = useState<string | null>(null);
5555
const [showLog, setShowLog] = useState(false);
56+
const [showCalc, setShowCalc] = useState(false);
5657
// Formulaire de remboursement — ouvert depuis une ligne de solde
5758
const [settleFor, setSettleFor] = useState<Balance | null>(null);
5859
const [amountInput, setAmountInput] = useState('');
@@ -63,6 +64,10 @@ export default function GroupDetailScreen() {
6364
queryKey: ['group', id],
6465
queryFn: () => groupsApi.get(id),
6566
enabled: !!id,
67+
// On revient toujours ici apres avoir touche a une depense : les soldes
68+
// doivent etre recalcules a l'arrivee, sans dependre des 30 s de cache par
69+
// defaut ni du fait qu'un ecran ait pense a invalider.
70+
refetchOnMount: 'always',
6671
});
6772

6873
const refresh = () => qc.invalidateQueries({ queryKey: ['group', id] });
@@ -123,17 +128,22 @@ export default function GroupDetailScreen() {
123128
// Compte les dépenses incomplètes
124129
const incompleteCount = (group.expenses || []).filter(isExpenseIncomplete).length;
125130

126-
// Solde net : ce qu'il a avancé moins ce qu'il doit. Mêmes données que les
127-
// remboursements ci-dessous — affichage seul, le calcul n'est pas modifié.
128-
const memberNet: Record<string, number> = {};
129-
group.members.forEach((m: any) => { memberNet[m.id] = 0; });
130-
(group.expenses || []).forEach((exp: any) => {
131-
const payments = exp.payments?.length > 0
132-
? exp.payments
133-
: [{ memberId: exp.paidByMemberId, amount: exp.totalAmount }];
134-
payments.forEach((p: any) => { memberNet[p.memberId] = (memberNet[p.memberId] || 0) + p.amount; });
135-
exp.splits?.forEach((sp: any) => { memberNet[sp.memberId] = (memberNet[sp.memberId] || 0) - sp.amount; });
136-
});
131+
// Position nette de chaque membre — calculee par le backend, qui est le seul
132+
// a voir les remboursements. La recalculer ici a partir des seules depenses
133+
// laissait les barres figees sur l'etat d'avant remboursement.
134+
// Le repli local ne sert qu'aux reponses d'un backend anterieur a ce champ.
135+
const memberNet: Record<string, number> = group.netByMember ?? (() => {
136+
const net: Record<string, number> = {};
137+
group.members.forEach((m: any) => { net[m.id] = 0; });
138+
(group.expenses || []).forEach((exp: any) => {
139+
const payments = exp.payments?.length > 0
140+
? exp.payments
141+
: [{ memberId: exp.paidByMemberId, amount: exp.totalAmount }];
142+
payments.forEach((p: any) => { net[p.memberId] = (net[p.memberId] || 0) + p.amount; });
143+
exp.splits?.forEach((sp: any) => { net[sp.memberId] = (net[sp.memberId] || 0) - sp.amount; });
144+
});
145+
return net;
146+
})();
137147
const netRows = group.members
138148
.map((m: any) => ({ member: m, net: Math.round((memberNet[m.id] || 0) * 100) / 100 }))
139149
.sort((a: any, b: any) => b.net - a.net);
@@ -363,22 +373,13 @@ export default function GroupDetailScreen() {
363373
<>
364374
<View style={styles.balancesHeader}>
365375
<SectionLabel label={t('settlements.section')} />
366-
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
367-
{myPendingCount > 0 && (
368-
<View style={styles.pendingBadge}>
369-
<Text style={styles.pendingBadgeText}>
370-
{t('settlements.pending_badge', { n: myPendingCount })}
371-
</Text>
372-
</View>
373-
)}
374-
<TouchableOpacity
375-
onPress={() => setShowLog(true)}
376-
style={styles.logBtn}
377-
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
378-
>
379-
<Text style={styles.logBtnText}>📋 {t('balances.detail')}</Text>
380-
</TouchableOpacity>
381-
</View>
376+
{myPendingCount > 0 && (
377+
<View style={styles.pendingBadge}>
378+
<Text style={styles.pendingBadgeText}>
379+
{t('settlements.pending_badge', { n: myPendingCount })}
380+
</Text>
381+
</View>
382+
)}
382383
</View>
383384

384385
<Card>
@@ -521,6 +522,12 @@ export default function GroupDetailScreen() {
521522
</React.Fragment>
522523
);
523524
})}
525+
526+
{/* Pleine largeur en bas de carte : dans le coin haut-droit la
527+
cible etait petite et loin du pouce. */}
528+
<TouchableOpacity style={styles.detailBtn} onPress={() => setShowLog(true)}>
529+
<Text style={styles.detailBtnText}>📋 {t('settlements.history_title')}</Text>
530+
</TouchableOpacity>
524531
</Card>
525532
</>
526533
)}
@@ -658,6 +665,82 @@ export default function GroupDetailScreen() {
658665
);
659666
})}
660667

668+
{/* Le calcul en clair — replie par defaut : c'est une verification,
669+
pas une lecture quotidienne. Les chiffres viennent du backend,
670+
ceux-la memes qui produisent les virements affiches. */}
671+
<TouchableOpacity style={styles.calcToggle} onPress={() => setShowCalc(v => !v)}>
672+
<Text style={styles.calcToggleText}>
673+
{showCalc ? t('settlements.calc_hide') : t('settlements.calc_show')}
674+
</Text>
675+
</TouchableOpacity>
676+
677+
{showCalc && (() => {
678+
const rows = group.netBreakdown;
679+
if (!rows) return (
680+
<Text style={styles.historyEmpty}>
681+
Le détail du calcul arrive avec la prochaine mise à jour du serveur.
682+
</Text>
683+
);
684+
const total = Math.round(
685+
group.members.reduce((s: number, m: any) => s + (rows[m.id]?.net ?? 0), 0) * 100
686+
) / 100;
687+
const ok = Math.abs(total) < 0.005;
688+
const line = (label: string, value: number, sign: string) =>
689+
Math.abs(value) < 0.005 ? null : (
690+
<View style={styles.calcLine} key={label}>
691+
<Text style={styles.calcLabel}>{sign} {label}</Text>
692+
<Text style={styles.calcValue}>{fmt(value)}</Text>
693+
</View>
694+
);
695+
return (
696+
<View style={{ marginBottom: 18 }}>
697+
<Text style={styles.historyLabel}>{t('settlements.calc_title')}</Text>
698+
{group.members.map((m: any) => {
699+
const r = rows[m.id];
700+
if (!r) return null;
701+
return (
702+
<View key={m.id} style={styles.calcCard}>
703+
<View style={styles.calcHead}>
704+
<Avatar initials={m.avatarInitials} color={m.avatarColor} size={20} />
705+
<Text style={styles.calcName} numberOfLines={1}>{m.displayName}</Text>
706+
</View>
707+
{line(t('settlements.calc_paid'), r.paid, '+')}
708+
{line(t('settlements.calc_share'), r.share, '−')}
709+
{line(t('settlements.calc_settled_own'), r.settledOwn, '+')}
710+
{line(t('settlements.calc_settled_as_payer'), r.settledAsPayer, '−')}
711+
{line(t('settlements.calc_paid_back'), r.settlementsPaid, '+')}
712+
{line(t('settlements.calc_received'), r.settlementsReceived, '−')}
713+
<View style={styles.calcTotalLine}>
714+
<Text style={styles.calcNetLabel}>= {t('settlements.calc_net')}</Text>
715+
<Text style={[
716+
styles.calcNetValue,
717+
r.net > 0.005 && { color: colors.green },
718+
r.net < -0.005 && { color: colors.amber },
719+
]}>
720+
{r.net > 0 ? '+' : ''}{fmt(r.net)}
721+
</Text>
722+
</View>
723+
</View>
724+
);
725+
})}
726+
727+
{/* La verification : la somme doit tomber a zero au centime. */}
728+
<View style={[styles.calcLine, { marginTop: 10, paddingHorizontal: 4 }]}>
729+
<Text style={styles.calcNetLabel}>{t('settlements.calc_total')}</Text>
730+
<Text style={[styles.calcNetValue, { color: ok ? colors.green : colors.amber }]}>
731+
{fmt(total)}
732+
</Text>
733+
</View>
734+
<Text style={[styles.calcHint, { color: ok ? colors.green : colors.amber }]}>
735+
{ok
736+
? t('settlements.calc_total_ok')
737+
: t('settlements.calc_total_bad', { amount: fmt(total) })}
738+
</Text>
739+
<Text style={styles.calcHint}>{t('settlements.calc_explain')}</Text>
740+
</View>
741+
);
742+
})()}
743+
661744
<Text style={[styles.historyLabel, { marginTop: 18 }]}>{t('balances.detail_title')}</Text>
662745
{Object.values(netLog).map((entry, i) => {
663746
const remaining = entry.total - entry.settled;
@@ -919,6 +1002,12 @@ const styles = StyleSheet.create({
9191002
},
9201003
pendingBadgeText: { fontSize: 10, color: colors.amber, fontWeight: '700' },
9211004
allSettled: { fontSize: 13, color: colors.text2, textAlign: 'center', paddingVertical: 10 },
1005+
detailBtn: {
1006+
marginTop: 14, minHeight: 48, borderRadius: radius.md,
1007+
alignItems: 'center', justifyContent: 'center',
1008+
backgroundColor: colors.accentBg, borderWidth: 1, borderColor: 'rgba(124,110,250,0.3)',
1009+
},
1010+
detailBtnText: { fontSize: 14, color: colors.accent2, fontWeight: '600' },
9221011
pendingCard: {
9231012
marginTop: 8, borderRadius: radius.sm, padding: 10,
9241013
backgroundColor: 'rgba(251,191,36,0.06)', borderWidth: 1, borderColor: 'rgba(251,191,36,0.25)',
@@ -941,6 +1030,26 @@ const styles = StyleSheet.create({
9411030
historyStatus: { fontSize: 11, flex: 1 },
9421031
historyNote: { fontSize: 11, color: colors.text3, marginTop: 4 },
9431032

1033+
calcToggle: {
1034+
minHeight: 44, borderRadius: radius.md, marginBottom: 16,
1035+
alignItems: 'center', justifyContent: 'center',
1036+
backgroundColor: colors.surface2, borderWidth: 0.5, borderColor: colors.border,
1037+
},
1038+
calcToggleText: { fontSize: 12, color: colors.text2, fontWeight: '600' },
1039+
calcCard: { backgroundColor: colors.surface2, borderRadius: radius.sm, padding: 10, marginBottom: 8 },
1040+
calcHead: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 4 },
1041+
calcName: { fontSize: 12, color: colors.text, fontWeight: '500', flex: 1 },
1042+
calcLine: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 2 },
1043+
calcLabel: { fontSize: 11, color: colors.text3, flex: 1 },
1044+
calcValue: { fontSize: 11, fontFamily: 'monospace', color: colors.text2 },
1045+
calcTotalLine: {
1046+
flexDirection: 'row', justifyContent: 'space-between',
1047+
paddingTop: 6, marginTop: 4, borderTopWidth: 0.5, borderTopColor: colors.border,
1048+
},
1049+
calcNetLabel: { fontSize: 12, color: colors.text2, fontWeight: '600' },
1050+
calcNetValue: { fontSize: 12, fontFamily: 'monospace', fontWeight: '700', color: colors.text3 },
1051+
calcHint: { fontSize: 11, color: colors.text3, lineHeight: 16, marginTop: 8 },
1052+
9441053
formDirection: { fontSize: 12, color: colors.text3, marginBottom: 18 },
9451054
formLabel: {
9461055
fontSize: 11, color: colors.text3, fontWeight: '700',

0 commit comments

Comments
 (0)