metatroncubeswdev 65a01af4d3
Some checks failed
CI / ci (push) Has been cancelled
CI / docker-build (push) Has been cancelled
Publish Docker image / docker (push) Has been cancelled
Upload sourcemaps / upload (push) Has been cancelled
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>
2026-10-17 16:24:38 -04:00

314 lines
9.3 KiB
TypeScript

import { eq, sql } from "drizzle-orm";
import type { Stripe } from "stripe";
import { db } from "@/db";
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: SyncedSubscriptionStatus;
isPaying: boolean;
stripeCustomerJson: Record<string, unknown>;
}) {
await db
.insert(billingCustomerStatus)
.values({
organizationId: args.organizationId,
isPaying: args.isPaying,
paidPlanId: args.planId,
paidPlanStatus: args.status,
customerJson: JSON.stringify(args.stripeCustomerJson),
syncedAt: new Date().toISOString(),
})
.onConflictDoUpdate({
target: billingCustomerStatus.organizationId,
set: {
isPaying: args.isPaying,
paidPlanId: args.planId,
paidPlanStatus: args.status,
customerJson: JSON.stringify(args.stripeCustomerJson),
syncedAt: new Date().toISOString(),
updatedAt: sql`(current_timestamp)`,
},
});
}
export interface OrganizationBillingDetails {
isPaying: boolean;
planId: "free" | "starter" | "pro" | "agency";
status:
| "active"
| "trialing"
| "canceled"
| "past_due"
| "incomplete"
| "none";
currentPeriodEnd: number | null;
daysRemaining: number | null;
hoursRemaining: number | null;
formattedExpiry: string | null;
isTrial: boolean;
cancelAtPeriodEnd: boolean;
interval: "monthly" | "annual";
customerId: string | null;
hasCustomer: boolean;
}
interface ParsedCustomerData {
periodEndMs: number | null;
isTrial: boolean;
cancelAtPeriodEnd: boolean;
customerId: string | null;
interval: "monthly" | "annual";
}
function parseStripeCustomerJson(
jsonStr: string,
isTrialing: boolean,
): ParsedCustomerData {
let periodEndMs: number | null = null;
let isTrial = isTrialing;
let cancelAtPeriodEnd = false;
let customerId: string | null = null;
let interval: "monthly" | "annual" = "monthly";
try {
const data = JSON.parse(jsonStr) as Record<string, unknown>;
const sub =
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
) {
customerId = String((rawCustomer as { id: unknown }).id);
}
if (sub) {
if (sub.status === "trialing" || Boolean(sub.trial_end)) {
isTrial = true;
}
cancelAtPeriodEnd = Boolean(sub.cancel_at_period_end);
const items = sub.items as
| {
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 rawSeconds = itemPeriodEnd ?? subPeriodEnd;
if (typeof rawSeconds === "number" && rawSeconds > 0) {
periodEndMs = rawSeconds * 1000;
}
if (items?.data?.[0]?.plan?.interval === "year") {
interval = "annual";
}
} else if (typeof data.expires_at === "number") {
periodEndMs = data.expires_at * 1000;
}
} catch {
// Ignore JSON parse errors
}
return { periodEndMs, isTrial, cancelAtPeriodEnd, customerId, interval };
}
function calculateTimeRemaining(periodEndMs: number | null) {
if (!periodEndMs) {
return { daysRemaining: null, hoursRemaining: null, formattedExpiry: null };
}
const diffMs = periodEndMs - Date.now();
const totalHours = Math.max(0, Math.floor(diffMs / (1000 * 60 * 60)));
const daysRemaining = Math.floor(totalHours / 24);
const hoursRemaining = totalHours % 24;
let formattedExpiry: string;
try {
formattedExpiry = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(periodEndMs));
} catch {
formattedExpiry = new Date(periodEndMs).toDateString();
}
return { daysRemaining, hoursRemaining, formattedExpiry };
}
export async function getOrganizationBillingStatus(
organizationId: string,
): Promise<OrganizationBillingDetails> {
const [row] = await db
.select()
.from(billingCustomerStatus)
.where(eq(billingCustomerStatus.organizationId, organizationId))
.limit(1);
if (!row) {
return {
isPaying: false,
planId: "free",
status: "none",
currentPeriodEnd: null,
daysRemaining: null,
hoursRemaining: null,
formattedExpiry: null,
isTrial: false,
cancelAtPeriodEnd: false,
interval: "monthly",
customerId: null,
hasCustomer: false,
};
}
const isValidPaying =
Boolean(row.isPaying) &&
(row.paidPlanStatus === "active" || row.paidPlanStatus === "trialing");
const validPlanIds = ["free", "starter", "pro", "agency"] as const;
const matchedPlan = validPlanIds.includes(
row.paidPlanId as (typeof validPlanIds)[number],
)
? (row.paidPlanId as "free" | "starter" | "pro" | "agency")
: "free";
const parsed = parseStripeCustomerJson(
row.customerJson,
row.paidPlanStatus === "trialing",
);
let finalPeriodEnd = parsed.periodEndMs;
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;
finalPeriodEnd = syncTime + days * 24 * 60 * 60 * 1000;
}
}
const { daysRemaining, hoursRemaining, formattedExpiry } =
calculateTimeRemaining(finalPeriodEnd);
return {
isPaying: isValidPaying,
planId: isValidPaying ? matchedPlan : "free",
status:
(row.paidPlanStatus as OrganizationBillingDetails["status"]) ?? "none",
currentPeriodEnd: finalPeriodEnd,
daysRemaining,
hoursRemaining,
formattedExpiry,
isTrial: parsed.isTrial,
cancelAtPeriodEnd: parsed.cancelAtPeriodEnd,
interval: parsed.interval,
customerId: parsed.customerId,
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)`,
},
});
}