From 5dc46785b4ebe321f8fdee5359575ed51da40c3f Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 2 Jul 2026 13:03:11 -0500 Subject: [PATCH] feat(socials): credit-gate both scrape endpoints (5 + posts credits) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Charges 5 credits plus 1 credit per requested post for each social profile scraped, priced from measured Apify costs (recoupable/chat#1836 cost research: worst case is YouTube at ~$0.003 × 2×posts items; 5+posts at 1 credit = $0.01 keeps ≥1.75x margin at posts=100). - getSocialScrapeCreditCost: 5 + (posts ?? 0) - ensureSocialScrapeCredits / deductSocialScrapeCredits: mirror the research family's gate (ensureCreditsOrShortCircuit → 402 with checkoutUrl) and deduct-on-success (recordCreditDeduction, never throws) - POST /api/socials/{id}/scrape: validator gates after auth+access; handler deducts only when the Apify run started (no charge on unsupported platform or scrape-start failure) - POST /api/artist/socials/scrape: gains auth (validateAuthContext) + artist access check (checkAccountArtistAccess) — it previously had none; gates on (5+posts) × linked profiles, deducts per profile whose scrape actually started; [] with no charge when the artist has no socials Contract: recoupable/docs#259. Closes the credit-gate item on recoupable/chat#1836. Co-Authored-By: Claude Fable 5 --- .../postArtistSocialsScrapeHandler.test.ts | 81 ++++++++++++++++--- lib/artist/postArtistSocialsScrapeHandler.ts | 33 ++++++++ .../deductSocialScrapeCredits.test.ts | 28 +++++++ .../ensureSocialScrapeCredits.test.ts | 31 +++++++ .../getSocialScrapeCreditCost.test.ts | 15 ++++ .../__tests__/postSocialScrapeHandler.test.ts | 24 +++++- .../validatePostSocialScrapeRequest.test.ts | 15 +++- lib/socials/deductSocialScrapeCredits.ts | 18 +++++ lib/socials/ensureSocialScrapeCredits.ts | 21 +++++ lib/socials/getSocialScrapeCreditCost.ts | 12 +++ lib/socials/postSocialScrapeHandler.ts | 6 ++ .../validatePostSocialScrapeRequest.ts | 14 +++- 12 files changed, 279 insertions(+), 19 deletions(-) create mode 100644 lib/socials/__tests__/deductSocialScrapeCredits.test.ts create mode 100644 lib/socials/__tests__/ensureSocialScrapeCredits.test.ts create mode 100644 lib/socials/__tests__/getSocialScrapeCreditCost.test.ts create mode 100644 lib/socials/deductSocialScrapeCredits.ts create mode 100644 lib/socials/ensureSocialScrapeCredits.ts create mode 100644 lib/socials/getSocialScrapeCreditCost.ts diff --git a/lib/artist/__tests__/postArtistSocialsScrapeHandler.test.ts b/lib/artist/__tests__/postArtistSocialsScrapeHandler.test.ts index 6ef63926..9fc090e4 100644 --- a/lib/artist/__tests__/postArtistSocialsScrapeHandler.test.ts +++ b/lib/artist/__tests__/postArtistSocialsScrapeHandler.test.ts @@ -1,17 +1,32 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { postArtistSocialsScrapeHandler } from "../postArtistSocialsScrapeHandler"; import { scrapeProfileUrlBatch } from "@/lib/apify/scrapeProfileUrlBatch"; import { selectAccountSocials } from "@/lib/supabase/account_socials/selectAccountSocials"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; +import { ensureSocialScrapeCredits } from "@/lib/socials/ensureSocialScrapeCredits"; +import { deductSocialScrapeCredits } from "@/lib/socials/deductSocialScrapeCredits"; vi.mock("@/lib/apify/scrapeProfileUrlBatch", () => ({ scrapeProfileUrlBatch: vi.fn() })); vi.mock("@/lib/supabase/account_socials/selectAccountSocials", () => ({ selectAccountSocials: vi.fn(), })); +vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn() })); +vi.mock("@/lib/artists/checkAccountArtistAccess", () => ({ checkAccountArtistAccess: vi.fn() })); +vi.mock("@/lib/socials/ensureSocialScrapeCredits", () => ({ ensureSocialScrapeCredits: vi.fn() })); +vi.mock("@/lib/socials/deductSocialScrapeCredits", () => ({ deductSocialScrapeCredits: vi.fn() })); vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({}) })); const ARTIST_ID = "660e8400-e29b-41d4-a716-446655440000"; +const ACCOUNT_ID = "770e8400-e29b-41d4-a716-446655440000"; +const authResult = { accountId: ACCOUNT_ID, authToken: "t", orgId: null }; + +const TWO_SOCIALS = [ + { social: { profile_url: "https://x.com/a", username: "a" } }, + { social: { profile_url: "https://youtube.com/@b", username: "b" } }, +]; const makeRequest = (body: unknown) => new NextRequest("http://localhost/api/artist/socials/scrape", { @@ -22,11 +37,13 @@ const makeRequest = (body: unknown) => describe("postArtistSocialsScrapeHandler", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(selectAccountSocials).mockResolvedValue([ - { social: { profile_url: "https://x.com/a", username: "a" } } as never, - ]); + vi.mocked(validateAuthContext).mockResolvedValue(authResult); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + vi.mocked(ensureSocialScrapeCredits).mockResolvedValue(null); + vi.mocked(selectAccountSocials).mockResolvedValue(TWO_SOCIALS as never); vi.mocked(scrapeProfileUrlBatch).mockResolvedValue([ { runId: "r1", datasetId: "d1", error: null }, + { runId: "r2", datasetId: "d2", error: null }, ]); }); @@ -36,28 +53,66 @@ describe("postArtistSocialsScrapeHandler", () => { expect(scrapeProfileUrlBatch).not.toHaveBeenCalled(); }); - it("scrapes without a posts depth by default (legacy behavior)", async () => { + it("propagates the auth error (endpoint is no longer open)", async () => { + const err = NextResponse.json({}, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(err); + expect( + await postArtistSocialsScrapeHandler(makeRequest({ artist_account_id: ARTIST_ID })), + ).toBe(err); + expect(selectAccountSocials).not.toHaveBeenCalled(); + expect(scrapeProfileUrlBatch).not.toHaveBeenCalled(); + }); + + it("returns 403 when the caller has no access to the artist", async () => { + vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); + const res = await postArtistSocialsScrapeHandler(makeRequest({ artist_account_id: ARTIST_ID })); + expect(res.status).toBe(403); + expect(checkAccountArtistAccess).toHaveBeenCalledWith(ACCOUNT_ID, ARTIST_ID); + expect(scrapeProfileUrlBatch).not.toHaveBeenCalled(); + }); + + it("gates on (5 + posts) × profiles credits and short-circuits with the 402", async () => { + const short = NextResponse.json({}, { status: 402 }); + vi.mocked(ensureSocialScrapeCredits).mockResolvedValue(short); + expect( + await postArtistSocialsScrapeHandler( + makeRequest({ artist_account_id: ARTIST_ID, posts: 20 }), + ), + ).toBe(short); + expect(ensureSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 50); + expect(scrapeProfileUrlBatch).not.toHaveBeenCalled(); + }); + + it("scrapes without a posts depth by default and deducts 5 credits per scraped profile", async () => { const res = await postArtistSocialsScrapeHandler(makeRequest({ artist_account_id: ARTIST_ID })); expect(res.status).toBe(200); + expect(ensureSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 10); expect(scrapeProfileUrlBatch).toHaveBeenCalledWith( - [{ profileUrl: "https://x.com/a", username: "a" }], + [ + { profileUrl: "https://x.com/a", username: "a" }, + { profileUrl: "https://youtube.com/@b", username: "b" }, + ], undefined, ); + expect(deductSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 10); }); - it("forwards posts to scrapeProfileUrlBatch", async () => { + it("forwards posts and deducts (5 + posts) per profile actually scraped", async () => { + vi.mocked(scrapeProfileUrlBatch).mockResolvedValue([ + { runId: "r1", datasetId: "d1", error: null }, + ]); await postArtistSocialsScrapeHandler(makeRequest({ artist_account_id: ARTIST_ID, posts: 20 })); - expect(scrapeProfileUrlBatch).toHaveBeenCalledWith( - [{ profileUrl: "https://x.com/a", username: "a" }], - 20, - ); + expect(ensureSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 50); + expect(scrapeProfileUrlBatch).toHaveBeenCalledWith(expect.any(Array), 20); + expect(deductSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 25); }); - it("returns [] when the artist has no socials", async () => { + it("returns [] and charges nothing when the artist has no socials", async () => { vi.mocked(selectAccountSocials).mockResolvedValue([]); const res = await postArtistSocialsScrapeHandler(makeRequest({ artist_account_id: ARTIST_ID })); expect(res.status).toBe(200); expect(await res.json()).toEqual([]); - expect(scrapeProfileUrlBatch).not.toHaveBeenCalled(); + expect(ensureSocialScrapeCredits).not.toHaveBeenCalled(); + expect(deductSocialScrapeCredits).not.toHaveBeenCalled(); }); }); diff --git a/lib/artist/postArtistSocialsScrapeHandler.ts b/lib/artist/postArtistSocialsScrapeHandler.ts index 24242971..53067082 100644 --- a/lib/artist/postArtistSocialsScrapeHandler.ts +++ b/lib/artist/postArtistSocialsScrapeHandler.ts @@ -1,14 +1,25 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { errorResponse } from "@/lib/networking/errorResponse"; import { scrapeProfileUrlBatch } from "@/lib/apify/scrapeProfileUrlBatch"; import { validateArtistSocialsScrapeBody } from "@/lib/artist/validateArtistSocialsScrapeBody"; import { selectAccountSocials } from "@/lib/supabase/account_socials/selectAccountSocials"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; +import { ensureSocialScrapeCredits } from "@/lib/socials/ensureSocialScrapeCredits"; +import { deductSocialScrapeCredits } from "@/lib/socials/deductSocialScrapeCredits"; +import { getSocialScrapeCreditCost } from "@/lib/socials/getSocialScrapeCreditCost"; /** * Handler for scraping artist social profiles. * * Body parameters: * - artist_account_id (required): The unique identifier of the artist account + * - posts (optional): Recent-posts depth forwarded to each platform scraper + * + * Costs 5 credits plus 1 per requested post, per social profile scraped. + * Credits are gated up-front against the number of linked profiles and + * deducted for the profiles whose scrape actually started. * * @param request - The request object containing the body with artist_account_id. * @returns A NextResponse with scraping results. @@ -22,6 +33,17 @@ export async function postArtistSocialsScrapeHandler(request: NextRequest): Prom return validatedBody; } + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const hasAccess = await checkAccountArtistAccess( + authResult.accountId, + validatedBody.artist_account_id, + ); + if (!hasAccess) { + return errorResponse("Unauthorized artist socials scrape attempt", 403); + } + const socials = await selectAccountSocials({ accountId: validatedBody.artist_account_id }); if (!socials.length) { @@ -31,6 +53,13 @@ export async function postArtistSocialsScrapeHandler(request: NextRequest): Prom }); } + const costPerProfile = getSocialScrapeCreditCost(validatedBody.posts); + const short = await ensureSocialScrapeCredits( + authResult.accountId, + costPerProfile * socials.length, + ); + if (short) return short; + const results = await scrapeProfileUrlBatch( socials.map(social => ({ profileUrl: social.social?.profile_url, @@ -39,6 +68,10 @@ export async function postArtistSocialsScrapeHandler(request: NextRequest): Prom validatedBody.posts, ); + if (results.length) { + await deductSocialScrapeCredits(authResult.accountId, costPerProfile * results.length); + } + return NextResponse.json(results, { status: 200, headers: getCorsHeaders(), diff --git a/lib/socials/__tests__/deductSocialScrapeCredits.test.ts b/lib/socials/__tests__/deductSocialScrapeCredits.test.ts new file mode 100644 index 00000000..7e965651 --- /dev/null +++ b/lib/socials/__tests__/deductSocialScrapeCredits.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { deductSocialScrapeCredits } from "../deductSocialScrapeCredits"; +import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; + +vi.mock("@/lib/credits/recordCreditDeduction", () => ({ recordCreditDeduction: vi.fn() })); + +const ACCOUNT_ID = "770e8400-e29b-41d4-a716-446655440000"; + +describe("deductSocialScrapeCredits", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("records the deduction with the given credits", async () => { + await deductSocialScrapeCredits(ACCOUNT_ID, 25); + expect(recordCreditDeduction).toHaveBeenCalledWith({ + accountId: ACCOUNT_ID, + creditsToDeduct: 25, + source: "api", + }); + }); + + it("never throws when the deduction fails (billing must not fail a started scrape)", async () => { + vi.mocked(recordCreditDeduction).mockRejectedValue(new Error("db down")); + await expect(deductSocialScrapeCredits(ACCOUNT_ID, 5)).resolves.toBeUndefined(); + }); +}); diff --git a/lib/socials/__tests__/ensureSocialScrapeCredits.test.ts b/lib/socials/__tests__/ensureSocialScrapeCredits.test.ts new file mode 100644 index 00000000..01ecddf9 --- /dev/null +++ b/lib/socials/__tests__/ensureSocialScrapeCredits.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; + +import { ensureSocialScrapeCredits } from "../ensureSocialScrapeCredits"; +import { ensureCreditsOrShortCircuit } from "@/lib/credits/ensureCreditsOrShortCircuit"; + +vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ + ensureCreditsOrShortCircuit: vi.fn(), +})); + +const ACCOUNT_ID = "770e8400-e29b-41d4-a716-446655440000"; + +describe("ensureSocialScrapeCredits", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(ensureCreditsOrShortCircuit).mockResolvedValue(null); + }); + + it("gates on the given credit amount", async () => { + expect(await ensureSocialScrapeCredits(ACCOUNT_ID, 25)).toBeNull(); + expect(ensureCreditsOrShortCircuit).toHaveBeenCalledWith( + expect.objectContaining({ accountId: ACCOUNT_ID, creditsToDeduct: 25 }), + ); + }); + + it("passes through the 402 short-circuit response", async () => { + const short = NextResponse.json({}, { status: 402 }); + vi.mocked(ensureCreditsOrShortCircuit).mockResolvedValue(short); + expect(await ensureSocialScrapeCredits(ACCOUNT_ID, 5)).toBe(short); + }); +}); diff --git a/lib/socials/__tests__/getSocialScrapeCreditCost.test.ts b/lib/socials/__tests__/getSocialScrapeCreditCost.test.ts new file mode 100644 index 00000000..bd4a5d9c --- /dev/null +++ b/lib/socials/__tests__/getSocialScrapeCreditCost.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from "vitest"; + +import { getSocialScrapeCreditCost } from "../getSocialScrapeCreditCost"; + +describe("getSocialScrapeCreditCost", () => { + it("charges the 5-credit base when posts is omitted", () => { + expect(getSocialScrapeCreditCost(undefined)).toBe(5); + }); + + it("charges 5 + posts when a depth is requested", () => { + expect(getSocialScrapeCreditCost(1)).toBe(6); + expect(getSocialScrapeCreditCost(20)).toBe(25); + expect(getSocialScrapeCreditCost(100)).toBe(105); + }); +}); diff --git a/lib/socials/__tests__/postSocialScrapeHandler.test.ts b/lib/socials/__tests__/postSocialScrapeHandler.test.ts index 54ce9b30..c645aa2c 100644 --- a/lib/socials/__tests__/postSocialScrapeHandler.test.ts +++ b/lib/socials/__tests__/postSocialScrapeHandler.test.ts @@ -5,19 +5,22 @@ import { postSocialScrapeHandler } from "../postSocialScrapeHandler"; import { validatePostSocialScrapeRequest } from "../validatePostSocialScrapeRequest"; import { selectSocials } from "@/lib/supabase/socials/selectSocials"; import { scrapeProfileUrl } from "@/lib/apify/scrapeProfileUrl"; +import { deductSocialScrapeCredits } from "../deductSocialScrapeCredits"; vi.mock("../validatePostSocialScrapeRequest", () => ({ validatePostSocialScrapeRequest: vi.fn(), })); vi.mock("@/lib/supabase/socials/selectSocials", () => ({ selectSocials: vi.fn() })); vi.mock("@/lib/apify/scrapeProfileUrl", () => ({ scrapeProfileUrl: vi.fn() })); +vi.mock("../deductSocialScrapeCredits", () => ({ deductSocialScrapeCredits: vi.fn() })); vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({}) })); const SOCIAL_ID = "550e8400-e29b-41d4-a716-446655440000"; +const ACCOUNT_ID = "770e8400-e29b-41d4-a716-446655440000"; const request = new NextRequest(`http://localhost/api/socials/${SOCIAL_ID}/scrape`, { method: "POST", }); -const validated = { social_id: SOCIAL_ID }; +const validated = { social_id: SOCIAL_ID, account_id: ACCOUNT_ID }; const social = { id: SOCIAL_ID, profile_url: "https://x.com/a", username: "a" }; describe("postSocialScrapeHandler", () => { @@ -40,22 +43,37 @@ describe("postSocialScrapeHandler", () => { expect(res.status).toBe(404); }); - it("returns 200 with { runId, datasetId } on success", async () => { + it("returns 200 with { runId, datasetId } on success and deducts the base 5 credits", async () => { vi.mocked(scrapeProfileUrl).mockResolvedValue({ runId: "r1", datasetId: "d1" } as never); const res = await postSocialScrapeHandler(request, SOCIAL_ID); expect(res.status).toBe(200); expect(await res.json()).toEqual({ runId: "r1", datasetId: "d1" }); expect(scrapeProfileUrl).toHaveBeenCalledWith(social.profile_url, social.username, undefined); + expect(deductSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 5); }); - it("forwards validated posts to scrapeProfileUrl", async () => { + it("forwards validated posts to scrapeProfileUrl and deducts 5 + posts credits", async () => { vi.mocked(validatePostSocialScrapeRequest).mockResolvedValue({ social_id: SOCIAL_ID, + account_id: ACCOUNT_ID, posts: 20, }); vi.mocked(scrapeProfileUrl).mockResolvedValue({ runId: "r1", datasetId: "d1" } as never); await postSocialScrapeHandler(request, SOCIAL_ID); expect(scrapeProfileUrl).toHaveBeenCalledWith(social.profile_url, social.username, 20); + expect(deductSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 25); + }); + + it("does not deduct credits when the scrape fails to start", async () => { + vi.mocked(scrapeProfileUrl).mockResolvedValue({ error: "boom" } as never); + await postSocialScrapeHandler(request, SOCIAL_ID); + expect(deductSocialScrapeCredits).not.toHaveBeenCalled(); + }); + + it("does not deduct credits when the platform is unsupported", async () => { + vi.mocked(scrapeProfileUrl).mockResolvedValue(null as never); + await postSocialScrapeHandler(request, SOCIAL_ID); + expect(deductSocialScrapeCredits).not.toHaveBeenCalled(); }); it("returns 400 when platform unsupported (scrapeProfileUrl returns null)", async () => { diff --git a/lib/socials/__tests__/validatePostSocialScrapeRequest.test.ts b/lib/socials/__tests__/validatePostSocialScrapeRequest.test.ts index d74d2c8d..ded2f4ec 100644 --- a/lib/socials/__tests__/validatePostSocialScrapeRequest.test.ts +++ b/lib/socials/__tests__/validatePostSocialScrapeRequest.test.ts @@ -6,6 +6,7 @@ import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { selectSocials } from "@/lib/supabase/socials/selectSocials"; import { selectAccountSocials } from "@/lib/supabase/account_socials/selectAccountSocials"; import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; +import { ensureSocialScrapeCredits } from "../ensureSocialScrapeCredits"; vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn() })); vi.mock("@/lib/supabase/socials/selectSocials", () => ({ selectSocials: vi.fn() })); @@ -15,6 +16,7 @@ vi.mock("@/lib/supabase/account_socials/selectAccountSocials", () => ({ vi.mock("@/lib/artists/checkAccountArtistAccess", () => ({ checkAccountArtistAccess: vi.fn(), })); +vi.mock("../ensureSocialScrapeCredits", () => ({ ensureSocialScrapeCredits: vi.fn() })); vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({}) })); const SOCIAL_ID = "550e8400-e29b-41d4-a716-446655440000"; @@ -35,6 +37,7 @@ describe("validatePostSocialScrapeRequest", () => { vi.mocked(selectSocials).mockResolvedValue([{ id: SOCIAL_ID } as never]); vi.mocked(selectAccountSocials).mockResolvedValue([{ account_id: ARTIST_ID } as never]); vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + vi.mocked(ensureSocialScrapeCredits).mockResolvedValue(null); }); it.each([["not-a-uuid"], [""], ["123"]])("returns 400 for invalid uuid %s", async id => { @@ -74,10 +77,12 @@ describe("validatePostSocialScrapeRequest", () => { expect(await validatePostSocialScrapeRequest(makeRequest(), SOCIAL_ID)).toEqual({ social_id: SOCIAL_ID, posts: undefined, + account_id: ACCOUNT_ID, }); + expect(ensureSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 5); }); - it("parses a valid posts query param", async () => { + it("parses a valid posts query param and gates on 5 + posts credits", async () => { const req = new NextRequest(`http://localhost/api/socials/${SOCIAL_ID}/scrape?posts=20`, { method: "POST", headers: { "x-api-key": "k" }, @@ -85,7 +90,15 @@ describe("validatePostSocialScrapeRequest", () => { expect(await validatePostSocialScrapeRequest(req, SOCIAL_ID)).toEqual({ social_id: SOCIAL_ID, posts: 20, + account_id: ACCOUNT_ID, }); + expect(ensureSocialScrapeCredits).toHaveBeenCalledWith(ACCOUNT_ID, 25); + }); + + it("short-circuits with the 402 when credits are insufficient", async () => { + const short = NextResponse.json({}, { status: 402 }); + vi.mocked(ensureSocialScrapeCredits).mockResolvedValue(short); + expect(await validatePostSocialScrapeRequest(makeRequest(), SOCIAL_ID)).toBe(short); }); it.each([["0"], ["101"], ["abc"], ["1.5"]])( diff --git a/lib/socials/deductSocialScrapeCredits.ts b/lib/socials/deductSocialScrapeCredits.ts new file mode 100644 index 00000000..41e9977b --- /dev/null +++ b/lib/socials/deductSocialScrapeCredits.ts @@ -0,0 +1,18 @@ +import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; + +/** + * Deduct credits for a scrape whose Apify run(s) started. Failures are logged, + * never thrown — a billing hiccup must not fail a scrape that is already + * running (mirrors the research family's deduction). + */ +export async function deductSocialScrapeCredits(accountId: string, credits: number): Promise { + try { + await recordCreditDeduction({ + accountId, + creditsToDeduct: credits, + source: "api", + }); + } catch (error) { + console.error("[socials] scrape credit deduction failed:", error); + } +} diff --git a/lib/socials/ensureSocialScrapeCredits.ts b/lib/socials/ensureSocialScrapeCredits.ts new file mode 100644 index 00000000..6b51db32 --- /dev/null +++ b/lib/socials/ensureSocialScrapeCredits.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { ensureCreditsOrShortCircuit } from "@/lib/credits/ensureCreditsOrShortCircuit"; +import { CREDIT_AUTO_RECHARGE_FALLBACK_SUCCESS_URL } from "@/lib/credits/const"; + +/** + * Per-route credit gate for the social scrape endpoints. Checks the account + * can cover `credits` (auto-recharging via a saved card if needed) before any + * Apify run is started. Returns a 402 NextResponse the caller can `return` + * directly, or `null` to signal "you're good, proceed." Deduction happens + * after the scrape starts, via `deductSocialScrapeCredits`. + */ +export function ensureSocialScrapeCredits( + accountId: string, + credits: number, +): Promise { + return ensureCreditsOrShortCircuit({ + accountId, + creditsToDeduct: credits, + successUrl: CREDIT_AUTO_RECHARGE_FALLBACK_SUCCESS_URL, + }); +} diff --git a/lib/socials/getSocialScrapeCreditCost.ts b/lib/socials/getSocialScrapeCreditCost.ts new file mode 100644 index 00000000..46cc5e27 --- /dev/null +++ b/lib/socials/getSocialScrapeCreditCost.ts @@ -0,0 +1,12 @@ +/** Base credits charged per social scrape (matches the research family's flat rate). */ +export const SOCIAL_SCRAPE_BASE_CREDIT_COST = 5; + +/** + * Credits for one profile scrape: 5 base + 1 per requested post. Priced from + * measured Apify costs (see recoupable/chat#1836): the worst case is YouTube at + * ~$0.003 × 2×posts dataset items, so 5 + posts credits (1 credit = $0.01) + * keeps ≥1.75× margin at posts=100 while staying one rule for every platform. + */ +export function getSocialScrapeCreditCost(posts?: number): number { + return SOCIAL_SCRAPE_BASE_CREDIT_COST + (posts ?? 0); +} diff --git a/lib/socials/postSocialScrapeHandler.ts b/lib/socials/postSocialScrapeHandler.ts index 7489d670..71080bdc 100644 --- a/lib/socials/postSocialScrapeHandler.ts +++ b/lib/socials/postSocialScrapeHandler.ts @@ -3,6 +3,8 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { selectSocials } from "@/lib/supabase/socials/selectSocials"; import { scrapeProfileUrl } from "@/lib/apify/scrapeProfileUrl"; import { validatePostSocialScrapeRequest } from "@/lib/socials/validatePostSocialScrapeRequest"; +import { deductSocialScrapeCredits } from "@/lib/socials/deductSocialScrapeCredits"; +import { getSocialScrapeCreditCost } from "@/lib/socials/getSocialScrapeCreditCost"; export async function postSocialScrapeHandler( request: NextRequest, @@ -49,6 +51,10 @@ export async function postSocialScrapeHandler( }, ); } + await deductSocialScrapeCredits( + validated.account_id, + getSocialScrapeCreditCost(validated.posts), + ); return NextResponse.json( { runId: scrapeResult.runId, diff --git a/lib/socials/validatePostSocialScrapeRequest.ts b/lib/socials/validatePostSocialScrapeRequest.ts index 8feefe62..c24bbfe3 100644 --- a/lib/socials/validatePostSocialScrapeRequest.ts +++ b/lib/socials/validatePostSocialScrapeRequest.ts @@ -6,6 +6,8 @@ import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { selectSocials } from "@/lib/supabase/socials/selectSocials"; import { selectAccountSocials } from "@/lib/supabase/account_socials/selectAccountSocials"; import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; +import { ensureSocialScrapeCredits } from "@/lib/socials/ensureSocialScrapeCredits"; +import { getSocialScrapeCreditCost } from "@/lib/socials/getSocialScrapeCreditCost"; export const postSocialScrapeParamsSchema = z.object({ social_id: z.string().uuid("social_id must be a valid UUID"), @@ -17,7 +19,9 @@ export const postSocialScrapeParamsSchema = z.object({ .optional(), }); -export type PostSocialScrapeParams = z.infer; +export type PostSocialScrapeParams = z.infer & { + account_id: string; +}; export async function validatePostSocialScrapeRequest( request: NextRequest, @@ -60,5 +64,11 @@ export async function validatePostSocialScrapeRequest( return errorResponse("Unauthorized social scrape attempt", 403); } - return { social_id, posts: parsed.data.posts }; + const short = await ensureSocialScrapeCredits( + authResult.accountId, + getSocialScrapeCreditCost(parsed.data.posts), + ); + if (short) return short; + + return { social_id, posts: parsed.data.posts, account_id: authResult.accountId }; }