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
32 changes: 32 additions & 0 deletions apps/web/__tests__/unit/developer-credits-webhook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ function makeCheckoutSession(overrides: Record<string, unknown> = {}) {
id: "cs_test_123",
customer: "cus_test",
subscription: null,
// Real Stripe `checkout.session.completed` events for a successful
// (synchronous) card payment carry payment_status: "paid"; the webhook
// only grants credits when paid.
payment_status: "paid",
payment_intent: "pi_test_abc",
metadata: {
type: "developer_credits",
Expand Down Expand Up @@ -196,6 +200,34 @@ describe("Stripe webhook — developer credits", () => {
);
});

it("does not grant credits when payment_status is not paid", async () => {
const session = makeCheckoutSession({ payment_status: "unpaid" });
mockStripe.webhooks.constructEvent.mockReturnValue({
type: "checkout.session.completed",
data: { object: session },
});
mockDbChain.limit.mockResolvedValueOnce([]);

const res = await POST(makeWebhookRequest());
expect(res.status).toBe(200);
expect(mockAddCredits).not.toHaveBeenCalled();
});

it("grants credits when an async payment later succeeds", async () => {
const session = makeCheckoutSession();
mockStripe.webhooks.constructEvent.mockReturnValue({
type: "checkout.session.async_payment_succeeded",
data: { object: session },
});
mockDbChain.limit.mockResolvedValueOnce([]);

const res = await POST(makeWebhookRequest());
expect(res.status).toBe(200);
expect(mockAddCredits).toHaveBeenCalledWith(
expect.objectContaining({ accountId: "account-001", amountCents: 2500 }),
);
});

it("skips duplicate webhook delivery (idempotency)", async () => {
const session = makeCheckoutSession();
mockStripe.webhooks.constructEvent.mockReturnValue({
Expand Down
13 changes: 12 additions & 1 deletion apps/web/actions/video/create-for-processing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,18 @@ export async function createVideoForServerProcessing({

if (!user) throw new Error("Unauthorized");

if (!userIsPro(user) && duration && duration > 300) {
// Free-tier length cap. `duration` here is client-supplied metadata sent at
// upload-start, so it is only authoritative when a positive value is given
// (instant recordings legitimately start with 0/unknown duration). A
// positive declared duration over the cap is rejected up-front; the real
// duration is only known at finalize, which is where the cap must ultimately
// be enforced.
if (
!userIsPro(user) &&
typeof duration === "number" &&
Number.isFinite(duration) &&
duration > 300
) {
Comment on lines +54 to +59

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Number.isFinite(duration) silently lets Infinity bypass this early cap — the old condition evaluated Infinity > 300 as true and threw upgrade_required, but Number.isFinite(Infinity) is false, so the check is skipped. The comment says finalize is the authoritative enforcement point, but if a client deliberately passes duration: Infinity the pre-upload rejection no longer fires. Dropping Number.isFinite while keeping typeof duration === "number" restores the original behaviour since NaN > 300 is already false.

Suggested change
if (
!userIsPro(user) &&
typeof duration === "number" &&
Number.isFinite(duration) &&
duration > 300
) {
if (
!userIsPro(user) &&
typeof duration === "number" &&
duration > 300
) {
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/actions/video/create-for-processing.ts
Line: 54-59

Comment:
`Number.isFinite(duration)` silently lets `Infinity` bypass this early cap — the old condition evaluated `Infinity > 300` as `true` and threw `upgrade_required`, but `Number.isFinite(Infinity)` is `false`, so the check is skipped. The comment says finalize is the authoritative enforcement point, but if a client deliberately passes `duration: Infinity` the pre-upload rejection no longer fires. Dropping `Number.isFinite` while keeping `typeof duration === "number"` restores the original behaviour since `NaN > 300` is already `false`.

```suggestion
	if (
		!userIsPro(user) &&
		typeof duration === "number" &&
		duration > 300
	) {
```

How can I resolve this? If you propose a fix, please make it concise.

throw new Error("upgrade_required");
}

Expand Down
138 changes: 81 additions & 57 deletions apps/web/app/api/webhooks/stripe/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,79 @@ import { addCreditsToAccount } from "@/lib/developer-credits";

const relevantEvents = new Set([
"checkout.session.completed",
"checkout.session.async_payment_succeeded",
"customer.subscription.updated",
"customer.subscription.deleted",
]);

async function grantDeveloperCredits(
session: Stripe.Checkout.Session,
): Promise<Response> {
const { accountId, amountCents } = session.metadata ?? {};
const paymentIntentId =
typeof session.payment_intent === "string" ? session.payment_intent : null;

if (!accountId || !amountCents || !paymentIntentId) {
console.error("Missing required metadata for developer credits:", {
accountId,
amountCents,
paymentIntentId,
});
return new Response("Missing metadata", { status: 400 });
}

// Only grant credits once the payment has actually settled. Without this
// guard a checkout session (e.g. an unpaid/async payment) could grant
// credits before money is captured.
if (session.payment_status !== "paid") {
console.log(
`Developer credits checkout not paid yet (payment_status=${session.payment_status}); skipping credit grant`,
{ accountId, paymentIntentId },
);
return NextResponse.json({ received: true });
}

console.log("Processing developer credits purchase:", {
accountId,
amountCents,
paymentIntentId,
});

const [existingTxn] = await db()
.select({ id: developerCreditTransactions.id })
.from(developerCreditTransactions)
.where(
and(
eq(developerCreditTransactions.accountId, accountId),
eq(developerCreditTransactions.referenceId, paymentIntentId),
eq(developerCreditTransactions.referenceType, "stripe_payment_intent"),
),
)
.limit(1);

if (existingTxn) {
console.log(
"Duplicate webhook delivery — transaction already exists:",
existingTxn.id,
);
return NextResponse.json({ received: true });
}

await addCreditsToAccount({
accountId,
amountCents: Number(amountCents),
referenceId: paymentIntentId,
referenceType: "stripe_payment_intent",
metadata: {
amountCents: Number(amountCents),
stripeSessionId: session.id,
},
});
Comment on lines +73 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth validating amountCents is numeric before granting credits; Number("abc") becomes NaN and would currently flow into addCreditsToAccount.

Suggested change
await addCreditsToAccount({
accountId,
amountCents: Number(amountCents),
referenceId: paymentIntentId,
referenceType: "stripe_payment_intent",
metadata: {
amountCents: Number(amountCents),
stripeSessionId: session.id,
},
});
const amountCentsNumber = Number(amountCents);
if (!Number.isFinite(amountCentsNumber) || amountCentsNumber <= 0) {
console.error("Invalid amountCents for developer credits:", {
accountId,
amountCents,
paymentIntentId,
});
return new Response("Invalid amountCents", { status: 400 });
}
await addCreditsToAccount({
accountId,
amountCents: amountCentsNumber,
referenceId: paymentIntentId,
referenceType: "stripe_payment_intent",
metadata: {
amountCents: amountCentsNumber,
stripeSessionId: session.id,
},
});


console.log("Developer credits added successfully");
return NextResponse.json({ received: true });
}

async function createGuestUser(
email: string,
): Promise<typeof users.$inferSelect> {
Expand Down Expand Up @@ -146,63 +215,7 @@ export const POST = async (req: Request) => {
});

if (session.metadata?.type === "developer_credits") {
const { accountId, amountCents } = session.metadata;
const paymentIntentId =
typeof session.payment_intent === "string"
? session.payment_intent
: null;

if (!accountId || !amountCents || !paymentIntentId) {
console.error("Missing required metadata for developer credits:", {
accountId,
amountCents,
paymentIntentId,
});
return new Response("Missing metadata", { status: 400 });
}

console.log("Processing developer credits purchase:", {
accountId,
amountCents,
paymentIntentId,
});

const [existingTxn] = await db()
.select({ id: developerCreditTransactions.id })
.from(developerCreditTransactions)
.where(
and(
eq(developerCreditTransactions.accountId, accountId),
eq(developerCreditTransactions.referenceId, paymentIntentId),
eq(
developerCreditTransactions.referenceType,
"stripe_payment_intent",
),
),
)
.limit(1);

if (existingTxn) {
console.log(
"Duplicate webhook delivery — transaction already exists:",
existingTxn.id,
);
return NextResponse.json({ received: true });
}

await addCreditsToAccount({
accountId,
amountCents: Number(amountCents),
referenceId: paymentIntentId,
referenceType: "stripe_payment_intent",
metadata: {
amountCents: Number(amountCents),
stripeSessionId: session.id,
},
});

console.log("Developer credits added successfully");
return NextResponse.json({ received: true });
return await grantDeveloperCredits(session);
}

const customer = await stripe().customers.retrieve(
Expand Down Expand Up @@ -352,6 +365,17 @@ export const POST = async (req: Request) => {
}
}

if (event.type === "checkout.session.async_payment_succeeded") {
console.log(
"Processing checkout.session.async_payment_succeeded event",
);
const session = event.data.object as Stripe.Checkout.Session;

if (session.metadata?.type === "developer_credits") {
return await grantDeveloperCredits(session);
}
}

if (event.type === "customer.subscription.updated") {
console.log("Processing customer.subscription.updated event");
const subscription = event.data.object as Stripe.Subscription;
Expand Down
Loading