Skip to content

Commit 6145963

Browse files
alexpmicheletclaude
andcommitted
feat(stripe-admin-override): bouton « Marquer KYC validé » comme chemin de secours webhook
Quand le webhook Stripe `account.updated` ne ramène pas le statut à `ready` (sandbox flaky, misconfig Connect, signature mismatch, network blip), l'admin restait coincé sur « En attente de KYC » à perpétuité même après avoir validé le KYC IRL avec le restaurateur. Seule solution avant ce commit : patcher la DB à la main via `convex run` — pas utilisable par Alex en prod. Surface : - Backend : nouveau `kbAdminMutation` `forceStripeStatusOverride` dans `lib/stripe/account.ts`, args `{tenantId, status: "ready"|"disabled"|"pending"}`. Refuse `INVALID_STATE` si pas de `stripeAccountId` (overrider un compte inexistant = nonsense). Auto-audité par `kbAdminMutation` (action `stripe.account.forceStatusOverride`). - Frontend : bloc « Override admin » sur `/parametres/stripe`, visible seulement quand un compte existe ET `stripeStatus !== "ready"`. Couleur warning amber (signal d'override), confirmation native `window.confirm` avant le flip. Toast succès / erreur. Tests : +6 cases (visible selon status × disabled/enabled selon isOverriding × callback wirée). 28/28 green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent e5e6b18 commit 6145963

4 files changed

Lines changed: 207 additions & 1 deletion

File tree

apps/admin/src/app/(app)/t/[tenantId]/parametres/stripe/page.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
*/
6060

6161
import { useState } from "react";
62-
import { useAction } from "convex/react";
62+
import { useAction, useMutation } from "convex/react";
6363
import { toast } from "sonner";
6464

6565
import { api } from "@packages/backend/convex/_generated/api";
@@ -93,9 +93,18 @@ export default function StripeSettingsPage() {
9393
api.lib.stripe.account.createStripeAccountLink,
9494
);
9595

96+
// Backup admin override (chemin de secours quand le webhook
97+
// account.updated ne ramène pas le statut à ready). Backend rejette si
98+
// pas de stripeAccountId + audit-log chaque flip. Root-only via
99+
// kbAdminMutation.
100+
const forceStripeStatusOverride = useMutation(
101+
api.lib.stripe.account.forceStripeStatusOverride,
102+
);
103+
96104
const [accountLink, setAccountLink] = useState<string | null>(null);
97105
const [isGenerating, setIsGenerating] = useState(false);
98106
const [genError, setGenError] = useState<string | null>(null);
107+
const [isOverriding, setIsOverriding] = useState(false);
99108
// Email du restaurant — requis par Stripe `/v1/accounts` (champ `email`).
100109
// Saisi par l'opérateur ici parce qu'aucun « prospect » n'existe pour un
101110
// tenant créé hors wizard (a posteriori).
@@ -152,6 +161,22 @@ export default function StripeSettingsPage() {
152161
}
153162
};
154163

164+
const handleForceReady = async (): Promise<void> => {
165+
if (isOverriding) return;
166+
if (!window.confirm("Confirmer l'override : marquer le KYC comme validé ?"))
167+
return;
168+
setIsOverriding(true);
169+
try {
170+
await forceStripeStatusOverride({ tenantId, status: "ready" });
171+
toast.success("Statut Stripe Connect forcé à « Prêt »");
172+
} catch (error) {
173+
const message = getConvexErrorMessage(error);
174+
toast.error("Impossible de forcer le statut", { description: message });
175+
} finally {
176+
setIsOverriding(false);
177+
}
178+
};
179+
155180
const handleCopy = async (url: string): Promise<void> => {
156181
try {
157182
if (
@@ -180,6 +205,8 @@ export default function StripeSettingsPage() {
180205
genError={genError}
181206
onGenerate={handleGenerate}
182207
onCopy={handleCopy}
208+
onForceReady={handleForceReady}
209+
isOverriding={isOverriding}
183210
/>
184211
);
185212
}

apps/admin/src/app/(app)/t/[tenantId]/parametres/stripe/stripe-settings-view.test.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,8 @@ function defaultProps(
205205
genError: null,
206206
onGenerate: vi.fn(),
207207
onCopy: vi.fn(),
208+
onForceReady: vi.fn(),
209+
isOverriding: false,
208210
...overrides,
209211
};
210212
}
@@ -409,6 +411,86 @@ describe("StripeSettingsView — email du restaurant (a posteriori)", () => {
409411
});
410412
});
411413

