Skip to content

Commit e5e6b18

Browse files
alexpmicheletclaude
andcommitted
fix(stripe-a-posteriori): demande l'email du restaurant avant /v1/accounts
La page « Stripe Connect a posteriori » hardcodait `email: ""` dans le prefill du compte Stripe Connect (commentaire : « Stripe Express collecte l'email du gérant pendant le KYC »). C'est faux : Stripe REJETTE un email vide sur `POST /v1/accounts` avec « Invalid email address: ». Le wizard s'en sortait parce que `prospect.email` existait à l'étape 1 ; pour un tenant créé hors wizard (a posteriori), il n'y a pas de prospect, donc pas de source d'email. Fix : ajout d'un champ « Email du restaurant » dans la view (Input contrôlé via `email` + `onEmailChange` props), avec validation regex client. Le bouton « Générer un lien Stripe Connect » reste désactivé tant que la syntaxe email est invalide. Comportement Régénérer : préserve le UX existant — `/v1/account_links` ne recrée pas le compte, donc l'email n'est pas relu. Le bouton « Régénérer » est enabled même avec `email = ""` (cf. test pinned). Tests : +6 cases pinning la nouvelle logique (email input rendu / masqué selon `hasAccount`, validation regex, callback `onEmailChange`, disable/enable du bouton, regenerate sans email). 22/22 green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f8fffa4 commit e5e6b18

3 files changed

Lines changed: 140 additions & 8 deletions

File tree

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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ export default function StripeSettingsPage() {
9696
const [accountLink, setAccountLink] = useState<string | null>(null);
9797
const [isGenerating, setIsGenerating] = useState(false);
9898
const [genError, setGenError] = useState<string | null>(null);
99+
// Email du restaurant — requis par Stripe `/v1/accounts` (champ `email`).
100+
// Saisi par l'opérateur ici parce qu'aucun « prospect » n'existe pour un
101+
// tenant créé hors wizard (a posteriori).
102+
const [email, setEmail] = useState("");
99103

100104
// Loading sentinel — the view requires a tenantName + the read state.
101105
if (stripeState === undefined) {
@@ -127,12 +131,12 @@ export default function StripeSettingsPage() {
127131
returnUrl,
128132
prefill: {
129133
siret: stripeState.siret,
130-
// Stripe Express collects the legal representative's email
131-
// during the KYC UI itself; we forward an empty string when
132-
// we don't have a manager email handy (matches the wizard
133-
// `Step3Form` discipline). The `business_type: "company"`
134-
// branch ignores `individual[*]` params anyway.
135-
email: "",
134+
// Email saisi par l'opérateur dans le formulaire (la view garde
135+
// le bouton désactivé tant que la syntaxe email est invalide).
136+
// Stripe `/v1/accounts` rejette un email vide ("Invalid email
137+
// address: "), donc on ne se permet PAS de pousser "" comme le
138+
// faisait le wizard quand `prospect.email` était null.
139+
email: email.trim(),
136140
},
137141
});
138142
setAccountLink(result.url);
@@ -165,6 +169,8 @@ export default function StripeSettingsPage() {
165169
return (
166170
<StripeSettingsView
167171
tenantName={stripeState.name}
172+
email={email}
173+
onEmailChange={setEmail}
168174
stripeAccountId={stripeState.stripeAccountId ?? undefined}
169175
stripeStatus={
170176
(stripeState.stripeStatus ?? undefined) as StripeStatus | undefined

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
@@ -196,6 +196,8 @@ function defaultProps(
196196
): StripeSettingsViewProps {
197197
return {
198198
tenantName: "Le Petit Bistrot",
199+
email: "contact@le-petit-bistrot.fr",
200+
onEmailChange: vi.fn(),
199201
stripeAccountId: undefined,
200202
stripeStatus: undefined,
201203
accountLink: null,
@@ -327,6 +329,86 @@ describe("StripeSettingsView — generate / regenerate CTA", () => {
327329
});
328330
});
329331

332+
describe("StripeSettingsView — email du restaurant (a posteriori)", () => {
333+
it("renders the email input when no Stripe account exists yet", () => {
334+
const tree = serialize(StripeSettingsView(defaultProps({ email: "" })));
335+
const emailInput = findBySlot(tree, "stripe-settings-email") as {
336+
props: { value?: string; type?: string };
337+
} | null;
338+
expect(emailInput).not.toBeNull();
339+
expect(emailInput?.props.type).toBe("email");
340+
});
341+
342+
it("hides the email input once an account exists (regenerate path)", () => {
343+
const tree = serialize(
344+
StripeSettingsView(
345+
defaultProps({
346+
stripeAccountId: ACCOUNT_ID,
347+
stripeStatus: "pending",
348+
email: "",
349+
}),
350+
),
351+
);
352+
expect(findBySlot(tree, "stripe-settings-email")).toBeNull();
353+
});
354+
355+
it("empty email → Générer button is disabled (Stripe rejects empty)", () => {
356+
const tree = serialize(StripeSettingsView(defaultProps({ email: "" })));
357+
const btn = findBySlot(tree, "stripe-settings-generate") as {
358+
props: { disabled?: boolean };
359+
} | null;
360+
expect(btn?.props.disabled).toBe(true);
361+
});
362+
363+
it("syntactically invalid email → Générer button is disabled", () => {
364+
const tree = serialize(
365+
StripeSettingsView(defaultProps({ email: "not-an-email" })),
366+
);
367+
const btn = findBySlot(tree, "stripe-settings-generate") as {
368+
props: { disabled?: boolean };
369+
} | null;
370+
expect(btn?.props.disabled).toBe(true);
371+
});
372+
373+
it("valid email → Générer button is enabled", () => {
374+
const tree = serialize(
375+
StripeSettingsView(defaultProps({ email: "alex@example.com" })),
376+
);
377+
const btn = findBySlot(tree, "stripe-settings-generate") as {
378+
props: { disabled?: boolean };
379+
} | null;
380+
expect(btn?.props.disabled).toBe(false);
381+
});
382+
383+
it("typing in the email input calls `onEmailChange` with the new value", () => {
384+
const onEmailChange = vi.fn();
385+
const tree = serialize(
386+
StripeSettingsView(defaultProps({ email: "", onEmailChange })),
387+
);
388+
const emailInput = findBySlot(tree, "stripe-settings-email") as {
389+
props: { onChange?: (e: { target: { value: string } }) => void };
390+
} | null;
391+
emailInput?.props.onChange?.({ target: { value: "new@example.com" } });
392+
expect(onEmailChange).toHaveBeenCalledWith("new@example.com");
393+
});
394+
395+
it("regenerate path: empty email still allowed (account exists, no `/v1/accounts` re-call)", () => {
396+
const tree = serialize(
397+
StripeSettingsView(
398+
defaultProps({
399+
stripeAccountId: ACCOUNT_ID,
400+
stripeStatus: "pending",
401+
email: "",
402+
}),
403+
),
404+
);
405+
const btn = findBySlot(tree, "stripe-settings-regenerate") as {
406+
props: { disabled?: boolean };
407+
} | null;
408+
expect(btn?.props.disabled).toBe(false);
409+
});
410+
});
411+
330412
describe("StripeSettingsView — generated URL display", () => {
331413
it("when `accountLink` is set, renders the URL in a read-only input", () => {
332414
const tree = serialize(

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

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ export type StripeStatus = "pending" | "ready" | "disabled";
7070
export type StripeSettingsViewProps = {
7171
/** Tenant name for the page header. */
7272
tenantName: string;
73+
/**
74+
* Email du restaurant utilisé pour pré-remplir le compte Stripe Connect
75+
* Express (champ `email` requis par l'API `/v1/accounts`). Saisi par
76+
* l'opérateur sur cette page parce qu'aucun « prospect » n'existe pour
77+
* un tenant créé hors wizard. Le bouton « Générer » reste désactivé tant
78+
* que `email` est vide ou syntaxiquement invalide.
79+
*/
80+
email: string;
81+
/** Setter contrôlé de l'email (parent owns state). */
82+
onEmailChange: (value: string) => void;
7383
/**
7484
* The Stripe Connect Express account id (`acct_xxx`) stamped on the
7585
* tenant once the action has been fired at least once. `undefined`
@@ -179,11 +189,15 @@ function statusCopy(
179189
// Component
180190
// ---------------------------------------------------------------------------
181191

192+
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
193+
182194
export function StripeSettingsView(
183195
props: StripeSettingsViewProps,
184196
): React.JSX.Element {
185197
const {
186198
tenantName,
199+
email,
200+
onEmailChange,
187201
stripeAccountId,
188202
stripeStatus,
189203
accountLink,
@@ -196,6 +210,12 @@ export function StripeSettingsView(
196210
const status = statusCopy(stripeAccountId, stripeStatus);
197211
const hasAccount = stripeAccountId !== undefined;
198212
const hasUrl = accountLink !== null;
213+
const isEmailValid = EMAIL_REGEX.test(email.trim());
214+
// Email n'est requis qu'à la PREMIÈRE génération (création du compte
215+
// Stripe). Une régénération ne crée qu'un nouveau `account_link` sur
216+
// un compte existant — pas de re-passage par `/v1/accounts`, donc
217+
// l'email du tenant n'est pas relu.
218+
const disableGenerate = isGenerating || (!hasAccount && !isEmailValid);
199219

200220
return (
201221
<div
@@ -251,6 +271,30 @@ export function StripeSettingsView(
251271
) : null}
252272
</Card>
253273

274+
{/* Email du restaurant — requis par Stripe `/v1/accounts` (`business
275+
email`). Pas de prospect ici (a posteriori), donc on demande à
276+
l'opérateur. Le bouton « Générer » reste désactivé tant que la
277+
syntaxe email n'est pas valide. */}
278+
{!hasAccount ? (
279+
<div className="flex flex-col gap-1.5">
280+
<Label htmlFor="stripe-settings-email">Email du restaurant</Label>
281+
<Input
282+
id="stripe-settings-email"
283+
data-slot="stripe-settings-email"
284+
type="email"
285+
value={email}
286+
onChange={(e) => onEmailChange(e.target.value)}
287+
placeholder="contact@le-petit-bistrot.fr"
288+
autoComplete="email"
289+
/>
290+
<p className="text-xs text-muted-foreground">
291+
Cet email sera attaché au compte Stripe Connect (reçus,
292+
notifications Stripe). Le KYC personnel du gérant sera collecté plus
293+
tard par Stripe lors de l&apos;onboarding.
294+
</p>
295+
</div>
296+
) : null}
297+
254298
{/* Generate / Regenerate CTA */}
255299
{hasAccount ? (
256300
<div className="flex items-center justify-start">
@@ -259,7 +303,7 @@ export function StripeSettingsView(
259303
variant="outline"
260304
data-slot="stripe-settings-regenerate"
261305
onClick={onGenerate}
262-
disabled={isGenerating}
306+
disabled={disableGenerate}
263307
>
264308
{isGenerating ? "Génération…" : "Régénérer un lien Stripe Connect"}
265309
</Button>
@@ -270,7 +314,7 @@ export function StripeSettingsView(
270314
type="button"
271315
data-slot="stripe-settings-generate"
272316
onClick={onGenerate}
273-
disabled={isGenerating}
317+
disabled={disableGenerate}
274318
>
275319
{isGenerating ? "Génération…" : "Générer un lien Stripe Connect"}
276320
</Button>

0 commit comments

Comments
 (0)