Skip to content

feat(socials): credit-gate both scrape endpoints (5 + posts credits) + auth on bulk#744

Merged
sweetmantech merged 1 commit into
mainfrom
feat/social-scrape-credit-gate
Jul 2, 2026
Merged

feat(socials): credit-gate both scrape endpoints (5 + posts credits) + auth on bulk#744
sweetmantech merged 1 commit into
mainfrom
feat/social-scrape-credit-gate

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements the credit-gate item on recoupable/chat#1836, priced per the Apify cost research. Contract: recoupable/docs#259merge order: docs → api.

Pricing: 5 credits + 1 per requested post, per profile scraped (1 credit = $0.01). Worst-case Apify cost is YouTube at $0.003 × 2×posts items ($0.60 at posts=100) → 105 credits keeps ≥1.75× margin; default scrape (5 credits) has 3–20× margin on every platform.

Layer Change
lib/socials/getSocialScrapeCreditCost.ts 5 + (posts ?? 0)
lib/socials/ensureSocialScrapeCredits.ts gate via ensureCreditsOrShortCircuit → 402 with checkoutUrl (auto-recharge path included)
lib/socials/deductSocialScrapeCredits.ts deduct-on-success via recordCreditDeduction; never throws (mirrors research)
POST /api/socials/{id}/scrape validator gates after auth + artist-access; handler deducts only when the run started (no charge on unsupported platform / failed start)
POST /api/artist/socials/scrape gains auth (validateAuthContext → 401) + artist access (checkAccountArtistAccess → 403) — previously fully open (audit); gates on (5+posts) × linked profiles, deducts per profile whose scrape started

⚠️ Behavior change: the bulk endpoint now requires auth, and both endpoints charge credits. No internal callers exist (grep across chat/api/tasks/admin found only the route itself); external consumers get documented 401/402/403s per docs#259.

Tests (TDD, red → green)

All new behavior written test-first and confirmed failing (10 failures across 6 files) before implementation:

  • getSocialScrapeCreditCost: 5 / 6 / 25 / 105
  • ensureSocialScrapeCredits / deductSocialScrapeCredits: gate passthrough, 402 short-circuit, never-throws deduction
  • single endpoint: payload carries account_id; ensure called with 5 (default) / 25 (posts=20); 402 short-circuit; deduct on success only (not on unsupported platform or failed start)
  • bulk endpoint: 401 propagation, 403 no-access, ensure (5+20)×2=50, deduct per started profile ((5+20)×1=25 when one of two starts), [] with zero charge when no socials

Affected domains (lib/socials, lib/artist, lib/apify, lib/credits): 324/324 pass. Full suite passes; tsc --noEmit unchanged at 200 pre-existing errors (none new); ESLint clean.

Preview verification to follow as a PR comment.

🤖 Generated with Claude Code


Summary by cubic

