Logs de diagnostic et rapprochement SumUp ↔ bénévole
- Une ligne de log par requête (route modèle, statut, durée, code d'erreur, compte), identifiant X-Request-ID repris dans chaque log et affiché à l'utilisateur sous une erreur, exceptions non gérées tracées (500 JSON) - Journal d'accès d'uvicorn coupé : il exposait le jeton du formulaire public - Logs métier : paiement SumUp, connexion, import, plannings, solveur - Nom du bénévole dans la description du checkout SumUp - Code et identifiant de transaction SumUp enregistrés au paiement (migration 0023), affichés sur la fiche et dans l'export Excel Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d722a81a87
commit
d0fa1e109c
@@ -70,7 +70,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
? ((detail as { violations: [] }).violations ?? [])
|
||||
: [];
|
||||
if (res.status === 401 && !path.includes('/auth/')) notifyUnauthorized();
|
||||
throw new ApiError(res.status, code, violations);
|
||||
const requestId = res.headers.get('X-Request-ID');
|
||||
if (res.status >= 500) {
|
||||
console.error(`API ${res.status} ${path} — référence ${requestId ?? '?'}`, detail);
|
||||
}
|
||||
throw new ApiError(res.status, code, violations, requestId);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
@@ -99,7 +103,7 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
// Corps non JSON (ex. erreur réseau) : garde le code générique.
|
||||
}
|
||||
if (res.status === 401) notifyUnauthorized();
|
||||
throw new ApiError(res.status, code, []);
|
||||
throw new ApiError(res.status, code, [], res.headers.get('X-Request-ID'));
|
||||
}
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
@@ -299,6 +299,8 @@ export function buildMockState(): MockState {
|
||||
mecenat_country: null,
|
||||
fee_payment_status: null,
|
||||
fee_paid_at: null,
|
||||
fee_transaction_code: null,
|
||||
fee_transaction_id: null,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
});
|
||||
@@ -316,6 +318,8 @@ export function buildMockState(): MockState {
|
||||
fee_amount: mecenat ? 40 : 15,
|
||||
fee_payment_status: 'PAID',
|
||||
fee_paid_at: v.created_at,
|
||||
fee_transaction_code: `TDEMO${String(i).padStart(5, '0')}`,
|
||||
fee_transaction_id: `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`,
|
||||
...(mecenat
|
||||
? { mecenat_address: '1 rue du Festival', mecenat_city: 'Rennes', mecenat_postal_code: '35000', mecenat_country: 'France' }
|
||||
: {}),
|
||||
|
||||
@@ -244,6 +244,8 @@ function promotePending(pending: MockPendingSubmission): VolunteerRead {
|
||||
mecenat_country: pending.mecenatCountry,
|
||||
fee_payment_status: 'PAID',
|
||||
fee_paid_at: now,
|
||||
fee_transaction_code: `TMOCK${String(state.seq).padStart(5, '0')}`,
|
||||
fee_transaction_id: `mock-transaction-${state.seq}`,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -1005,6 +1007,8 @@ export function createMockApi(): PlanningApi {
|
||||
mecenat_country: null,
|
||||
fee_payment_status: null,
|
||||
fee_paid_at: null,
|
||||
fee_transaction_code: null,
|
||||
fee_transaction_id: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -1310,6 +1314,8 @@ export function createMockApi(): PlanningApi {
|
||||
mecenat_country: null,
|
||||
fee_payment_status: null,
|
||||
fee_paid_at: null,
|
||||
fee_transaction_code: null,
|
||||
fee_transaction_id: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -1778,6 +1784,8 @@ export function createMockApi(): PlanningApi {
|
||||
mecenat_country: null,
|
||||
fee_payment_status: null,
|
||||
fee_paid_at: null,
|
||||
fee_transaction_code: null,
|
||||
fee_transaction_id: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
@@ -117,6 +117,12 @@ export interface VolunteerRead {
|
||||
fee_amount: number | null;
|
||||
fee_payment_status: FeePaymentStatus | null;
|
||||
fee_paid_at: string | null;
|
||||
/** Transaction SumUp qui a réglé la cotisation : `fee_transaction_code` est
|
||||
* le code affiché dans le tableau de bord SumUp (ex. `TEENSK4W2K`),
|
||||
* `fee_transaction_id` l'identifiant technique. `null` tant que non payée
|
||||
* (ou payée avant que l'appli ne l'enregistre). */
|
||||
fee_transaction_code: string | null;
|
||||
fee_transaction_id: string | null;
|
||||
/** Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un type
|
||||
* de paiement « mécénat ». */
|
||||
mecenat_address: string | null;
|
||||
|
||||
@@ -233,6 +233,9 @@ export class ApiError extends Error {
|
||||
public readonly status: number,
|
||||
public readonly code: string,
|
||||
public readonly violations: Violation[] = [],
|
||||
/** En-tête `X-Request-ID` de la réponse : référence à communiquer pour
|
||||
* retrouver l'erreur dans les logs du serveur (`app/request_logging.py`). */
|
||||
public readonly requestId: string | null = null,
|
||||
) {
|
||||
super(code);
|
||||
this.name = 'ApiError';
|
||||
|
||||
@@ -35,6 +35,11 @@ export function ErrorNote({ error }: { error: unknown }) {
|
||||
<span className="mt-1 block font-mono text-[10px] font-normal opacity-80">
|
||||
{known ? t(code) : code}
|
||||
</span>
|
||||
{error instanceof ApiError && error.requestId ? (
|
||||
<span className="mt-1 block select-all font-mono text-[10px] font-normal opacity-80">
|
||||
{t('app.error.reference', { id: error.requestId })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"app.loading": "Chargement…",
|
||||
"common.now": "MAINTENANT",
|
||||
"app.error.generic": "Une erreur est survenue",
|
||||
"app.error.reference": "Référence à communiquer : {id}",
|
||||
"error.generic": "Erreur inattendue",
|
||||
"error.internal": "Erreur interne du serveur",
|
||||
"error.not_found": "Ressource introuvable",
|
||||
"error.forbidden": "Accès refusé",
|
||||
"error.unauthorized": "Authentification requise",
|
||||
@@ -307,6 +309,11 @@
|
||||
"config.vol.fee.mecenat": "Mécénat — reçu fiscal",
|
||||
"config.vol.fee.onSite": "Paiera sur place",
|
||||
"config.vol.fee.none": "aucune cotisation déclarée",
|
||||
"config.vol.fee.paidAt": "Payée le",
|
||||
"config.vol.fee.transaction": "Transaction SumUp",
|
||||
"config.vol.fee.transactionHint": "Code de la transaction tel qu'affiché dans le tableau de bord SumUp : recherchez-le là-bas pour retrouver ce paiement. Côté SumUp, la description de la transaction porte aussi le nom du bénévole.",
|
||||
"config.vol.fee.transactionUnknown": "non enregistrée (paiement antérieur à ce suivi)",
|
||||
"config.vol.fee.transactionId": "Identifiant",
|
||||
"config.vol.fee.onSiteShort": "Sur place",
|
||||
"config.vol.fee.onlineShort": "En ligne",
|
||||
"config.vol.fee.mecenatShort": "Mécénat",
|
||||
|
||||
@@ -37,7 +37,7 @@ import { ErrorNote, Loading } from '@/components/state';
|
||||
import { feeMode } from '@/lib/fee';
|
||||
import { foldAccents } from '@/lib/planning';
|
||||
import { t } from '@/i18n';
|
||||
import { shortDayLabel } from '@/lib/time';
|
||||
import { datePart, shortDayLabel, timePart } from '@/lib/time';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
const MAP_FIELDS: Array<{ key: ImportFieldKey; required: boolean }> = [
|
||||
@@ -273,6 +273,7 @@ function FeeSection({
|
||||
{volunteer.fee_payment_label} — {volunteer.fee_amount} €
|
||||
</p>
|
||||
) : null}
|
||||
{volunteer.fee_payment_status === 'PAID' ? <FeeTransaction volunteer={volunteer} /> : null}
|
||||
{volunteer.mecenat_address ? (
|
||||
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
|
||||
{t('config.vol.mecenatAddress')} : {volunteer.mecenat_address},{' '}
|
||||
@@ -298,6 +299,45 @@ function FeeSection({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rapprochement avec SumUp : date du paiement et transaction qui l'a réglé.
|
||||
* Le code (`TEENSK4W2K`) est celui que montre le tableau de bord SumUp —
|
||||
* c'est lui qu'on y recherche pour retrouver le paiement de ce bénévole.
|
||||
* Absent pour un paiement confirmé avant que l'appli ne l'enregistre.
|
||||
*/
|
||||
function FeeTransaction({ volunteer }: { volunteer: VolunteerRead }) {
|
||||
const paidAt = volunteer.fee_paid_at;
|
||||
return (
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-[10px] gap-y-[3px] font-mono text-[10.5px]">
|
||||
{paidAt ? (
|
||||
<>
|
||||
<dt className="opacity-70">{t('config.vol.fee.paidAt')}</dt>
|
||||
<dd>
|
||||
{datePart(paidAt).split('-').reverse().join('/')} {timePart(paidAt)}
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
<dt className="flex items-center gap-[4px] opacity-70">
|
||||
{t('config.vol.fee.transaction')}{' '}
|
||||
<HelpTip text={t('config.vol.fee.transactionHint')} />
|
||||
</dt>
|
||||
<dd className="min-w-0">
|
||||
{volunteer.fee_transaction_code ? (
|
||||
<span className="select-all break-all font-bold">{volunteer.fee_transaction_code}</span>
|
||||
) : (
|
||||
<span className="opacity-60">{t('config.vol.fee.transactionUnknown')}</span>
|
||||
)}
|
||||
</dd>
|
||||
{volunteer.fee_transaction_id ? (
|
||||
<>
|
||||
<dt className="opacity-70">{t('config.vol.fee.transactionId')}</dt>
|
||||
<dd className="min-w-0 select-all break-all opacity-70">{volunteer.fee_transaction_id}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Postes dont ce bénévole est référent (`Position.lead_account_id`). Cocher
|
||||
* un poste crée ou met à niveau son accès de connexion en RÉFÉRENT (LEAD) —
|
||||
|
||||
Reference in New Issue
Block a user