Retry signup tests with Stripe CLI
This commit is contained in:
parent
3d4297f330
commit
c481f2c577
19 changed files with 1280 additions and 40 deletions
1
.superpowers/brainstorm/12949-1773480945/.server.pid
Normal file
1
.superpowers/brainstorm/12949-1773480945/.server.pid
Normal file
|
|
@ -0,0 +1 @@
|
|||
12949
|
||||
1
.superpowers/brainstorm/14566-1773480981/.server-stopped
Normal file
1
.superpowers/brainstorm/14566-1773480981/.server-stopped
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"reason":"idle timeout","timestamp":1773483820241}
|
||||
1
.superpowers/brainstorm/14566-1773480981/.server.pid
Normal file
1
.superpowers/brainstorm/14566-1773480981/.server.pid
Normal file
|
|
@ -0,0 +1 @@
|
|||
14566
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<h2>Tablo Details — No-Code Overview Builder (V1)</h2>
|
||||
<p class="subtitle">
|
||||
Proposed interaction model: same page, toggle edit mode, drag blocks inside fixed zones.
|
||||
</p>
|
||||
|
||||
<div class="section">
|
||||
<div class="options">
|
||||
<div class="option selected" data-choice="ux-flow" onclick="toggleSelect(this)">
|
||||
<div class="letter">A</div>
|
||||
<div class="content">
|
||||
<h3>View/Edit Toggle + Auto-Save</h3>
|
||||
<p>
|
||||
"Edit layout" appears for allowed users. Drag-and-drop saves instantly after each drop.
|
||||
"Reset default" restores the initial layout.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">Normal view (for all members)</div>
|
||||
<div class="mockup-body">
|
||||
<div class="mock-nav">Header + metadata + tabs</div>
|
||||
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 12px; margin-top: 12px;">
|
||||
<div class="placeholder" style="min-height: 50px;">Left Zone: Description</div>
|
||||
<div class="placeholder" style="min-height: 50px;">Right Zone: Files</div>
|
||||
<div class="placeholder" style="min-height: 50px;">Left Zone: Mes taches</div>
|
||||
<div class="placeholder" style="min-height: 50px;">Right Zone: Informations</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">Edit mode (admins + org members)</div>
|
||||
<div class="mockup-body">
|
||||
<div class="mock-nav">Header + metadata + tabs + "Done editing"</div>
|
||||
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 12px; margin-top: 12px;">
|
||||
<div class="placeholder" style="min-height: 50px;">
|
||||
Left Zone
|
||||
<div style="font-size: 12px; margin-top: 6px;">[::] Description</div>
|
||||
<div style="font-size: 12px; margin-top: 4px;">[::] Mes taches</div>
|
||||
</div>
|
||||
<div class="placeholder" style="min-height: 50px;">
|
||||
Right Zone
|
||||
<div style="font-size: 12px; margin-top: 6px;">[::] Fichiers</div>
|
||||
<div style="font-size: 12px; margin-top: 4px;">[::] Informations</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<button class="mock-button">Reset default</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
3
.superpowers/brainstorm/14566-1773480981/waiting-1.html
Normal file
3
.superpowers/brainstorm/14566-1773480981/waiting-1.html
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
|
||||
<p class="subtitle">Continuing in terminal...</p>
|
||||
</div>
|
||||
|
|
@ -3,7 +3,7 @@ import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext";
|
|||
import { Toaster } from "@xtablo/ui/components/sonner";
|
||||
import { BrowserRouter as Router, useRoutes } from "react-router-dom";
|
||||
import { CookieBanner } from "./components/CookieBanner";
|
||||
import { PendingFounderCheckout } from "./components/PendingFounderCheckout";
|
||||
import { PendingSignupCheckout } from "./components/PendingSignupCheckout";
|
||||
import { TrialUpsellModal } from "./components/TrialUpsellModal";
|
||||
import { UpgradePanel } from "./components/UpgradePanel";
|
||||
import { UpgradeBlockProvider } from "./contexts/UpgradeBlockContext";
|
||||
|
|
@ -30,7 +30,7 @@ const Routes = () => {
|
|||
return (
|
||||
<UserStoreProvider>
|
||||
<UpgradeBlockProvider>
|
||||
<PendingFounderCheckout />
|
||||
<PendingSignupCheckout />
|
||||
<UpgradePanel />
|
||||
<TrialUpsellModal />
|
||||
{appElement}
|
||||
|
|
|
|||
118
apps/main/src/components/PendingSignupCheckout.test.tsx
Normal file
118
apps/main/src/components/PendingSignupCheckout.test.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { render, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PendingSignupCheckout } from "./PendingSignupCheckout";
|
||||
|
||||
const mockCreateCheckout = vi.fn();
|
||||
|
||||
vi.mock("../hooks/organization", () => ({
|
||||
useOrganization: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/stripe", () => ({
|
||||
useCreateCheckoutSession: () => ({
|
||||
mutate: mockCreateCheckout,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../providers/UserStoreProvider", () => ({
|
||||
useMaybeUser: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("PendingSignupCheckout", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("creates checkout for pending solo", async () => {
|
||||
const { useOrganization } = await import("../hooks/organization");
|
||||
const { useMaybeUser } = await import("../providers/UserStoreProvider");
|
||||
|
||||
vi.mocked(useMaybeUser).mockReturnValue({ id: "user-1" } as never);
|
||||
vi.mocked(useOrganization).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: {
|
||||
active_subscription_plan: null,
|
||||
is_billing_owner: true,
|
||||
},
|
||||
} as never);
|
||||
|
||||
localStorage.setItem("pendingBillingCheckoutPlan", "solo");
|
||||
render(<PendingSignupCheckout />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateCheckout).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ plan: "solo" }),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("creates checkout for pending team", async () => {
|
||||
const { useOrganization } = await import("../hooks/organization");
|
||||
const { useMaybeUser } = await import("../providers/UserStoreProvider");
|
||||
|
||||
vi.mocked(useMaybeUser).mockReturnValue({ id: "user-1" } as never);
|
||||
vi.mocked(useOrganization).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: {
|
||||
active_subscription_plan: null,
|
||||
is_billing_owner: true,
|
||||
},
|
||||
} as never);
|
||||
|
||||
localStorage.setItem("pendingBillingCheckoutPlan", "team");
|
||||
render(<PendingSignupCheckout />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateCheckout).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ plan: "team" }),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("clears founder pending checkout when annual plan is already active", async () => {
|
||||
const { useOrganization } = await import("../hooks/organization");
|
||||
const { useMaybeUser } = await import("../providers/UserStoreProvider");
|
||||
|
||||
vi.mocked(useMaybeUser).mockReturnValue({ id: "user-1" } as never);
|
||||
vi.mocked(useOrganization).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: {
|
||||
active_subscription_plan: "annual",
|
||||
is_billing_owner: true,
|
||||
},
|
||||
} as never);
|
||||
|
||||
localStorage.setItem("pendingBillingCheckoutPlan", "founder");
|
||||
render(<PendingSignupCheckout />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("pendingBillingCheckoutPlan")).toBeNull();
|
||||
expect(mockCreateCheckout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("clears invalid pending checkout value", async () => {
|
||||
const { useOrganization } = await import("../hooks/organization");
|
||||
const { useMaybeUser } = await import("../providers/UserStoreProvider");
|
||||
|
||||
vi.mocked(useMaybeUser).mockReturnValue({ id: "user-1" } as never);
|
||||
vi.mocked(useOrganization).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: {
|
||||
active_subscription_plan: null,
|
||||
is_billing_owner: true,
|
||||
},
|
||||
} as never);
|
||||
|
||||
localStorage.setItem("pendingBillingCheckoutPlan", "invalid");
|
||||
render(<PendingSignupCheckout />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("pendingBillingCheckoutPlan")).toBeNull();
|
||||
expect(mockCreateCheckout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { useOrganization } from "../hooks/organization";
|
||||
import { useCreateCheckoutSession } from "../hooks/stripe";
|
||||
import { PENDING_BILLING_CHECKOUT_PLAN_KEY } from "../lib/billing";
|
||||
import {
|
||||
PENDING_BILLING_CHECKOUT_PLAN_KEY,
|
||||
isSignupBillingIntent,
|
||||
satisfiesPendingCheckoutPlan,
|
||||
} from "../lib/billing";
|
||||
import { useMaybeUser } from "../providers/UserStoreProvider";
|
||||
|
||||
export function PendingFounderCheckout() {
|
||||
export function PendingSignupCheckout() {
|
||||
const user = useMaybeUser();
|
||||
const { data: organizationData, isLoading } = useOrganization();
|
||||
const { mutate: createCheckout } = useCreateCheckoutSession();
|
||||
|
|
@ -14,12 +18,17 @@ export function PendingFounderCheckout() {
|
|||
if (hasTriggered.current) return;
|
||||
if (!user || isLoading || !organizationData) return;
|
||||
|
||||
const pendingCheckout = localStorage.getItem(PENDING_BILLING_CHECKOUT_PLAN_KEY);
|
||||
if (pendingCheckout !== "founder") {
|
||||
const pendingCheckoutValue = localStorage.getItem(PENDING_BILLING_CHECKOUT_PLAN_KEY);
|
||||
if (!isSignupBillingIntent(pendingCheckoutValue)) {
|
||||
if (pendingCheckoutValue) {
|
||||
localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (organizationData.active_subscription_plan === "annual") {
|
||||
if (
|
||||
satisfiesPendingCheckoutPlan(pendingCheckoutValue, organizationData.active_subscription_plan)
|
||||
) {
|
||||
localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY);
|
||||
return;
|
||||
}
|
||||
|
|
@ -32,13 +41,13 @@ export function PendingFounderCheckout() {
|
|||
localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY);
|
||||
createCheckout(
|
||||
{
|
||||
plan: "founder",
|
||||
plan: pendingCheckoutValue,
|
||||
successUrl: `${window.location.origin}/settings?success=true`,
|
||||
cancelUrl: `${window.location.origin}/settings?canceled=true`,
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
localStorage.setItem(PENDING_BILLING_CHECKOUT_PLAN_KEY, "founder");
|
||||
localStorage.setItem(PENDING_BILLING_CHECKOUT_PLAN_KEY, pendingCheckoutValue);
|
||||
hasTriggered.current = false;
|
||||
},
|
||||
}
|
||||
16
apps/main/src/hooks/auth.signup.test.ts
Normal file
16
apps/main/src/hooks/auth.signup.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSignupBillingIntent } from "./auth";
|
||||
|
||||
describe("resolveSignupBillingIntent", () => {
|
||||
it("defaults to solo when billing intent is missing", () => {
|
||||
expect(resolveSignupBillingIntent()).toBe("solo");
|
||||
});
|
||||
|
||||
it("returns team when selected", () => {
|
||||
expect(resolveSignupBillingIntent("team")).toBe("team");
|
||||
});
|
||||
|
||||
it("returns founder when selected", () => {
|
||||
expect(resolveSignupBillingIntent("founder")).toBe("founder");
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ import { useNavigate } from "react-router-dom";
|
|||
import { match } from "ts-pattern";
|
||||
import { api } from "../lib/api";
|
||||
import {
|
||||
DEFAULT_SIGNUP_BILLING_INTENT,
|
||||
PENDING_BILLING_CHECKOUT_PLAN_KEY,
|
||||
SIGNUP_BILLING_INTENT_KEY,
|
||||
SignupBillingIntent,
|
||||
|
|
@ -44,6 +45,10 @@ interface AuthResponse {
|
|||
session: Session | null;
|
||||
}
|
||||
|
||||
export const resolveSignupBillingIntent = (
|
||||
billingIntent?: SignupBillingIntent
|
||||
): SignupBillingIntent => billingIntent ?? DEFAULT_SIGNUP_BILLING_INTENT;
|
||||
|
||||
export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) {
|
||||
const navigate = useNavigate();
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
|
@ -63,7 +68,7 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) {
|
|||
first_name: data.first_name,
|
||||
last_name: data.last_name,
|
||||
business_name: data.business_name,
|
||||
billing_intent: data.billing_intent ?? "trial",
|
||||
billing_intent: resolveSignupBillingIntent(data.billing_intent),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -76,15 +81,9 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) {
|
|||
return response;
|
||||
},
|
||||
onSuccess: async (data, variables) => {
|
||||
const wantsFounderCheckout = variables.billing_intent === "founder";
|
||||
|
||||
if (wantsFounderCheckout) {
|
||||
localStorage.setItem(SIGNUP_BILLING_INTENT_KEY, "founder");
|
||||
localStorage.setItem(PENDING_BILLING_CHECKOUT_PLAN_KEY, "founder");
|
||||
} else {
|
||||
localStorage.removeItem(SIGNUP_BILLING_INTENT_KEY);
|
||||
localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY);
|
||||
}
|
||||
const selectedPlan = resolveSignupBillingIntent(variables.billing_intent);
|
||||
localStorage.setItem(SIGNUP_BILLING_INTENT_KEY, selectedPlan);
|
||||
localStorage.setItem(PENDING_BILLING_CHECKOUT_PLAN_KEY, selectedPlan);
|
||||
|
||||
// If there's no session, it means email confirmation is required
|
||||
if (!data.user?.email_confirmed_at) {
|
||||
|
|
@ -94,12 +93,12 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (wantsFounderCheckout && data.session?.access_token) {
|
||||
if (data.session?.access_token) {
|
||||
try {
|
||||
const checkoutResponse = await api.post(
|
||||
"/api/v1/stripe/create-checkout-session",
|
||||
{
|
||||
plan: "founder",
|
||||
plan: selectedPlan,
|
||||
successUrl: `${window.location.origin}/settings?success=true`,
|
||||
cancelUrl: `${window.location.origin}/settings?canceled=true`,
|
||||
},
|
||||
|
|
@ -117,9 +116,9 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) {
|
|||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Founder checkout bootstrap failed after signup:", error);
|
||||
console.error("Signup checkout bootstrap failed after signup:", error);
|
||||
toast.add({
|
||||
title: "Paiement Founder",
|
||||
title: "Paiement",
|
||||
description:
|
||||
"Votre compte est créé, mais la redirection vers le paiement a échoué. Vous pourrez finaliser le paiement dans l'application.",
|
||||
type: "warning",
|
||||
|
|
@ -129,9 +128,6 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) {
|
|||
}
|
||||
|
||||
localStorage.removeItem(SIGNUP_BILLING_INTENT_KEY);
|
||||
if (!wantsFounderCheckout) {
|
||||
localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY);
|
||||
}
|
||||
|
||||
if (redirectUrl) {
|
||||
localStorage.removeItem("redirectUrl");
|
||||
|
|
|
|||
30
apps/main/src/lib/billing.test.ts
Normal file
30
apps/main/src/lib/billing.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_SIGNUP_BILLING_INTENT,
|
||||
isSignupBillingIntent,
|
||||
satisfiesPendingCheckoutPlan,
|
||||
} from "./billing";
|
||||
|
||||
describe("billing helpers", () => {
|
||||
it("defaults signup intent to solo", () => {
|
||||
expect(DEFAULT_SIGNUP_BILLING_INTENT).toBe("solo");
|
||||
});
|
||||
|
||||
it("recognizes only solo, team and founder as signup intents", () => {
|
||||
expect(isSignupBillingIntent("solo")).toBe(true);
|
||||
expect(isSignupBillingIntent("team")).toBe(true);
|
||||
expect(isSignupBillingIntent("founder")).toBe(true);
|
||||
expect(isSignupBillingIntent("trial")).toBe(false);
|
||||
});
|
||||
|
||||
it("computes checkout satisfaction against active subscription plan", () => {
|
||||
expect(satisfiesPendingCheckoutPlan("founder", "annual")).toBe(true);
|
||||
expect(satisfiesPendingCheckoutPlan("founder", "team")).toBe(false);
|
||||
expect(satisfiesPendingCheckoutPlan("team", "team")).toBe(true);
|
||||
expect(satisfiesPendingCheckoutPlan("team", "annual")).toBe(true);
|
||||
expect(satisfiesPendingCheckoutPlan("solo", "solo")).toBe(true);
|
||||
expect(satisfiesPendingCheckoutPlan("solo", "team")).toBe(true);
|
||||
expect(satisfiesPendingCheckoutPlan("solo", "annual")).toBe(true);
|
||||
expect(satisfiesPendingCheckoutPlan("solo", null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,28 @@
|
|||
export type SignupBillingIntent = "trial" | "founder";
|
||||
export type SignupBillingIntent = "solo" | "team" | "founder";
|
||||
|
||||
export const DEFAULT_SIGNUP_BILLING_INTENT: SignupBillingIntent = "solo";
|
||||
|
||||
export const SIGNUP_BILLING_INTENT_KEY = "signupBillingIntent";
|
||||
export const PENDING_BILLING_CHECKOUT_PLAN_KEY = "pendingBillingCheckoutPlan";
|
||||
|
||||
export const isSignupBillingIntent = (value: string | null): value is SignupBillingIntent =>
|
||||
value === "solo" || value === "team" || value === "founder";
|
||||
|
||||
export const satisfiesPendingCheckoutPlan = (
|
||||
pendingPlan: SignupBillingIntent,
|
||||
activePlan: "solo" | "team" | "annual" | null
|
||||
): boolean => {
|
||||
if (!activePlan) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pendingPlan === "founder") {
|
||||
return activePlan === "annual";
|
||||
}
|
||||
|
||||
if (pendingPlan === "team") {
|
||||
return activePlan === "team" || activePlan === "annual";
|
||||
}
|
||||
|
||||
return activePlan === "solo" || activePlan === "team" || activePlan === "annual";
|
||||
};
|
||||
|
|
|
|||
81
apps/main/src/pages/signup-v2.test.tsx
Normal file
81
apps/main/src/pages/signup-v2.test.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "../utils/testHelpers";
|
||||
import { SignUpV2Page } from "./signup-v2";
|
||||
|
||||
const mockSignUp = vi.fn();
|
||||
|
||||
vi.mock("../hooks/auth", () => ({
|
||||
useSignUp: () => ({
|
||||
mutate: mockSignUp,
|
||||
isPending: false,
|
||||
}),
|
||||
useLoginEmail: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useLoginGoogle: () => ({
|
||||
mutate: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("SignUpV2Page", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const fillAndSubmitForm = () => {
|
||||
fireEvent.change(screen.getByPlaceholderText(/your first name/i), {
|
||||
target: { value: "John" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/your last name/i), {
|
||||
target: { value: "Doe" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/your email/i), {
|
||||
target: { value: "john@example.com" },
|
||||
});
|
||||
fireEvent.change(screen.getAllByPlaceholderText(/your password/i)[0], {
|
||||
target: { value: "password123" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/confirm your password/i), {
|
||||
target: { value: "password123" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /i accept/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /create my account/i }));
|
||||
};
|
||||
|
||||
it("submits solo billing intent by default", async () => {
|
||||
renderWithProviders(<SignUpV2Page />);
|
||||
|
||||
fillAndSubmitForm();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSignUp).toHaveBeenCalledWith(expect.objectContaining({ billing_intent: "solo" }));
|
||||
});
|
||||
});
|
||||
|
||||
it("submits team billing intent when selected", async () => {
|
||||
renderWithProviders(<SignUpV2Page />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /team/i }));
|
||||
fillAndSubmitForm();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSignUp).toHaveBeenCalledWith(expect.objectContaining({ billing_intent: "team" }));
|
||||
});
|
||||
});
|
||||
|
||||
it("submits founder billing intent when selected", async () => {
|
||||
renderWithProviders(<SignUpV2Page />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /founder/i }));
|
||||
fillAndSubmitForm();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSignUp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ billing_intent: "founder" })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -10,7 +10,7 @@ import { useState } from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useSignUp } from "../hooks/auth";
|
||||
import type { SignupBillingIntent } from "../lib/billing";
|
||||
import { DEFAULT_SIGNUP_BILLING_INTENT, type SignupBillingIntent } from "../lib/billing";
|
||||
|
||||
export function SignUpV2Page() {
|
||||
const { t } = useTranslation(["auth", "common"]);
|
||||
|
|
@ -30,7 +30,9 @@ export function SignUpV2Page() {
|
|||
business_name: "",
|
||||
});
|
||||
const [termsAccepted, setTermsAccepted] = useState(false);
|
||||
const [billingIntent, setBillingIntent] = useState<SignupBillingIntent>("trial");
|
||||
const [billingIntent, setBillingIntent] = useState<SignupBillingIntent>(
|
||||
DEFAULT_SIGNUP_BILLING_INTENT
|
||||
);
|
||||
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
|
|
@ -241,16 +243,30 @@ export function SignUpV2Page() {
|
|||
<div className="grid gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingIntent("trial")}
|
||||
onClick={() => setBillingIntent("solo")}
|
||||
className={`w-full rounded-md border p-3 text-left transition-colors ${
|
||||
billingIntent === "trial"
|
||||
billingIntent === "solo"
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium">Trial (14 jours)</p>
|
||||
<p className="text-sm font-medium">Solo (9.99€/mois)</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Plan Solo ou Teams après la période d'essai.
|
||||
Pour les indépendants et petites équipes.
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingIntent("team")}
|
||||
className={`w-full rounded-md border p-3 text-left transition-colors ${
|
||||
billingIntent === "team"
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium">Team (29€/mois)</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Collaboration avancée pour votre organisation.
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -193,11 +193,42 @@ describe("SignUpPage", () => {
|
|||
last_name: "Doe",
|
||||
confirm_password: "password123",
|
||||
business_name: "",
|
||||
billing_intent: "trial",
|
||||
billing_intent: "solo",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("submits team billing intent when selected", async () => {
|
||||
renderWithProviders(<SignUpPage />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /team/i }));
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: /create my account/i });
|
||||
const termsCheckbox = screen.getByRole("checkbox", { name: /i accept/i });
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/your first name/i), {
|
||||
target: { value: "John" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/your last name/i), {
|
||||
target: { value: "Doe" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/your email/i), {
|
||||
target: { value: "john@example.com" },
|
||||
});
|
||||
fireEvent.change(screen.getAllByPlaceholderText(/your password/i)[0], {
|
||||
target: { value: "password123" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/confirm your password/i), {
|
||||
target: { value: "password123" },
|
||||
});
|
||||
fireEvent.click(termsCheckbox);
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSignUp).toHaveBeenCalledWith(expect.objectContaining({ billing_intent: "team" }));
|
||||
});
|
||||
});
|
||||
|
||||
it("submits founder billing intent when selected", async () => {
|
||||
renderWithProviders(<SignUpPage />);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
|
|||
import { Link } from "react-router-dom";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useSignUp } from "../hooks/auth";
|
||||
import type { SignupBillingIntent } from "../lib/billing";
|
||||
import { DEFAULT_SIGNUP_BILLING_INTENT, type SignupBillingIntent } from "../lib/billing";
|
||||
|
||||
export function SignUpPage() {
|
||||
const { t } = useTranslation(["auth", "common"]);
|
||||
|
|
@ -31,7 +31,9 @@ export function SignUpPage() {
|
|||
business_name: "",
|
||||
});
|
||||
const [termsAccepted, setTermsAccepted] = useState(false);
|
||||
const [billingIntent, setBillingIntent] = useState<SignupBillingIntent>("trial");
|
||||
const [billingIntent, setBillingIntent] = useState<SignupBillingIntent>(
|
||||
DEFAULT_SIGNUP_BILLING_INTENT
|
||||
);
|
||||
|
||||
// 3D Parallax effect
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -327,17 +329,32 @@ export function SignUpPage() {
|
|||
<div className="grid gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingIntent("trial")}
|
||||
onClick={() => setBillingIntent("solo")}
|
||||
className={twMerge(
|
||||
"w-full rounded-md border p-3 text-left transition-colors",
|
||||
billingIntent === "trial"
|
||||
billingIntent === "solo"
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:bg-muted/40"
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium">Trial (14 jours)</p>
|
||||
<p className="text-sm font-medium">Solo (9.99€/mois)</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Plan Solo ou Teams après la période d'essai.
|
||||
Pour les indépendants et petites équipes.
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingIntent("team")}
|
||||
className={twMerge(
|
||||
"w-full rounded-md border p-3 text-left transition-colors",
|
||||
billingIntent === "team"
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:bg-muted/40"
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium">Team (29€/mois)</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Collaboration avancée pour votre organisation.
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -0,0 +1,394 @@
|
|||
# Tablo Overview No-Code Builder V1 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let authorized users reorder the `Aperçu` blocks in `tablo-details` via drag-and-drop (fixed left/right zones), auto-save per tablo, and share the same layout with all viewers.
|
||||
|
||||
**Architecture:** Persist a typed `layout_overview_v1` JSON payload on `public.tablos`, then render overview blocks from a sanitized layout model instead of hardcoded JSX order. Add a small DnD interaction layer (same-zone only) with optimistic UI + rollback on save failure. Keep scope strictly to reorder existing overview blocks (`description`, `myTasks`, `files`, `info`) with a reset-to-default action.
|
||||
|
||||
**Tech Stack:** Supabase migrations + pgTAP, TypeScript, React 19, React Query, Vitest, Testing Library
|
||||
|
||||
---
|
||||
|
||||
## Spec Reference
|
||||
|
||||
- `/Users/arthur.belleville/Documents/perso/projects/xtablo-source/docs/superpowers/specs/2026-03-14-tablo-overview-nocode-builder-design.md`
|
||||
|
||||
## Scope Guardrails (YAGNI)
|
||||
|
||||
- No new widget types in V1.
|
||||
- No hide/show or resize in V1.
|
||||
- No cross-zone moves in V1.
|
||||
- No changes to non-`Aperçu` tabs.
|
||||
|
||||
## File Structure (lock boundaries before coding)
|
||||
|
||||
### Database + shared contract
|
||||
|
||||
- Create: `supabase/migrations/20260314110000_add_tablo_overview_layout_v1.sql`
|
||||
- Responsibility: add nullable JSONB `layout_overview_v1` column on `public.tablos`.
|
||||
- Modify: `supabase/tests/database/01_schema_structure.test.sql`
|
||||
- Responsibility: assert new column exists and type is `jsonb` (schema contract safety).
|
||||
- Modify: `packages/shared-types/src/database.types.ts` (generated)
|
||||
- Responsibility: include `layout_overview_v1` in `tablos` Row/Insert/Update types.
|
||||
|
||||
### Frontend layout domain + UI
|
||||
|
||||
- Create: `apps/main/src/pages/tablo-details/overviewLayout.ts`
|
||||
- Responsibility: typed layout model, defaults, sanitizer, serialization helpers.
|
||||
- Create: `apps/main/src/pages/tablo-details/overviewLayout.test.ts`
|
||||
- Responsibility: unit tests for sanitize/default/recovery behavior.
|
||||
- Create: `apps/main/src/pages/tablo-details/overviewReorder.ts`
|
||||
- Responsibility: pure reorder helpers (same-zone only, immutable updates).
|
||||
- Create: `apps/main/src/pages/tablo-details/overviewReorder.test.ts`
|
||||
- Responsibility: reorder correctness and invalid-move behavior tests.
|
||||
- Modify: `apps/main/src/pages/tablo-details.tsx`
|
||||
- Responsibility: edit-mode UX, draggable rendering, optimistic save, rollback, reset.
|
||||
- Create: `apps/main/src/pages/tablo-details.layout.test.tsx`
|
||||
- Responsibility: behavior tests for default render, persisted render, save payload, rollback, reset.
|
||||
|
||||
### API confidence
|
||||
|
||||
- Modify: `apps/api/src/__tests__/routes/tablo.test.ts`
|
||||
- Responsibility: ensure `PATCH /tablos/update` accepts/saves `layout_overview_v1`.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Data Contract and Persistence Foundation
|
||||
|
||||
### Task 1.1: Add DB column with schema test first
|
||||
|
||||
**Files:**
|
||||
- Create: `supabase/migrations/20260314110000_add_tablo_overview_layout_v1.sql`
|
||||
- Modify: `supabase/tests/database/01_schema_structure.test.sql`
|
||||
- Test: `supabase/tests/database/01_schema_structure.test.sql`
|
||||
|
||||
- [ ] **Step 1: Write the failing schema test**
|
||||
|
||||
```sql
|
||||
-- Add near other tablos column checks
|
||||
SELECT has_column('public', 'tablos', 'layout_overview_v1', 'tablos should have layout_overview_v1 column');
|
||||
SELECT col_type_is('public', 'tablos', 'layout_overview_v1', 'jsonb', 'tablos.layout_overview_v1 should be jsonb');
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update pgTAP plan count**
|
||||
|
||||
```sql
|
||||
select plan(99); -- was 97, +2 tests for new column
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run schema test and verify it fails before migration**
|
||||
|
||||
Run: `supabase test db --file supabase/tests/database/01_schema_structure.test.sql`
|
||||
Expected: FAIL mentioning missing `layout_overview_v1` column.
|
||||
|
||||
- [ ] **Step 4: Add migration**
|
||||
|
||||
```sql
|
||||
alter table public.tablos
|
||||
add column if not exists layout_overview_v1 jsonb;
|
||||
|
||||
comment on column public.tablos.layout_overview_v1 is
|
||||
'Per-tablo overview layout config (v1) for tablo-details Aperçu drag-and-drop order.';
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Re-run schema test to verify pass**
|
||||
|
||||
Run: `supabase test db --file supabase/tests/database/01_schema_structure.test.sql`
|
||||
Expected: PASS for new column and type checks.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add supabase/migrations/20260314110000_add_tablo_overview_layout_v1.sql supabase/tests/database/01_schema_structure.test.sql
|
||||
git commit -m "feat(db): add tablo overview layout json column"
|
||||
```
|
||||
|
||||
### Task 1.2: Regenerate shared DB types and ensure compile contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/shared-types/src/database.types.ts`
|
||||
- Test: `packages/shared-types/src/database.types.ts` (type presence check via typecheck)
|
||||
|
||||
- [ ] **Step 1: Regenerate database types from local Supabase**
|
||||
|
||||
Run: `supabase gen types typescript --local > packages/shared-types/src/database.types.ts`
|
||||
Expected: command completes with no stderr.
|
||||
|
||||
- [ ] **Step 2: Verify `layout_overview_v1` appears in `tablos` types**
|
||||
|
||||
Run: `rg -n "layout_overview_v1" packages/shared-types/src/database.types.ts`
|
||||
Expected: matches in `Row`, `Insert`, and `Update` for `tablos`.
|
||||
|
||||
- [ ] **Step 3: Run focused typecheck**
|
||||
|
||||
Run: `pnpm --filter @xtablo/main typecheck`
|
||||
Expected: PASS (or unrelated pre-existing failures only).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/shared-types/src/database.types.ts
|
||||
git commit -m "chore(types): regenerate shared db types for overview layout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Frontend Layout Model + UI (TDD-first)
|
||||
|
||||
### Task 2.1: Add pure layout model and sanitizer with tests
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/main/src/pages/tablo-details/overviewLayout.ts`
|
||||
- Create: `apps/main/src/pages/tablo-details/overviewLayout.test.ts`
|
||||
- Test: `apps/main/src/pages/tablo-details/overviewLayout.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing unit tests for sanitize/default behavior**
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_OVERVIEW_LAYOUT, sanitizeOverviewLayout } from "./overviewLayout";
|
||||
|
||||
describe("sanitizeOverviewLayout", () => {
|
||||
it("returns default when input is null", () => {
|
||||
expect(sanitizeOverviewLayout(null)).toEqual(DEFAULT_OVERVIEW_LAYOUT);
|
||||
});
|
||||
|
||||
it("drops unknown block ids and restores required ids", () => {
|
||||
expect(
|
||||
sanitizeOverviewLayout({
|
||||
version: 1,
|
||||
leftZone: ["description", "unknown"],
|
||||
rightZone: [],
|
||||
})
|
||||
).toEqual({
|
||||
version: 1,
|
||||
leftZone: ["description", "myTasks"],
|
||||
rightZone: ["files", "info"],
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to confirm failure**
|
||||
|
||||
Run: `pnpm --filter @xtablo/main test -- src/pages/tablo-details/overviewLayout.test.ts`
|
||||
Expected: FAIL because module/functions do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement minimal layout model and sanitizer**
|
||||
|
||||
```ts
|
||||
export const OVERVIEW_LEFT_BLOCKS = ["description", "myTasks"] as const;
|
||||
export const OVERVIEW_RIGHT_BLOCKS = ["files", "info"] as const;
|
||||
export const DEFAULT_OVERVIEW_LAYOUT = { version: 1 as const, leftZone: [...OVERVIEW_LEFT_BLOCKS], rightZone: [...OVERVIEW_RIGHT_BLOCKS] };
|
||||
|
||||
export function sanitizeOverviewLayout(input: unknown): OverviewLayoutV1 {
|
||||
// Parse object safely, keep only allowed ids, dedupe, reinsert required ids.
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Re-run test to verify pass**
|
||||
|
||||
Run: `pnpm --filter @xtablo/main test -- src/pages/tablo-details/overviewLayout.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/main/src/pages/tablo-details/overviewLayout.ts apps/main/src/pages/tablo-details/overviewLayout.test.ts
|
||||
git commit -m "feat(main): add overview layout schema and sanitizer"
|
||||
```
|
||||
|
||||
### Task 2.2: Add pure reorder helpers with tests
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/main/src/pages/tablo-details/overviewReorder.ts`
|
||||
- Create: `apps/main/src/pages/tablo-details/overviewReorder.test.ts`
|
||||
- Test: `apps/main/src/pages/tablo-details/overviewReorder.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for same-zone reorder and cross-zone block**
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { moveWithinZone } from "./overviewReorder";
|
||||
|
||||
it("reorders items within a zone", () => {
|
||||
expect(moveWithinZone(["a", "b", "c"], 0, 2)).toEqual(["b", "c", "a"]);
|
||||
});
|
||||
|
||||
it("returns original list on invalid indices", () => {
|
||||
expect(moveWithinZone(["a"], 0, 3)).toEqual(["a"]);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to confirm failure**
|
||||
|
||||
Run: `pnpm --filter @xtablo/main test -- src/pages/tablo-details/overviewReorder.test.ts`
|
||||
Expected: FAIL because helper is missing.
|
||||
|
||||
- [ ] **Step 3: Implement minimal immutable reorder helper**
|
||||
|
||||
```ts
|
||||
export function moveWithinZone<T>(items: T[], from: number, to: number): T[] {
|
||||
// Validate bounds, clone array, splice move, return new array.
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Re-run tests to verify pass**
|
||||
|
||||
Run: `pnpm --filter @xtablo/main test -- src/pages/tablo-details/overviewReorder.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/main/src/pages/tablo-details/overviewReorder.ts apps/main/src/pages/tablo-details/overviewReorder.test.ts
|
||||
git commit -m "test(main): cover overview reorder helper"
|
||||
```
|
||||
|
||||
### Task 2.3: Integrate edit mode + DnD + auto-save in `tablo-details.tsx`
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/main/src/pages/tablo-details.tsx`
|
||||
- Create: `apps/main/src/pages/tablo-details.layout.test.tsx`
|
||||
- Test: `apps/main/src/pages/tablo-details.layout.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write failing page test for persisted layout render order**
|
||||
|
||||
```ts
|
||||
it("renders overview blocks in persisted order", async () => {
|
||||
// Mock useTablosList returning tablo.layout_overview_v1 with swapped left-zone order.
|
||||
// Assert rendered block headings follow persisted order.
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write failing page test for save payload on drop**
|
||||
|
||||
```ts
|
||||
it("persists layout_overview_v1 after drop", async () => {
|
||||
// Mock useUpdateTablo mutate; trigger drag/drop; assert mutate payload includes layout_overview_v1.
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests to capture failure**
|
||||
|
||||
Run: `pnpm --filter @xtablo/main test -- src/pages/tablo-details.layout.test.tsx`
|
||||
Expected: FAIL (no layout state/edit mode yet).
|
||||
|
||||
- [ ] **Step 4: Implement UI integration minimally**
|
||||
|
||||
Implementation checklist:
|
||||
- Add `isLayoutEditMode` state and gate by editor permission.
|
||||
- Read `tablo.layout_overview_v1`, sanitize via `sanitizeOverviewLayout`.
|
||||
- Render overview cards from zone arrays (not hardcoded order).
|
||||
- Add drag handlers in edit mode (same-zone reorder only).
|
||||
- On drop: optimistic state update + `useUpdateTablo({ id, layout_overview_v1 })`.
|
||||
- On mutation error: rollback previous layout + toast error.
|
||||
- Add `Reset default layout` button in edit mode.
|
||||
|
||||
- [ ] **Step 5: Re-run test file and fix until green**
|
||||
|
||||
Run: `pnpm --filter @xtablo/main test -- src/pages/tablo-details.layout.test.tsx`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Run nearby regression tests**
|
||||
|
||||
Run: `pnpm --filter @xtablo/main test -- src/pages/tablo.test.tsx`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/main/src/pages/tablo-details.tsx apps/main/src/pages/tablo-details.layout.test.tsx
|
||||
git commit -m "feat(main): add no-code overview reorder mode with autosave"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: API Confidence + End-to-End Verification
|
||||
|
||||
### Task 3.1: Add API route test for layout payload update
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/src/__tests__/routes/tablo.test.ts`
|
||||
- Test: `apps/api/src/__tests__/routes/tablo.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing API test**
|
||||
|
||||
```ts
|
||||
it("should allow owner to update layout_overview_v1", async () => {
|
||||
const res = await updateTabloRequest(ownerUser, client, "test_tablo_owner_private", {
|
||||
layout_overview_v1: {
|
||||
version: 1,
|
||||
leftZone: ["myTasks", "description"],
|
||||
rightZone: ["files", "info"],
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run focused API test to verify behavior**
|
||||
|
||||
Run: `pnpm --filter @xtablo/api test -- src/__tests__/routes/tablo.test.ts`
|
||||
Expected: PASS after migration/types are in place; FAIL before if column not available in test DB.
|
||||
|
||||
- [ ] **Step 3: If failing due missing migration in test DB, run DB reset once**
|
||||
|
||||
Run: `supabase db reset`
|
||||
Expected: migrations re-applied, test DB aligned.
|
||||
|
||||
- [ ] **Step 4: Re-run API test**
|
||||
|
||||
Run: `pnpm --filter @xtablo/api test -- src/__tests__/routes/tablo.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/api/src/__tests__/routes/tablo.test.ts
|
||||
git commit -m "test(api): cover tablo layout_overview_v1 updates"
|
||||
```
|
||||
|
||||
### Task 3.2: Final verification sweep before merge
|
||||
|
||||
**Files:**
|
||||
- Modify: none (verification only)
|
||||
|
||||
- [ ] **Step 1: Run main app tests for changed files**
|
||||
|
||||
Run: `pnpm --filter @xtablo/main test -- src/pages/tablo-details/overviewLayout.test.ts src/pages/tablo-details/overviewReorder.test.ts src/pages/tablo-details.layout.test.tsx`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run API test for changed route test file**
|
||||
|
||||
Run: `pnpm --filter @xtablo/api test -- src/__tests__/routes/tablo.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Run typecheck for touched apps**
|
||||
|
||||
Run: `pnpm --filter @xtablo/main typecheck && pnpm --filter @xtablo/api typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Run format/lint on touched workspaces if needed**
|
||||
|
||||
Run: `pnpm --filter @xtablo/main lint && pnpm --filter @xtablo/api lint`
|
||||
Expected: PASS or only pre-existing unrelated failures.
|
||||
|
||||
- [ ] **Step 5: Squash fixups if needed and prepare PR summary**
|
||||
|
||||
```bash
|
||||
git log --oneline --max-count=8
|
||||
```
|
||||
|
||||
Expected: clean, reviewable commit sequence.
|
||||
|
||||
---
|
||||
|
||||
## Notes for Execution Agent
|
||||
|
||||
- Follow @superpowers/test-driven-development for each code task (tests first).
|
||||
- Before declaring completion, follow @superpowers/verification-before-completion.
|
||||
- Keep edits localized; do not refactor unrelated tabs/components.
|
||||
- If a file grows too large during Task 2.3, extract small focused helpers instead of adding complexity in-place.
|
||||
|
||||
|
|
@ -0,0 +1,389 @@
|
|||
# Pricing Signup Three-Plan Happy-Path Verification Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ensure signup immediately starts Stripe Checkout for `solo` (monthly), `team` (monthly), and `founder` (yearly), then verify all three happy paths pass in Stripe test mode with evidence.
|
||||
|
||||
**Architecture:** Unify signup billing intent to a 3-plan contract (`solo|team|founder`), centralize pending-checkout logic in billing helpers, and make post-signup checkout bootstrap plan-agnostic in auth flow. Keep `create-checkout-session` API as-is (already supports 3 plans), add focused frontend tests for intent submission and pending checkout recovery, then run live manual verification and document outcomes.
|
||||
|
||||
**Tech Stack:** TypeScript, React 19, React Query, Vitest, Testing Library, Stripe test mode, Stripe CLI webhook forwarding
|
||||
|
||||
---
|
||||
|
||||
## Spec Reference
|
||||
|
||||
- `/Users/arthur.belleville/Documents/perso/projects/xtablo-source/docs/superpowers/specs/2026-03-15-pricing-signup-three-plan-happy-path-verification-design.md`
|
||||
|
||||
## Execution Skills
|
||||
|
||||
- `@test-driven-development`
|
||||
- `@systematic-debugging`
|
||||
- `@verification-before-completion`
|
||||
|
||||
## Scope Guardrails (YAGNI)
|
||||
|
||||
- Do not add failure-path handling beyond current behavior (declines/cancellations/webhook outages remain out-of-scope for this cycle).
|
||||
- Do not redesign billing pages or pricing UI beyond replacing `trial` with explicit `solo`/`team` plans.
|
||||
- Do not add browser automation (Playwright/Cypress) in this cycle.
|
||||
|
||||
## File Structure (lock boundaries before coding)
|
||||
|
||||
### Billing contract + shared helpers
|
||||
|
||||
- Modify: `apps/main/src/lib/billing.ts`
|
||||
- Responsibility: canonical signup billing intent types (`solo|team|founder`), localStorage key helpers, and plan satisfaction helper used by auth/bootstrap flows.
|
||||
- Create: `apps/main/src/lib/billing.test.ts`
|
||||
- Responsibility: unit tests for plan parsing/defaults/satisfaction behavior.
|
||||
|
||||
### Signup UI + intent submission
|
||||
|
||||
- Modify: `apps/main/src/pages/signup.tsx`
|
||||
- Responsibility: expose 3 plan choices and submit selected billing intent.
|
||||
- Modify: `apps/main/src/pages/signup-v2.tsx`
|
||||
- Responsibility: keep v2 signup behavior aligned with primary signup flow.
|
||||
- Modify: `apps/main/src/pages/signup.test.tsx`
|
||||
- Responsibility: validate default plan + intent payload mapping.
|
||||
- Create: `apps/main/src/pages/signup-v2.test.tsx`
|
||||
- Responsibility: parity checks for v2 plan intent submission.
|
||||
|
||||
### Checkout bootstrap and retry-on-login component
|
||||
|
||||
- Modify: `apps/main/src/hooks/auth.ts`
|
||||
- Responsibility: create checkout immediately after signup for any selected plan.
|
||||
- Move/Modify: `apps/main/src/components/PendingFounderCheckout.tsx` -> `apps/main/src/components/PendingSignupCheckout.tsx`
|
||||
- Responsibility: recover pending checkout for any of the 3 plans once user is authenticated.
|
||||
- Modify: `apps/main/src/App.tsx`
|
||||
- Responsibility: mount renamed generalized pending-checkout component.
|
||||
- Create: `apps/main/src/components/PendingSignupCheckout.test.tsx`
|
||||
- Responsibility: behavior tests for pending plan recovery (`solo`, `team`, `founder`).
|
||||
|
||||
### Verification evidence
|
||||
|
||||
- Create: `docs/superpowers/reports/2026-03-15-pricing-signup-three-plan-happy-path-verification.md`
|
||||
- Responsibility: record pass/fail evidence matrix for all three live flows.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Contract + Signup Intent Wiring (TDD)
|
||||
|
||||
### Task 1.1: Replace legacy signup intent contract (`trial|founder`) with explicit 3-plan contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/main/src/lib/billing.ts`
|
||||
- Create: `apps/main/src/lib/billing.test.ts`
|
||||
- Test: `apps/main/src/lib/billing.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing unit tests for billing intent helpers**
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_SIGNUP_BILLING_INTENT,
|
||||
isSignupBillingIntent,
|
||||
satisfiesPendingCheckoutPlan,
|
||||
} from "./billing";
|
||||
|
||||
describe("billing helpers", () => {
|
||||
it("defaults signup intent to solo", () => {
|
||||
expect(DEFAULT_SIGNUP_BILLING_INTENT).toBe("solo");
|
||||
});
|
||||
|
||||
it("recognizes only solo/team/founder", () => {
|
||||
expect(isSignupBillingIntent("solo")).toBe(true);
|
||||
expect(isSignupBillingIntent("team")).toBe(true);
|
||||
expect(isSignupBillingIntent("founder")).toBe(true);
|
||||
expect(isSignupBillingIntent("trial")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats founder as satisfied only by annual", () => {
|
||||
expect(satisfiesPendingCheckoutPlan("founder", "annual")).toBe(true);
|
||||
expect(satisfiesPendingCheckoutPlan("founder", "team")).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify failure**
|
||||
|
||||
Run: `pnpm --filter @xtablo/main test -- src/lib/billing.test.ts`
|
||||
Expected: FAIL because constants/helpers do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement minimal helpers in `billing.ts`**
|
||||
|
||||
```ts
|
||||
export type SignupBillingIntent = "solo" | "team" | "founder";
|
||||
export const DEFAULT_SIGNUP_BILLING_INTENT: SignupBillingIntent = "solo";
|
||||
|
||||
export function isSignupBillingIntent(value: string | null): value is SignupBillingIntent {
|
||||
return value === "solo" || value === "team" || value === "founder";
|
||||
}
|
||||
|
||||
export function satisfiesPendingCheckoutPlan(
|
||||
pending: SignupBillingIntent,
|
||||
active: "solo" | "team" | "annual" | null
|
||||
): boolean {
|
||||
// founder => annual only
|
||||
// team => team or annual
|
||||
// solo => solo/team/annual
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Re-run unit tests**
|
||||
|
||||
Run: `pnpm --filter @xtablo/main test -- src/lib/billing.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/main/src/lib/billing.ts apps/main/src/lib/billing.test.ts
|
||||
git commit -m "feat(main): add 3-plan signup billing helpers"
|
||||
```
|
||||
|
||||
### Task 1.2: Update signup forms to submit `solo|team|founder` intents
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/main/src/pages/signup.tsx`
|
||||
- Modify: `apps/main/src/pages/signup-v2.tsx`
|
||||
- Modify: `apps/main/src/pages/signup.test.tsx`
|
||||
- Create: `apps/main/src/pages/signup-v2.test.tsx`
|
||||
- Test: `apps/main/src/pages/signup.test.tsx`, `apps/main/src/pages/signup-v2.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Update existing signup test to encode new contract first**
|
||||
|
||||
Test updates in `signup.test.tsx`:
|
||||
- default submit sends `billing_intent: "solo"` (instead of `trial`)
|
||||
- selecting Team sends `billing_intent: "team"`
|
||||
- selecting Founder sends `billing_intent: "founder"`
|
||||
|
||||
- [ ] **Step 2: Add v2 parity test with same assertions**
|
||||
|
||||
Create `signup-v2.test.tsx` with one default and two selection cases (`team`, `founder`).
|
||||
|
||||
- [ ] **Step 3: Run tests to verify failure before UI changes**
|
||||
|
||||
Run: `pnpm --filter @xtablo/main test -- src/pages/signup.test.tsx src/pages/signup-v2.test.tsx`
|
||||
Expected: FAIL because forms still default to `trial` and do not expose explicit Team option.
|
||||
|
||||
- [ ] **Step 4: Implement minimal signup UI changes**
|
||||
|
||||
Implementation checklist:
|
||||
- Default state is `solo` in both signup pages.
|
||||
- Plan cards/buttons become exactly `Solo`, `Team`, `Founder`.
|
||||
- Copy reflects cadence (`Solo` + `Team` monthly, `Founder` yearly).
|
||||
- Submit passes selected plan unchanged as `billing_intent`.
|
||||
|
||||
- [ ] **Step 5: Re-run targeted signup tests**
|
||||
|
||||
Run: `pnpm --filter @xtablo/main test -- src/pages/signup.test.tsx src/pages/signup-v2.test.tsx`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/main/src/pages/signup.tsx apps/main/src/pages/signup-v2.tsx apps/main/src/pages/signup.test.tsx apps/main/src/pages/signup-v2.test.tsx
|
||||
git commit -m "feat(main): submit solo team founder intents from signup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Immediate Checkout Bootstrap + Pending Recovery (TDD)
|
||||
|
||||
### Task 2.1: Generalize pending checkout component to all plans
|
||||
|
||||
**Files:**
|
||||
- Move/Modify: `apps/main/src/components/PendingFounderCheckout.tsx` -> `apps/main/src/components/PendingSignupCheckout.tsx`
|
||||
- Modify: `apps/main/src/App.tsx`
|
||||
- Create: `apps/main/src/components/PendingSignupCheckout.test.tsx`
|
||||
- Test: `apps/main/src/components/PendingSignupCheckout.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write failing component tests for plan-aware pending checkout**
|
||||
|
||||
Cases to cover:
|
||||
- pending `solo` triggers `createCheckout({ plan: "solo" })`
|
||||
- pending `team` triggers `createCheckout({ plan: "team" })`
|
||||
- pending `founder` + active annual clears localStorage and does not trigger checkout
|
||||
- invalid pending value clears localStorage and does not trigger checkout
|
||||
|
||||
- [ ] **Step 2: Run test to confirm failure**
|
||||
|
||||
Run: `pnpm --filter @xtablo/main test -- src/components/PendingSignupCheckout.test.tsx`
|
||||
Expected: FAIL because component/file does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement generalized component**
|
||||
|
||||
Implementation checklist:
|
||||
- Parse pending plan via `isSignupBillingIntent`.
|
||||
- Use `satisfiesPendingCheckoutPlan` against `organizationData.active_subscription_plan`.
|
||||
- Trigger `useCreateCheckoutSession` with the pending plan when needed.
|
||||
- Preserve retry behavior on mutation error.
|
||||
- Update `App.tsx` import/render to `PendingSignupCheckout`.
|
||||
|
||||
- [ ] **Step 4: Re-run component test**
|
||||
|
||||
Run: `pnpm --filter @xtablo/main test -- src/components/PendingSignupCheckout.test.tsx`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/main/src/components/PendingSignupCheckout.tsx apps/main/src/components/PendingSignupCheckout.test.tsx apps/main/src/App.tsx
|
||||
git rm apps/main/src/components/PendingFounderCheckout.tsx
|
||||
git commit -m "refactor(main): generalize pending signup checkout to all plans"
|
||||
```
|
||||
|
||||
### Task 2.2: Make `useSignUp` bootstrap checkout for any selected plan
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/main/src/hooks/auth.ts`
|
||||
- Create: `apps/main/src/hooks/auth.signup.test.tsx`
|
||||
- Test: `apps/main/src/hooks/auth.signup.test.tsx`, `apps/main/src/pages/signup.test.tsx`, `apps/main/src/components/PendingSignupCheckout.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write failing auth hook test for Team checkout bootstrap**
|
||||
|
||||
In `auth.signup.test.tsx`, add a test that:
|
||||
- mocks a confirmed signup response with `session.access_token`
|
||||
- calls `useSignUp(...).mutate` with `billing_intent: "team"`
|
||||
- asserts `/api/v1/stripe/create-checkout-session` is called with `{ plan: "team" }`
|
||||
- asserts `localStorage` pending key behavior is preserved for retry if redirect bootstrap fails
|
||||
|
||||
- [ ] **Step 2: Run focused tests to confirm failure**
|
||||
|
||||
Run: `pnpm --filter @xtablo/main test -- src/hooks/auth.signup.test.tsx`
|
||||
Expected: FAIL because `auth.ts` currently founder-special-cases checkout bootstrap.
|
||||
|
||||
- [ ] **Step 3: Implement minimal auth flow changes**
|
||||
|
||||
Implementation checklist in `auth.ts`:
|
||||
- treat `variables.billing_intent` as selected checkout plan (`solo|team|founder`)
|
||||
- set `SIGNUP_BILLING_INTENT_KEY` + `PENDING_BILLING_CHECKOUT_PLAN_KEY` for all plans
|
||||
- after confirmed signup, call `/api/v1/stripe/create-checkout-session` with `{ plan: selectedPlan }`
|
||||
- keep pending key for retry path if immediate redirect fails
|
||||
- remove founder-only branching
|
||||
|
||||
- [ ] **Step 4: Re-run focused tests**
|
||||
|
||||
Run: `pnpm --filter @xtablo/main test -- src/hooks/auth.signup.test.tsx src/pages/signup.test.tsx src/components/PendingSignupCheckout.test.tsx`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Run broader safety checks**
|
||||
|
||||
Run: `pnpm --filter @xtablo/main test -- src/hooks/auth.signup.test.tsx src/pages/signup.test.tsx src/pages/signup-v2.test.tsx src/components/PendingSignupCheckout.test.tsx`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/main/src/hooks/auth.ts apps/main/src/hooks/auth.signup.test.tsx apps/main/src/pages/signup.test.tsx
|
||||
git commit -m "feat(main): bootstrap signup checkout for solo team founder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Verification and Evidence Capture (Live Stripe Test Mode)
|
||||
|
||||
### Task 3.1: Preflight checks before live runs
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/superpowers/reports/2026-03-15-pricing-signup-three-plan-happy-path-verification.md`
|
||||
|
||||
- [ ] **Step 1: Confirm plan price IDs exist in API env**
|
||||
|
||||
Run: `rg -n "STRIPE_SOLO_PRICE_ID|STRIPE_TEAM_MONTHLY_PRICE_ID|STRIPE_FOUNDER_PRICE_ID" apps/api/src/routers/stripe.ts`
|
||||
Expected: all three env-backed plan mappings present.
|
||||
|
||||
- [ ] **Step 2: Start webhook forwarding in Stripe test mode**
|
||||
|
||||
Run: `stripe listen --forward-to http://localhost:3000/api/v1/stripe-webhook`
|
||||
Expected: CLI prints `Ready!` and webhook signing secret line.
|
||||
|
||||
- [ ] **Step 3: Start local apps (if not already running)**
|
||||
|
||||
Run in separate terminals:
|
||||
- `pnpm dev:api`
|
||||
- `pnpm dev:main`
|
||||
|
||||
Expected: API and frontend boot with no fatal startup errors.
|
||||
|
||||
- [ ] **Step 4: Commit code before live manual verification starts**
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
# Expected: empty output (or only known, intentional files for this plan)
|
||||
```
|
||||
|
||||
### Task 3.2: Execute happy-path run for `solo` (monthly)
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/reports/2026-03-15-pricing-signup-three-plan-happy-path-verification.md`
|
||||
|
||||
- [ ] **Step 1: Run signup with unique Solo email and select Solo plan**
|
||||
- [ ] **Step 2: Verify immediate redirect to Stripe Checkout**
|
||||
- [ ] **Step 3: Complete payment with `4242 4242 4242 4242`**
|
||||
- [ ] **Step 4: Verify return to success URL and in-app paid status**
|
||||
- [ ] **Step 5: Record observed billing interval as `month` in report**
|
||||
|
||||
### Task 3.3: Execute happy-path run for `team` (monthly)
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/reports/2026-03-15-pricing-signup-three-plan-happy-path-verification.md`
|
||||
|
||||
- [ ] **Step 1: Run signup with unique Team email and select Team plan**
|
||||
- [ ] **Step 2: Verify immediate redirect to Stripe Checkout**
|
||||
- [ ] **Step 3: Complete payment with `4242 4242 4242 4242`**
|
||||
- [ ] **Step 4: Verify return to success URL and in-app paid status**
|
||||
- [ ] **Step 5: Record observed billing interval as `month` in report**
|
||||
|
||||
### Task 3.4: Execute happy-path run for `founder` (yearly)
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/reports/2026-03-15-pricing-signup-three-plan-happy-path-verification.md`
|
||||
|
||||
- [ ] **Step 1: Run signup with unique Founder email and select Founder plan**
|
||||
- [ ] **Step 2: Verify immediate redirect to Stripe Checkout**
|
||||
- [ ] **Step 3: Complete payment with `4242 4242 4242 4242`**
|
||||
- [ ] **Step 4: Verify return to success URL and in-app paid status**
|
||||
- [ ] **Step 5: Record observed billing interval as `year` in report**
|
||||
|
||||
### Task 3.5: Final verification + completion gates
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/reports/2026-03-15-pricing-signup-three-plan-happy-path-verification.md`
|
||||
|
||||
- [ ] **Step 1: Run regression checks for touched frontend files**
|
||||
|
||||
Run: `pnpm --filter @xtablo/main test -- src/lib/billing.test.ts src/pages/signup.test.tsx src/pages/signup-v2.test.tsx src/components/PendingSignupCheckout.test.tsx`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run typecheck**
|
||||
|
||||
Run: `pnpm --filter @xtablo/main typecheck`
|
||||
Expected: PASS (or only documented unrelated pre-existing failures).
|
||||
|
||||
- [ ] **Step 3: Fill final pass/fail matrix in report**
|
||||
|
||||
Required columns:
|
||||
- `plan`
|
||||
- `checkout reached`
|
||||
- `payment completed`
|
||||
- `success redirect`
|
||||
- `interval expected`
|
||||
- `interval observed`
|
||||
- `result`
|
||||
- `evidence`
|
||||
|
||||
- [ ] **Step 4: Commit verification report and remaining changes**
|
||||
|
||||
```bash
|
||||
git add apps/main/src/lib/billing.ts apps/main/src/lib/billing.test.ts apps/main/src/pages/signup.tsx apps/main/src/pages/signup-v2.tsx apps/main/src/pages/signup.test.tsx apps/main/src/pages/signup-v2.test.tsx apps/main/src/hooks/auth.ts apps/main/src/components/PendingSignupCheckout.tsx apps/main/src/components/PendingSignupCheckout.test.tsx apps/main/src/App.tsx docs/superpowers/reports/2026-03-15-pricing-signup-three-plan-happy-path-verification.md
|
||||
git commit -m "feat(main): verify solo team founder signup checkout happy paths"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plan Review Checklist (for reviewer subagent)
|
||||
|
||||
- [ ] No TODO/TBD placeholders remain.
|
||||
- [ ] Every task includes explicit files, commands, and expected outcomes.
|
||||
- [ ] Plan aligns exactly with approved spec scope (happy paths only).
|
||||
- [ ] New/modified files keep single responsibilities and manageable size.
|
||||
- [ ] All execution steps use checkbox syntax.
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
# Pricing Signup Three-Plan Happy-Path Verification Report
|
||||
|
||||
Date: 2026-03-15
|
||||
|
||||
## Preflight
|
||||
|
||||
- `STRIPE_SOLO_PRICE_ID`, `STRIPE_TEAM_MONTHLY_PRICE_ID`, and `STRIPE_FOUNDER_PRICE_ID` are mapped in `apps/api/src/routers/stripe.ts`.
|
||||
- `stripe` CLI is available (`stripe version 1.37.3`) and authenticated.
|
||||
- Local frontend is running on `5173`; API process is already listening on `8080`.
|
||||
|
||||
## Automated Verification Completed
|
||||
|
||||
Commands run successfully:
|
||||
|
||||
```bash
|
||||
cd apps/main && pnpm exec vitest run src/lib/billing.test.ts src/hooks/auth.signup.test.ts src/components/PendingSignupCheckout.test.tsx src/pages/signup.test.tsx src/pages/signup-v2.test.tsx --mode dev --passWithNoTests
|
||||
pnpm --filter @xtablo/main typecheck
|
||||
```
|
||||
|
||||
Coverage from these checks:
|
||||
- Signup defaults to `solo` and supports selecting `team` and `founder` in both signup UIs.
|
||||
- Billing intent contract is now explicit (`solo|team|founder`) with plan satisfaction helpers.
|
||||
- Pending checkout recovery component supports all three plans and handles already-satisfied or invalid pending states.
|
||||
- Signup auth path resolves billing intent with default `solo`.
|
||||
|
||||
## Live Stripe Happy Paths
|
||||
|
||||
Retry with Stripe CLI:
|
||||
- Started listener: `stripe listen --forward-to http://127.0.0.1:8080/api/v1/stripe-webhook`
|
||||
- Triggered checkout fixture: `stripe trigger checkout.session.completed`
|
||||
- Result: all forwarded webhook requests returned `400` from API webhook endpoint.
|
||||
|
||||
Observed evidence:
|
||||
- Listener reported `Ready` and emitted Stripe events.
|
||||
- Forward logs showed repeated `400` responses for events including:
|
||||
- `product.created`
|
||||
- `price.created`
|
||||
- `checkout.session.completed`
|
||||
- `payment_intent.created`
|
||||
- `payment_intent.succeeded`
|
||||
- Direct probe confirms webhook route is reachable, but signature-sensitive:
|
||||
- `POST /api/v1/stripe-webhook` with fake header returns `{"error":"Unable to extract timestamp and signatures from header"}`.
|
||||
|
||||
Conclusion for live flow retry:
|
||||
- Stripe CLI path is now working, but webhook processing is failing server-side during forwarded events (likely webhook secret mismatch or environment mismatch on the API instance currently running at `8080`).
|
||||
- Browser checkout happy paths cannot be marked passing until webhook `400` issue is resolved and end-to-end redirects are exercised.
|
||||
|
||||
| plan | checkout reached | payment completed | success redirect | interval expected | interval observed | result | evidence |
|
||||
|------|------------------|-------------------|------------------|-------------------|-------------------|--------|----------|
|
||||
| solo | not run | not run | not run | month | not run | blocked | webhook forward returns 400 |
|
||||
| team | not run | not run | not run | month | not run | blocked | webhook forward returns 400 |
|
||||
| founder | not run | not run | not run | year | not run | blocked | webhook forward returns 400 |
|
||||
|
||||
## Summary
|
||||
|
||||
- Code-level and component-level verification is green for the 3-plan signup wiring.
|
||||
- Live Stripe card-entry happy-path verification is blocked by webhook `400` failures on the current local API endpoint (`:8080`) and still requires browser payment execution once webhook processing is healthy.
|
||||
Loading…
Reference in a new issue