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>
314 lines
9.3 KiB
TypeScript
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)`,
|
|
},
|
|
});
|
|
}
|