From c8a52e66b091669ba9285dd32fa914c0ad8f869d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 20:51:04 +0000 Subject: [PATCH] Set credit pack floor to 400 percent service-cost markup --- .../dashboard/projects/[id]/repos/page.tsx | 6 +- app/(app)/dashboard/settings/billing/page.tsx | 4 +- app/(marketing)/page.tsx | 14 ++-- app/(marketing)/pricing/page.tsx | 8 +- app/llms.txt/route.ts | 4 +- components/json-ld.tsx | 4 +- components/report/apply-fix-button.tsx | 3 +- docs/pricing-400-percent-markup-2026-09-13.md | 43 ++++++++++ lib/ads/pricing.ts | 19 ++--- lib/credits.ts | 81 ++++++++++--------- lib/pricing-policy.ts | 18 +++++ tests/ad-solvency.test.ts | 5 +- tests/contract/credits.test.ts | 4 +- tests/credit-invoice-pricing.test.ts | 55 +++++++++++++ tests/credits-per-credit-label.test.ts | 11 ++- tests/pricing-display.test.ts | 34 ++++++++ tests/pricing-policy.test.ts | 37 +++++++++ 17 files changed, 275 insertions(+), 75 deletions(-) create mode 100644 docs/pricing-400-percent-markup-2026-09-13.md create mode 100644 lib/pricing-policy.ts create mode 100644 tests/credit-invoice-pricing.test.ts create mode 100644 tests/pricing-display.test.ts create mode 100644 tests/pricing-policy.test.ts diff --git a/app/(app)/dashboard/projects/[id]/repos/page.tsx b/app/(app)/dashboard/projects/[id]/repos/page.tsx index d5d46101..464ac95f 100644 --- a/app/(app)/dashboard/projects/[id]/repos/page.tsx +++ b/app/(app)/dashboard/projects/[id]/repos/page.tsx @@ -11,7 +11,7 @@ import { getOrMintInstallationToken, } from "@/lib/github/installations"; import { listInstallationRepos } from "@/lib/github/app"; -import { DEFAULT_PROJECT_ENGINES, type Engine } from "@/lib/credits"; +import { DEFAULT_PROJECT_ENGINES, SCAN_CREDITS, SCAN_RACK_CENTS, dollars, type Engine } from "@/lib/credits"; import type { ProjectStatus } from "@/app/actions/projects"; import { AddRepoModal } from "./add-repo-modal"; import { RemoveButton } from "./remove-button"; @@ -190,8 +190,8 @@ export default async function ProjectReposPage({
  • Apply Fix (audit findings) opens a Claude- - authored PR that patches one specific check. Costs 20 credits - (~$1) per run; refunded on failure. + authored PR that patches one specific check. Costs {SCAN_CREDITS} credits + ({dollars(SCAN_RACK_CENTS)} before volume discounts) per run; refunded on failure.
  • Future: webhook-triggered audits on push to diff --git a/app/(app)/dashboard/settings/billing/page.tsx b/app/(app)/dashboard/settings/billing/page.tsx index 0a293e72..8dec894e 100644 --- a/app/(app)/dashboard/settings/billing/page.tsx +++ b/app/(app)/dashboard/settings/billing/page.tsx @@ -5,6 +5,8 @@ import { PurchaseStatusBanner } from "./purchase-status-banner"; import { CREDIT_PACKS, CREDIT_RACK_CENTS, + SCAN_CREDITS, + SCAN_RACK_CENTS, discountPct, dollars, perScanCents, @@ -78,7 +80,7 @@ export default async function BillingPage({ credits
    - 1 AI-model scan = 20 credits (~$1). Scheduled re-runs spend the same per engine. + 1 AI-model scan = {SCAN_CREDITS} credits ({dollars(SCAN_RACK_CENTS)} before volume discounts). Scheduled re-runs spend the same per engine.
    diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx index 39a41e60..ee5fa7fd 100644 --- a/app/(marketing)/page.tsx +++ b/app/(marketing)/page.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { HeroAuditForm } from "@/components/hero-audit-form"; -import { ANON_DAILY_SCANS, SCAN_CREDITS, SIGNUP_CREDITS } from "@/lib/credits"; +import { ANON_DAILY_SCANS, SCAN_CREDITS, SIGNUP_CREDITS, SCAN_RACK_CENTS, SCAN_MIN_CENTS, dollars } from "@/lib/credits"; import { OrganizationJsonLd, SoftwareApplicationJsonLd, FaqJsonLd } from "@/components/json-ld"; import { PerformancePreview } from "@/components/report/performance-preview"; import { UptimePreview } from "@/components/report/uptime-preview"; @@ -34,7 +34,7 @@ const faqs = [ }, { q: "Is the free tier really free?", - a: `Yes โ€” anonymous visitors get ${ANON_DAILY_SCANS} audits per day per IP with no signup, and signing up unlocks ${SIGNUP_CREDITS} free credits (${SIGNUP_CREDITS / SCAN_CREDITS} AI-model scan). Paid scans cost ${SCAN_CREDITS} credits (~$1) with volume discounts down to $0.50/scan at the 100-scan pack. No subscription, credits never expire.`, + a: `Yes โ€” anonymous visitors get ${ANON_DAILY_SCANS} audits per day per IP with no signup, and signing up unlocks ${SIGNUP_CREDITS} free credits (${SIGNUP_CREDITS / SCAN_CREDITS} AI-model scan). Paid scans cost ${SCAN_CREDITS} credits (${dollars(SCAN_RACK_CENTS)} at standard price) with volume discounts down to ${dollars(SCAN_MIN_CENTS)}/scan at the 100-scan pack. No subscription, credits never expire.`, }, ]; @@ -106,7 +106,7 @@ export default function HomePage() { - + @@ -240,7 +240,7 @@ export default function HomePage() {

    Connect a webhook once and CrawlProof can research topics, draft long-form posts, attach a hero image, and deliver articles to your CMS on schedule. - Each published post uses 20 credits (~$1). + Each published post uses {SCAN_CREDITS} credits ({dollars(SCAN_RACK_CENTS)} before volume discounts).

    @@ -362,7 +362,7 @@ function AutoblogLaunchGraphic() { return (
    @@ -373,7 +373,7 @@ function AutoblogLaunchGraphic() {
    Autoblog
    -
    ~$1
    +
    {dollars(SCAN_RACK_CENTS)}
    @@ -403,7 +403,7 @@ function AutoblogLaunchGraphic() {
    CrawlProof
    -
    ~$1
    +
    {dollars(SCAN_RACK_CENTS)}
    diff --git a/app/(marketing)/pricing/page.tsx b/app/(marketing)/pricing/page.tsx index c69147b9..17a5f17f 100644 --- a/app/(marketing)/pricing/page.tsx +++ b/app/(marketing)/pricing/page.tsx @@ -7,6 +7,8 @@ import { LEAD_RUN_CREDITS, OUTREACH_CREDITS, SCAN_CREDITS, + SCAN_RACK_CENTS, + SCAN_MIN_CENTS, SIGNUP_CREDITS, discountPct, dollars, @@ -16,12 +18,12 @@ import { export const metadata = { title: "Pricing", description: - "Rule-based AEO scans are free up to the daily limit. AI-model scans cost 20 credits (~$1) each, with volume discounts up to 50% off.", + `Rule-based AEO scans are free up to the daily limit. AI-model scans cost ${SCAN_CREDITS} credits (${dollars(SCAN_RACK_CENTS)}) each, with volume discounts down to ${dollars(SCAN_MIN_CENTS)} per scan.`, alternates: { canonical: "/pricing" }, openGraph: { title: "Pricing ยท CrawlProof", description: - "Rule-based scans are free up to the daily limit. AI-model scans cost 20 credits (~$1) each, volume discounts to 50%.", + `Rule-based scans are free up to the daily limit. AI-model scans cost ${SCAN_CREDITS} credits (${dollars(SCAN_RACK_CENTS)}) each, with volume discounts down to ${dollars(SCAN_MIN_CENTS)} per scan.`, url: "/pricing", }, }; @@ -32,7 +34,7 @@ export default function PricingPage() {

    Pay per scan

    Rule-based scans are free up to the daily limit. AI-model scans cost{" "} - {SCAN_CREDITS} credits (~$1) each. Buy credits with crypto via CoinPay; + {SCAN_CREDITS} credits ({dollars(SCAN_RACK_CENTS)} before volume discounts) each. Buy credits with card or crypto via CoinPay; no subscription, no expiry.

    diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts index f703ff0b..43a6aea7 100644 --- a/app/llms.txt/route.ts +++ b/app/llms.txt/route.ts @@ -1,4 +1,4 @@ -import { SCAN_CREDITS, SIGNUP_CREDITS } from "@/lib/credits"; +import { SCAN_CREDITS, SIGNUP_CREDITS, SCAN_RACK_CENTS, SCAN_MIN_CENTS, dollars } from "@/lib/credits"; import { env } from "@/lib/env"; const body = `# CrawlProof @@ -7,7 +7,7 @@ const body = `# CrawlProof ## Product - Free single-URL AEO audit, 10 per day per IP, no signup required. -- Signed-in users get ${SIGNUP_CREDITS} free credits (${SIGNUP_CREDITS / SCAN_CREDITS} AI-model scan); each AI-model scan costs ${SCAN_CREDITS} credits (~$1, volume discounts down to $0.50/scan at the 100-scan pack). No subscription โ€” credits never expire. +- Signed-in users get ${SIGNUP_CREDITS} free credits (${SIGNUP_CREDITS / SCAN_CREDITS} AI-model scan); each AI-model scan costs ${SCAN_CREDITS} credits (${dollars(SCAN_RACK_CENTS)} before volume discounts, down to ${dollars(SCAN_MIN_CENTS)}/scan at the 100-scan pack). No subscription โ€” credits never expire. - Saved projects, scheduled re-audits, multi-engine LLM scans (Claude Sonnet 4.6, OpenAI GPT-5 Mini, Gemini 2.5 Pro, Perplexity Sonar Pro, Qwen Plus, Kimi v2.6, DeepSeek V4, Z.AI GLM-5.2), consolidated PDF reports, and diff view. - Identifies as CrawlProofBot/1.0 (+https://crawlproof.com/bot). diff --git a/components/json-ld.tsx b/components/json-ld.tsx index 99789df9..385c1f27 100644 --- a/components/json-ld.tsx +++ b/components/json-ld.tsx @@ -1,5 +1,5 @@ import { env } from "@/lib/env"; -import { ANON_DAILY_SCANS, CREDIT_PACKS, SIGNUP_CREDITS } from "@/lib/credits"; +import { ANON_DAILY_SCANS, CREDIT_PACKS, SIGNUP_CREDITS, SCAN_CREDITS, dollars, perScanCents } from "@/lib/credits"; function Tag({ data }: { data: unknown }) { return ( @@ -59,7 +59,7 @@ export function SoftwareApplicationJsonLd() { name: p.label, price: (p.amountCents / 100).toFixed(2), priceCurrency: "USD", - description: `${p.credits} scan${p.credits === 1 ? "" : "s"} ยท $${(p.amountCents / p.credits / 100).toFixed(2)}/scan`, + description: `${p.credits} credits ยท ${p.credits / SCAN_CREDITS} scan${p.credits === SCAN_CREDITS ? "" : "s"} ยท ${dollars(perScanCents(p))}/scan`, })), ], }} diff --git a/components/report/apply-fix-button.tsx b/components/report/apply-fix-button.tsx index dc7945c4..38f37ecb 100644 --- a/components/report/apply-fix-button.tsx +++ b/components/report/apply-fix-button.tsx @@ -1,5 +1,6 @@ "use client"; +import { SCAN_CREDITS, SCAN_RACK_CENTS, dollars } from "@/lib/credits"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -281,7 +282,7 @@ export function ApplyFixButton({ {findingTitle}

    - Costs 20 credits (~$1). Claude reads the relevant + Costs {SCAN_CREDITS} credits ({dollars(SCAN_RACK_CENTS)} before volume discounts). Claude reads the relevant files, proposes a minimal patch, and opens a PR. We refund the credits if the run fails.

    diff --git a/docs/pricing-400-percent-markup-2026-09-13.md b/docs/pricing-400-percent-markup-2026-09-13.md new file mode 100644 index 00000000..2e0ce804 --- /dev/null +++ b/docs/pricing-400-percent-markup-2026-09-13.md @@ -0,0 +1,43 @@ +# Credit pricing: 400% markup on service estimates + +New purchases use a minimum price of five times the documented service-cost estimates, before affiliate and payment fees. The existing 30% affiliate commission remains in force. This is an 80% margin on the estimated service cost at the floor, not an 80% net margin after all expenses. + +## Catalog + +| Pack | Credits | Price | Per scan | Per credit | Discount | +| --- | ---: | ---: | ---: | ---: | ---: | +| Starter | 20 | $6 | $6 | $0.30 | 0% | +| 10 scans | 200 | $54 | $5.40 | $0.27 | 10% | +| 50 scans | 1,000 | $210 | $4.20 | $0.21 | 30% | +| 100 scans | 2,000 | $300 | $3 | $0.15 | 50% | + +`lib/pricing-policy.ts` holds the service estimates and markup. `lib/credits.ts` converts each action estimate into a per-credit floor, takes the largest floor, and sets a rack price that still meets it after the largest discount. Every catalog entry is calculated and guarded against falling below that floor. + +## Cost basis and limits + +| Action | Existing service estimate | Credit charge | Five-times estimate per credit | +| --- | ---: | ---: | ---: | +| AI scan | 26c, from the previous scan catalog estimate | 20 | 6.5c | +| Lead run | 4.9c, from the existing per-tick search/drafting/render estimate | 3 | 8.34c, rounding the action quote up | +| Outreach | 3c, from `docs/social-posting-prd.md` browser/proxy/CAPTCHA/compute estimate | 1 | 15c | + +Outreach sets the 15c credit floor. A 50% maximum volume discount requires a 30c rack price. Pricing from the scan estimate alone would underprice the other documented services. + +These are estimates, not enforced spending ceilings. Custom lead drafts, SMS destinations and segments, article images, GitHub fix loops, retries and x402 paid fetches have variable costs. Existing production AI usage records capture individual calls, not complete job costs. This catalog must not be presented as a guarantee of 400% markup on every actual job or on total operating costs. Update the estimates as complete job measurements become available. + +Articles, guest posts and GitHub fixes still consume 20 credits. Signup grants, existing balances and action deductions are unchanged. New purchase prices cannot retroactively improve the margin on credits already sold. + +## Checkout and accounting + +Card and crypto invoices both read the catalog server-side. The invoice route records the quoted price and credit count before asking CoinPay to create a payment. Caller-supplied amounts cannot lower the quote. Pending and completed purchases retain their stored amounts and credit counts; completion, affiliate conversion and receipts continue using those records. + +Ad budgets retain their established 5c-per-credit accounting denomination. This is a redemption value, separate from the price paid to buy credits. The SQL click and deposit-match functions, publisher payouts, historical ledger rows and existing campaign dollar limits remain unchanged. Raising the credit purchase price must not also raise the publisher payout rate and erase the intended markup. The first-deposit bonus remains ad-only. + +The homepage, pricing page, billing page, GitHub fix controls, structured offers and llms.txt derive their dollar claims from the catalog. Structured offers distinguish credits from scan equivalents. + +## Verification + +- Cost-floor tests cover every catalog pack against every documented service estimate after discounts. +- Invoice tests cover all four packs for both card and crypto, including caller-supplied stale prices and invalid pack IDs. +- Rendered pricing, structured offers and llms.txt agree on the new prices. +- Existing ad-solvency tests retain a historical-purchase fixture and check every new pack. diff --git a/lib/ads/pricing.ts b/lib/ads/pricing.ts index 62eee32e..d9dfc012 100644 --- a/lib/ads/pricing.ts +++ b/lib/ads/pricing.ts @@ -1,18 +1,19 @@ -// Ad network pricing. Advertiser spend is denominated in the existing credit -// unit (1 credit = 5ยข, see lib/credits.ts CREDIT_RACK_CENTS) so one currency -// governs the app. v1 is a flat CPC; auction pricing is a later phase. +// Ad budget accounting retains its established 5c redemption denomination. +// This is separate from the purchase price of credits in lib/credits.ts. +// Changing purchase prices must not revalue existing campaign budgets, ledger +// rows, publisher claims or deposit-match promises. Matches the ad SQL RPCs. -// Advertiser spend value per credit (rack). Matches CREDIT_RACK_CENTS. +// Advertiser budget value per credit; this is not its cash purchase price. export const CREDIT_CENTS = 5; // Publisher cash-out value per credit. // -// Deliberately BELOW the cheapest credit pack (2.5c on the 100-scan tier), not -// equal to it. Setting it at the floor price left the deepest pack with a 1:1 +// Deliberately below even the old cheapest credit pack (2.5c per credit), not +// tied to the current purchase price. Setting it at the floor price left the deepest pack with a 1:1 // spread, and a deposit match on top of that pushed cash in per credit under // the payout rate โ€” see 20260731120000_ad_solvency.sql. At 2.0c the publisher // earns 1.4c/credit after the platform rate, which keeps a margin on every -// pack (72% at rack, 44% on the deepest) with room for the match. +// historical pack with room for the match; repricing new packs does not raise payouts. export const CREDIT_FLOOR_CENTS = 2.0; // Minimum real cash that must sit behind every credit granted, in cents. The @@ -20,7 +21,7 @@ export const CREDIT_FLOOR_CENTS = 2.0; // margin over the 1.4c publisher payout rate. Mirrors ad_apply_deposit_bonus(). export const MIN_CASH_PER_CREDIT_CENTS = 1.75; -// Default bid / cost-per-click, in credits. 4 credits = $0.20 at rack. +// Default bid / cost-per-click, in credits. 4 credits uses $0.20 of ad budget. export const CPC_CREDITS = 4; export const DEFAULT_BID_CREDITS = CPC_CREDITS; export const CPC_CENTS = CPC_CREDITS * CREDIT_CENTS; @@ -52,7 +53,7 @@ export const MIN_PAYOUT_CENTS = 500; // $5.00 // Deposit-match promo: the first deposit is matched 100% of the credits // BOUGHT (not of the dollar amount at rack โ€” that over-granted on discounted -// packs), capped at $100 of rack value and further capped so the deposit never +// packs), capped at $100 of ad budget value and further capped so the deposit never // dilutes below MIN_CASH_PER_CREDIT_CENTS. ad_apply_deposit_bonus() in // 20260731120000_ad_solvency.sql is the source of truth. export const DEPOSIT_MATCH_RATE = 1.0; diff --git a/lib/credits.ts b/lib/credits.ts index 52105087..3f2255c3 100644 --- a/lib/credits.ts +++ b/lib/credits.ts @@ -1,15 +1,8 @@ -// Credit pack catalog. -// 1 credit โ‰ˆ $0.05 (a "nickel credit") at full rack rate. Larger packs come -// with a sliding-scale discount off that rate. Credits are the universal, -// integer-only spend unit: cheap actions (outreach) cost 1 credit, expensive -// AI actions (a scan, an article, a guest post, a GitHub auto-fix) cost -// SCAN_CREDITS each โ€” which keeps an AI action at ~$1 rack like before. +import { MAX_VOLUME_DISCOUNT_PERCENT, SERVICE_COST_CENTS, quoteServiceCostMarkup } from "./pricing-policy"; -// Rack price of one credit, in cents. Drives discount math + UI strikethroughs. -export const CREDIT_RACK_CENTS = 5; - -// Credits charged for one expensive AI action (scan / article / guest post / -// auto-fix). 20 ร— $0.05 = $1.00 rack, unchanged from the old 1-credit-=-$1 era. +// Credits remain the integer spend unit across scans, articles, fixes and +// outreach. New purchase prices cover the documented service-cost estimates +// even at the deepest volume discount; affiliate/payment fees are separate. export const SCAN_CREDITS = 20; /** @@ -48,7 +41,7 @@ export const OUTREACH_CREDITS = 1; * listing isn't billed twice for the same hire. * * Serving costs us almost nothing (the jobs feed is a cached read), so this is - * priced as a listing fee rather than off cost: 5c a posting at rack, against + * priced as a listing fee rather than off cost, against * $200-and-up to list the same role on a real job board. */ export const JOB_POSTING_CREDITS = 1; @@ -63,9 +56,9 @@ export const JOB_POSTING_CREDITS = 1; * the AI is under a fifth of it, so pricing off model cost alone would * undercharge by roughly five times. * - * Three credits is 15c at rack and 7.5c on the deepest pack, which keeps a - * margin at every tier. Two would have been exactly break-even for anyone on - * the 100-scan pack. + * The credit catalog includes this service estimate when setting its price + * floor, so the deepest volume discount still covers five times that estimate. + * These estimates do not bound custom drafts, retries or future provider rates. * * Charged only when a tick actually spends: the cron fires every fifteen * minutes, so billing an idle campaign per tick would cost a user 288 credits @@ -115,19 +108,42 @@ export type CreditPack = { popular?: boolean; }; -// credits = scan-equivalents ร— SCAN_CREDITS; amountCents unchanged from the -// $1/scan era, so the same dollars now buy 20ร— the (smaller) credits. +/** Documented cost basis per billable action; variable jobs can cost more. */ +export const CREDIT_SERVICE_BASIS = [ + { action: "scan", serviceCents: SERVICE_COST_CENTS.scan, credits: SCAN_CREDITS }, + { action: "lead run", serviceCents: SERVICE_COST_CENTS.leadRun, credits: LEAD_RUN_CREDITS }, + { action: "outreach", serviceCents: SERVICE_COST_CENTS.outreach, credits: OUTREACH_CREDITS }, +] as const; + +export const MIN_CREDIT_CENTS = Math.max( + ...CREDIT_SERVICE_BASIS.map((basis) => quoteServiceCostMarkup(basis.serviceCents) / basis.credits), +); + +// The biggest discount must still meet the cost floor. Current estimates set +// a 15c floor and 30c rack rate. This prices new purchases, not past receipts. +export const CREDIT_RACK_CENTS = Math.ceil( + MIN_CREDIT_CENTS / (1 - MAX_VOLUME_DISCOUNT_PERCENT / 100), +); + +function pricedPack(id: string, label: string, scans: number, discount: number, popular?: boolean): CreditPack { + const credits = scans * SCAN_CREDITS; + const amountCents = Math.ceil(credits * CREDIT_RACK_CENTS * (1 - discount / 100)); + if (amountCents < credits * MIN_CREDIT_CENTS) { + throw new RangeError("Credit pack discount falls below the service-cost markup floor."); + } + return { id, label, credits, amountCents, ...(popular ? { popular } : {}) }; +} + export const CREDIT_PACKS: CreditPack[] = [ - { id: "pack-1", label: "Starter", credits: 20, amountCents: 100 }, // $1.00/scan โ€” full rack rate - { id: "pack-10", label: "10 scans", credits: 200, amountCents: 900 }, // $0.90/scan โ€” 10% off - { id: "pack-50", label: "50 scans", credits: 1000, amountCents: 3500, popular: true }, // $0.70/scan โ€” 30% off - // Deepest bundle is anchored at ~2ร— the worst-case per-scan cost - // (Claude Sonnet 4.6 ~$0.26 โ†’ cap of $0.52). Rounded to $0.50/scan - // so Claude lands at ~92% markup and the floor stays at or below - // the 100% markup ceiling. - { id: "pack-100", label: "100 scans", credits: 2000, amountCents: 5000 }, // $0.50/scan โ€” 50% off + pricedPack("pack-1", "Starter", 1, 0), + pricedPack("pack-10", "10 scans", 10, 10), + pricedPack("pack-50", "50 scans", 50, 30, true), + pricedPack("pack-100", "100 scans", 100, MAX_VOLUME_DISCOUNT_PERCENT), ]; +export const SCAN_RACK_CENTS = SCAN_CREDITS * CREDIT_RACK_CENTS; +export const SCAN_MIN_CENTS = Math.min(...CREDIT_PACKS.map(perScanCents)); + export function findPack(id: string): CreditPack | undefined { return CREDIT_PACKS.find((p) => p.id === id); } @@ -152,20 +168,7 @@ export function perCreditCents(pack: CreditPack): number { return Math.round(pack.amountCents / pack.credits); } -/** - * Exact per-credit price, as displayed. - * - * Every pack's true per-credit price lands on a half-cent โ€” $9/200 = $0.045, - * $35/1000 = $0.035, $50/2000 = $0.025 โ€” and rounding half-up sent all three - * *upward*, so the page advertised $0.05, $0.04 and $0.03. Overstating your own - * price on the pricing page is a strange way to lose an argument with a - * customer who can do the division. - * - * Three decimals, with a trailing zero trimmed only down to two, so a pack that - * really is a round number ($1.00/20 = $0.05) reads "$0.05" rather than - * "$0.050" โ€” and a hypothetical $0.10 never degrades to "$0.1", which reads as - * a typo rather than a price. - */ +/** Exact per-credit prices retain fractional cents instead of rounding upward. */ export function perCreditLabel(pack: CreditPack): string { const exact = pack.amountCents / pack.credits / 100; const three = exact.toFixed(3); diff --git a/lib/pricing-policy.ts b/lib/pricing-policy.ts new file mode 100644 index 00000000..7eec1363 --- /dev/null +++ b/lib/pricing-policy.ts @@ -0,0 +1,18 @@ +/** Markup on estimated service costs, before affiliate and payment fees. */ +export const TARGET_MARKUP_PERCENT = 400; +export const MAX_VOLUME_DISCOUNT_PERCENT = 50; + +/** Existing service-cost estimates, in cents per action. */ +export const SERVICE_COST_CENTS = { + scan: 26, + leadRun: 4.9, + outreach: 3, +} as const; + +/** A 400% markup charges five times cost. Round up to a whole cent. */ +export function quoteServiceCostMarkup(serviceCents: number): number { + if (!Number.isFinite(serviceCents) || serviceCents < 0) { + throw new RangeError("Service cost must be finite and non-negative."); + } + return Math.ceil(serviceCents * (1 + TARGET_MARKUP_PERCENT / 100)); +} diff --git a/tests/ad-solvency.test.ts b/tests/ad-solvency.test.ts index 6cff7f49..33a8fc96 100644 --- a/tests/ad-solvency.test.ts +++ b/tests/ad-solvency.test.ts @@ -76,7 +76,8 @@ describe("deposit match stays solvent", () => { ); it("caps the match on the deepest pack instead of doubling it", () => { - const deepest = CREDIT_PACKS.at(-1)!; // $50 / 2000 credits = 2.5c each + // Old purchases must keep the historical promise when they settle later. + const deepest = { amountCents: 5000, credits: 2000 }; const bonus = depositBonusCredits(deepest.amountCents, deepest.credits); // A naive 100% match would grant 2000 and drop cash in to 1.25c/credit โ€” // below the 1.4c payout rate. The solvency cap holds it to 857. @@ -85,7 +86,7 @@ describe("deposit match stays solvent", () => { }); it("grants a full 100% match where the pack can afford it", () => { - const starter = CREDIT_PACKS[0]; // $1.00 / 20 credits = 5c each + const starter = CREDIT_PACKS[0]; expect(depositBonusCredits(starter.amountCents, starter.credits)).toBe(20); }); diff --git a/tests/contract/credits.test.ts b/tests/contract/credits.test.ts index 7aa340e1..ede93d34 100644 --- a/tests/contract/credits.test.ts +++ b/tests/contract/credits.test.ts @@ -44,7 +44,7 @@ describe("credit packs catalog", () => { it("discountPct is 0 for the rack-rate starter", () => { const starter = findPack("pack-1")!; expect(discountPct(starter)).toBe(0); - expect(perScanCents(starter)).toBe(100); + expect(perScanCents(starter)).toBe(600); }); it("discount increases monotonically with pack size", () => { @@ -60,6 +60,6 @@ describe("credit packs catalog", () => { it("100-pack ships the big-bag 50% discount", () => { const big = findPack("pack-100")!; expect(discountPct(big)).toBe(50); - expect(perScanCents(big)).toBe(50); + expect(perScanCents(big)).toBe(300); }); }); diff --git a/tests/credit-invoice-pricing.test.ts b/tests/credit-invoice-pricing.test.ts new file mode 100644 index 00000000..c63ec5fb --- /dev/null +++ b/tests/credit-invoice-pricing.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CREDIT_PACKS } from "@/lib/credits"; + +const mocks = vi.hoisted(() => ({ + insert: vi.fn(), + update: vi.fn(), + createPayment: vi.fn(), + getUser: vi.fn(), +})); + +vi.mock("@/lib/supabase/server", () => ({ + createClient: async () => ({ auth: { getUser: mocks.getUser } }), +})); +vi.mock("@/lib/supabase/service", () => ({ + serviceClient: () => ({ + from: () => ({ insert: mocks.insert, update: mocks.update }), + }), +})); +vi.mock("@/lib/coinpay", () => ({ createPayment: mocks.createPayment })); +vi.mock("@/lib/affiliate/attribution", () => ({ attributeUser: vi.fn() })); + +import { POST } from "@/app/api/credits/create-invoice/route"; + +beforeEach(() => { + vi.resetAllMocks(); + mocks.getUser.mockResolvedValue({ data: { user: { id: "buyer", email: "buyer@example.com" } } }); + mocks.insert.mockReturnValue({ select: () => ({ single: async () => ({ data: { id: "purchase" }, error: null }) }) }); + mocks.update.mockReturnValue({ eq: async () => ({ error: null }) }); + mocks.createPayment.mockResolvedValue({ paymentId: "payment", hostedUrl: "https://coinpayportal.com/pay/payment" }); +}); + +describe.each(["card", "usdc"])("%s credit checkout", (currency) => { + it.each(CREDIT_PACKS)("stores and bills the current $id quote, ignoring a supplied old price", async (pack) => { + const response = await POST(new Request("https://crawlproof.com/api/credits/create-invoice", { + method: "POST", + body: JSON.stringify({ packId: pack.id, currency, amountCents: 100, credits: 999999 }), + })); + expect(response.status).toBe(200); + expect(mocks.insert).toHaveBeenCalledWith(expect.objectContaining({ + pack_id: pack.id, amount_cents: pack.amountCents, credits_added: pack.credits, + })); + expect(mocks.createPayment).toHaveBeenCalledWith(expect.objectContaining({ + amountCents: pack.amountCents, credits: pack.credits, currency, + })); + }); +}); + +it("does not create a purchase for an unknown pack", async () => { + const response = await POST(new Request("https://crawlproof.com/api/credits/create-invoice", { + method: "POST", body: JSON.stringify({ packId: "retired", currency: "card" }), + })); + expect(response.status).toBe(400); + expect(mocks.insert).not.toHaveBeenCalled(); + expect(mocks.createPayment).not.toHaveBeenCalled(); +}); diff --git a/tests/credits-per-credit-label.test.ts b/tests/credits-per-credit-label.test.ts index 4b4a0468..4a2c9f2c 100644 --- a/tests/credits-per-credit-label.test.ts +++ b/tests/credits-per-credit-label.test.ts @@ -15,11 +15,14 @@ import { } from "@/lib/credits"; describe("perCreditLabel", () => { + it("preserves fractional cents on historical packs", () => { + expect(perCreditLabel({ id: "historical", label: "Historical", amountCents: 900, credits: 200 })).toBe("$0.045"); + }); it.each([ - ["pack-1", "$0.05"], // $1.00 / 20 โ€” genuinely round - ["pack-10", "$0.045"], // $9 / 200, was shown as $0.05 - ["pack-50", "$0.035"], // $35 / 1000, was shown as $0.04 - ["pack-100", "$0.025"], // $50 / 2000, was shown as $0.03 + ["pack-1", "$0.30"], // $6 / 20 + ["pack-10", "$0.27"], // $54 / 200 + ["pack-50", "$0.21"], // $210 / 1000 + ["pack-100", "$0.15"], // $300 / 2000 ])("prices %s at %s", (id, expected) => { const pack = findPack(id); expect(pack).toBeDefined(); diff --git a/tests/pricing-display.test.ts b/tests/pricing-display.test.ts new file mode 100644 index 00000000..7ec24cd8 --- /dev/null +++ b/tests/pricing-display.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import PricingPage, { metadata } from "@/app/(marketing)/pricing/page"; +import { SoftwareApplicationJsonLd } from "@/components/json-ld"; +import { GET } from "@/app/llms.txt/route"; + +describe("published pricing", () => { + it("renders all four new pack totals and the exact credit prices", () => { + const html = renderToStaticMarkup(createElement(PricingPage)); + for (const price of ["$6", "$54", "$210", "$300"]) { + expect(html).toContain(`>${price}<`); + } + for (const price of ["$0.30", "$0.27", "$0.21", "$0.15"]) { + expect(html).toContain(`>${price}/credit<`); + } + expect(metadata.description).toContain("20 credits ($6)"); + expect(metadata.description).toContain("$3 per scan"); + expect(html).not.toContain("~$1"); + }); + + it("uses scan equivalents rather than credit counts in structured offers", () => { + const html = renderToStaticMarkup(createElement(SoftwareApplicationJsonLd)); + const json = JSON.parse(html.slice(html.indexOf(">") + 1, html.lastIndexOf(""))); + expect(json.offers.map((offer: { price: string }) => offer.price)).toEqual(["0", "6.00", "54.00", "210.00", "300.00"]); + expect(json.offers.at(-1).description).toBe("2000 credits ยท 100 scans ยท $3/scan"); + }); + + it("serves the same prices to agents", async () => { + const text = await (await GET()).text(); + expect(text).toContain("20 credits ($6 before volume discounts, down to $3/scan"); + expect(text).not.toContain("$0.50/scan"); + }); +}); diff --git a/tests/pricing-policy.test.ts b/tests/pricing-policy.test.ts new file mode 100644 index 00000000..9dd1a349 --- /dev/null +++ b/tests/pricing-policy.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { CREDIT_PACKS, CREDIT_SERVICE_BASIS, SCAN_CREDITS, discountPct } from "@/lib/credits"; +import { quoteServiceCostMarkup } from "@/lib/pricing-policy"; +import { PAYS } from "@/lib/affiliate/program"; + +describe("400% service-cost markup", () => { + it("means five times cost and rounds upward", () => { + expect(quoteServiceCostMarkup(26)).toBe(130); + expect(quoteServiceCostMarkup(4.9)).toBe(25); + expect(quoteServiceCostMarkup(3)).toBe(15); + }); + + it.each([-1, NaN, Infinity])("rejects invalid service cost %s", (cost) => { + expect(() => quoteServiceCostMarkup(cost)).toThrow(RangeError); + }); + + it.each(CREDIT_PACKS)("$id covers every documented service estimate after its discount", (pack) => { + for (const basis of CREDIT_SERVICE_BASIS) { + const revenue = pack.amountCents * basis.credits / pack.credits; + expect(revenue).toBeGreaterThanOrEqual(5 * basis.serviceCents); + } + }); + + it("preserves the four pack sizes and volume discounts", () => { + expect(CREDIT_PACKS.map((p) => [p.id, p.credits, p.amountCents, discountPct(p)])).toEqual([ + ["pack-1", 20, 600, 0], + ["pack-10", 200, 5400, 10], + ["pack-50", 1000, 21000, 30], + ["pack-100", 2000, 30000, 50], + ]); + expect(SCAN_CREDITS).toBe(20); + }); + + it("keeps the affiliate commission separate from the service-cost target", () => { + expect(PAYS.find((p) => p.event === "sale" && p.kind === "percent")?.value).toBe(30); + }); +});