Adds credit gating to both social scrape endpoints at 5 credits + 1 per requested post per profile, and adds auth + artist access checks to the bulk artist scrape. Aligns the API with the cost model and docs in recoupable/docs#259 and closes the credit-gate requirement in recoupable/chat#1836.

  • New Features

    • Upfront credit check with 402 short-circuit and checkoutUrl; cost = 5 + posts per profile.
    • POST /api/socials/{id}/scrape: validates auth and artist access, forwards posts, deducts only if the Apify run starts.
    • POST /api/artist/socials/scrape: now requires auth and artist access; gates on (5 + posts) × linked profiles; deducts per profile that starts; returns [] with no charge when no socials.
  • Migration

    • Merge order: docs → api (recoupable/docs#259).
    • External callers must include auth and handle 401/402/403 responses.

Written for commit 5dc4678. Summary will update on new commits.

Review in cubic

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 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api Ready Ready Preview Jul 2, 2026 6:04pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@sweetmantech, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 38 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a7061ad2-eb95-4410-bffe-e824b1e5e2b5

📥 Commits

Reviewing files that changed from the base of the PR and between d5f649f and 5dc4678.

⛔ Files ignored due to path filters (6)
  • lib/artist/__tests__/postArtistSocialsScrapeHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/socials/__tests__/deductSocialScrapeCredits.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/socials/__tests__/ensureSocialScrapeCredits.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/socials/__tests__/getSocialScrapeCreditCost.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/socials/__tests__/postSocialScrapeHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/socials/__tests__/validatePostSocialScrapeRequest.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (6)
  • lib/artist/postArtistSocialsScrapeHandler.ts
  • lib/socials/deductSocialScrapeCredits.ts
  • lib/socials/ensureSocialScrapeCredits.ts
  • lib/socials/getSocialScrapeCreditCost.ts
  • lib/socials/postSocialScrapeHandler.ts
  • lib/socials/validatePostSocialScrapeRequest.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/social-scrape-credit-gate

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 12 files

Confidence score: 3/5

  • In lib/socials/deductSocialScrapeCredits.ts, deductSocialScrapeCredits forwards credits without validating for positive finite values, so a negative/zero/NaN input could create invalid billing ledger entries and incorrect customer balances if merged as-is — add a guard that rejects non-finite or non-positive amounts before calling the downstream deduction path.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/socials/deductSocialScrapeCredits.ts">

<violation number="1" location="lib/socials/deductSocialScrapeCredits.ts:8">
P2: This billing wrapper doesn't validate the `credits` amount before passing it downstream. If an upstream caller ever passes a negative, zero, or non-finite value, it could write invalid ledger deductions without any runtime protection. Consider adding a defensive guard — for example, early-return (or log and return) when `credits` is not a positive finite integer — so a bug in a caller can't silently corrupt the credit ledger.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client as "External Client"
    participant Route as "API Route"
    participant Auth as "validateAuthContext"
    participant Access as "checkAccountArtistAccess"
    participant Credits as "ensureSocialScrapeCredits"
    participant Gate as "ensureCreditsOrShortCircuit"
    participant Cost as "getSocialScrapeCreditCost"
    participant Socials as "selectAccountSocials / selectSocials"
    participant Apify as "scrapeProfileUrl / Batch"
    participant Deduct as "deductSocialScrapeCredits"
    participant Deduction as "recordCreditDeduction"

    Note over Client,Deduction: "POST /api/socials/{id}/scrape (single profile)"
    Client->>Route: "POST /api/socials/{id}/scrape?posts=N"
    Route->>Auth: "validateAuthContext(request)"
    Auth-->>Route: "authResult (accountId)"
    Route->>Socials: "selectSocials(social_id)"
    Socials-->>Route: "social record"
    Route->>Access: "checkAccountArtistAccess(accountId, artistId)"
    Access-->>Route: "boolean"
    alt No access
        Route-->>Client: "403 Unauthorized"
    else Access granted
        Route->>Cost: "getSocialScrapeCreditCost(posts)"
        Cost-->>Route: "5 + (posts ?? 0)"
        Route->>Credits: "ensureSocialScrapeCredits(accountId, cost)"
        Credits->>Gate: "ensureCreditsOrShortCircuit(accountId, cost, successUrl)"
        alt Insufficient credits
            Gate-->>Credits: "402 NextResponse (checkoutUrl)"
            Credits-->>Route: "402 response"
            Route-->>Client: "402 Payment Required"
        else Sufficient credits
            Gate-->>Credits: "null"
            Credits-->>Route: "null"
            Route->>Apify: "scrapeProfileUrl(profile_url, username, posts)"
            alt Unsupported platform
                Apify-->>Route: "null"
                Route-->>Client: "400 Unsupported"
            else Scrape started
                Apify-->>Route: "{ runId, datasetId }"
                Route->>Deduct: "deductSocialScrapeCredits(accountId, cost)"
                Deduct->>Deduction: "recordCreditDeduction(accountId, cost, \"api\")"
                Note over Deduct: "Never throws, logs on failure"
                Deduct-->>Route: "void"
                Route-->>Client: "200 { runId, datasetId }"
            end
        end
    end

    Note over Client,Deduction: "POST /api/artist/socials/scrape (bulk)"
    Client->>Route: "POST /api/artist/socials/scrape?posts=N"
    Route->>Auth: "validateAuthContext(request)"
    alt No auth
        Auth-->>Route: "401 NextResponse"
        Route-->>Client: "401 Unauthorized"
    else Auth OK
        Auth-->>Route: "authResult (accountId)"
        Route->>Access: "checkAccountArtistAccess(accountId, artistId)"
        alt No access
            Route-->>Client: "403 Forbidden"
        else Access granted
            Route->>Socials: "selectAccountSocials(artistId)"
            Socials-->>Route: "linked social profiles []"
            alt No socials
                Route-->>Client: "200 [] (no charge)"
            else Has socials
                Route->>Cost: "getSocialScrapeCreditCost(posts)"
                Cost-->>Route: "5 + (posts ?? 0)"
                Route->>Credits: "ensureSocialScrapeCredits(accountId, cost * N_profiles)"
                Credits->>Gate: "ensureCreditsOrShortCircuit(accountId, totalCost, successUrl)"
                alt Insufficient credits
                    Gate-->>Credits: "402 NextResponse"
                    Credits-->>Route: "402 response"
                    Route-->>Client: "402 Payment Required"
                else Sufficient credits
                    Gate-->>Credits: "null"
                    Credits-->>Route: "null"
                    Route->>Apify: "scrapeProfileUrlBatch(profilesArray, posts)"
                    Apify-->>Route: "results (each with runId/datasetId/error)"
                    alt At least one scrape started
                        Route->>Deduct: "deductSocialScrapeCredits(accountId, cost * startedRuns)"
                        Deduct->>Deduction: "recordCreditDeduction(...)"
                        Note over Deduct: "Deduct only for profiles that started"
                        Deduct-->>Route: "void"
                    end
                    Route-->>Client: "200 [results]"
                end
            end
        end
    end
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

* 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<void> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: This billing wrapper doesn't validate the credits amount before passing it downstream. If an upstream caller ever passes a negative, zero, or non-finite value, it could write invalid ledger deductions without any runtime protection. Consider adding a defensive guard — for example, early-return (or log and return) when credits is not a positive finite integer — so a bug in a caller can't silently corrupt the credit ledger.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/socials/deductSocialScrapeCredits.ts, line 8:

<comment>This billing wrapper doesn't validate the `credits` amount before passing it downstream. If an upstream caller ever passes a negative, zero, or non-finite value, it could write invalid ledger deductions without any runtime protection. Consider adding a defensive guard — for example, early-return (or log and return) when `credits` is not a positive finite integer — so a bug in a caller can't silently corrupt the credit ledger.</comment>

<file context>
@@ -0,0 +1,18 @@
+ * 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<void> {
+  try {
+    await recordCreditDeduction({
</file context>

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification (2026-07-02) ✅

Preview: api-d46rm97ew-recoup.vercel.app (deployment resolved via deployments?sha=<PR head>). Caller: preview-env account fb678396-…ce77b (RECOUP_ORG admin, effectively unlimited credits). Balance read via GET /api/accounts/{id}/credits before/after every call.

Check Expected Actual Result
Single scrape, no auth 401 401
Bulk scrape, no auth 401 401 — endpoint was fully open before this PR
posts=0 (single query / bulk body) 400 before auth 400 / 400 (posts must be between 1 and 100)
Default single scrape 200, −5 credits 200 (run MtBv2iYCVZhgk9YeH), balance 999998603 → 999998598 = −5 exactly
Single scrape ?posts=20 200, −25 credits 200 (run ahNa9TVfAa0yoOnpK), 999998598 → 999998573 = −25 exactly
Bulk default (4 linked profiles) 200, −20 credits (5×4) 200 (4 runs started), 999998573 → 999998553 = −20 exactly
Bulk on artist with no socials 200 [], no charge 200 [], balance unchanged (caller is org admin so access passes; zero deduction)

Not triggerable live on this account, covered by unit tests: 402 (balance is ~1B credits) and 403 (caller is RECOUP_ORG admin, so checkAccountArtistAccess passes for any artist — correct behavior, observed as 200 [] with no charge on a nonexistent artist id).

Docs reconciliation (docs#259, second commit 9b57882): per review direction, the price now lives on the shared credits.mdx "What's billed" table (social scrape moves out of the free row) instead of per-endpoint descriptions — no sibling endpoint states its cost inline. The OpenAPI diff keeps only what the endpoints now emit: 402 responses (research-family precedent) and auth (security + 401/403) on the bulk endpoint.

Merge order: docs#259 → this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant