Fix critical billing bugs, add local usage caps, publish sales demo
Three launch blockers from the Oct 17 direct-Stripe billing work: - verifyStripeCheckoutSession defaulted an unconfirmed checkout session to "active"/isPaying:true, and had no billing permission check, so an abandoned (unpaid) checkout marked the org as a paying subscriber for free. Now defaults to "incomplete" and only trusts a real subscription status or a confirmed paid payment_status; also pins org id to the caller's own organization instead of trusting session metadata. - The Stripe webhook fell back to parsing an unsigned request body as a real event whenever STRIPE_WEBHOOK_SECRET was unset, letting anyone POST a fake checkout.session.completed and grant any org a paid plan. STRIPE_WEBHOOK_SECRET is now required; unsigned requests are refused. Also maps Stripe's subscription.status properly (trialing counted as active) instead of collapsing anything but "active" to "canceled". - meterDataforseoCall and every credit-check in subscription.ts skipped all usage metering/capping whenever AUTUMN_SECRET_KEY was unset — which is exactly the mode the Oct 17 work introduced for self-host direct Stripe billing. Added a local per-org monthly usage ledger (usage_ledger table, sqlite + pg) checked against each plan's monthly data budget (src/shared/crawlerxPlanBudgets.ts, from the PRD's unit economics table) so DataForSEO spend is capped even without Autumn. Also: documented STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRET/AUTUMN_SECRET_KEY in .env.example, and published the CrawlerX product demo as a static page at /demo.html (self-contained, no backend dependency). Verified: tsc --noEmit clean, schema-parity test green, full vitest suite green except two pre-existing unrelated failures (confirmed via stash), knip/oxlint show no new findings over the pre-change baseline. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
98694c56ea
commit
65a01af4d3
15
.env.example
15
.env.example
@ -45,6 +45,21 @@
|
||||
# LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID=replace-with-your-loops-reset-template-id
|
||||
# LOOPS_TRANSACTIONAL_INVITATION_ID=replace-with-your-loops-invitation-template-id
|
||||
|
||||
# Direct Stripe billing (AUTH_MODE=hosted). Required to accept real payments.
|
||||
# Get these from the Stripe Dashboard -> Developers -> API keys, and
|
||||
# -> Webhooks (add an endpoint for <your-domain>/api/stripe/webhook and copy
|
||||
# its signing secret). STRIPE_WEBHOOK_SECRET is required, not optional: the
|
||||
# webhook refuses every event without it rather than trusting an unsigned
|
||||
# request body.
|
||||
# STRIPE_SECRET_KEY=replace-with-your-stripe-secret-key
|
||||
# STRIPE_WEBHOOK_SECRET=replace-with-your-stripe-webhook-signing-secret
|
||||
|
||||
# Optional. Only needed if you're running the upstream Autumn-metered hosted
|
||||
# SaaS instead of direct Stripe billing above. Leave unset for direct Stripe —
|
||||
# DataForSEO usage is then metered against a local per-org monthly ledger
|
||||
# instead (src/server/billing/subscription.ts, src/shared/crawlerxPlanBudgets.ts).
|
||||
# AUTUMN_SECRET_KEY=replace-with-your-autumn-secret-key
|
||||
|
||||
# Optional in self-hosted modes. Required if you want AI features like SAM,
|
||||
# the in-app SEO agent. Create a key at https://openrouter.ai/settings/keys.
|
||||
# OPENROUTER_API_KEY=replace-with-your-openrouter-api-key
|
||||
|
||||
8
drizzle-pg/0025_oval_korg.sql
Normal file
8
drizzle-pg/0025_oval_korg.sql
Normal file
@ -0,0 +1,8 @@
|
||||
CREATE TABLE "usage_ledger" (
|
||||
"organization_id" text PRIMARY KEY NOT NULL,
|
||||
"period_month" text NOT NULL,
|
||||
"credits_used" integer DEFAULT 0 NOT NULL,
|
||||
"updated_at" text DEFAULT to_char(now() AT TIME ZONE 'utc', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "usage_ledger" ADD CONSTRAINT "usage_ledger_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
|
||||
4309
drizzle-pg/meta/0025_snapshot.json
Normal file
4309
drizzle-pg/meta/0025_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -176,6 +176,13 @@
|
||||
"when": 1789226958095,
|
||||
"tag": "0024_heavy_arachne",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "7",
|
||||
"when": 1792268181660,
|
||||
"tag": "0025_oval_korg",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
7
drizzle/0047_last_tony_stark.sql
Normal file
7
drizzle/0047_last_tony_stark.sql
Normal file
@ -0,0 +1,7 @@
|
||||
CREATE TABLE `usage_ledger` (
|
||||
`organization_id` text PRIMARY KEY NOT NULL,
|
||||
`period_month` text NOT NULL,
|
||||
`credits_used` integer DEFAULT 0 NOT NULL,
|
||||
`updated_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
3921
drizzle/meta/0047_snapshot.json
Normal file
3921
drizzle/meta/0047_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -330,6 +330,13 @@
|
||||
"when": 1789226956645,
|
||||
"tag": "0046_wooden_khan",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 47,
|
||||
"version": "6",
|
||||
"when": 1792268176220,
|
||||
"tag": "0047_last_tony_stark",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
520
public/demo.html
Normal file
520
public/demo.html
Normal file
@ -0,0 +1,520 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CrawlerX Product Demo</title>
|
||||
<meta name="claude:viewport-fit-sides" content="cover">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,700&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
|
||||
<style>
|
||||
/* Layout: app shell — guided tour strip on top, left nav of 8 screens, working canvas on the right */
|
||||
:root{
|
||||
--bg:#F3F5F7; --surface:#FFFFFF; --sunk:#E9EDF1; --line:#D5DCE3;
|
||||
--ink:#14202B; --muted:#5A6B7A;
|
||||
--accent:#0E6E6A; --accent-soft:#D7EDEB; --accent-ink:#FFFFFF;
|
||||
--mark:#C8811F; --mark-soft:#F8E8CF;
|
||||
--good:#2E7D4F; --good-soft:#DCEFE3; --warn:#B7791F; --warn-soft:#F6EAD3; --bad:#B4413A; --bad-soft:#F5DEDB;
|
||||
--f-display:"Bricolage Grotesque", "Segoe UI", system-ui, sans-serif;
|
||||
--f-body:"IBM Plex Sans", "Segoe UI", system-ui, sans-serif;
|
||||
--f-mono:"IBM Plex Mono", ui-monospace, Consolas, monospace;
|
||||
}
|
||||
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){
|
||||
--bg:#0F161C; --surface:#162029; --sunk:#1D2933; --line:#2C3A46;
|
||||
--ink:#E6EDF2; --muted:#94A6B5;
|
||||
--accent:#3FB6AE; --accent-soft:#16383A; --accent-ink:#06201F;
|
||||
--mark:#E3A44A; --mark-soft:#3A2C17;
|
||||
--good:#5CC48A; --good-soft:#15301F; --warn:#E0A84B; --warn-soft:#352915; --bad:#E4776F; --bad-soft:#3A1E1C; color-scheme:dark}}
|
||||
:root[data-theme="dark"]{
|
||||
--bg:#0F161C; --surface:#162029; --sunk:#1D2933; --line:#2C3A46;
|
||||
--ink:#E6EDF2; --muted:#94A6B5;
|
||||
--accent:#3FB6AE; --accent-soft:#16383A; --accent-ink:#06201F;
|
||||
--mark:#E3A44A; --mark-soft:#3A2C17;
|
||||
--good:#5CC48A; --good-soft:#15301F; --warn:#E0A84B; --warn-soft:#352915; --bad:#E4776F; --bad-soft:#3A1E1C; color-scheme:dark}
|
||||
*{box-sizing:border-box}
|
||||
body{background:var(--bg);color:var(--ink);font-family:var(--f-body);font-size:14px;line-height:1.5;margin:0;
|
||||
padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px));padding-block:16px 40px}
|
||||
h1,h2,h3{font-family:var(--f-display);text-wrap:balance;margin:0;letter-spacing:-.01em}
|
||||
h1{font-size:26px}h2{font-size:20px}h3{font-size:15px;font-family:var(--f-body);font-weight:600}
|
||||
p{margin:0}
|
||||
.mono,.num{font-family:var(--f-mono);font-variant-numeric:tabular-nums}
|
||||
.muted{color:var(--muted)}
|
||||
.wrap{max-width:1180px;margin:0 auto;display:grid;gap:14px}
|
||||
button{font:inherit;cursor:pointer}
|
||||
button:focus-visible,[tabindex]:focus-visible,select:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||
.label{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);font-weight:600}
|
||||
|
||||
/* Header */
|
||||
.top{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px}
|
||||
.brand{display:flex;align-items:center;gap:10px}
|
||||
.logo{width:34px;height:34px;border-radius:9px;background:var(--accent);display:grid;place-items:center}
|
||||
.logo svg{width:20px;height:20px}
|
||||
.brand small{display:block;color:var(--muted);font-size:12px}
|
||||
.sample{font-size:12px;background:var(--mark-soft);color:var(--ink);border:1px solid var(--mark);padding:3px 10px;border-radius:999px}
|
||||
|
||||
/* Tour strip */
|
||||
.tour{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;display:grid;gap:10px}
|
||||
.tour-head{display:flex;flex-wrap:wrap;gap:10px;align-items:baseline;justify-content:space-between}
|
||||
.tour-text{max-width:78ch}
|
||||
.tour-text b{font-weight:600}
|
||||
.dots{display:flex;gap:6px;flex-wrap:wrap}
|
||||
.dot{width:28px;height:6px;border-radius:3px;background:var(--line);border:0;padding:0}
|
||||
.dot.on{background:var(--accent)}
|
||||
.dot.done{background:var(--accent-soft)}
|
||||
.tour-nav{display:flex;gap:8px}
|
||||
.btn{border:1px solid var(--line);background:var(--surface);color:var(--ink);padding:7px 14px;border-radius:8px;font-weight:500}
|
||||
.btn:hover{background:var(--sunk)}
|
||||
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-ink)}
|
||||
.btn.primary:hover{filter:brightness(1.08)}
|
||||
.btn.mark{background:var(--mark);border-color:var(--mark);color:#1A1205}
|
||||
.btn:disabled{opacity:.45;cursor:default}
|
||||
|
||||
/* App shell */
|
||||
.app{display:grid;grid-template-columns:210px minmax(0,1fr);background:var(--surface);border:1px solid var(--line);border-radius:12px;overflow:hidden;min-height:620px}
|
||||
.nav{background:var(--sunk);border-right:1px solid var(--line);padding:12px 8px;display:flex;flex-direction:column;gap:2px}
|
||||
.nav .ws{padding:6px 10px 12px;font-size:12px;color:var(--muted)}
|
||||
.nav .ws b{display:block;color:var(--ink);font-size:13px}
|
||||
.nav button{display:flex;align-items:center;gap:10px;text-align:left;border:0;background:transparent;color:var(--ink);padding:8px 10px;border-radius:7px;font-size:13.5px}
|
||||
.nav button .n{font-family:var(--f-mono);font-size:11px;color:var(--muted);width:16px}
|
||||
.nav button:hover{background:var(--surface)}
|
||||
.nav button.on{background:var(--surface);box-shadow:inset 3px 0 0 var(--accent);font-weight:600}
|
||||
.nav .foot{margin-top:auto;padding:10px;font-size:12px;color:var(--muted)}
|
||||
.meter{height:6px;background:var(--line);border-radius:3px;overflow:hidden;margin-top:4px}
|
||||
.meter i{display:block;height:100%;background:var(--accent)}
|
||||
.canvas{padding:20px;display:grid;gap:16px;align-content:start;min-width:0}
|
||||
.screen-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-end;gap:10px}
|
||||
.screen-head p{color:var(--muted);margin-top:4px}
|
||||
|
||||
.grid{display:grid;gap:12px}
|
||||
.g2{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.g3{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
.g4{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||
.panel{border:1px solid var(--line);border-radius:10px;padding:14px;min-width:0;display:grid;gap:10px;align-content:start}
|
||||
.stat .v{font-family:var(--f-display);font-size:28px;font-weight:700;font-variant-numeric:tabular-nums}
|
||||
.stat .d{font-size:12px}
|
||||
.up{color:var(--good)}.down{color:var(--bad)}
|
||||
.pill{display:inline-block;font-size:11.5px;padding:2px 8px;border-radius:999px;font-weight:500;white-space:nowrap}
|
||||
.p-good{background:var(--good-soft);color:var(--good)}
|
||||
.p-warn{background:var(--warn-soft);color:var(--warn)}
|
||||
.p-bad{background:var(--bad-soft);color:var(--bad)}
|
||||
.p-acc{background:var(--accent-soft);color:var(--accent)}
|
||||
.p-mark{background:var(--mark-soft);color:var(--mark)}
|
||||
|
||||
.tbl-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:10px}
|
||||
table{border-collapse:collapse;width:100%;font-size:13px}
|
||||
th,td{padding:9px 12px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap}
|
||||
th{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);font-weight:600;background:var(--sunk)}
|
||||
tr:last-child td{border-bottom:0}
|
||||
td.r,th.r{text-align:right}
|
||||
tbody tr.click{cursor:pointer}
|
||||
tbody tr.click:hover{background:var(--sunk)}
|
||||
.health{display:flex;align-items:center;gap:8px}
|
||||
.hbar{width:70px;height:6px;border-radius:3px;background:var(--line);overflow:hidden}
|
||||
.hbar i{display:block;height:100%}
|
||||
|
||||
.actions{display:grid;gap:8px;counter-reset:a}
|
||||
.act{display:grid;grid-template-columns:28px minmax(0,1fr) auto;gap:10px;align-items:center;padding:10px;border:1px solid var(--line);border-radius:8px}
|
||||
.act .k{font-family:var(--f-mono);font-size:12px;width:24px;height:24px;border-radius:6px;background:var(--accent-soft);color:var(--accent);display:grid;place-items:center;font-weight:500}
|
||||
.act small{display:block;color:var(--muted)}
|
||||
|
||||
/* Onboarding */
|
||||
.steps-on{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
|
||||
.field{display:grid;gap:4px}
|
||||
.field label{font-size:12px;color:var(--muted);font-weight:500}
|
||||
.field input,.field select,textarea{font:inherit;border:1px solid var(--line);background:var(--surface);color:var(--ink);border-radius:8px;padding:8px 10px;width:100%}
|
||||
textarea{font-family:var(--f-mono);font-size:12.5px;min-height:150px;resize:vertical}
|
||||
.check{display:flex;gap:8px;align-items:center;font-size:13px}
|
||||
.crawl-log{font-family:var(--f-mono);font-size:12px;background:var(--sunk);border-radius:8px;padding:10px;min-height:150px;display:grid;gap:2px;align-content:start}
|
||||
|
||||
/* Audit + fix */
|
||||
.split{display:grid;grid-template-columns:minmax(0,.9fr) minmax(0,1.1fr);gap:12px}
|
||||
.issue{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:10px;align-items:center;padding:10px;border:1px solid var(--line);border-radius:8px;cursor:pointer;background:var(--surface);text-align:left;color:var(--ink);width:100%}
|
||||
.issue:hover{background:var(--sunk)}
|
||||
.issue.on{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}
|
||||
.issue.fixed{opacity:.6}
|
||||
.sev{width:8px;height:32px;border-radius:4px}
|
||||
.code{font-family:var(--f-mono);font-size:12px;background:var(--sunk);border-radius:8px;padding:10px;white-space:pre-wrap;word-break:break-word;overflow-x:auto}
|
||||
.code.new{background:var(--accent-soft)}
|
||||
.code del{color:var(--bad);text-decoration:line-through}
|
||||
.prog{display:grid;gap:6px}
|
||||
.pstep{display:flex;gap:8px;align-items:center;font-size:13px;color:var(--muted)}
|
||||
.pstep .b{width:16px;height:16px;border-radius:50%;border:2px solid var(--line);flex:none}
|
||||
.pstep.go{color:var(--ink)}.pstep.go .b{border-color:var(--mark);border-top-color:transparent;animation:spin .8s linear infinite}
|
||||
.pstep.ok{color:var(--ink)}.pstep.ok .b{border-color:var(--good);background:var(--good)}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.score{display:flex;align-items:center;gap:14px}
|
||||
.ring{width:84px;height:84px}
|
||||
|
||||
/* Keywords */
|
||||
.tabs{display:flex;gap:4px;flex-wrap:wrap}
|
||||
.tab{border:1px solid var(--line);background:var(--surface);color:var(--ink);padding:6px 12px;border-radius:999px;font-size:13px}
|
||||
.tab.on{background:var(--ink);color:var(--surface);border-color:var(--ink)}
|
||||
.kd{display:inline-flex;align-items:center;gap:6px}
|
||||
.kd i{width:9px;height:9px;border-radius:50%}
|
||||
|
||||
/* Geo grid */
|
||||
.geo{display:grid;grid-template-columns:repeat(7,1fr);gap:4px;max-width:330px;aspect-ratio:1;width:100%;position:relative}
|
||||
.geo span{display:grid;place-items:center;border-radius:50%;font-family:var(--f-mono);font-size:12px;font-weight:500}
|
||||
.legend{display:flex;gap:12px;flex-wrap:wrap;font-size:12px;color:var(--muted)}
|
||||
.legend i{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:4px;vertical-align:-1px}
|
||||
.spark{width:100%;height:120px}
|
||||
|
||||
/* AI vis */
|
||||
.yes{color:var(--good);font-weight:600}.no{color:var(--muted)}
|
||||
.share{display:grid;gap:8px}
|
||||
.srow{display:grid;grid-template-columns:150px minmax(0,1fr) 44px;gap:10px;align-items:center;font-size:13px}
|
||||
.sbar{height:10px;background:var(--sunk);border-radius:5px;overflow:hidden}
|
||||
.sbar i{display:block;height:100%;background:var(--muted)}
|
||||
.sbar i.me{background:var(--accent)}
|
||||
|
||||
/* Report */
|
||||
.report{border:1px solid var(--line);border-radius:10px;overflow:hidden}
|
||||
.rhead{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:14px 16px;background:var(--sunk);flex-wrap:wrap}
|
||||
.agency-logo{font-family:var(--f-display);font-weight:700;font-size:17px}
|
||||
.rbody{padding:16px;display:grid;gap:14px}
|
||||
.phone{border:1px solid var(--line);border-radius:22px;padding:12px;background:var(--sunk);max-width:300px;display:grid;gap:8px}
|
||||
.bubble{background:var(--surface);border-radius:10px;padding:10px;font-size:13px;border:1px solid var(--line)}
|
||||
.bubble .t{font-size:11px;color:var(--muted);text-align:right}
|
||||
|
||||
/* Pricing */
|
||||
.plans{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}
|
||||
.plan{border:1px solid var(--line);border-radius:10px;padding:14px;display:grid;gap:8px;align-content:start}
|
||||
.plan.feat{border:2px solid var(--accent)}
|
||||
.plan .price{font-family:var(--f-display);font-size:26px;font-weight:700}
|
||||
.plan ul{margin:0;padding-left:18px;display:grid;gap:3px;font-size:13px}
|
||||
.toggle{display:inline-flex;border:1px solid var(--line);border-radius:999px;overflow:hidden}
|
||||
.toggle button{border:0;background:transparent;color:var(--ink);padding:6px 14px;font-size:13px}
|
||||
.toggle button.on{background:var(--accent);color:var(--accent-ink)}
|
||||
.ck{color:var(--good);font-weight:600}.x{color:var(--bad)}
|
||||
|
||||
.toast{position:fixed;left:50%;transform:translateX(-50%);bottom:calc(20px + env(safe-area-inset-bottom,0px));background:var(--ink);color:var(--surface);padding:10px 16px;border-radius:8px;font-size:13px;z-index:20;max-width:calc(100% - 32px)}
|
||||
|
||||
@media (max-width:900px){
|
||||
.app{grid-template-columns:1fr}
|
||||
.nav{flex-direction:row;overflow-x:auto;border-right:0;border-bottom:1px solid var(--line)}
|
||||
.nav .ws,.nav .foot{display:none}
|
||||
.nav button{white-space:nowrap}
|
||||
.g4,.plans{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.split,.g2,.g3,.steps-on{grid-template-columns:1fr}
|
||||
}
|
||||
@media (max-width:480px){.g4,.plans{grid-template-columns:1fr}.canvas{padding:14px}.srow{grid-template-columns:110px minmax(0,1fr) 40px}}
|
||||
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header class="top">
|
||||
<div class="brand">
|
||||
<div class="logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2"><circle cx="10.5" cy="10.5" r="6.5"/><path d="M15.5 15.5 21 21M7.5 10.5h6M10.5 7.5v6"/></svg></div>
|
||||
<div><h1>CrawlerX</h1><small>Find it. Fix it. Prove it. SEO for 10 sites at ₹2,499/month.</small></div>
|
||||
</div>
|
||||
<span class="sample">Interactive demo · all sites and numbers are sample data</span>
|
||||
</header>
|
||||
|
||||
<section class="tour" aria-live="polite">
|
||||
<div class="tour-head">
|
||||
<span class="label" id="tourStep">Step 1 of 8</span>
|
||||
<div class="dots" id="dots"></div>
|
||||
</div>
|
||||
<p class="tour-text" id="tourText"></p>
|
||||
<div class="tour-nav">
|
||||
<button class="btn" id="prev">Back</button>
|
||||
<button class="btn primary" id="next">Next step</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="app">
|
||||
<nav class="nav" id="nav" aria-label="Product screens">
|
||||
<div class="ws"><b>Pixelkraft Digital</b>Agency workspace · Pro plan</div>
|
||||
</nav>
|
||||
<main class="canvas" id="canvas"></main>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast" id="toast" hidden></div>
|
||||
|
||||
<script>
|
||||
const $ = s => document.querySelector(s);
|
||||
const SCREENS = [
|
||||
{id:'onboard', name:'Get started',
|
||||
tour:'<b>Onboarding, under 5 minutes.</b> An agency signs up with Google or a phone OTP, pastes its client sites (or imports them from Search Console), and CrawlerX starts crawling all 10 at once. Press <b>Start crawl</b> to watch it.'},
|
||||
{id:'dash', name:'Portfolio',
|
||||
tour:'<b>One screen for every client site.</b> Health score, traffic from Google Search Console, tracked ranks and AI mentions per site, plus this week\'s <b>Next 5 actions</b> ranked by impact. Click a site or an action to open its audit.'},
|
||||
{id:'audit', name:'Audit & fix',
|
||||
tour:'<b>The core difference: we fix, not just find.</b> Pick an issue, read the AI-written fix, then <b>Approve and push</b>. CrawlerX writes it to WordPress or Shopify, re-crawls the page and confirms the fix is live. Watch the health score move.'},
|
||||
{id:'kw', name:'Keywords',
|
||||
tour:'<b>Keyword research built for India.</b> Volumes by city, intent and difficulty, in English and regional languages. Switch the language tabs, then add keywords to rank tracking in one click.'},
|
||||
{id:'rank', name:'Ranks & local',
|
||||
tour:'<b>Rank tracking plus a local map.</b> Weekly Google positions with a trend line, and a 7×7 geo-grid showing where the clinic ranks block by block across the city, the view Local Falcon and Synup sell separately.'},
|
||||
{id:'ai', name:'AI visibility',
|
||||
tour:'<b>Does ChatGPT recommend you?</b> CrawlerX asks the AI engines the questions customers ask, logs who gets mentioned and cited, and shows your share against competitors. Semrush sells this as a separate $199+ plan.'},
|
||||
{id:'report', name:'Client report',
|
||||
tour:'<b>White-label reporting.</b> The agency\'s own logo, a plain-language summary of what was fixed and what moved, sent as a PDF and a WhatsApp message every Monday.'},
|
||||
{id:'price', name:'Plans',
|
||||
tour:'<b>Priced for Indian agencies.</b> 10 sites for ₹2,499 a month with UPI Autopay and GST invoices, against $139.95 for one Semrush Pro seat. Toggle the currency and compare.'}
|
||||
];
|
||||
let cur = 0;
|
||||
const nav = $('#nav');
|
||||
SCREENS.forEach((s,i)=>{
|
||||
const b=document.createElement('button');b.id='nav-'+s.id;b.innerHTML=`<span class="n">${i+1}</span>${s.name}`;
|
||||
b.onclick=()=>go(i);nav.appendChild(b);
|
||||
});
|
||||
const foot=document.createElement('div');foot.className='foot';
|
||||
foot.innerHTML='Usage this month<div class="meter"><i style="width:38%"></i></div><span class="num">171 / 450</span> keyword lookups';
|
||||
nav.appendChild(foot);
|
||||
const dots=$('#dots');
|
||||
SCREENS.forEach((s,i)=>{const d=document.createElement('button');d.className='dot';d.setAttribute('aria-label','Go to step '+(i+1)+': '+s.name);d.onclick=()=>go(i);dots.appendChild(d)});
|
||||
$('#prev').onclick=()=>go(cur-1);$('#next').onclick=()=>go(cur+1);
|
||||
|
||||
function toast(t){const el=$('#toast');el.textContent=t;el.hidden=false;clearTimeout(toast.t);toast.t=setTimeout(()=>el.hidden=true,2600)}
|
||||
|
||||
/* ---------- sample data ---------- */
|
||||
const SITES=[
|
||||
{d:'smileline-dental.in',c:'Pune',h:64,t:'+12%',k:'38/120',ai:'2 of 12',st:'warn'},
|
||||
{d:'annapoorna-sweets.com',c:'Coimbatore',h:81,t:'+4%',k:'51/90',ai:'5 of 12',st:'good'},
|
||||
{d:'zenyoga-studio.in',c:'Bengaluru',h:72,t:'+9%',k:'22/60',ai:'1 of 10',st:'warn'},
|
||||
{d:'kraftleather.co',c:'Kanpur',h:49,t:'−6%',k:'14/80',ai:'0 of 10',st:'bad'},
|
||||
{d:'brightpath-tuitions.in',c:'Jaipur',h:77,t:'+15%',k:'30/45',ai:'3 of 8',st:'good'},
|
||||
{d:'greenleaf-organics.in',c:'Hyderabad',h:68,t:'+2%',k:'19/70',ai:'2 of 10',st:'warn'},
|
||||
{d:'metroautoparts.in',c:'Chennai',h:58,t:'−3%',k:'27/110',ai:'1 of 12',st:'bad'},
|
||||
{d:'lotus-ayurveda.in',c:'Kochi',h:85,t:'+7%',k:'44/60',ai:'6 of 10',st:'good'},
|
||||
{d:'urbannest-realty.in',c:'Gurugram',h:71,t:'+1%',k:'16/50',ai:'1 of 8',st:'warn'},
|
||||
{d:'chaicraft-cafe.in',c:'Indore',h:79,t:'+11%',k:'12/25',ai:'2 of 6',st:'good'}
|
||||
];
|
||||
const ISSUES=[
|
||||
{id:'i1',sev:'bad',title:'Missing LocalBusiness schema',pages:'Homepage',impact:'High',why:'Google and AI engines can\'t read your address, hours and phone as structured facts. Clinics with schema are cited more often in AI answers.',
|
||||
before:'\x3C!-- no structured data on page -->',
|
||||
after:'\x3Cscript type="application/ld+json">\n{\n "@context": "https://schema.org",\n "@type": "Dentist",\n "name": "SmileLine Dental Clinic",\n "address": {\n "@type": "PostalAddress",\n "streetAddress": "12 FC Road, Shivajinagar",\n "addressLocality": "Pune", "postalCode": "411005",\n "addressCountry": "IN"\n },\n "telephone": "+91-20-0000-0000",\n "openingHours": "Mo-Sa 10:00-20:00"\n}\n\x3C/script>',gain:6},
|
||||
{id:'i2',sev:'bad',title:'Title tags too long on 14 pages',pages:'14 service pages',impact:'High',why:'Titles over 60 characters get cut in results. The fix keeps the main keyword and city in the first 50 characters.',
|
||||
before:'<title><del>Welcome to SmileLine Dental Clinic | Best Dentist | Root Canal Treatment | Teeth Whitening | Pune Maharashtra</del></title>',
|
||||
after:'<title>Root Canal Treatment in Pune | SmileLine Dental</title>',gain:5},
|
||||
{id:'i3',sev:'warn',title:'38 images without alt text',pages:'Gallery, Services',impact:'Medium',why:'Alt text helps image search and accessibility. The AI describes each image from its content and file context.',
|
||||
before:'<img src="/img/IMG_4471.jpg">',
|
||||
after:'<img src="/img/IMG_4471.jpg"\n alt="Dentist explaining a dental X-ray to a patient at SmileLine, Pune">',gain:3},
|
||||
{id:'i4',sev:'warn',title:'Slow mobile LCP (4.1 s)',pages:'Homepage',impact:'Medium',why:'The hero image is a 2.4 MB JPEG. Serving WebP at the right size brings LCP under 2.5 s.',
|
||||
before:'<img src="/hero.jpg" width="2400">',
|
||||
after:'<img src="/hero-1200.webp" width="1200" height="640"\n fetchpriority="high" alt="SmileLine clinic reception">',gain:4},
|
||||
{id:'i5',sev:'warn',title:'No FAQ block on service pages',pages:'6 service pages',impact:'Medium',why:'Question-and-answer content is what AI engines quote. The draft answers the 4 questions patients search most for each service.',
|
||||
before:'\x3C!-- page ends after pricing table -->',
|
||||
after:'<section class="faq">\n <h2>Root canal FAQs</h2>\n <h3>Is a root canal painful?</h3>\n <p>With local anaesthesia most patients feel pressure,\n not pain. Treatment takes 60–90 minutes.</p>\n ...\n</section>',gain:3}
|
||||
];
|
||||
let health=64; const fixed=new Set(); let sel='i1';
|
||||
const KW={
|
||||
en:[['dentist near me','Pune','40,500','Local',38,'good'],['root canal treatment cost pune','Pune','2,900','Commercial',22,'good'],['teeth whitening pune','Pune','1,600','Commercial',31,'good'],['best dental clinic in pune','Pune','3,600','Local',57,'warn'],['dental implants price','India','14,800','Commercial',64,'bad'],['braces for adults','India','6,600','Info',45,'warn']],
|
||||
hi:[['दांत का डॉक्टर','India','9,900','Local',18,'good'],['दांत दर्द का इलाज','India','22,200','Info',26,'good'],['रूट कैनाल क्या है','India','4,400','Info',12,'good'],['दांतों की सफाई खर्च','India','1,300','Commercial',15,'good']],
|
||||
ta:[['பல் மருத்துவர்','Tamil Nadu','6,600','Local',14,'good'],['பல் வலி தீர்வு','Tamil Nadu','3,600','Info',11,'good'],['பல் கிளினிக் அருகில்','Chennai','1,900','Local',20,'good']]
|
||||
};
|
||||
let kwLang='en';
|
||||
|
||||
/* ---------- screens ---------- */
|
||||
const R={};
|
||||
R.onboard=()=>`
|
||||
<div class="screen-head"><div><h2>Add your client sites</h2><p>Paste up to 10 domains, one per line, or import them from Google Search Console.</p></div></div>
|
||||
<div class="steps-on">
|
||||
<div class="panel"><span class="label">1 · Account</span>
|
||||
<div class="field"><label for="ob-name">Agency name</label><input id="ob-name" value="Pixelkraft Digital"></div>
|
||||
<div class="field"><label for="ob-phone">Mobile (OTP login)</label><input id="ob-phone" value="+91 98xxx xxx21"></div>
|
||||
<div class="field"><label for="ob-lang">Interface language</label><select id="ob-lang"><option>English</option><option>हिन्दी</option><option>தமிழ்</option></select></div>
|
||||
</div>
|
||||
<div class="panel"><span class="label">2 · Sites</span>
|
||||
<textarea id="ob-sites" aria-label="Site list">${SITES.map(s=>s.d).join('\n')}</textarea>
|
||||
<label class="check"><input type="checkbox" id="ob-gsc" checked> Connect Google Search Console for real clicks</label>
|
||||
<label class="check"><input type="checkbox" id="ob-gbp" checked> Connect Google Business Profiles</label>
|
||||
</div>
|
||||
<div class="panel"><span class="label">3 · First crawl</span>
|
||||
<div class="crawl-log" id="log"><span class="muted">Waiting to start…</span></div>
|
||||
<button class="btn primary" id="startCrawl">Start crawl</button>
|
||||
</div>
|
||||
</div>`;
|
||||
R.dash=()=>{
|
||||
const avg=Math.round(SITES.reduce((a,s)=>a+(s.d==='smileline-dental.in'?health:s.h),0)/SITES.length);
|
||||
return `
|
||||
<div class="screen-head"><div><h2>Portfolio · 10 sites</h2><p>Week of 12 Oct 2026 · data from your crawls, Search Console and tracked SERPs</p></div><span class="pill p-acc">Next crawl: Mon 19 Oct, 03:00 IST</span></div>
|
||||
<div class="grid g4">
|
||||
<div class="panel stat"><span class="label">Avg health score</span><span class="v num">${avg}</span><span class="d up">+5 since last month</span></div>
|
||||
<div class="panel stat"><span class="label">Clicks (GSC, 28 days)</span><span class="v num">48,210</span><span class="d up">+8.4% vs previous 28 days</span></div>
|
||||
<div class="panel stat"><span class="label">Keywords in top 10</span><span class="v num">273</span><span class="d">of 300 tracked · <span class="up">+19</span></span></div>
|
||||
<div class="panel stat"><span class="label">AI answers mentioning you</span><span class="v num">23</span><span class="d">of 96 tracked prompts</span></div>
|
||||
</div>
|
||||
<div class="grid" style="grid-template-columns:minmax(0,1.5fr) minmax(0,1fr)">
|
||||
<div class="tbl-wrap"><table><thead><tr><th>Site</th><th>City</th><th>Health</th><th class="r">Clicks</th><th class="r">Top 10</th><th class="r">AI mentions</th></tr></thead><tbody>
|
||||
${SITES.map(s=>{const h=s.d==='smileline-dental.in'?health:s.h;const st=h>=75?'good':h>=60?'warn':'bad';return `<tr class="click" tabindex="0" data-site="${s.d}"><td>${s.d}</td><td class="muted">${s.c}</td><td><span class="health"><span class="hbar"><i style="width:${h}%;background:var(--${st})"></i></span><span class="num">${h}</span></span></td><td class="r num ${s.t.startsWith('+')?'up':'down'}">${s.t}</td><td class="r num">${s.k}</td><td class="r num">${s.ai}</td></tr>`}).join('')}
|
||||
</tbody></table></div>
|
||||
<div class="panel"><h3>Next 5 actions this week</h3><p class="muted" style="font-size:12.5px">Ranked by expected traffic gain. Each one opens with the fix already written.</p>
|
||||
<div class="actions">
|
||||
${[['Add LocalBusiness schema','smileline-dental.in · homepage','High','p-bad'],['Shorten 14 title tags','smileline-dental.in','High','p-bad'],['Fix 22 broken internal links','kraftleather.co','High','p-bad'],['Answer “best leather bags kanpur” in an FAQ','kraftleather.co · AI gap','Med','p-warn'],['Compress hero image (LCP 4.1 s)','metroautoparts.in','Med','p-warn']].map((a,i)=>`<div class="act"><span class="k">${i+1}</span><div>${a[0]}<small>${a[1]}</small></div><span class="pill ${a[3]}">${a[2]}</span></div>`).join('')}
|
||||
</div>
|
||||
<button class="btn primary" id="openAudit">Open fixes for smileline-dental.in</button>
|
||||
</div>
|
||||
</div>`};
|
||||
function ring(v){const c=2*Math.PI*34,st=v>=75?'good':v>=60?'warn':'bad';return `<svg class="ring" viewBox="0 0 84 84" role="img" aria-label="Health score ${v} of 100"><circle cx="42" cy="42" r="34" fill="none" stroke="var(--line)" stroke-width="8"/><circle cx="42" cy="42" r="34" fill="none" stroke="var(--${st})" stroke-width="8" stroke-linecap="round" stroke-dasharray="${c*v/100} ${c}" transform="rotate(-90 42 42)"/><text x="42" y="48" text-anchor="middle" font-family="Bricolage Grotesque, sans-serif" font-size="22" font-weight="700" fill="var(--ink)">${v}</text></svg>`}
|
||||
R.audit=()=>{
|
||||
const it=ISSUES.find(x=>x.id===sel);const done=fixed.has(it.id);
|
||||
return `
|
||||
<div class="screen-head"><div><h2>smileline-dental.in · Audit</h2><p>Crawled 412 pages · 128 checks · last crawl 14 Oct 2026, 03:12 IST</p></div>
|
||||
<div class="score">${ring(health)}<div><span class="label">Health score</span><p class="muted" style="font-size:12.5px">${fixed.size} of ${ISSUES.length} top issues fixed</p></div></div></div>
|
||||
<div class="split">
|
||||
<div class="grid" style="align-content:start">
|
||||
<span class="label">Top issues by impact</span>
|
||||
${ISSUES.map(x=>`<button class="issue ${x.id===sel?'on':''} ${fixed.has(x.id)?'fixed':''}" data-issue="${x.id}"><span class="sev" style="background:var(--${fixed.has(x.id)?'good':x.sev})"></span><span><b style="font-weight:600">${x.title}</b><br><small class="muted">${x.pages}</small></span>${fixed.has(x.id)?'<span class="pill p-good">Fixed</span>':`<span class="pill ${x.sev==='bad'?'p-bad':'p-warn'}">${x.impact}</span>`}</button>`).join('')}
|
||||
<p class="muted" style="font-size:12.5px">+ 46 lower-impact issues (redirect chains, missing H1 on 3 pages, mixed content)</p>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>${it.title}</h3>
|
||||
<p>${it.why}</p>
|
||||
<span class="label">Now on the site</span><div class="code">${it.before.replace(/<(?!\/?del>)/g,'<')}</div>
|
||||
<span class="label">AI-generated fix <span class="pill p-acc" style="text-transform:none;letter-spacing:0">editable</span></span><div class="code new" contenteditable="true" spellcheck="false" aria-label="Generated fix, editable">${it.after.replace(/</g,'<')}</div>
|
||||
<div class="prog" id="prog" ${done?'':'hidden'}>
|
||||
<div class="pstep ${done?'ok':''}" id="ps1"><span class="b"></span>Pushing to WordPress (via CrawlerX plugin)</div>
|
||||
<div class="pstep ${done?'ok':''}" id="ps2"><span class="b"></span>Re-crawling affected pages</div>
|
||||
<div class="pstep ${done?'ok':''}" id="ps3"><span class="b"></span>Verified live · Google re-index requested via Search Console</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn mark" id="approve" ${done?'disabled':''}>${done?'Fix is live':'Approve and push to WordPress'}</button>
|
||||
<button class="btn" id="copyFix">Copy code</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`};
|
||||
R.kw=()=>`
|
||||
<div class="screen-head"><div><h2>Keyword research</h2><p>Seed: <b>dentist</b> · location: Pune, Maharashtra · Google India</p></div>
|
||||
<div class="tabs" role="tablist">${[['en','English'],['hi','हिन्दी'],['ta','தமிழ்']].map(([k,l])=>`<button class="tab ${kwLang===k?'on':''}" data-lang="${k}" role="tab" aria-selected="${kwLang===k}">${l}</button>`).join('')}</div></div>
|
||||
<div class="tbl-wrap"><table><thead><tr><th>Keyword</th><th>Location</th><th class="r">Monthly searches</th><th>Intent</th><th>Difficulty</th><th></th></tr></thead><tbody>
|
||||
${KW[kwLang].map((r,i)=>`<tr><td>${r[0]}</td><td class="muted">${r[1]}</td><td class="r num">${r[2]}</td><td><span class="pill p-acc">${r[3]}</span></td><td><span class="kd"><i style="background:var(--${r[5]})"></i><span class="num">${r[4]}</span>/100</span></td><td class="r"><button class="btn" data-track="${i}" style="padding:4px 10px;font-size:12.5px">Track</button></td></tr>`).join('')}
|
||||
</tbody></table></div>
|
||||
<div class="grid g3">
|
||||
<div class="panel"><span class="label">Quick win</span><p><b>Regional-language demand is untapped.</b> Hindi “दांत दर्द का इलाज” gets 22,200 searches with difficulty 26. No Pune clinic has a Hindi page for it.</p></div>
|
||||
<div class="panel"><span class="label">Content brief</span><p>One click turns a keyword into a brief: headings, questions to answer, word count and internal links.</p><button class="btn" id="brief">Generate brief</button></div>
|
||||
<div class="panel"><span class="label">Data note</span><p class="muted">Volumes are monthly averages from Google Ads data via our data provider, cached and refreshed every 30 days.</p></div>
|
||||
</div>`;
|
||||
function geo(){const g=[];const center=3;for(let r=0;r<7;r++)for(let c=0;c<7;c++){const d=Math.hypot(r-center,c-center*1.1+.6);let v=Math.max(1,Math.round(1+d*2.3+((r*7+c)%3)));if(v>20)v='20+';g.push(v)}return g}
|
||||
R.rank=()=>{const g=geo();const col=v=>v==='20+'||v>10?'bad':v>3?'warn':'good';
|
||||
const pts=[18,17,15,15,13,12,11,9,8,7,6,4];const w=520,h=120,x=i=>20+i*(w-40)/(pts.length-1),y=v=>10+(v-1)/19*(h-30);
|
||||
return `
|
||||
<div class="screen-head"><div><h2>Ranks & local · smileline-dental.in</h2><p>Keyword: <b>dentist near me</b> · tracked weekly · Google mobile</p></div><span class="pill p-good">Position 4 · up 14 in 12 weeks</span></div>
|
||||
<div class="grid g2">
|
||||
<div class="panel"><h3>Local geo-grid · 7 × 7 points, 0.5 km apart</h3>
|
||||
<div class="geo" role="img" aria-label="Rank by map location; ranks 1 to 3 near the clinic, falling to 20+ at the edges">${g.map(v=>`<span style="background:var(--${col(v)}-soft);color:var(--${col(v)})">${v}</span>`).join('')}</div>
|
||||
<div class="legend"><span><i style="background:var(--good)"></i>Rank 1–3</span><span><i style="background:var(--warn)"></i>4–10</span><span><i style="background:var(--bad)"></i>11+</span></div>
|
||||
<p class="muted" style="font-size:12.5px">Share of local visibility: <b class="num">41%</b>. The clinic owns the map within about 1 km of FC Road and loses to two competitors in Kothrud.</p>
|
||||
</div>
|
||||
<div class="panel"><h3>Position over 12 weeks</h3>
|
||||
<svg class="spark" viewBox="0 0 ${w} ${h}" role="img" aria-label="Position improved from 18 to 4 over 12 weeks" preserveAspectRatio="none">
|
||||
${[1,10,20].map(v=>`<line x1="20" x2="${w-20}" y1="${y(v)}" y2="${y(v)}" stroke="var(--line)"/><text x="0" y="${y(v)+4}" font-size="10" fill="var(--muted)" font-family="IBM Plex Mono, monospace">${v}</text>`).join('')}
|
||||
<polyline fill="none" stroke="var(--accent)" stroke-width="2.5" points="${pts.map((v,i)=>x(i)+','+y(v)).join(' ')}"/>
|
||||
<circle cx="${x(pts.length-1)}" cy="${y(4)}" r="5" fill="var(--accent)"/>
|
||||
</svg>
|
||||
<div class="tbl-wrap"><table><thead><tr><th>Keyword</th><th class="r">Pos.</th><th class="r">Change</th><th>SERP feature</th></tr></thead><tbody>
|
||||
<tr><td>dentist near me</td><td class="r num">4</td><td class="r num up">+14</td><td>Map pack</td></tr>
|
||||
<tr><td>root canal treatment cost pune</td><td class="r num">2</td><td class="r num up">+5</td><td>AI Overview cited</td></tr>
|
||||
<tr><td>teeth whitening pune</td><td class="r num">7</td><td class="r num up">+3</td><td>People also ask</td></tr>
|
||||
<tr><td>dental implants price</td><td class="r num">23</td><td class="r num down">−2</td><td>—</td></tr>
|
||||
</tbody></table></div>
|
||||
</div>
|
||||
</div>`};
|
||||
R.ai=()=>{
|
||||
const P=[['best dentist in pune for root canal',1,1,0,1],['painless dental clinic shivajinagar',1,0,1,1],['how much does teeth whitening cost in pune',0,1,0,1],['dentist open on sunday pune',0,0,0,0],['affordable braces pune for adults',0,0,1,0]];
|
||||
const mk=v=>v?'<span class="yes">Mentioned</span>':'<span class="no">—</span>';
|
||||
const S=[['SmileLine Dental (you)',42,1],['Clove Dental',58,0],['Sabka Dentist',35,0],['Dr. Mehta\'s Clinic',21,0]];
|
||||
return `
|
||||
<div class="screen-head"><div><h2>AI visibility · smileline-dental.in</h2><p>12 tracked prompts · checked weekly in ChatGPT, Gemini, Perplexity and Google AI Overviews</p></div><span class="pill p-acc">Score 42 / 100 · up from 17</span></div>
|
||||
<div class="tbl-wrap"><table><thead><tr><th>Prompt customers ask</th><th>ChatGPT</th><th>Gemini</th><th>Perplexity</th><th>AI Overview</th></tr></thead><tbody>
|
||||
${P.map(p=>`<tr><td>${p[0]}</td><td>${mk(p[1])}</td><td>${mk(p[2])}</td><td>${mk(p[3])}</td><td>${mk(p[4])}</td></tr>`).join('')}
|
||||
</tbody></table></div>
|
||||
<div class="grid g2">
|
||||
<div class="panel"><h3>Share of AI mentions vs competitors</h3><div class="share">
|
||||
${S.map(s=>`<div class="srow"><span>${s[0]}</span><span class="sbar"><i class="${s[2]?'me':''}" style="width:${s[1]}%"></i></span><span class="num r">${s[1]}%</span></div>`).join('')}
|
||||
</div><p class="muted" style="font-size:12px">Share of the 48 prompt-engine checks this week that named each clinic.</p></div>
|
||||
<div class="panel"><h3>Why the AI skipped you on “dentist open on sunday pune”</h3>
|
||||
<div class="actions">
|
||||
<div class="act"><span class="k">1</span><div>Sunday hours are only in an image<small>Add opening hours as text and in schema</small></div><span class="pill p-bad">High</span></div>
|
||||
<div class="act"><span class="k">2</span><div>No mention on Practo or Justdial listing<small>AI engines cite these directories for clinics</small></div><span class="pill p-warn">Med</span></div>
|
||||
<div class="act"><span class="k">3</span><div>robots.txt blocks GPTBot<small>Allow AI crawlers to read the site</small></div><span class="pill p-warn">Med</span></div>
|
||||
</div>
|
||||
<button class="btn primary" id="aiFix">Send these to the fix queue</button>
|
||||
</div>
|
||||
</div>`};
|
||||
R.report=()=>`
|
||||
<div class="screen-head"><div><h2>Client report · October 2026</h2><p>Sent under your agency's brand. Clients never see CrawlerX.</p></div><button class="btn primary" id="send">Schedule for every Monday</button></div>
|
||||
<div class="grid" style="grid-template-columns:minmax(0,1.6fr) minmax(0,1fr)">
|
||||
<div class="report">
|
||||
<div class="rhead"><span class="agency-logo">Pixelkraft Digital</span><span class="muted">Monthly SEO report · SmileLine Dental Clinic, Pune</span></div>
|
||||
<div class="rbody">
|
||||
<p><b>Summary in plain words.</b> Google clicks grew 12% this month. You now appear 4th for “dentist near me” across most of Shivajinagar, and ChatGPT recommends SmileLine for root canal questions in Pune.</p>
|
||||
<div class="grid g3">
|
||||
<div class="panel stat"><span class="label">Website visits from Google</span><span class="v num">3,912</span><span class="d up">+12%</span></div>
|
||||
<div class="panel stat"><span class="label">Calls from Google Maps</span><span class="v num">184</span><span class="d up">+21%</span></div>
|
||||
<div class="panel stat"><span class="label">Health score</span><span class="v num">${health}</span><span class="d up">from 64</span></div>
|
||||
</div>
|
||||
<div><span class="label">What we fixed</span><ul style="margin:6px 0 0;padding-left:18px;display:grid;gap:3px"><li>Added clinic details as structured data so Google and AI read them correctly</li><li>Rewrote 14 page titles to show the service and “Pune” first</li><li>Described 38 images for image search</li></ul></div>
|
||||
<div><span class="label">Next month</span><p>Hindi page for “दांत दर्द का इलाज”, Sunday hours in text, Practo listing update.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phone" aria-label="WhatsApp preview">
|
||||
<span class="label">WhatsApp preview</span>
|
||||
<div class="bubble"><b>Pixelkraft Digital</b><br>Hi Dr. Kulkarni, your October SEO report is ready.<br>• Google visits +12%<br>• Maps calls +21%<br>• Now #4 for “dentist near me”<br>Full PDF attached.<div class="t">Mon 09:00</div></div>
|
||||
<div class="bubble">📄 SmileLine_SEO_Oct2026.pdf · 6 pages<div class="t">Mon 09:00</div></div>
|
||||
</div>
|
||||
</div>`;
|
||||
let cur$='inr';
|
||||
R.price=()=>{
|
||||
const p={inr:['₹0','₹999','₹2,499','₹7,499'],usd:['$0','$15','$29','$79']}[cur$];
|
||||
return `
|
||||
<div class="screen-head"><div><h2>Plans</h2><p>All modules on every paid plan. Plans differ only by how many sites and how much data. Prices exclude 18% GST.</p></div>
|
||||
<div class="toggle" role="group" aria-label="Currency"><button class="${cur$==='inr'?'on':''}" data-cur="inr">₹ INR</button><button class="${cur$==='usd'?'on':''}" data-cur="usd">$ USD</button></div></div>
|
||||
<div class="plans">
|
||||
<div class="plan"><h3>Free</h3><span class="price">${p[0]}</span><ul><li>1 site, 100-page audit</li><li>10 tracked keywords</li><li>30 keyword lookups/month</li></ul></div>
|
||||
<div class="plan"><h3>Starter</h3><span class="price">${p[1]}<small class="muted" style="font-size:13px;font-weight:400">/mo</small></span><ul><li>3 sites, 1,000 pages each</li><li>100 tracked keywords</li><li>150 lookups, 40 AI checks</li><li>AI fixes + push</li></ul></div>
|
||||
<div class="plan feat"><span class="pill p-acc" style="justify-self:start">Most agencies pick this</span><h3>Pro</h3><span class="price">${p[2]}<small class="muted" style="font-size:13px;font-weight:400">/mo</small></span><ul><li><b>10 sites</b>, 3,000 pages each</li><li>300 tracked keywords + geo-grid</li><li>450 lookups, 120 AI checks</li><li>White-label PDF + WhatsApp reports</li></ul></div>
|
||||
<div class="plan"><h3>Agency</h3><span class="price">${p[3]}<small class="muted" style="font-size:13px;font-weight:400">/mo</small></span><ul><li>30 sites, 5,000 pages each</li><li>1,000 tracked keywords</li><li>Client portal, 5 seats</li><li>API + MCP access</li></ul></div>
|
||||
</div>
|
||||
<div class="tbl-wrap"><table><thead><tr><th>Compare at the 10-site level</th><th>CrawlerX Pro</th><th>Semrush Pro</th><th>Ubersuggest Individual</th><th>SE Ranking Essential</th></tr></thead><tbody>
|
||||
<tr><td>Price per month</td><td><b>${cur$==='inr'?'₹2,499':'$29'}</b></td><td>$139.95</td><td>$29</td><td>$59</td></tr>
|
||||
<tr><td>Sites / projects included</td><td>10</td><td>5 projects</td><td>limits are per domain</td><td>5 projects</td></tr>
|
||||
<tr><td>Writes and pushes the fix</td><td class="ck">Yes</td><td class="x">No</td><td class="x">No</td><td class="x">No</td></tr>
|
||||
<tr><td>AI search visibility</td><td class="ck">Included</td><td>Semrush One, from $199</td><td class="x">No</td><td>Add-on</td></tr>
|
||||
<tr><td>Local geo-grid</td><td class="ck">Included</td><td>Local add-on</td><td class="x">No</td><td>Add-on</td></tr>
|
||||
<tr><td>White-label reports</td><td class="ck">Included</td><td>Business plan only</td><td class="x">No</td><td>Higher plans</td></tr>
|
||||
<tr><td>INR billing, UPI Autopay, GST invoice</td><td class="ck">Yes</td><td>INR billing</td><td class="x">No</td><td class="x">No</td></tr>
|
||||
<tr><td>Hindi / Tamil interface and keywords</td><td class="ck">Yes</td><td>Keywords only</td><td>Keywords only</td><td>Keywords only</td></tr>
|
||||
</tbody></table></div>
|
||||
<p class="muted" style="font-size:12px">Competitor prices are published list prices as of 2026 from public pricing pages and reviews; limits vary by plan and change often.</p>`};
|
||||
|
||||
/* ---------- wiring ---------- */
|
||||
function bind(id){
|
||||
if(id==='onboard'){$('#startCrawl').onclick=runCrawl}
|
||||
if(id==='dash'){document.querySelectorAll('[data-site]').forEach(r=>{const f=()=>{if(r.dataset.site==='smileline-dental.in')go(2);else toast('In the full product this opens '+r.dataset.site+'. The demo follows smileline-dental.in.')};r.onclick=f;r.onkeydown=e=>{if(e.key==='Enter')f()}});$('#openAudit').onclick=()=>go(2)}
|
||||
if(id==='audit'){
|
||||
document.querySelectorAll('[data-issue]').forEach(b=>b.onclick=()=>{sel=b.dataset.issue;render()});
|
||||
$('#approve').onclick=approve;
|
||||
$('#copyFix').onclick=()=>{const t=document.querySelector('.code.new').innerText;const ok=()=>toast('Fix copied');try{navigator.clipboard.writeText(t).then(ok,()=>toast('Select the code and copy it manually'))}catch(e){toast('Select the code and copy it manually')}};
|
||||
}
|
||||
if(id==='kw'){document.querySelectorAll('[data-lang]').forEach(b=>b.onclick=()=>{kwLang=b.dataset.lang;render()});document.querySelectorAll('[data-track]').forEach(b=>b.onclick=()=>{b.textContent='Tracking';b.disabled=true;toast('Added to rank tracking · 299 of 300 used')});$('#brief').onclick=()=>toast('Brief drafted: 7 headings, 6 questions, ~1,400 words')}
|
||||
if(id==='ai'){$('#aiFix').onclick=()=>toast('3 fixes added to the queue for smileline-dental.in')}
|
||||
if(id==='report'){$('#send').onclick=()=>toast('Scheduled: PDF + WhatsApp every Monday 09:00 IST')}
|
||||
if(id==='price'){document.querySelectorAll('[data-cur]').forEach(b=>b.onclick=()=>{cur$=b.dataset.cur;render()})}
|
||||
}
|
||||
function runCrawl(){
|
||||
const log=$('#log'),btn=$('#startCrawl');btn.disabled=true;log.innerHTML='';
|
||||
const lines=['Fetching robots.txt and sitemaps for 10 sites…','Rendering JavaScript pages with headless Chrome…',...SITES.slice(0,6).map(s=>`✓ ${s.d} — ${200+Math.round(s.h*4)} pages`),'… 4 more sites','Pulling Search Console clicks (28 days)…','Done. 3,906 pages, 612 issues, 47 fixes ready.'];
|
||||
lines.forEach((l,i)=>setTimeout(()=>{const s=document.createElement('span');s.textContent=l;log.appendChild(s);if(i===lines.length-1){btn.textContent='Open portfolio';btn.disabled=false;btn.onclick=()=>go(1)}},i*380));
|
||||
}
|
||||
function approve(){
|
||||
const id=sel,it=ISSUES.find(x=>x.id===id);$('#approve').disabled=true;$('#prog').hidden=false;
|
||||
const st=['#ps1','#ps2','#ps3'];
|
||||
st.forEach((s,i)=>{setTimeout(()=>{$(s).className='pstep go'},i*900);setTimeout(()=>{$(s).className='pstep ok';if(i===2){fixed.add(id);health=Math.min(100,health+it.gain);toast(`Fix is live · health ${health-it.gain} → ${health}`);const next=ISSUES.find(x=>!fixed.has(x.id));if(next)sel=next.id;setTimeout(render,700)}},i*900+800)});
|
||||
}
|
||||
function render(){$('#canvas').innerHTML=R[SCREENS[cur].id]();bind(SCREENS[cur].id)}
|
||||
function go(i){
|
||||
if(i<0||i>=SCREENS.length)return;cur=i;
|
||||
$('#tourStep').textContent=`Step ${i+1} of ${SCREENS.length} · ${SCREENS[i].name}`;
|
||||
$('#tourText').innerHTML=SCREENS[i].tour;
|
||||
document.querySelectorAll('.dot').forEach((d,k)=>d.className='dot'+(k===i?' on':k<i?' done':''));
|
||||
document.querySelectorAll('.nav button').forEach((b,k)=>b.classList.toggle('on',k===i));
|
||||
$('#prev').disabled=i===0;$('#next').textContent=i===SCREENS.length-1?'End of tour':'Next step';$('#next').disabled=i===SCREENS.length-1;
|
||||
render();
|
||||
try{localStorage.setItem('cx-step',i)}catch(e){}
|
||||
}
|
||||
let start=0;try{start=+localStorage.getItem('cx-step')||0}catch(e){}
|
||||
go(Math.min(start,SCREENS.length-1));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -20,3 +20,20 @@ export const billingCustomerStatus = sqliteTable("billing_customer_status", {
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// Local (non-Autumn) monthly usage ledger — the self-host / direct-Stripe
|
||||
// equivalent of Autumn's `usage_credits` balance. One row per organization;
|
||||
// `periodMonth` ("YYYY-MM") marks which calendar month `creditsUsed` covers,
|
||||
// and a read for a stale month treats the balance as 0 rather than carrying
|
||||
// it over (see resetIfNewPeriod in stripe-sync.ts). Only consulted when
|
||||
// AUTUMN_SECRET_KEY is unset — see src/server/billing/subscription.ts.
|
||||
export const usageLedger = sqliteTable("usage_ledger", {
|
||||
organizationId: text("organization_id")
|
||||
.primaryKey()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
periodMonth: text("period_month").notNull(),
|
||||
creditsUsed: integer("credits_used").notNull().default(0),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { boolean, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { boolean, integer, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { organization } from "./better-auth-schema";
|
||||
|
||||
// See src/db/pg/app.schema.ts for why timestamps are ISO-8601 UTC text.
|
||||
@ -19,3 +19,14 @@ export const billingCustomerStatus = pgTable("billing_customer_status", {
|
||||
createdAt: text("created_at").notNull().default(isoNow),
|
||||
updatedAt: text("updated_at").notNull().default(isoNow),
|
||||
});
|
||||
|
||||
// See src/db/billing.schema.ts (sqlite) for the field notes — must stay
|
||||
// structurally identical, enforced by schema-parity.test.ts.
|
||||
export const usageLedger = pgTable("usage_ledger", {
|
||||
organizationId: text("organization_id")
|
||||
.primaryKey()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
periodMonth: text("period_month").notNull(),
|
||||
creditsUsed: integer("credits_used").notNull().default(0),
|
||||
updatedAt: text("updated_at").notNull().default(isoNow),
|
||||
});
|
||||
|
||||
@ -104,6 +104,7 @@ export const {
|
||||
member,
|
||||
invitation,
|
||||
billingCustomerStatus,
|
||||
usageLedger,
|
||||
ga4Connections,
|
||||
gscConnections,
|
||||
telemetryState,
|
||||
|
||||
@ -1,35 +1,56 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Stripe } from "stripe";
|
||||
import { getOptionalEnvValue } from "@/server/lib/runtime-env";
|
||||
import { syncStripeSubscriptionStatus } from "@/server/billing/stripe-sync";
|
||||
import {
|
||||
mapStripeSubscriptionStatus,
|
||||
syncStripeSubscriptionStatus,
|
||||
} from "@/server/billing/stripe-sync";
|
||||
|
||||
async function handleStripeWebhook(request: Request): Promise<Response> {
|
||||
const stripeSecretKey = await getOptionalEnvValue("STRIPE_SECRET_KEY");
|
||||
if (!stripeSecretKey) {
|
||||
return Response.json({ error: "Stripe key not configured" }, { status: 500 });
|
||||
return Response.json(
|
||||
{ error: "Stripe key not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const stripe = new Stripe(stripeSecretKey);
|
||||
const signature = request.headers.get("stripe-signature");
|
||||
const webhookSecret = await getOptionalEnvValue("STRIPE_WEBHOOK_SECRET");
|
||||
|
||||
let event: Stripe.Event;
|
||||
const payload = await request.text();
|
||||
// STRIPE_WEBHOOK_SECRET is required, never an optional hardening step: this
|
||||
// endpoint sets an organization's paid-subscription status, so an unsigned
|
||||
// request must never be trusted as a real Stripe event. Without it (or
|
||||
// without the signature header), refuse rather than falling back to
|
||||
// parsing the body as JSON — that fallback let anyone POST a fake
|
||||
// `checkout.session.completed` and mark any org as paying for free.
|
||||
if (!webhookSecret) {
|
||||
console.error(
|
||||
"Stripe webhook received but STRIPE_WEBHOOK_SECRET is not configured — refusing to process it.",
|
||||
);
|
||||
return Response.json(
|
||||
{ error: "Webhook secret not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
if (!signature) {
|
||||
return Response.json(
|
||||
{ error: "Missing stripe-signature header" },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (webhookSecret && signature) {
|
||||
const payload = await request.text();
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
|
||||
} catch (err) {
|
||||
console.error("Stripe webhook signature verification failed:", err);
|
||||
return Response.json({ error: "Invalid signature" }, { status: 400 });
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
event = JSON.parse(payload) as Stripe.Event;
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
@ -50,19 +71,23 @@ async function handleStripeWebhook(request: Request): Promise<Response> {
|
||||
break;
|
||||
}
|
||||
|
||||
case "customer.subscription.updated": {
|
||||
case "customer.subscription.updated":
|
||||
case "customer.subscription.created": {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
const organizationId = subscription.metadata?.organizationId;
|
||||
const planId = subscription.metadata?.planId ?? "pro";
|
||||
const isActive = subscription.status === "active";
|
||||
const status = mapStripeSubscriptionStatus(subscription.status);
|
||||
|
||||
if (organizationId) {
|
||||
await syncStripeSubscriptionStatus({
|
||||
organizationId,
|
||||
planId,
|
||||
status: isActive ? "active" : "canceled",
|
||||
isPaying: isActive,
|
||||
stripeCustomerJson: subscription as unknown as Record<string, unknown>,
|
||||
status,
|
||||
isPaying: status === "active" || status === "trialing",
|
||||
stripeCustomerJson: subscription as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
});
|
||||
}
|
||||
break;
|
||||
@ -79,7 +104,10 @@ async function handleStripeWebhook(request: Request): Promise<Response> {
|
||||
planId,
|
||||
status: "canceled",
|
||||
isPaying: false,
|
||||
stripeCustomerJson: subscription as unknown as Record<string, unknown>,
|
||||
stripeCustomerJson: subscription as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
});
|
||||
}
|
||||
break;
|
||||
@ -92,7 +120,10 @@ async function handleStripeWebhook(request: Request): Promise<Response> {
|
||||
return Response.json({ received: true });
|
||||
} catch (err) {
|
||||
console.error("Error processing Stripe webhook:", err);
|
||||
return Response.json({ error: "Failed to process webhook" }, { status: 500 });
|
||||
return Response.json(
|
||||
{ error: "Failed to process webhook" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,11 +1,41 @@
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import type { Stripe } from "stripe";
|
||||
import { db } from "@/db";
|
||||
import { billingCustomerStatus } from "@/db/schema";
|
||||
import { billingCustomerStatus, usageLedger } from "@/db/schema";
|
||||
|
||||
type SyncedSubscriptionStatus =
|
||||
| "active"
|
||||
| "trialing"
|
||||
| "canceled"
|
||||
| "past_due"
|
||||
| "incomplete";
|
||||
|
||||
// Stripe.Subscription.Status carries more values than we store (e.g.
|
||||
// "unpaid", "paused", "incomplete_expired") plus a forward-compat string
|
||||
// branch, so a plain equality narrow on the input doesn't narrow the return
|
||||
// type — switch on literals and return our own constants instead. Anything
|
||||
// we don't actively treat as active/paying collapses to "canceled".
|
||||
export function mapStripeSubscriptionStatus(
|
||||
status: Stripe.Subscription.Status,
|
||||
): SyncedSubscriptionStatus {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "active";
|
||||
case "trialing":
|
||||
return "trialing";
|
||||
case "past_due":
|
||||
return "past_due";
|
||||
case "incomplete":
|
||||
return "incomplete";
|
||||
default:
|
||||
return "canceled";
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncStripeSubscriptionStatus(args: {
|
||||
organizationId: string;
|
||||
planId: string;
|
||||
status: "active" | "trialing" | "canceled" | "past_due" | "incomplete";
|
||||
status: SyncedSubscriptionStatus;
|
||||
isPaying: boolean;
|
||||
stripeCustomerJson: Record<string, unknown>;
|
||||
}) {
|
||||
@ -35,7 +65,13 @@ export async function syncStripeSubscriptionStatus(args: {
|
||||
export interface OrganizationBillingDetails {
|
||||
isPaying: boolean;
|
||||
planId: "free" | "starter" | "pro" | "agency";
|
||||
status: "active" | "trialing" | "canceled" | "past_due" | "incomplete" | "none";
|
||||
status:
|
||||
| "active"
|
||||
| "trialing"
|
||||
| "canceled"
|
||||
| "past_due"
|
||||
| "incomplete"
|
||||
| "none";
|
||||
currentPeriodEnd: number | null;
|
||||
daysRemaining: number | null;
|
||||
hoursRemaining: number | null;
|
||||
@ -55,7 +91,10 @@ interface ParsedCustomerData {
|
||||
interval: "monthly" | "annual";
|
||||
}
|
||||
|
||||
function parseStripeCustomerJson(jsonStr: string, isTrialing: boolean): ParsedCustomerData {
|
||||
function parseStripeCustomerJson(
|
||||
jsonStr: string,
|
||||
isTrialing: boolean,
|
||||
): ParsedCustomerData {
|
||||
let periodEndMs: number | null = null;
|
||||
let isTrial = isTrialing;
|
||||
let cancelAtPeriodEnd = false;
|
||||
@ -65,14 +104,20 @@ function parseStripeCustomerJson(jsonStr: string, isTrialing: boolean): ParsedCu
|
||||
try {
|
||||
const data = JSON.parse(jsonStr) as Record<string, unknown>;
|
||||
const sub =
|
||||
data && typeof data.subscription === "object" && data.subscription !== null
|
||||
data &&
|
||||
typeof data.subscription === "object" &&
|
||||
data.subscription !== null
|
||||
? (data.subscription as Record<string, unknown>)
|
||||
: data;
|
||||
|
||||
const rawCustomer = data.customer ?? sub?.customer;
|
||||
if (typeof rawCustomer === "string") {
|
||||
customerId = rawCustomer;
|
||||
} else if (rawCustomer && typeof rawCustomer === "object" && "id" in rawCustomer) {
|
||||
} else if (
|
||||
rawCustomer &&
|
||||
typeof rawCustomer === "object" &&
|
||||
"id" in rawCustomer
|
||||
) {
|
||||
customerId = String((rawCustomer as { id: unknown }).id);
|
||||
}
|
||||
|
||||
@ -83,10 +128,17 @@ function parseStripeCustomerJson(jsonStr: string, isTrialing: boolean): ParsedCu
|
||||
cancelAtPeriodEnd = Boolean(sub.cancel_at_period_end);
|
||||
|
||||
const items = sub.items as
|
||||
| { data?: Array<{ current_period_end?: number; plan?: { interval?: string } }> }
|
||||
| {
|
||||
data?: Array<{
|
||||
current_period_end?: number;
|
||||
plan?: { interval?: string };
|
||||
}>;
|
||||
}
|
||||
| undefined;
|
||||
const itemPeriodEnd = items?.data?.[0]?.current_period_end;
|
||||
const subPeriodEnd = (sub.current_period_end ?? sub.trial_end) as number | undefined;
|
||||
const subPeriodEnd = (sub.current_period_end ?? sub.trial_end) as
|
||||
| number
|
||||
| undefined;
|
||||
const rawSeconds = itemPeriodEnd ?? subPeriodEnd;
|
||||
if (typeof rawSeconds === "number" && rawSeconds > 0) {
|
||||
periodEndMs = rawSeconds * 1000;
|
||||
@ -174,7 +226,7 @@ export async function getOrganizationBillingStatus(
|
||||
if (isValidPaying && !finalPeriodEnd && row.syncedAt) {
|
||||
const syncTime = new Date(row.syncedAt).getTime();
|
||||
if (!Number.isNaN(syncTime)) {
|
||||
const days = parsed.isTrial ? 7 : (parsed.interval === "annual" ? 365 : 30);
|
||||
const days = parsed.isTrial ? 7 : parsed.interval === "annual" ? 365 : 30;
|
||||
finalPeriodEnd = syncTime + days * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
@ -185,7 +237,8 @@ export async function getOrganizationBillingStatus(
|
||||
return {
|
||||
isPaying: isValidPaying,
|
||||
planId: isValidPaying ? matchedPlan : "free",
|
||||
status: (row.paidPlanStatus as OrganizationBillingDetails["status"]) ?? "none",
|
||||
status:
|
||||
(row.paidPlanStatus as OrganizationBillingDetails["status"]) ?? "none",
|
||||
currentPeriodEnd: finalPeriodEnd,
|
||||
daysRemaining,
|
||||
hoursRemaining,
|
||||
@ -197,3 +250,64 @@ export async function getOrganizationBillingStatus(
|
||||
hasCustomer: Boolean(parsed.customerId),
|
||||
};
|
||||
}
|
||||
|
||||
// "YYYY-MM" in UTC, used as the local ledger's reset boundary. Calendar-month
|
||||
// (not rolling 30-day) to match how Autumn's monthly feature resets, and
|
||||
// because it's what a customer expects from "150 lookups / month".
|
||||
function currentPeriodMonth(): string {
|
||||
return new Date().toISOString().slice(0, 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* Credits consumed so far in the current calendar month, from the local
|
||||
* ledger kept when there's no Autumn behind the app (direct-Stripe self-host
|
||||
* mode). A row from a past month reads as 0 — it's rewritten on the next
|
||||
* spend, not eagerly reset here, so a month with no usage needs no write.
|
||||
*/
|
||||
export async function getLocalUsageCreditsUsed(
|
||||
organizationId: string,
|
||||
): Promise<number> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(usageLedger)
|
||||
.where(eq(usageLedger.organizationId, organizationId))
|
||||
.limit(1);
|
||||
|
||||
if (!row || row.periodMonth !== currentPeriodMonth()) return 0;
|
||||
return row.creditsUsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds `creditsDelta` to the organization's local usage ledger, rolling over
|
||||
* to a fresh 0 balance first if the stored row is from a previous month.
|
||||
* Companion to `trackUsageCreditSpend`'s Autumn branch — this is the
|
||||
* no-Autumn equivalent of that function's `autumn.track` calls.
|
||||
*/
|
||||
export async function addLocalUsageCredits(
|
||||
organizationId: string,
|
||||
creditsDelta: number,
|
||||
): Promise<void> {
|
||||
if (creditsDelta <= 0) return;
|
||||
const periodMonth = currentPeriodMonth();
|
||||
|
||||
// Single upsert, not read-then-write: concurrent spends from the same org
|
||||
// must add onto each other atomically rather than racing a JS-side sum.
|
||||
await db
|
||||
.insert(usageLedger)
|
||||
.values({
|
||||
organizationId,
|
||||
periodMonth,
|
||||
creditsUsed: creditsDelta,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: usageLedger.organizationId,
|
||||
set: {
|
||||
periodMonth,
|
||||
// Roll over to a fresh balance when the stored row is from a past
|
||||
// month; otherwise add onto it in the same statement.
|
||||
creditsUsed: sql`CASE WHEN ${usageLedger.periodMonth} = ${periodMonth} THEN ${usageLedger.creditsUsed} + ${creditsDelta} ELSE ${creditsDelta} END`,
|
||||
updatedAt: sql`(current_timestamp)`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -5,11 +5,24 @@ import {
|
||||
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
||||
} from "@/shared/billing";
|
||||
|
||||
const { checkMock, getOrCreateMock, kvGetMock, kvPutMock } = vi.hoisted(() => ({
|
||||
const {
|
||||
checkMock,
|
||||
getOrCreateMock,
|
||||
kvGetMock,
|
||||
kvPutMock,
|
||||
getOptionalEnvValueMock,
|
||||
getOrgBillingStatusMock,
|
||||
getLocalUsedMock,
|
||||
addLocalCreditsMock,
|
||||
} = vi.hoisted(() => ({
|
||||
checkMock: vi.fn(),
|
||||
getOrCreateMock: vi.fn(),
|
||||
kvGetMock: vi.fn(),
|
||||
kvPutMock: vi.fn(),
|
||||
getOptionalEnvValueMock: vi.fn(),
|
||||
getOrgBillingStatusMock: vi.fn(),
|
||||
getLocalUsedMock: vi.fn(),
|
||||
addLocalCreditsMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("cloudflare:workers", () => ({
|
||||
@ -27,6 +40,15 @@ vi.mock("@/server/billing/autumn", () => ({
|
||||
|
||||
vi.mock("@/server/lib/runtime-env", () => ({
|
||||
isHostedServerAuthMode: vi.fn(),
|
||||
getOptionalEnvValue: getOptionalEnvValueMock,
|
||||
}));
|
||||
|
||||
// The local (non-Autumn) ledger subscription.ts falls back to — mocked as a
|
||||
// collaborator, like autumn.ts above, not exercised against real SQL here.
|
||||
vi.mock("@/server/billing/stripe-sync", () => ({
|
||||
getOrganizationBillingStatus: getOrgBillingStatusMock,
|
||||
getLocalUsageCreditsUsed: getLocalUsedMock,
|
||||
addLocalUsageCredits: addLocalCreditsMock,
|
||||
}));
|
||||
|
||||
// subscription.ts now imports posthog (for trackUsageCreditSpend); stub it so
|
||||
@ -39,6 +61,7 @@ import {
|
||||
assertUsageCreditsAvailable,
|
||||
customerHasPaidPlan,
|
||||
getOrCreateOrganizationCustomer,
|
||||
trackUsageCreditSpend,
|
||||
} from "./subscription";
|
||||
|
||||
describe("subscription billing", () => {
|
||||
@ -46,6 +69,12 @@ describe("subscription billing", () => {
|
||||
vi.clearAllMocks();
|
||||
kvGetMock.mockResolvedValue(null);
|
||||
kvPutMock.mockResolvedValue(undefined);
|
||||
// Default to the Autumn-metered path so every pre-existing test below is
|
||||
// unaffected; the local-ledger tests override this to simulate self-host
|
||||
// direct-Stripe mode (no Autumn key configured).
|
||||
getOptionalEnvValueMock.mockImplementation(async (name: string) =>
|
||||
name === "AUTUMN_SECRET_KEY" ? "autumn_secret_test" : undefined,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -194,4 +223,55 @@ describe("subscription billing", () => {
|
||||
cacheError,
|
||||
);
|
||||
});
|
||||
|
||||
// Direct-Stripe self-host mode (AUTUMN_SECRET_KEY unset): DataForSEO spend
|
||||
// must still be capped, against the org's plan budget via the local
|
||||
// ledger, rather than running unmetered.
|
||||
describe("local usage ledger (no Autumn configured)", () => {
|
||||
beforeEach(() => {
|
||||
getOptionalEnvValueMock.mockImplementation(async () => undefined);
|
||||
});
|
||||
|
||||
it("allows the call while usage is under the plan's monthly budget", async () => {
|
||||
getOrgBillingStatusMock.mockResolvedValue({
|
||||
isPaying: true,
|
||||
planId: "starter",
|
||||
});
|
||||
getLocalUsedMock.mockResolvedValue(3000); // starter budget is 3110 credits
|
||||
|
||||
await expect(assertUsageCreditsAvailable("org_123")).resolves.toEqual({
|
||||
monthlyRemaining: 110,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses the call once the plan's monthly budget is used up", async () => {
|
||||
getOrgBillingStatusMock.mockResolvedValue({
|
||||
isPaying: true,
|
||||
planId: "starter",
|
||||
});
|
||||
getLocalUsedMock.mockResolvedValue(3110);
|
||||
|
||||
await expect(
|
||||
assertUsageCreditsAvailable("org_123"),
|
||||
).rejects.toMatchObject({ code: "INSUFFICIENT_CREDITS" });
|
||||
});
|
||||
|
||||
it("writes DataForSEO spend to the local ledger instead of Autumn", async () => {
|
||||
await trackUsageCreditSpend({
|
||||
customer: {
|
||||
organizationId: "org_123",
|
||||
userId: "user_123",
|
||||
userEmail: "alice@example.com",
|
||||
},
|
||||
customerId: "org_123",
|
||||
creditFeature: "keyword_research",
|
||||
costUsd: 0.01,
|
||||
monthlyRemaining: 0,
|
||||
});
|
||||
|
||||
// $0.01 * 1.28 markup * 1000 credits/$ = 12.8, rounded up to 13.
|
||||
expect(addLocalCreditsMock).toHaveBeenCalledWith("org_123", 13);
|
||||
expect(checkMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -14,7 +14,12 @@ import { autumn, AUTUMN_TRACK_RETRY_OPTIONS } from "@/server/billing/autumn";
|
||||
import { captureServerEvent } from "@/server/lib/posthog";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { getOptionalEnvValue } from "@/server/lib/runtime-env";
|
||||
import { getOrganizationBillingStatus } from "@/server/billing/stripe-sync";
|
||||
import {
|
||||
addLocalUsageCredits,
|
||||
getLocalUsageCreditsUsed,
|
||||
getOrganizationBillingStatus,
|
||||
} from "@/server/billing/stripe-sync";
|
||||
import { getPlanMonthlyBudgetUsd } from "@/shared/crawlerxPlanBudgets";
|
||||
|
||||
export type BillingCustomerContext = Pick<
|
||||
EnsuredUserContext,
|
||||
@ -122,7 +127,22 @@ async function getUsageCreditsRemaining(customerId: string): Promise<{
|
||||
}> {
|
||||
const autumnKey = await getOptionalEnvValue("AUTUMN_SECRET_KEY");
|
||||
if (!autumnKey) {
|
||||
return { monthlyRemaining: 100000, topupRemaining: 0 };
|
||||
// No Autumn behind this deploy (direct-Stripe self-host mode): read the
|
||||
// local ledger against the org's plan budget instead. `customerId` is
|
||||
// the organizationId here — see getOrCreateOrganizationCustomer above.
|
||||
// There is no topup pool in this mode yet.
|
||||
const status = await getOrganizationBillingStatus(customerId);
|
||||
const budgetUsd = getPlanMonthlyBudgetUsd(
|
||||
status.isPaying ? status.planId : "free",
|
||||
);
|
||||
const budgetCredits = Math.round(
|
||||
budgetUsd * AUTUMN_SEO_DATA_CREDITS_PER_USD,
|
||||
);
|
||||
const used = await getLocalUsageCreditsUsed(customerId);
|
||||
return {
|
||||
monthlyRemaining: Math.max(0, budgetCredits - used),
|
||||
topupRemaining: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const [monthlyCheck, topupCheck] = await Promise.all([
|
||||
@ -181,16 +201,29 @@ async function getUsageCreditsRemaining(customerId: string): Promise<{
|
||||
export async function checkUsageCreditsDepleted(
|
||||
customer: BillingCustomerContext,
|
||||
): Promise<{ depleted: boolean; monthlyRemaining: number }> {
|
||||
const autumnKey = await getOptionalEnvValue("AUTUMN_SECRET_KEY");
|
||||
if (!autumnKey) {
|
||||
return { depleted: false, monthlyRemaining: 100000 };
|
||||
}
|
||||
|
||||
const check = await getUsageCreditsRemaining(customer.organizationId);
|
||||
if (check.monthlyRemaining + check.topupRemaining > 0) {
|
||||
return { depleted: false, monthlyRemaining: check.monthlyRemaining };
|
||||
}
|
||||
|
||||
const autumnKey = await getOptionalEnvValue("AUTUMN_SECRET_KEY");
|
||||
if (!autumnKey) {
|
||||
// Local ledger reads are strongly consistent (one Postgres/SQLite row),
|
||||
// unlike Autumn's — no need for the confirm-against-the-full-customer
|
||||
// retry below.
|
||||
await captureServerEvent({
|
||||
distinctId: customer.userId,
|
||||
event: "usage:credits_gate_refused",
|
||||
organizationId: customer.organizationId,
|
||||
properties: {
|
||||
project_id: customer.projectId,
|
||||
monthly_remaining: check.monthlyRemaining,
|
||||
topup_remaining: check.topupRemaining,
|
||||
},
|
||||
});
|
||||
return { depleted: true, monthlyRemaining: check.monthlyRemaining };
|
||||
}
|
||||
|
||||
// No try/catch: if this second read fails while the first said depleted,
|
||||
// the whole gate errors rather than guessing — the turn fails generically
|
||||
// and retryably instead of refusing with a possibly-false paywall.
|
||||
@ -238,11 +271,6 @@ export async function checkUsageCreditsDepleted(
|
||||
export async function assertUsageCreditsAvailable(
|
||||
customerId: string,
|
||||
): Promise<{ monthlyRemaining: number }> {
|
||||
const autumnKey = await getOptionalEnvValue("AUTUMN_SECRET_KEY");
|
||||
if (!autumnKey) {
|
||||
return { monthlyRemaining: 100000 };
|
||||
}
|
||||
|
||||
const { monthlyRemaining, topupRemaining } =
|
||||
await getUsageCreditsRemaining(customerId);
|
||||
|
||||
@ -268,25 +296,12 @@ export async function trackUsageCreditSpend(args: {
|
||||
monthlyRemaining: number;
|
||||
properties?: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
const autumnKey = await getOptionalEnvValue("AUTUMN_SECRET_KEY");
|
||||
if (!autumnKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const totalCostUsd = roundUsdForBilling(args.costUsd * SEO_DATA_COST_MARKUP);
|
||||
const totalCostCredits = Math.ceil(
|
||||
totalCostUsd * AUTUMN_SEO_DATA_CREDITS_PER_USD,
|
||||
);
|
||||
if (totalCostCredits <= 0) return;
|
||||
|
||||
// Clamp at 0: Autumn balances can read negative after an overdraft, and a
|
||||
// negative monthly reading here would inflate the topup deduction.
|
||||
const monthlyDeduct = Math.min(
|
||||
Math.max(args.monthlyRemaining, 0),
|
||||
totalCostCredits,
|
||||
);
|
||||
const topupDeduct = totalCostCredits - monthlyDeduct;
|
||||
|
||||
const properties = {
|
||||
currency: "USD",
|
||||
creditFeature: args.creditFeature,
|
||||
@ -295,6 +310,35 @@ export async function trackUsageCreditSpend(args: {
|
||||
...args.properties,
|
||||
};
|
||||
|
||||
const autumnKey = await getOptionalEnvValue("AUTUMN_SECRET_KEY");
|
||||
if (!autumnKey) {
|
||||
// No topup pool in local-ledger mode yet — the whole charge goes against
|
||||
// the monthly budget.
|
||||
await addLocalUsageCredits(args.customer.organizationId, totalCostCredits);
|
||||
await captureServerEvent({
|
||||
distinctId: args.customer.userId,
|
||||
event: "usage:credits_consume",
|
||||
organizationId: args.customer.organizationId,
|
||||
properties: {
|
||||
project_id: args.customer.projectId,
|
||||
credit_feature: args.creditFeature,
|
||||
monthly_credits: totalCostCredits,
|
||||
topup_credits: 0,
|
||||
total_credits: totalCostCredits,
|
||||
cost_usd: totalCostUsd,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Clamp at 0: Autumn balances can read negative after an overdraft, and a
|
||||
// negative monthly reading here would inflate the topup deduction.
|
||||
const monthlyDeduct = Math.min(
|
||||
Math.max(args.monthlyRemaining, 0),
|
||||
totalCostCredits,
|
||||
);
|
||||
const topupDeduct = totalCostCredits - monthlyDeduct;
|
||||
|
||||
if (monthlyDeduct > 0) {
|
||||
await autumn.track(
|
||||
{
|
||||
|
||||
@ -14,13 +14,19 @@ interface TrackCallArg {
|
||||
properties?: { balanceFeatureId: string };
|
||||
}
|
||||
|
||||
const { checkMock, trackMock, getOrCreateMock, isHostedServerAuthModeMock } =
|
||||
vi.hoisted(() => ({
|
||||
const {
|
||||
checkMock,
|
||||
trackMock,
|
||||
getOrCreateMock,
|
||||
isHostedServerAuthModeMock,
|
||||
getOptionalEnvValueMock,
|
||||
} = vi.hoisted(() => ({
|
||||
checkMock: vi.fn(),
|
||||
trackMock: vi.fn<(arg: TrackCallArg) => void>(),
|
||||
getOrCreateMock: vi.fn(),
|
||||
isHostedServerAuthModeMock: vi.fn(),
|
||||
}));
|
||||
getOptionalEnvValueMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("cloudflare:workers", () => ({
|
||||
waitUntil: vi.fn(),
|
||||
@ -47,6 +53,16 @@ vi.mock("@/server/billing/subscription", async (importOriginal) => {
|
||||
|
||||
vi.mock("@/server/lib/runtime-env", () => ({
|
||||
isHostedServerAuthMode: isHostedServerAuthModeMock,
|
||||
getOptionalEnvValue: getOptionalEnvValueMock,
|
||||
}));
|
||||
|
||||
// Not exercised while the real subscription module takes its Autumn branch
|
||||
// above, but it's a static import of subscription.ts now (the local-ledger
|
||||
// fallback) that would otherwise pull in @/db's cloudflare:workers binding.
|
||||
vi.mock("@/server/billing/stripe-sync", () => ({
|
||||
getOrganizationBillingStatus: vi.fn(),
|
||||
getLocalUsageCreditsUsed: vi.fn(),
|
||||
addLocalUsageCredits: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/lib/posthog", () => ({
|
||||
@ -120,6 +136,9 @@ const backlinksInput = {
|
||||
function setupHostedMode() {
|
||||
isHostedServerAuthModeMock.mockResolvedValue(true);
|
||||
getOrCreateMock.mockResolvedValue({ id: "org_123" });
|
||||
// `restoreMocks: true` (vitest.config.ts) wipes any hoist-time default
|
||||
// between tests, so set this per test rather than once at the top.
|
||||
getOptionalEnvValueMock.mockResolvedValue("autumn_secret_test");
|
||||
}
|
||||
|
||||
function mockBalances(monthly: number, topup: number) {
|
||||
|
||||
@ -56,10 +56,7 @@ import {
|
||||
fetchLlmResponse,
|
||||
fetchLlmTopPages,
|
||||
} from "@/server/lib/dataforseo/ai";
|
||||
import {
|
||||
getOptionalEnvValue,
|
||||
isHostedServerAuthMode,
|
||||
} from "@/server/lib/runtime-env";
|
||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
|
||||
export { mapDataforseoPathToCreditFeature };
|
||||
@ -162,9 +159,8 @@ async function meterDataforseoCall<T>(
|
||||
creditFeature?: CreditFeature,
|
||||
): Promise<T> {
|
||||
const isHostedMode = await isHostedServerAuthMode();
|
||||
const autumnKey = await getOptionalEnvValue("AUTUMN_SECRET_KEY");
|
||||
|
||||
if (!isHostedMode || !autumnKey) {
|
||||
if (!isHostedMode) {
|
||||
const result = await execute();
|
||||
return result.data;
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import { AppError } from "@/server/lib/errors";
|
||||
import { CRAWLERX_PLANS } from "@/client/features/billing/PricingData";
|
||||
import {
|
||||
getOrganizationBillingStatus,
|
||||
mapStripeSubscriptionStatus,
|
||||
syncStripeSubscriptionStatus,
|
||||
} from "@/server/billing/stripe-sync";
|
||||
|
||||
@ -24,7 +25,11 @@ export const createStripeCheckoutSession = createServerFn({ method: "POST" })
|
||||
requireOrgPermission(context, { billing: ["manage"] });
|
||||
|
||||
const stripeSecretKey = await getOptionalEnvValue("STRIPE_SECRET_KEY");
|
||||
if (!stripeSecretKey || stripeSecretKey.includes("replace_with_your_stripe_secret_key") || stripeSecretKey.includes("your_stripe_secret_key_here")) {
|
||||
if (
|
||||
!stripeSecretKey ||
|
||||
stripeSecretKey.includes("replace_with_your_stripe_secret_key") ||
|
||||
stripeSecretKey.includes("your_stripe_secret_key_here")
|
||||
) {
|
||||
throw new AppError(
|
||||
"AUTH_CONFIG_MISSING",
|
||||
"AUTH_CONFIG_MISSING: Stripe payment is not configured yet. Please set your STRIPE_SECRET_KEY in your .env file.",
|
||||
@ -107,7 +112,10 @@ export const createStripeCheckoutSession = createServerFn({ method: "POST" })
|
||||
});
|
||||
|
||||
if (!session.url) {
|
||||
throw new AppError("INTERNAL_ERROR", "Failed to generate Stripe checkout URL.");
|
||||
throw new AppError(
|
||||
"INTERNAL_ERROR",
|
||||
"Failed to generate Stripe checkout URL.",
|
||||
);
|
||||
}
|
||||
|
||||
return { checkoutUrl: session.url };
|
||||
@ -123,8 +131,13 @@ export const verifyStripeCheckoutSession = createServerFn({ method: "POST" })
|
||||
.middleware(requireAuthenticatedContext)
|
||||
.validator(z.object({ sessionId: z.string() }))
|
||||
.handler(async ({ data, context }) => {
|
||||
requireOrgPermission(context, { billing: ["manage"] });
|
||||
|
||||
const stripeSecretKey = await getOptionalEnvValue("STRIPE_SECRET_KEY");
|
||||
if (!stripeSecretKey || stripeSecretKey.includes("replace_with_your_stripe_secret_key")) {
|
||||
if (
|
||||
!stripeSecretKey ||
|
||||
stripeSecretKey.includes("replace_with_your_stripe_secret_key")
|
||||
) {
|
||||
throw new AppError("INTERNAL_ERROR", "Stripe secret key not configured");
|
||||
}
|
||||
|
||||
@ -133,25 +146,46 @@ export const verifyStripeCheckoutSession = createServerFn({ method: "POST" })
|
||||
expand: ["subscription", "customer"],
|
||||
});
|
||||
|
||||
const orgId = session.metadata?.organizationId ?? context.organizationId;
|
||||
// Always the caller's own org, never the session's metadata — a user
|
||||
// must not be able to update another org's billing status by passing in
|
||||
// a checkout session id (e.g. one leaked via a Referer header or logs)
|
||||
// that happens to belong to someone else.
|
||||
if (
|
||||
session.metadata?.organizationId &&
|
||||
session.metadata.organizationId !== context.organizationId
|
||||
) {
|
||||
throw new AppError(
|
||||
"FORBIDDEN",
|
||||
"This checkout session does not belong to your organization.",
|
||||
);
|
||||
}
|
||||
const orgId = context.organizationId;
|
||||
const planId = session.metadata?.planId ?? "starter";
|
||||
|
||||
let subStatus = "active";
|
||||
// Default to "incomplete", not "active": a session the shopper abandoned
|
||||
// before paying has neither a subscription object nor payment_status
|
||||
// "paid", and defaulting to "active" there marked every abandoned
|
||||
// checkout as a paying subscriber for free. Only Stripe's own signal —
|
||||
// a subscription object with a real status, or a confirmed paid
|
||||
// payment_status — may mark the org as paying.
|
||||
let subStatus:
|
||||
| "active"
|
||||
| "trialing"
|
||||
| "canceled"
|
||||
| "past_due"
|
||||
| "incomplete" = "incomplete";
|
||||
if (session.subscription && typeof session.subscription === "object") {
|
||||
subStatus = session.subscription.status;
|
||||
subStatus = mapStripeSubscriptionStatus(session.subscription.status);
|
||||
} else if (session.payment_status === "paid") {
|
||||
subStatus = "active";
|
||||
}
|
||||
|
||||
const isPaying =
|
||||
subStatus === "active" ||
|
||||
subStatus === "trialing" ||
|
||||
session.payment_status === "paid";
|
||||
const isPaying = subStatus === "active" || subStatus === "trialing";
|
||||
|
||||
await syncStripeSubscriptionStatus({
|
||||
organizationId: orgId,
|
||||
planId,
|
||||
status: subStatus as "active" | "trialing" | "canceled" | "past_due" | "incomplete",
|
||||
status: subStatus,
|
||||
isPaying,
|
||||
stripeCustomerJson: session as unknown as Record<string, unknown>,
|
||||
});
|
||||
@ -170,7 +204,10 @@ export const createStripePortalSession = createServerFn({ method: "POST" })
|
||||
requireOrgPermission(context, { billing: ["manage"] });
|
||||
|
||||
const stripeSecretKey = await getOptionalEnvValue("STRIPE_SECRET_KEY");
|
||||
if (!stripeSecretKey || stripeSecretKey.includes("replace_with_your_stripe_secret_key")) {
|
||||
if (
|
||||
!stripeSecretKey ||
|
||||
stripeSecretKey.includes("replace_with_your_stripe_secret_key")
|
||||
) {
|
||||
throw new AppError(
|
||||
"INTERNAL_ERROR",
|
||||
"Stripe payment is not configured yet. Please set your STRIPE_SECRET_KEY in your .env file.",
|
||||
@ -181,7 +218,9 @@ export const createStripePortalSession = createServerFn({ method: "POST" })
|
||||
const baseUrl =
|
||||
(await getOptionalEnvValue("BETTER_AUTH_URL")) ?? "http://localhost:3001";
|
||||
|
||||
const billingStatus = await getOrganizationBillingStatus(context.organizationId);
|
||||
const billingStatus = await getOrganizationBillingStatus(
|
||||
context.organizationId,
|
||||
);
|
||||
let customerId = billingStatus.customerId;
|
||||
|
||||
if (!customerId) {
|
||||
@ -195,7 +234,10 @@ export const createStripePortalSession = createServerFn({ method: "POST" })
|
||||
}
|
||||
|
||||
if (!customerId) {
|
||||
throw new AppError("NOT_FOUND", "No Stripe customer found for this account.");
|
||||
throw new AppError(
|
||||
"NOT_FOUND",
|
||||
"No Stripe customer found for this account.",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
27
src/shared/crawlerxPlanBudgets.ts
Normal file
27
src/shared/crawlerxPlanBudgets.ts
Normal file
@ -0,0 +1,27 @@
|
||||
// Monthly DataForSEO spend ceiling per plan, in raw provider USD (before the
|
||||
// platform markup). Enforced only in local-ledger mode — see
|
||||
// src/server/billing/subscription.ts — when AUTUMN_SECRET_KEY is unset, which
|
||||
// is the direct-Stripe self-host path with no Autumn metering behind it.
|
||||
//
|
||||
// Values are the "Data Cost (Full Use)" column from docs/CRAWLERX_PRD.md's
|
||||
// unit-economics table — the ceiling the plan's price was modeled against.
|
||||
// Keep in sync with the plan limits in
|
||||
// src/client/features/billing/PricingData.ts; they describe the same plans
|
||||
// from two sides (feature limits vs. the dollar budget those limits cost).
|
||||
export const CRAWLERX_PLAN_MONTHLY_BUDGET_USD: Record<
|
||||
"free" | "starter" | "pro" | "agency",
|
||||
number
|
||||
> = {
|
||||
free: 0.3,
|
||||
starter: 3.11,
|
||||
pro: 10.3,
|
||||
agency: 33.57,
|
||||
};
|
||||
|
||||
export function getPlanMonthlyBudgetUsd(planId: string): number {
|
||||
return (
|
||||
CRAWLERX_PLAN_MONTHLY_BUDGET_USD[
|
||||
planId as keyof typeof CRAWLERX_PLAN_MONTHLY_BUDGET_USD
|
||||
] ?? CRAWLERX_PLAN_MONTHLY_BUDGET_USD.free
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user