Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ Routes use short paths (defined in `app/src/components/Router.tsx`):
- Local file support
- Priority support

Pro access is determined by Stripe subscription status (`active` or `trialing`) via `useHasProAccess()` in `lib/hooks.ts`.
Pro access is determined by Stripe subscription status (`active` or `trialing`) OR an active **30-Day Pass** via `useHasProAccess()` in `lib/hooks.ts`. The pass is a $9 one-time `mode: "payment"` checkout whose PaymentIntent carries `metadata.ff_pass`; entitlement is derived live in `api/customer-info.ts` via `api/_lib/_pass.ts` (created + 30 days, refunds/disputes invalidate). No webhooks, no DB. Pass holders authenticate to the AI endpoints with their PaymentIntent id (`useProAiToken()` in `lib/hooks.ts`).

The sandbox warning modal (`SandboxWarning.tsx`) appears after 3 minutes of editing to encourage upgrade.

Expand Down Expand Up @@ -459,8 +459,9 @@ Run the full verification suite after making changes:
pnpm -F api check # API types (~2s)
pnpm -F app check # App types (~10s)

# 2. Unit tests (8 suites, 32 tests, ~28s)
pnpm -F app test -- --watchAll=false
# 2. Unit tests
pnpm -F api test # API unit tests (~1s, @swc/jest)
pnpm -F app test -- --watchAll=false # App unit tests (~30s)

# 3. E2E tests (~37s, requires `pnpm start` running on port 3000)
pnpm -F app e2e
Expand All @@ -472,8 +473,8 @@ pnpm -F app e2e

**Command:** `pnpm -F app test -- --watchAll=false`
**Framework:** Jest via react-scripts + React Testing Library
**Status:** 8 suites, 32 passed, 5 todo, 0 failures
**Duration:** ~28 seconds
**Status:** 23 suites, 322 passed, 5 todo, 0 failures
**Duration:** ~30 seconds

Test files are in `app/src/` alongside source code (e.g., `Graph.test.tsx`, `AppContextProvider.test.tsx`, `toVisio.test.ts`).

