-
Notifications
You must be signed in to change notification settings - Fork 9
feat(measurement-jobs): free-tier card gate (setup mode) + instant backfill drain (chat#1796) #671
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; | |
| import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; | ||
| import { findStripeCustomerForAccount } from "@/lib/stripe/findStripeCustomerForAccount"; | ||
| import { findDefaultPaymentMethodForCustomer } from "@/lib/stripe/findDefaultPaymentMethodForCustomer"; | ||
| import { createStripeSession } from "@/lib/stripe/createStripeSession"; | ||
| import { createCardOnFileSession } from "@/lib/stripe/createCardOnFileSession"; | ||
| import { CREDIT_AUTO_RECHARGE_FALLBACK_SUCCESS_URL } from "@/lib/credits/const"; | ||
|
|
||
| /** | ||
|
|
@@ -22,7 +22,10 @@ export async function ensureSongstatsPaymentMethod( | |
| const paymentMethod = customerId ? await findDefaultPaymentMethodForCustomer(customerId) : null; | ||
| if (paymentMethod) return null; | ||
|
|
||
| const session = await createStripeSession(accountId, CREDIT_AUTO_RECHARGE_FALLBACK_SUCCESS_URL); | ||
| const session = await createCardOnFileSession( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: JSDoc still references "subscription + trial" but the call site now uses createCardOnFileSession (mode: "setup", $0, no subscription). Docstring is misleading after this change. Prompt for AI agents |
||
| accountId, | ||
| CREDIT_AUTO_RECHARGE_FALLBACK_SUCCESS_URL, | ||
| ); | ||
| return NextResponse.json( | ||
| { | ||
| status: "error", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
|
|
||
| const { checkoutSessionsCreate, resolveStripeCustomerForAccountMock } = vi.hoisted(() => ({ | ||
| checkoutSessionsCreate: vi.fn(), | ||
| resolveStripeCustomerForAccountMock: vi.fn(), | ||
| })); | ||
|
|
||
| vi.mock("@/lib/stripe/client", () => ({ | ||
| default: { checkout: { sessions: { create: checkoutSessionsCreate } } }, | ||
| })); | ||
| vi.mock("@/lib/stripe/resolveStripeCustomerForAccount", () => ({ | ||
| resolveStripeCustomerForAccount: resolveStripeCustomerForAccountMock, | ||
| })); | ||
|
|
||
| const { createCardOnFileSession } = await import("@/lib/stripe/createCardOnFileSession"); | ||
|
|
||
| describe("createCardOnFileSession", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| checkoutSessionsCreate.mockResolvedValue({ id: "cs_x", url: "https://checkout/setup" }); | ||
| resolveStripeCustomerForAccountMock.mockResolvedValue("cus_acc1"); | ||
| }); | ||
|
|
||
| it("creates a setup-mode session (collect card only, no subscription/price)", async () => { | ||
| await createCardOnFileSession("acc-1", "https://example.com/success"); | ||
|
|
||
| expect(resolveStripeCustomerForAccountMock).toHaveBeenCalledWith("acc-1"); | ||
| const params = checkoutSessionsCreate.mock.calls[0][0]; | ||
| expect(params).toMatchObject({ | ||
| customer: "cus_acc1", | ||
| mode: "setup", | ||
| client_reference_id: "acc-1", | ||
| success_url: "https://example.com/success", | ||
| }); | ||
| // free tier: no subscription, no line_items/price | ||
| expect(params.mode).not.toBe("subscription"); | ||
| expect(params.line_items).toBeUndefined(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import type Stripe from "stripe"; | ||
| import stripeClient from "@/lib/stripe/client"; | ||
| import { resolveStripeCustomerForAccount } from "@/lib/stripe/resolveStripeCustomerForAccount"; | ||
|
|
||
| /** | ||
| * A Stripe Checkout session that **only collects a card on file** — `mode: | ||
| * "setup"`, $0, no subscription or price. This is the "free tier": the account | ||
| * saves a payment method (so metered Songstats usage can be charged later via | ||
| * the credits system) without committing to any recurring plan. Needs no Stripe | ||
| * product. Contrast with {@link createStripeSession}, which is the paid | ||
| * subscription flow. | ||
| * | ||
| * @param accountId - The account to attach the card to. | ||
| * @param successUrl - Where Stripe redirects after the card is saved. | ||
| */ | ||
| export async function createCardOnFileSession( | ||
| accountId: string, | ||
| successUrl: string, | ||
| ): Promise<Stripe.Checkout.Session> { | ||
| const metadata = { accountId }; | ||
| const customer = await resolveStripeCustomerForAccount(accountId); | ||
|
|
||
| return stripeClient.checkout.sessions.create({ | ||
| customer, | ||
| mode: "setup", | ||
| currency: "usd", | ||
| client_reference_id: accountId, | ||
| metadata, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Missing setup_intent_data: { metadata } — the session-level metadata is set but not propagated to the SetupIntent sub-resource, unlike createStripeSession which propagates metadata via subscription_data. Any future webhook on setup_intent events won't have accountId. Prompt for AI agents |
||
| success_url: successUrl, | ||
| }); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Comment says "fire-and-forget" but
await start(...)blocks the response on the workflow-scheduling call. A scheduling delay/hang instart()delays the already-successful enqueue result from reaching the caller.Prompt for AI agents