diff --git a/apps/web/__tests__/unit/developer-credits-webhook.test.ts b/apps/web/__tests__/unit/developer-credits-webhook.test.ts index 7a0fc14eba8..51362a9f728 100644 --- a/apps/web/__tests__/unit/developer-credits-webhook.test.ts +++ b/apps/web/__tests__/unit/developer-credits-webhook.test.ts @@ -109,6 +109,10 @@ function makeCheckoutSession(overrides: Record = {}) { 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", @@ -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({ diff --git a/apps/web/actions/video/create-for-processing.ts b/apps/web/actions/video/create-for-processing.ts index eda37e45a6a..281f7a78ffc 100644 --- a/apps/web/actions/video/create-for-processing.ts +++ b/apps/web/actions/video/create-for-processing.ts @@ -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 + ) { throw new Error("upgrade_required"); } diff --git a/apps/web/app/api/webhooks/stripe/route.ts b/apps/web/app/api/webhooks/stripe/route.ts index f5d84e3dd68..e0f3dff4f06 100644 --- a/apps/web/app/api/webhooks/stripe/route.ts +++ b/apps/web/app/api/webhooks/stripe/route.ts @@ -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 { + 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, + }, + }); + + console.log("Developer credits added successfully"); + return NextResponse.json({ received: true }); +} + async function createGuestUser( email: string, ): Promise { @@ -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( @@ -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;