From f99277e0bcd6dff79f1f67e0f933b8d2dfbd8597 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Tue, 16 Jun 2026 11:40:47 -0500 Subject: [PATCH] feat(measurement-jobs): free-tier card gate (setup mode) + instant backfill drain (#671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two chat#1796 refinements on the historical (Songstats) path: 1. Free-tier card-on-file link. The gate was issuing the paid subscription checkout ($99/mo after a 30-day trial). New createCardOnFileSession uses Stripe Checkout `mode: "setup"` — collects a card for $0, no subscription, no Stripe product. The account then pays only for metered usage via credits. 2. Instant drain. After enqueuing a historical job, fire-and-forget start(songstatsBackfillWorkflow) so the backfill begins immediately instead of waiting up to 24h for the cron. Safe by reuse: the workflow's budget gate (limit − reserve − rolling-30d ledger) caps it to the Songstats quota and SKIP LOCKED prevents double-claiming with the daily cron, which stays as the backstop. Only kicks when something was actually enqueued. 26 new/updated unit tests; research+stripe+workflows suite 453 green; tsc/lint/format clean. --- .../enqueueHistoricalBackfill.test.ts | 19 +++++++++ .../ensureSongstatsPaymentMethod.test.ts | 12 +++--- .../enqueueHistoricalBackfill.ts | 15 +++++++ .../ensureSongstatsPaymentMethod.ts | 7 +++- .../__tests__/createCardOnFileSession.test.ts | 39 +++++++++++++++++++ lib/stripe/createCardOnFileSession.ts | 31 +++++++++++++++ 6 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 lib/stripe/__tests__/createCardOnFileSession.test.ts create mode 100644 lib/stripe/createCardOnFileSession.ts diff --git a/lib/research/measurement_jobs/__tests__/enqueueHistoricalBackfill.test.ts b/lib/research/measurement_jobs/__tests__/enqueueHistoricalBackfill.test.ts index 20cde74a7..03010fa41 100644 --- a/lib/research/measurement_jobs/__tests__/enqueueHistoricalBackfill.test.ts +++ b/lib/research/measurement_jobs/__tests__/enqueueHistoricalBackfill.test.ts @@ -3,6 +3,7 @@ import { enqueueHistoricalBackfill } from "../enqueueHistoricalBackfill"; import { resolveScopeSongs } from "../resolveScopeSongs"; import { selectSongMeasurements } from "@/lib/supabase/song_measurements/selectSongMeasurements"; import { upsertSongstatsBackfillQueue } from "@/lib/supabase/songstats_backfill_queue/upsertSongstatsBackfillQueue"; +import { start } from "workflow/api"; vi.mock("../resolveScopeSongs", () => ({ resolveScopeSongs: vi.fn() })); vi.mock("@/lib/supabase/song_measurements/selectSongMeasurements", () => ({ @@ -11,6 +12,10 @@ vi.mock("@/lib/supabase/song_measurements/selectSongMeasurements", () => ({ vi.mock("@/lib/supabase/songstats_backfill_queue/upsertSongstatsBackfillQueue", () => ({ upsertSongstatsBackfillQueue: vi.fn(), })); +vi.mock("workflow/api", () => ({ start: vi.fn() })); +vi.mock("@/app/workflows/songstatsBackfillWorkflow", () => ({ + songstatsBackfillWorkflow: vi.fn(), +})); describe("enqueueHistoricalBackfill", () => { beforeEach(() => { @@ -47,6 +52,8 @@ describe("enqueueHistoricalBackfill", () => { expect(r).toEqual({ data: { status: "success", source: "historical", id: null, enqueued: 2, skipped: 1 }, }); + // kick the drain immediately so the user doesn't wait for the daily cron + expect(start).toHaveBeenCalledTimes(1); }); it("ranks a song with no prior measurement at 0", async () => { @@ -57,4 +64,16 @@ describe("enqueueHistoricalBackfill", () => { expect(upsertSongstatsBackfillQueue).toHaveBeenCalledWith({ song: "I9", rank_score: 0 }); }); + + it("does NOT kick the drain when everything was already backfilled (nothing enqueued)", async () => { + vi.mocked(resolveScopeSongs).mockResolvedValue(["I1"]); + vi.mocked(selectSongMeasurements).mockResolvedValue([ + { song: "I1", value: 500, data_source: "songstats" }, + ] as never); + + const r = await enqueueHistoricalBackfill({ isrcs: ["I1"] }); + + expect((r as { data: { enqueued: number } }).data.enqueued).toBe(0); + expect(start).not.toHaveBeenCalled(); + }); }); diff --git a/lib/research/measurement_jobs/__tests__/ensureSongstatsPaymentMethod.test.ts b/lib/research/measurement_jobs/__tests__/ensureSongstatsPaymentMethod.test.ts index 1e2c1f072..d75629a53 100644 --- a/lib/research/measurement_jobs/__tests__/ensureSongstatsPaymentMethod.test.ts +++ b/lib/research/measurement_jobs/__tests__/ensureSongstatsPaymentMethod.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { ensureSongstatsPaymentMethod } from "../ensureSongstatsPaymentMethod"; import { findStripeCustomerForAccount } from "@/lib/stripe/findStripeCustomerForAccount"; import { findDefaultPaymentMethodForCustomer } from "@/lib/stripe/findDefaultPaymentMethodForCustomer"; -import { createStripeSession } from "@/lib/stripe/createStripeSession"; +import { createCardOnFileSession } from "@/lib/stripe/createCardOnFileSession"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({})) })); vi.mock("@/lib/stripe/findStripeCustomerForAccount", () => ({ @@ -11,7 +11,7 @@ vi.mock("@/lib/stripe/findStripeCustomerForAccount", () => ({ vi.mock("@/lib/stripe/findDefaultPaymentMethodForCustomer", () => ({ findDefaultPaymentMethodForCustomer: vi.fn(), })); -vi.mock("@/lib/stripe/createStripeSession", () => ({ createStripeSession: vi.fn() })); +vi.mock("@/lib/stripe/createCardOnFileSession", () => ({ createCardOnFileSession: vi.fn() })); describe("ensureSongstatsPaymentMethod", () => { beforeEach(() => vi.clearAllMocks()); @@ -23,17 +23,17 @@ describe("ensureSongstatsPaymentMethod", () => { const r = await ensureSongstatsPaymentMethod("acc_1"); expect(r).toBeNull(); - expect(createStripeSession).not.toHaveBeenCalled(); + expect(createCardOnFileSession).not.toHaveBeenCalled(); }); it("402s with a free-tier checkout link when there is no Stripe customer", async () => { vi.mocked(findStripeCustomerForAccount).mockResolvedValue(null); - vi.mocked(createStripeSession).mockResolvedValue({ url: "https://checkout/free" } as never); + vi.mocked(createCardOnFileSession).mockResolvedValue({ url: "https://checkout/free" } as never); const r = await ensureSongstatsPaymentMethod("acc_1"); expect(findDefaultPaymentMethodForCustomer).not.toHaveBeenCalled(); - expect(createStripeSession).toHaveBeenCalledWith("acc_1", expect.any(String)); + expect(createCardOnFileSession).toHaveBeenCalledWith("acc_1", expect.any(String)); expect((r as Response).status).toBe(402); expect(await (r as Response).json()).toMatchObject({ status: "error", @@ -44,7 +44,7 @@ describe("ensureSongstatsPaymentMethod", () => { it("402s with a checkout link when the customer exists but has no card", async () => { vi.mocked(findStripeCustomerForAccount).mockResolvedValue("cus_1"); vi.mocked(findDefaultPaymentMethodForCustomer).mockResolvedValue(null); - vi.mocked(createStripeSession).mockResolvedValue({ url: "https://checkout/free" } as never); + vi.mocked(createCardOnFileSession).mockResolvedValue({ url: "https://checkout/free" } as never); const r = await ensureSongstatsPaymentMethod("acc_1"); diff --git a/lib/research/measurement_jobs/enqueueHistoricalBackfill.ts b/lib/research/measurement_jobs/enqueueHistoricalBackfill.ts index 3c0cc7af5..ffd6f3f20 100644 --- a/lib/research/measurement_jobs/enqueueHistoricalBackfill.ts +++ b/lib/research/measurement_jobs/enqueueHistoricalBackfill.ts @@ -1,6 +1,8 @@ +import { start } from "workflow/api"; import { resolveScopeSongs } from "./resolveScopeSongs"; import { selectSongMeasurements } from "@/lib/supabase/song_measurements/selectSongMeasurements"; import { upsertSongstatsBackfillQueue } from "@/lib/supabase/songstats_backfill_queue/upsertSongstatsBackfillQueue"; +import { songstatsBackfillWorkflow } from "@/app/workflows/songstatsBackfillWorkflow"; import type { CreateMeasurementJobBody } from "./validateCreateMeasurementJobRequest"; const METRIC = "platform_displayed_play_count"; @@ -61,5 +63,18 @@ export async function enqueueHistoricalBackfill( enqueued += batch.length; } + // Kick the drain now instead of waiting for the daily cron. The workflow's own + // budget gate (limit − reserve − rolling-30d ledger) means it only drains what + // the Songstats quota allows and then stops; SKIP LOCKED claims keep it from + // double-processing alongside the cron, which stays as the backstop. Fire-and- + // forget — a scheduling hiccup must not fail the (already-enqueued) job. + if (enqueued > 0) { + try { + await start(songstatsBackfillWorkflow); + } catch (error) { + console.error("[measurement-jobs] failed to kick backfill drain:", error); + } + } + return { data: { status: "success", source: "historical", id: null, enqueued, skipped } }; } diff --git a/lib/research/measurement_jobs/ensureSongstatsPaymentMethod.ts b/lib/research/measurement_jobs/ensureSongstatsPaymentMethod.ts index 71e3dd37c..8654dd19e 100644 --- a/lib/research/measurement_jobs/ensureSongstatsPaymentMethod.ts +++ b/lib/research/measurement_jobs/ensureSongstatsPaymentMethod.ts @@ -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( + accountId, + CREDIT_AUTO_RECHARGE_FALLBACK_SUCCESS_URL, + ); return NextResponse.json( { status: "error", diff --git a/lib/stripe/__tests__/createCardOnFileSession.test.ts b/lib/stripe/__tests__/createCardOnFileSession.test.ts new file mode 100644 index 000000000..14d1e0016 --- /dev/null +++ b/lib/stripe/__tests__/createCardOnFileSession.test.ts @@ -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(); + }); +}); diff --git a/lib/stripe/createCardOnFileSession.ts b/lib/stripe/createCardOnFileSession.ts new file mode 100644 index 000000000..f94959542 --- /dev/null +++ b/lib/stripe/createCardOnFileSession.ts @@ -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 { + const metadata = { accountId }; + const customer = await resolveStripeCustomerForAccount(accountId); + + return stripeClient.checkout.sessions.create({ + customer, + mode: "setup", + currency: "usd", + client_reference_id: accountId, + metadata, + success_url: successUrl, + }); +}