Expand All @@ -485,10 +486,9 @@ Test utilities in `app/src/test-utils.tsx` wrap render with all providers (AppCo
- `transformIgnorePatterns` excludes `react-use-localstorage`, `monaco-editor`, and `monaco-editor-core` from ignore (forces Babel transform for ESM compat)
- If a new ESM-only dependency causes "Cannot use import statement outside a module" errors, add it to the negation pattern in transformIgnorePatterns

### API Type Check
### API Type Check + Unit Tests

**Command:** `pnpm -F api check`
**What it does:** `tsc --noEmit` — type checking only, no tests
**Commands:** `pnpm -F api check` (`tsc --noEmit`) and `pnpm -F api test` (Jest via @swc/jest, config in `api/jest.config.js`). Test files sit next to source (e.g. `api/_lib/_pass.test.ts`, `api/prompt/_parseFlowchart.test.ts`).

### E2E Tests (Playwright)

Expand Down
120 changes: 120 additions & 0 deletions api/_lib/_pass.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import type { default as Stripe } from "stripe";
import {
PASS_DURATION_SECONDS,
getActivePass,
isPaymentIntentActivePass,
} from "./_pass";

const NOW = 1_800_000_000;
const DAY = 24 * 60 * 60;

function makePi({
id = "pi_valid",
created = NOW - 5 * DAY,
status = "succeeded",
metadata = { ff_pass: "30-day" } as Record<string, string>,
refunded = false,
disputed = false,
amountRefunded = 0,
}: {
id?: string;
created?: number;
status?: string;
metadata?: Record<string, string>;
refunded?: boolean;
disputed?: boolean;
amountRefunded?: number;
} = {}): Stripe.PaymentIntent {
return {
id,
created,
status,
metadata,
latest_charge: {
refunded,
disputed,
amount_refunded: amountRefunded,
},
} as unknown as Stripe.PaymentIntent;
}

describe("getActivePass", () => {
test("fresh succeeded pass is active with correct expiry", () => {
const pi = makePi();
expect(getActivePass([pi], NOW)).toEqual({
expiresAt: pi.created + PASS_DURATION_SECONDS,
paymentIntentId: "pi_valid",
});
});

test("pass created 31 days ago is expired", () => {
expect(getActivePass([makePi({ created: NOW - 31 * DAY })], NOW)).toBeNull();
});

test("payment without pass metadata is ignored", () => {
expect(getActivePass([makePi({ metadata: {} })], NOW)).toBeNull();
});

test("non-succeeded payment is ignored", () => {
expect(
getActivePass([makePi({ status: "requires_payment_method" })], NOW)
).toBeNull();
});

test("fully refunded pass is invalid", () => {
expect(getActivePass([makePi({ refunded: true })], NOW)).toBeNull();
});

test("disputed (charged-back) pass is invalid", () => {
expect(getActivePass([makePi({ disputed: true })], NOW)).toBeNull();
});

test("partially refunded pass stays valid (deliberate)", () => {
expect(
getActivePass([makePi({ amountRefunded: 450 })], NOW)
).not.toBeNull();
});

test("refunded newer pass does not kill a still-valid older one", () => {
const older = makePi({ id: "pi_older", created: NOW - 10 * DAY });
const newer = makePi({
id: "pi_newer",
created: NOW - 1 * DAY,
refunded: true,
});
expect(getActivePass([newer, older], NOW)).toEqual({
expiresAt: older.created + PASS_DURATION_SECONDS,
paymentIntentId: "pi_older",
});
});

test("with multiple valid passes the latest expiry wins", () => {
const older = makePi({ id: "pi_older", created: NOW - 20 * DAY });
const newer = makePi({ id: "pi_newer", created: NOW - 2 * DAY });
expect(getActivePass([older, newer], NOW)?.paymentIntentId).toBe(
"pi_newer"
);
});

test("empty list is null", () => {
expect(getActivePass([], NOW)).toBeNull();
});
});

describe("isPaymentIntentActivePass", () => {
test("valid fresh pass", () => {
expect(isPaymentIntentActivePass(makePi(), NOW)).toBe(true);
});

test("expired pass", () => {
expect(
isPaymentIntentActivePass(makePi({ created: NOW - 31 * DAY }), NOW)
).toBe(false);
});

test("disputed pass", () => {
expect(isPaymentIntentActivePass(makePi({ disputed: true }), NOW)).toBe(
false
);
});
});
55 changes: 55 additions & 0 deletions api/_lib/_pass.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { default as Stripe } from "stripe";

export const PASS_METADATA_KEY = "ff_pass";
export const PASS_DURATION_SECONDS = 30 * 24 * 60 * 60;

export type ActivePass = {
/** Epoch seconds, same unit as subscription.current_period_end */
expiresAt: number;
paymentIntentId: string;
};

/**
* A PaymentIntent counts as a paid pass only if it succeeded, carries the
* pass metadata, and its charge was neither refunded nor disputed. With no
* webhooks, this predicate is the only enforcement point for refunds and
* chargebacks. Callers must expand `latest_charge`.
*/
function isValidPassPayment(pi: Stripe.PaymentIntent): boolean {
const charge = pi.latest_charge as Stripe.Charge | null;
return (
pi.status === "succeeded" &&
Boolean(pi.metadata?.[PASS_METADATA_KEY]) &&
!charge?.refunded &&
!charge?.disputed
);
}

/**
* Returns the pass with the latest expiry among all valid pass payments —
* not simply the newest PaymentIntent, so a refunded newer purchase can
* never invalidate a still-valid older one. Null when none is active.
*/
export function getActivePass(
paymentIntents: Stripe.PaymentIntent[],
nowSeconds: number
): ActivePass | null {
let best: ActivePass | null = null;
for (const pi of paymentIntents) {
if (!isValidPassPayment(pi)) continue;
const expiresAt = pi.created + PASS_DURATION_SECONDS;
if (!best || expiresAt > best.expiresAt) {
best = { expiresAt, paymentIntentId: pi.id };
}
}
return best && best.expiresAt > nowSeconds ? best : null;
}

export function isPaymentIntentActivePass(
pi: Stripe.PaymentIntent,
nowSeconds: number
): boolean {
return (
isValidPassPayment(pi) && pi.created + PASS_DURATION_SECONDS > nowSeconds
);
}
72 changes: 56 additions & 16 deletions api/create-checkout-session.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import { VercelApiHandler } from "@vercel/node";
import type { default as Stripe } from "stripe";
import { stripe } from "./_lib/_stripe";
import { getBaseUrl } from "./_lib/_helpers";
import { PASS_METADATA_KEY } from "./_lib/_pass";

const subscriptionTypes = {
monthly: process.env.STRIPE_PRICE_ID,
yearly: process.env.STRIPE_PRICE_ID_YEARLY,
const plans = {
monthly: {
priceId: process.env.STRIPE_PRICE_ID,
mode: "subscription" as const,
},
yearly: {
priceId: process.env.STRIPE_PRICE_ID_YEARLY,
mode: "subscription" as const,
},
pass: {
priceId: process.env.STRIPE_PRICE_ID_PASS,
mode: "payment" as const,
},
};

const handler: VercelApiHandler = async (req, res) => {
Expand All @@ -14,25 +26,53 @@ const handler: VercelApiHandler = async (req, res) => {
return;
}

const priceId = subscriptionTypes[plan as keyof typeof subscriptionTypes];
if (!priceId) {
const planConfig = plans[plan as keyof typeof plans];
if (!planConfig?.priceId) {
res.status(400).send("Invalid plan");
return;
}

try {
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [
{
price: priceId,
quantity: 1,
let session: Stripe.Checkout.Session;
if (planConfig.mode === "payment") {
// The pass entitlement is later found by listing PaymentIntents on the
// customer that getCustomerFromToken resolves by email (newest-first),
// so the payment must land on that same customer — otherwise a freshly
// created pass-customer would shadow one holding subscription history.
const existing = await stripe.customers.list({ email, limit: 10 });
const customer = existing.data
.filter((c) => !c.deleted)
.sort((a, b) => b.created - a.created)[0];

session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price: planConfig.priceId, quantity: 1 }],
payment_intent_data: {
metadata: { [PASS_METADATA_KEY]: "30-day" },
},
],
customer_email: email,
success_url: `${getBaseUrl()}/success`,
cancel_url: `${getBaseUrl()}/pricing`,
});
invoice_creation: { enabled: true },
// Setting customer/customer_email locks the email field on the
// Stripe page, keeping the payment attached to the account's email.
...(customer
? { customer: customer.id }
: { customer_email: email, customer_creation: "always" as const }),
success_url: `${getBaseUrl()}/success?pass=true`,
cancel_url: `${getBaseUrl()}/pricing`,
});
} else {
session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [
{
price: planConfig.priceId,
quantity: 1,
},
],
customer_email: email,
success_url: `${getBaseUrl()}/success`,
cancel_url: `${getBaseUrl()}/pricing`,
});
}

