get to prod tasks
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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()]),
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
89
web/src/server/lib/tier.ts
Normal file
89
web/src/server/lib/tier.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user