get to prod tasks

This commit is contained in:
2026-05-26 16:06:34 -04:00
parent 04e839640f
commit 5214412fff
105 changed files with 7447 additions and 38 deletions

View File

@@ -1,7 +1 @@
export { ColorWaveBackground } from "./ColorWaveBackground";
export { default as HeroSection } from "./HeroSection";
export { default as HowItWorksSection } from "./HowItWorksSection";
export { default as FeaturesGridSection } from "./FeaturesGridSection";
export { default as ForUsersSection } from "./ForUsersSection";
export { default as WhyKordantSection } from "./WhyKordantSection";
export { default as CTABannerSection } from "./CTABannerSection";

View File

@@ -1,14 +1,15 @@
import { createResource } from "solid-js";
import { createResource, createMemo } from "solid-js";
import { api } from "~/lib/api";
const FEATURE_TIERS: Record<string, string> = {
voiceprint: "plus",
hometitle: "plus",
removebrokers: "plus",
darkwatch_realtime: "premium",
voiceprint_batch: "plus",
hometitle_scan: "plus",
removebrokers_unlimited: "premium",
};
const TIER_ORDER = ["free", "basic", "plus", "premium"];
const TIER_ORDER = { free: -1, basic: 0, plus: 1, premium: 2 };
export function useSubscription() {
const [subscription] = createResource(() =>
@@ -16,17 +17,38 @@ export function useSubscription() {
);
const tier = () => subscription()?.tier ?? "free";
const effectiveTier = () => subscription()?.effectiveTier ?? "free";
const isTrialing = () => subscription()?.isTrialing ?? false;
const trials = () => subscription()?.trials ?? [];
const hasFeature = (feature: string) => {
const requiredTier = FEATURE_TIERS[feature];
if (!requiredTier) return true;
return TIER_ORDER.indexOf(tier()) >= TIER_ORDER.indexOf(requiredTier);
const currentLevel = TIER_ORDER[effectiveTier() as keyof typeof TIER_ORDER] ?? TIER_ORDER.free;
const requiredLevel = TIER_ORDER[requiredTier as keyof typeof TIER_ORDER] ?? 0;
if (currentLevel >= requiredLevel) return true;
const now = new Date();
return trials().some(
(t: { feature: string; status: string; expiresAt: string | Date }) =>
t.feature === feature && t.status === "active" && new Date(t.expiresAt) > now,
);
};
const requestFeatureTrial = api.billing.requestFeatureTrial.mutate;
const upgradeFromTrial = api.billing.upgradeFromTrial.mutate;
return {
subscription,
tier,
effectiveTier,
isTrialing,
trials,
isLoading: subscription.loading,
hasFeature,
requestFeatureTrial,
upgradeFromTrial,
};
}

View File

@@ -1,8 +1,8 @@
import { For, Show, onMount } from "solid-js";
import { For, onMount } from "solid-js";
import { Title } from "@solidjs/meta";
import { A } from "@solidjs/router";
import { cn } from "~/lib/utils";
import { Button, Badge, Card } from "~/components/ui";
import { Button, Card } from "~/components/ui";
import { Typewriter } from "~/components/ui/Typewriter";
import { ColorWaveBackground } from "~/components/landing/ColorWaveBackground";
import PageContainer from "~/components/layout/PageContainer";

View File

@@ -87,7 +87,7 @@ const faqs: FAQ[] = [
},
{
q: "What happens after my free trial?",
a: "Your trial includes full access to your selected plan for 14 days. You can cancel anytime before the trial ends with no charge.",
a: "Your trial includes Basic features for 14 days. Upgrade anytime during your trial to unlock Plus or Premium features immediately. If you don't upgrade, your account will remain active with Basic features.",
},
{
q: "Can I remove my data from brokers?",
@@ -220,10 +220,10 @@ export default function PricingPage() {
Protection That Fits{" "}
<span class="text-gradient-primary">Your Budget</span>
</h1>
<p class="text-xl text-text-secondary mb-8 max-w-2xl mx-auto">
Start with a 14-day free trial. No credit card required. Cancel
anytime.
</p>
<p class="text-xl text-text-secondary mb-8 max-w-2xl mx-auto">
Start with a 14-day free trial of Basic features. No credit card
required. Upgrade anytime to unlock Plus or Premium.
</p>
<div class="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-text-tertiary">
<span class="flex items-center gap-1.5">
<CheckIcon />

View File

@@ -8,6 +8,8 @@ import {
CancelSubscriptionSchema,
ReactivateSubscriptionSchema,
ListInvoicesSchema,
RequestFeatureTrialSchema,
UpgradeFromTrialSchema,
} from "../schemas/billing";
import {
getOrCreateCustomer,
@@ -16,18 +18,86 @@ import {
cancelSubscription,
reactivateSubscription,
listInvoices,
mapStripeProductToTier,
} from "~/server/services/billing.service";
import { db } from "~/server/db";
import { subscriptions } from "~/server/db/schema/subscription";
import { stripe } from "~/server/stripe";
import {
getEffectiveTier,
getActiveTrials,
createFeatureTrial,
} from "~/server/lib/tier";
export const billingRouter = createTRPCRouter({
getSubscription: protectedProcedure.query(async ({ ctx }) => {
const sub = await db.query.subscriptions.findFirst({
where: eq(subscriptions.userId, ctx.user.id),
});
return sub ?? null;
if (!sub) return null;
const trials = await getActiveTrials(ctx.user.id);
return {
...sub,
effectiveTier: getEffectiveTier(sub.tier as "basic" | "plus" | "premium", sub.status as "active" | "trialing"),
isTrialing: sub.status === "trialing",
trials,
};
}),
requestFeatureTrial: protectedProcedure
.input(wrap(RequestFeatureTrialSchema))
.mutation(async ({ ctx, input }) => {
const sub = await db.query.subscriptions.findFirst({
where: eq(subscriptions.userId, ctx.user.id),
});
if (!sub || sub.status !== "active" || sub.tier !== "basic") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Feature trials are available for active Basic subscribers",
});
}
const trial = await createFeatureTrial(ctx.user.id, input.feature, 7);
return { trial };
}),
upgradeFromTrial: protectedProcedure
.input(wrap(UpgradeFromTrialSchema))
.mutation(async ({ ctx, input }) => {
const sub = await db.query.subscriptions.findFirst({
where: eq(subscriptions.userId, ctx.user.id),
});
if (!sub || sub.status !== "trialing") {
throw new TRPCError({
code: "FORBIDDEN",
message: "No active trial to upgrade from",
});
}
const priceMap: Record<string, string | undefined> = {
basic: process.env.STRIPE_PRICE_BASIC,
plus: process.env.STRIPE_PRICE_PLUS,
premium: process.env.STRIPE_PRICE_PREMIUM,
};
const priceId = priceMap[input.plan];
if (!priceId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid plan" });
}
// Cancel current trial subscription
await stripe.subscriptions.cancel(sub.stripeId!);
return createCheckoutSession(
ctx.user.id,
ctx.user.email,
priceId,
input.returnUrl,
);
}),
createCheckoutSession: protectedProcedure
.input(wrap(CreateCheckoutSessionSchema))
.mutation(async ({ ctx, input }) => {

View File

@@ -21,3 +21,12 @@ export const ListInvoicesSchema = object({
limit: optional(string(), "10"),
startingAfter: optional(string()),
});
export const RequestFeatureTrialSchema = object({
feature: picklist(["voiceprint", "hometitle", "removebrokers"]),
});
export const UpgradeFromTrialSchema = object({
plan: picklist(["basic", "plus", "premium"]),
returnUrl: string([url()]),
});

View File

@@ -44,3 +44,15 @@ export const subscriptions = sqliteTable("subscriptions", {
stripeIdIdx: index("subscriptions_stripe_id_idx").on(table.stripeId),
tierIdx: index("subscriptions_tier_idx").on(table.tier),
}));
export const featureTrials = sqliteTable("feature_trials", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
feature: text("feature").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
status: text("status").default("active").notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
}, (table) => ({
userIdFeatureIdx: index("feature_trials_user_feature_idx").on(table.userId, table.feature),
expiresAtIdx: index("feature_trials_expires_at_idx").on(table.expiresAt),
}));

View File

@@ -1,9 +1,10 @@
import cron from "node-cron";
import { eq, and } from "drizzle-orm";
import { eq, and, inArray } from "drizzle-orm";
import { db } from "~/server/db";
import { subscriptions } from "~/server/db/schema";
import { getQueue } from "./queue";
import type { JobType } from "./queue";
import { getEffectiveTier } from "~/server/lib/tier";
const TIER_SCHEDULES: Record<string, Array<{ type: JobType; cron: string }>> = {
basic: [
@@ -65,10 +66,17 @@ export async function registerSchedules(): Promise<void> {
const activeSubs = await db
.select()
.from(subscriptions)
.where(and(eq(subscriptions.status, "active")));
.where(and(
inArray(subscriptions.status, ["active", "trialing"]),
));
for (const sub of activeSubs) {
const schedules = TIER_SCHEDULES[sub.tier];
// Trial users always get basic-tier schedule
const effectiveTier = getEffectiveTier(
sub.tier as "basic" | "plus" | "premium",
sub.status as "active" | "trialing",
);
const schedules = TIER_SCHEDULES[effectiveTier];
if (!schedules) continue;
for (const schedule of schedules) {

View File

@@ -0,0 +1,89 @@
import { db } from "~/server/db";
import { featureTrials } from "~/server/db/schema/subscription";
import { and, eq, gte } from "drizzle-orm";
export type Tier = "basic" | "plus" | "premium";
export type SubscriptionStatus = "active" | "past_due" | "canceled" | "unpaid" | "trialing";
export const TIER_ORDER: Record<Tier, number> = { basic: 0, plus: 1, premium: 2 };
export const FEATURE_TIERS: Record<string, Tier> = {
voiceprint: "plus",
hometitle: "plus",
removebrokers: "plus",
darkwatch_realtime: "premium",
removebrokers_unlimited: "premium",
};
export interface FeatureTrial {
id: string;
feature: string;
expiresAt: Date;
status: string;
}
export interface SubWithEffectiveTier {
id: string;
userId: string;
tier: Tier;
status: SubscriptionStatus;
effectiveTier: Tier;
isTrialing: boolean;
trials: FeatureTrial[];
}
export function getEffectiveTier(tier: Tier, status: SubscriptionStatus): Tier {
if (status === "trialing") return "basic";
return tier;
}
export function isTrialing(status: SubscriptionStatus): boolean {
return status === "trialing";
}
export function hasFeatureAccess(sub: SubWithEffectiveTier, feature: string): boolean {
const requiredTier = FEATURE_TIERS[feature];
if (!requiredTier) return true;
const subTierLevel = TIER_ORDER[sub.effectiveTier] ?? 0;
const requiredLevel = TIER_ORDER[requiredTier] ?? 0;
if (subTierLevel >= requiredLevel) return true;
const now = new Date();
return sub.trials.some(
(t) => t.feature === feature && t.status === "active" && t.expiresAt > now,
);
}
export async function getActiveTrials(userId: string): Promise<FeatureTrial[]> {
const now = new Date();
return db
.select()
.from(featureTrials)
.where(and(
eq(featureTrials.userId, userId),
gte(featureTrials.expiresAt, now),
eq(featureTrials.status, "active"),
));
}
export async function createFeatureTrial(
userId: string,
feature: string,
days: number = 7,
): Promise<FeatureTrial> {
const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
const [trial] = await db
.insert(featureTrials)
.values({
userId,
feature,
expiresAt,
status: "active",
})
.returning();
return trial;
}

View File

@@ -226,7 +226,7 @@ export async function handleWebhookEvent(event: Stripe.Event) {
}
}
function mapStripeProductToTier(priceId: string): Tier {
export function mapStripeProductToTier(priceId: string): Tier {
if (priceId === process.env.STRIPE_PRICE_BASIC) return "basic";
if (priceId === process.env.STRIPE_PRICE_PLUS) return "plus";
if (priceId === process.env.STRIPE_PRICE_PREMIUM) return "premium";

View File

@@ -6,6 +6,11 @@ import { watchlistItems, exposures, subscriptions, securityReports } from "~/ser
import { scanHIBP, scanSecurityTrails, scanCensys, scanShodan, scanForums } from "./darkwatch/scan.engine";
import { processExposure } from "./darkwatch/alert.pipeline";
import type { ScanResult } from "./darkwatch/scan.engine";
import {
getEffectiveTier,
getActiveTrials,
type SubWithEffectiveTier,
} from "~/server/lib/tier";
interface ScanState {
status: "idle" | "running" | "completed" | "failed";
@@ -22,16 +27,28 @@ function hashValue(value: string): string {
return createHash("sha256").update(value.toLowerCase().trim()).digest("hex");
}
async function getSubscription(userId: string) {
async function getSubscription(userId: string): Promise<SubWithEffectiveTier> {
const [sub] = await db
.select()
.from(subscriptions)
.where(and(eq(subscriptions.userId, userId), eq(subscriptions.status, "active")))
.where(and(
eq(subscriptions.userId, userId),
inArray(subscriptions.status, ["active", "trialing"]),
))
.limit(1);
if (!sub) {
throw new TRPCError({ code: "NOT_FOUND", message: "No active subscription found" });
}
return sub;
const trials = await getActiveTrials(userId);
return {
id: sub.id,
userId: sub.userId,
tier: sub.tier as SubWithEffectiveTier["tier"],
status: sub.status as SubWithEffectiveTier["status"],
effectiveTier: getEffectiveTier(sub.tier as SubWithEffectiveTier["tier"], sub.status as SubWithEffectiveTier["status"]),
isTrialing: sub.status === "trialing",
trials,
};
}
export async function getWatchlistItems(userId: string) {
@@ -174,7 +191,7 @@ export async function getExposureDetails(userId: string, exposureId: string) {
export async function checkTierLimits(userId: string): Promise<{ allowed: boolean; reason?: string }> {
const sub = await getSubscription(userId);
const tier = sub.tier;
const tier = sub.effectiveTier;
if (tier === "premium") {
return { allowed: true };

View File

@@ -1,5 +1,5 @@
import { TRPCError } from "@trpc/server";
import { eq, and, desc, count, gte } from "drizzle-orm";
import { eq, and, desc, count, gte, inArray } from "drizzle-orm";
import { db } from "~/server/db";
import {
subscriptions,
@@ -21,21 +21,42 @@ import {
parseAddress,
getLastSnapshot,
} from "./hometitle/scanner";
import {
getEffectiveTier,
getActiveTrials,
hasFeatureAccess,
type SubWithEffectiveTier,
} from "~/server/lib/tier";
async function getSubscription(userId: string) {
async function getSubscription(userId: string): Promise<SubWithEffectiveTier> {
const [sub] = await db
.select()
.from(subscriptions)
.where(and(eq(subscriptions.userId, userId), eq(subscriptions.status, "active")))
.where(and(
eq(subscriptions.userId, userId),
inArray(subscriptions.status, ["active", "trialing"]),
))
.limit(1);
if (!sub) {
throw new TRPCError({ code: "NOT_FOUND", message: "No active subscription found" });
}
return sub;
const trials = await getActiveTrials(userId);
return {
id: sub.id,
userId: sub.userId,
tier: sub.tier as SubWithEffectiveTier["tier"],
status: sub.status as SubWithEffectiveTier["status"],
effectiveTier: getEffectiveTier(sub.tier as SubWithEffectiveTier["tier"], sub.status as SubWithEffectiveTier["status"]),
isTrialing: sub.status === "trialing",
trials,
};
}
export async function getProperties(userId: string) {
const sub = await getSubscription(userId);
if (!hasFeatureAccess(sub, "hometitle")) {
throw new TRPCError({ code: "FORBIDDEN", message: "HomeTitle requires a Plus subscription or active feature trial" });
}
const items = await db
.select()
.from(propertyWatchlistItems)
@@ -56,6 +77,9 @@ export async function addProperty(
ownerName?: string,
) {
const sub = await getSubscription(userId);
if (!hasFeatureAccess(sub, "hometitle")) {
throw new TRPCError({ code: "FORBIDDEN", message: "HomeTitle requires a Plus subscription or active feature trial" });
}
const parsed = parseAddress(address);
const coords = await geocodeAddress(address);
@@ -94,6 +118,9 @@ export async function addProperty(
export async function removeProperty(userId: string, propertyId: string) {
const sub = await getSubscription(userId);
if (!hasFeatureAccess(sub, "hometitle")) {
throw new TRPCError({ code: "FORBIDDEN", message: "HomeTitle requires a Plus subscription or active feature trial" });
}
const [item] = await db
.select()
@@ -121,6 +148,9 @@ export async function removeProperty(userId: string, propertyId: string) {
export async function getSnapshots(userId: string, propertyId: string) {
const sub = await getSubscription(userId);
if (!hasFeatureAccess(sub, "hometitle")) {
throw new TRPCError({ code: "FORBIDDEN", message: "HomeTitle requires a Plus subscription or active feature trial" });
}
const [item] = await db
.select()
@@ -152,6 +182,9 @@ export async function getChanges(
filters?: { severity?: string; changeType?: string },
) {
const sub = await getSubscription(userId);
if (!hasFeatureAccess(sub, "hometitle")) {
throw new TRPCError({ code: "FORBIDDEN", message: "HomeTitle requires a Plus subscription or active feature trial" });
}
const [item] = await db
.select()
@@ -202,6 +235,9 @@ export async function getChanges(
export async function getAlerts(userId: string) {
const sub = await getSubscription(userId);
if (!hasFeatureAccess(sub, "hometitle")) {
throw new TRPCError({ code: "FORBIDDEN", message: "HomeTitle requires a Plus subscription or active feature trial" });
}
const items = await db
.select()
@@ -248,7 +284,7 @@ export async function getAlerts(userId: string) {
async function checkTierLimits(userId: string): Promise<{ allowed: boolean; reason?: string }> {
const sub = await getSubscription(userId);
const tier = sub.tier;
const tier = sub.effectiveTier;
if (tier === "premium") {
return { allowed: true };
@@ -289,6 +325,10 @@ async function checkTierLimits(userId: string): Promise<{ allowed: boolean; reas
export async function runScan(userId: string): Promise<{ scanId: string }> {
const sub = await getSubscription(userId);
if (!hasFeatureAccess(sub, "hometitle")) {
throw new TRPCError({ code: "FORBIDDEN", message: "HomeTitle requires a Plus subscription or active feature trial" });
}
const tierCheck = await checkTierLimits(userId);
if (!tierCheck.allowed) {
throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: tierCheck.reason });

View File

@@ -1,6 +1,12 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TRPCError } from "@trpc/server";
vi.mock("~/server/lib/tier", () => ({
getEffectiveTier: vi.fn((tier) => tier),
getActiveTrials: vi.fn().mockResolvedValue([]),
TIER_ORDER: { basic: 0, plus: 1, premium: 2 },
}));
vi.mock("~/server/db", () => ({
db: {
query: {

View File

@@ -1,19 +1,37 @@
import { TRPCError } from "@trpc/server";
import { eq, and, desc, count } from "drizzle-orm";
import { eq, and, desc, count, inArray } from "drizzle-orm";
import { db } from "~/server/db";
import { subscriptions, securityReports, reportSchedules } from "~/server/db/schema";
import { compileData, renderHTML, generatePDF, uploadPDF } from "./reports/generator";
import {
getEffectiveTier,
getActiveTrials,
type SubWithEffectiveTier,
TIER_ORDER,
} from "~/server/lib/tier";
async function getSubscription(userId: string) {
async function getSubscription(userId: string): Promise<SubWithEffectiveTier> {
const [sub] = await db
.select()
.from(subscriptions)
.where(and(eq(subscriptions.userId, userId), eq(subscriptions.status, "active")))
.where(and(
eq(subscriptions.userId, userId),
inArray(subscriptions.status, ["active", "trialing"]),
))
.limit(1);
if (!sub) {
throw new TRPCError({ code: "NOT_FOUND", message: "No active subscription found" });
}
return sub;
const trials = await getActiveTrials(userId);
return {
id: sub.id,
userId: sub.userId,
tier: sub.tier as SubWithEffectiveTier["tier"],
status: sub.status as SubWithEffectiveTier["status"],
effectiveTier: getEffectiveTier(sub.tier as SubWithEffectiveTier["tier"], sub.status as SubWithEffectiveTier["status"]),
isTrialing: sub.status === "trialing",
trials,
};
}
function getReportTypeLabel(reportType: string): string {
@@ -81,8 +99,7 @@ export async function generateReport(
const sub = await getSubscription(userId);
const requiredTier = reportType === "ANNUAL_PREMIUM" ? "premium" : reportType === "MONTHLY_PLUS" ? "plus" : "basic";
const tiers: Record<string, number> = { basic: 0, plus: 1, premium: 2 };
if ((tiers[sub.tier] ?? 0) < tiers[requiredTier]) {
if ((TIER_ORDER[sub.effectiveTier] ?? 0) < TIER_ORDER[requiredTier]) {
throw new TRPCError({
code: "FORBIDDEN",
message: `${getReportTypeLabel(reportType)} reports require ${requiredTier} tier subscription`,