414+
describe("StripeSettingsView — admin override (backup webhook)", () => {
415+
it("no account yet → override block is HIDDEN (override sans compte = nonsense)", () => {
416+
const tree = serialize(StripeSettingsView(defaultProps()));
417+
expect(findBySlot(tree, "stripe-settings-override-block")).toBeNull();
418+
expect(findBySlot(tree, "stripe-settings-force-ready")).toBeNull();
419+
});
420+
421+
it("status `ready` → override block is HIDDEN (déjà ready, no-op)", () => {
422+
const tree = serialize(
423+
StripeSettingsView(
424+
defaultProps({
425+
stripeAccountId: ACCOUNT_ID,
426+
stripeStatus: "ready",
427+
}),
428+
),
429+
);
430+
expect(findBySlot(tree, "stripe-settings-override-block")).toBeNull();
431+
});
432+
433+
it("status `pending` → override block is VISIBLE (chemin de secours)", () => {
434+
const tree = serialize(
435+
StripeSettingsView(
436+
defaultProps({
437+
stripeAccountId: ACCOUNT_ID,
438+
stripeStatus: "pending",
439+
}),
440+
),
441+
);
442+
const block = findBySlot(tree, "stripe-settings-override-block");
443+
expect(block).not.toBeNull();
444+
expect(allText(block)).toMatch(/Override admin/i);
445+
});
446+
447+
it("status `disabled` → override block is VISIBLE (admin peut ré-activer)", () => {
448+
const tree = serialize(
449+
StripeSettingsView(
450+
defaultProps({
451+
stripeAccountId: ACCOUNT_ID,
452+
stripeStatus: "disabled",
453+
}),
454+
),
455+
);
456+
expect(findBySlot(tree, "stripe-settings-override-block")).not.toBeNull();
457+
});
458+
459+
it("clicking « Marquer comme … » calls `onForceReady`", () => {
460+
const onForceReady = vi.fn();
461+
const tree = serialize(
462+
StripeSettingsView(
463+
defaultProps({
464+
stripeAccountId: ACCOUNT_ID,
465+
stripeStatus: "pending",
466+
onForceReady,
467+
}),
468+
),
469+
);
470+
const btn = findBySlot(tree, "stripe-settings-force-ready") as {
471+
props: { onClick?: () => void };
472+
} | null;
473+
btn?.props.onClick?.();
474+
expect(onForceReady).toHaveBeenCalledTimes(1);
475+
});
476+
477+
it("override button is disabled while `isOverriding` is true", () => {
478+
const tree = serialize(
479+
StripeSettingsView(
480+
defaultProps({
481+
stripeAccountId: ACCOUNT_ID,
482+
stripeStatus: "pending",
483+
isOverriding: true,
484+
}),
485+
),
486+
);
487+
const btn = findBySlot(tree, "stripe-settings-force-ready") as {
488+
props: { disabled?: boolean };
489+
} | null;
490+
expect(btn?.props.disabled).toBe(true);
491+
});
492+
});
493+
412494
describe("StripeSettingsView — generated URL display", () => {
413495
it("when `accountLink` is set, renders the URL in a read-only input", () => {
414496
const tree = serialize(

apps/admin/src/app/(app)/t/[tenantId]/parametres/stripe/stripe-settings-view.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,20 @@ export type StripeSettingsViewProps = {
129129
* Threaded as a prop so the view stays purely presentational.
130130
*/
131131
onCopy: (url: string) => void;
132+
/**
133+
* Override manuel admin du statut Stripe — chemin de secours quand le
134+
* webhook `account.updated` n'arrive pas (sandbox flaky, signature
135+
* mismatch, misconfig Connect). L'admin a complété le KYC IRL avec le
136+
* resto mais le badge reste « En attente » → un clic ici flippe le
137+
* status à `ready`. Audit-logged côté backend.
138+
*
139+
* Threaded comme prop pour garder la view purement présentationnelle.
140+
* Affiché uniquement quand un compte existe (`hasAccount`) ET que le
141+
* status n'est pas déjà `ready` (sinon overrider à ready est un no-op).
142+
*/
143+
onForceReady: () => void;
144+
/** Disable le bouton override pendant l'in-flight mutation. */
145+
isOverriding: boolean;
132146
};
133147

134148
// ---------------------------------------------------------------------------
@@ -205,6 +219,8 @@ export function StripeSettingsView(
205219
genError,
206220
onGenerate,
207221
onCopy,
222+
onForceReady,
223+
isOverriding,
208224
} = props;
209225

210226
const status = statusCopy(stripeAccountId, stripeStatus);
@@ -216,6 +232,9 @@ export function StripeSettingsView(
216232
// un compte existant — pas de re-passage par `/v1/accounts`, donc
217233
// l'email du tenant n'est pas relu.
218234
const disableGenerate = isGenerating || (!hasAccount && !isEmailValid);
235+
// L'override admin n'a de sens que si un compte existe ET que le status
236+
// n'est pas déjà `ready` (sinon flipper à ready est un no-op).
237+
const canForceReady = hasAccount && stripeStatus !== "ready";
219238

220239
return (
221240
<div
@@ -321,6 +340,38 @@ export function StripeSettingsView(
321340
</div>
322341
)}
323342

343+
{/* Admin override — chemin de secours si le webhook account.updated
344+
n'arrive pas après un KYC validé IRL (Stripe sandbox flaky,
345+
misconfig Connect, signature mismatch). Audit-logged côté backend.
346+
Affiché seulement quand un compte existe ET status !== "ready". */}
347+
{canForceReady ? (
348+
<div
349+
data-slot="stripe-settings-override-block"
350+
className="rounded-md border border-amber-300/60 bg-amber-50 p-3"
351+
>
352+
<p className="mb-2 text-sm font-medium text-amber-900">
353+
Override admin — marquer KYC validé manuellement
354+
</p>
355+
<p className="mb-3 text-xs text-amber-800">
356+
À utiliser si le KYC a été validé avec le gérant mais que le statut
357+
reste bloqué (webhook Stripe non reçu). Le changement est tracé dans
358+
le journal d&apos;audit.
359+
</p>
360+
<Button
361+
type="button"
362+
variant="outline"
363+
data-slot="stripe-settings-force-ready"
364+
onClick={onForceReady}
365+
disabled={isOverriding}
366+
className="border-amber-400 bg-white hover:bg-amber-100"
367+
>
368+
{isOverriding
369+
? "Application…"
370+
: "Marquer comme « Prêt à recevoir les paiements »"}
371+
</Button>
372+
</div>
373+
) : null}
374+
324375
{/* URL display + copy button (only once we have a URL) */}
325376
{hasUrl ? (
326377
<div className="flex flex-col gap-1.5">

packages/backend/convex/lib/stripe/account.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
kbAdminMutation,
88
kbAdminQuery,
99
setTenantStripeAccount,
10+
setTenantStripeStatus,
1011
} from "../tenancy";
1112

1213
/**
@@ -106,6 +107,51 @@ export const stampStripeAccount = kbAdminMutation({
106107
},
107108
});
108109

110+
/**
111+
* Root-only manual override of the tenant's Stripe Connect status. Backup path
112+
* for when the `account.updated` webhook fails to fire / arrive (Stripe Connect
113+
* webhook misconfig, sandbox flakiness, signature mismatch, network blip) — the
114+
* admin completed the KYC with the resto IRL but the badge stays « En attente »
115+
* forever. With this mutation, the admin can flip the status from the UI in one
116+
* click instead of waiting / debugging the webhook.
117+
*
118+
* Auto-audited via `kbAdminMutation` so every manual override leaves a trail
119+
* (who, when, from-status → to-status — the wrapper records the action + actor;
120+
* we add an explicit richer `logAudit` carrying the previous status for ops
121+
* reconstruction, same pattern as `tenant.activate`).
122+
*
123+
* Refuses if the tenant has no `stripeAccountId` yet (overriding the status of
124+
* a non-existent account makes no sense — generate a Stripe Connect link first).
125+
*
126+
* Args:
127+
* - `tenantId` — the tenant to patch
128+
* - `status` — `"ready" | "disabled" | "pending"` (the full union; admin can
129+
* roll back if a misclick happened — the audit log records it).
130+
*/
131+
export const forceStripeStatusOverride = kbAdminMutation({
132+
args: {
133+
tenantId: v.id("tenants"),
134+
status: v.union(
135+
v.literal("ready"),
136+
v.literal("disabled"),
137+
v.literal("pending"),
138+
),
139+
},
140+
action: "stripe.account.forceStatusOverride",
141+
handler: async (ctx, args): Promise<void> => {
142+
const tenant = await getTenantById(ctx, args.tenantId);
143+
if (tenant === null) throw tenantNotFound();
144+
if (tenant.stripeAccountId === undefined) {
145+
throw new ConvexError({
146+
code: "INVALID_STATE",
147+
message:
148+
"Cannot force a Stripe status on a tenant with no Stripe account. Generate a Stripe Connect link first.",
149+
});
150+
}
151+
await setTenantStripeStatus(ctx, args.tenantId, args.status);
152+
},
153+
});
154+
109155
/**
110156
* Root-only: create (or reuse) the resto's Stripe Connect Express account,
111157
* PRE-FILLED with its SIRET / email / first + last name, and return a fresh

0 commit comments

Comments
 (0)