res.json({ url: session.url });
} catch (error) {
Expand Down
25 changes: 18 additions & 7 deletions api/customer-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { validStripePrices } from "./_lib/_validStripePrices";
import { stripe } from "./_lib/_stripe";
import type { default as Stripe } from "stripe";
import { getCustomerFromToken } from "./_lib/_helpers";
import { getActivePass } from "./_lib/_pass";

export default async function customerInfo(
req: VercelRequest,
Expand All @@ -14,15 +15,23 @@ export default async function customerInfo(
let subscription: Stripe.Subscription | null = null;

if (!customer) {
res.json({ customerId: null, subscription: null });
res.json({ customerId: null, subscription: null, pass: null });
return;
}

const { data: subscriptions } = await stripe.subscriptions.list({
customer: customer.id,
limit: 100,
status: "all",
});
const [{ data: subscriptions }, { data: paymentIntents }] =
await Promise.all([
stripe.subscriptions.list({
customer: customer.id,
limit: 100,
status: "all",
}),
stripe.paymentIntents.list({
customer: customer.id,
limit: 100,
expand: ["data.latest_charge"],
}),
]);

// get subscriptions with known prices
const appSubs = subscriptions.filter((subscription) => {
Expand All @@ -37,7 +46,9 @@ export default async function customerInfo(

subscription = appSubs?.[0] ?? null;

res.json({ customerId: customer.id, subscription });
const pass = getActivePass(paymentIntents, Math.floor(Date.now() / 1000));

res.json({ customerId: customer.id, subscription, pass });
} catch (error) {
console.error(error);
return res.status(400).json({ error });
Expand Down
Loading
Loading