feat: billing period for subscriptions, costs in redundancy analysis
This commit is contained in:
@@ -149,6 +149,20 @@ async function ensureVoterTokenColumn(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureBillingPeriodColumn(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const exists = await db.execute(
|
||||||
|
sql`SELECT EXISTS (SELECT FROM information_schema.columns WHERE table_name = 'tool_costs' AND column_name = 'billing_period')`,
|
||||||
|
);
|
||||||
|
const rows = exists.rows as [{ exists: boolean }];
|
||||||
|
if (rows[0]?.exists) return;
|
||||||
|
await db.execute(sql`ALTER TABLE "tool_costs" ADD COLUMN "billing_period" text`);
|
||||||
|
logger.info("billing_period column added to tool_costs");
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "Failed to add billing_period column");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureAdminTier(): Promise<void> {
|
async function ensureAdminTier(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const result = await db
|
const result = await db
|
||||||
@@ -169,6 +183,7 @@ async function start(): Promise<void> {
|
|||||||
await ensureToolRelationsTable();
|
await ensureToolRelationsTable();
|
||||||
await ensureToolCostsTable();
|
await ensureToolCostsTable();
|
||||||
await ensureVoterTokenColumn();
|
await ensureVoterTokenColumn();
|
||||||
|
await ensureBillingPeriodColumn();
|
||||||
await ensureUserColumns();
|
await ensureUserColumns();
|
||||||
await seedAdminUser();
|
await seedAdminUser();
|
||||||
await ensureAdminTier();
|
await ensureAdminTier();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Router, type IRouter } from "express";
|
import { Router, type IRouter } from "express";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { db, toolsTable, ratingsTable } from "@workspace/db";
|
import { db, toolsTable, ratingsTable, toolCostsTable } from "@workspace/db";
|
||||||
import { requireAuth } from "../middleware/auth";
|
import { requireAuth } from "../middleware/auth";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
@@ -12,6 +12,10 @@ router.get("/admin/redundancy", requireAuth, async (_req, res): Promise<void> =>
|
|||||||
.select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
|
.select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
|
||||||
.from(ratingsTable);
|
.from(ratingsTable);
|
||||||
|
|
||||||
|
const allCosts = await db
|
||||||
|
.select()
|
||||||
|
.from(toolCostsTable);
|
||||||
|
|
||||||
const ratingsByTool = new Map<number, { usefulness: number; usability: number }[]>();
|
const ratingsByTool = new Map<number, { usefulness: number; usability: number }[]>();
|
||||||
for (const r of allRatings) {
|
for (const r of allRatings) {
|
||||||
const arr = ratingsByTool.get(r.toolId) ?? [];
|
const arr = ratingsByTool.get(r.toolId) ?? [];
|
||||||
@@ -19,16 +23,34 @@ router.get("/admin/redundancy", requireAuth, async (_req, res): Promise<void> =>
|
|||||||
ratingsByTool.set(r.toolId, arr);
|
ratingsByTool.set(r.toolId, arr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const costsByTool = new Map<number, typeof allCosts>();
|
||||||
|
for (const c of allCosts) {
|
||||||
|
const arr = costsByTool.get(c.toolId) ?? [];
|
||||||
|
arr.push(c);
|
||||||
|
costsByTool.set(c.toolId, arr);
|
||||||
|
}
|
||||||
|
|
||||||
function buildStats(t: typeof toolsTable.$inferSelect) {
|
function buildStats(t: typeof toolsTable.$inferSelect) {
|
||||||
const ratings = ratingsByTool.get(t.id) ?? [];
|
const ratings = ratingsByTool.get(t.id) ?? [];
|
||||||
const count = ratings.length;
|
const count = ratings.length;
|
||||||
const avgU = count > 0 ? ratings.reduce((s, r) => s + r.usefulness, 0) / count : null;
|
const avgU = count > 0 ? ratings.reduce((s, r) => s + r.usefulness, 0) / count : null;
|
||||||
const avgUs = count > 0 ? ratings.reduce((s, r) => s + r.usability, 0) / count : null;
|
const avgUs = count > 0 ? ratings.reduce((s, r) => s + r.usability, 0) / count : null;
|
||||||
|
const costs = costsByTool.get(t.id) ?? [];
|
||||||
|
const totalMonthly = costs.reduce((sum, c) => {
|
||||||
|
if (c.licenseType === "free" || c.cost == null) return sum;
|
||||||
|
const amount = Number(c.cost);
|
||||||
|
if (c.licenseType === "one_time") return sum;
|
||||||
|
if (c.billingPeriod === "yearly") return sum + amount / 12;
|
||||||
|
if (c.billingPeriod === "quarterly") return sum + amount / 3;
|
||||||
|
return sum + amount; // monthly or subscription default
|
||||||
|
}, 0);
|
||||||
return {
|
return {
|
||||||
id: t.id, name: t.name, category: t.category, features: t.features ?? [], tags: t.tags ?? [],
|
id: t.id, name: t.name, category: t.category, features: t.features ?? [], tags: t.tags ?? [],
|
||||||
ratingCount: count,
|
ratingCount: count,
|
||||||
avgUsefulness: avgU, avgUsability: avgUs,
|
avgUsefulness: avgU, avgUsability: avgUs,
|
||||||
avgCombined: avgU != null && avgUs != null ? (avgU + avgUs) / 2 : null,
|
avgCombined: avgU != null && avgUs != null ? (avgU + avgUs) / 2 : null,
|
||||||
|
costs,
|
||||||
|
totalMonthly,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +62,7 @@ router.get("/admin/redundancy", requireAuth, async (_req, res): Promise<void> =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = Object.entries(grouped).map(([category, items]) => {
|
const result = Object.entries(grouped).map(([category, items]) => {
|
||||||
const pairs: { a: any; b: any; overlap: number; scoreDiff: number }[] = [];
|
const pairs: { a: any; b: any; overlap: number; scoreDiff: number; costDiff: number }[] = [];
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
for (let j = i + 1; j < items.length; j++) {
|
for (let j = i + 1; j < items.length; j++) {
|
||||||
const aFeat = new Set(items[i].features);
|
const aFeat = new Set(items[i].features);
|
||||||
@@ -49,11 +71,13 @@ router.get("/admin/redundancy", requireAuth, async (_req, res): Promise<void> =>
|
|||||||
const total = new Set([...aFeat, ...bFeat]).size;
|
const total = new Set([...aFeat, ...bFeat]).size;
|
||||||
const overlap = total > 0 ? Math.round((shared / total) * 100) : 0;
|
const overlap = total > 0 ? Math.round((shared / total) * 100) : 0;
|
||||||
const scoreDiff = (items[j].avgCombined ?? 0) - (items[i].avgCombined ?? 0);
|
const scoreDiff = (items[j].avgCombined ?? 0) - (items[i].avgCombined ?? 0);
|
||||||
pairs.push({ a: items[i], b: items[j], overlap, scoreDiff });
|
const costDiff = (items[j].totalMonthly ?? 0) - (items[i].totalMonthly ?? 0);
|
||||||
|
pairs.push({ a: items[i], b: items[j], overlap, scoreDiff, costDiff });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pairs.sort((a, b) => b.overlap - a.overlap || Math.abs(b.scoreDiff) - Math.abs(a.scoreDiff));
|
pairs.sort((a, b) => b.overlap - a.overlap || Math.abs(b.scoreDiff) - Math.abs(a.scoreDiff));
|
||||||
return { category, tools: items, pairs: pairs.slice(0, 5) };
|
const totalMonthlyCost = items.reduce((s, t) => s + (t.totalMonthly ?? 0), 0);
|
||||||
|
return { category, tools: items, pairs: pairs.slice(0, 5), totalMonthlyCost };
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(result);
|
res.json(result);
|
||||||
|
|||||||
@@ -30,11 +30,12 @@ router.post("/tools/:id/costs", requireAuth, requireFeature("costs"), async (req
|
|||||||
const [tool] = await db.select().from(toolsTable).where(eq(toolsTable.id, toolId));
|
const [tool] = await db.select().from(toolsTable).where(eq(toolsTable.id, toolId));
|
||||||
if (!tool) { res.status(404).json({ error: "Tool not found" }); return; }
|
if (!tool) { res.status(404).json({ error: "Tool not found" }); return; }
|
||||||
|
|
||||||
const { licenseType, cost, currency, renewalDate, notes } = req.body;
|
const { licenseType, billingPeriod, cost, currency, renewalDate, notes } = req.body;
|
||||||
|
|
||||||
const [entry] = await db.insert(toolCostsTable).values({
|
const [entry] = await db.insert(toolCostsTable).values({
|
||||||
toolId,
|
toolId,
|
||||||
licenseType: licenseType ?? "free",
|
licenseType: licenseType ?? "free",
|
||||||
|
billingPeriod: billingPeriod ?? null,
|
||||||
cost: cost ?? null,
|
cost: cost ?? null,
|
||||||
currency: currency ?? "EUR",
|
currency: currency ?? "EUR",
|
||||||
renewalDate: renewalDate ? new Date(renewalDate) : null,
|
renewalDate: renewalDate ? new Date(renewalDate) : null,
|
||||||
@@ -53,9 +54,10 @@ router.patch("/costs/:id", requireAuth, async (req, res): Promise<void> => {
|
|||||||
const [existing] = await db.select().from(toolCostsTable).where(eq(toolCostsTable.id, id));
|
const [existing] = await db.select().from(toolCostsTable).where(eq(toolCostsTable.id, id));
|
||||||
if (!existing) { res.status(404).json({ error: "Cost entry not found" }); return; }
|
if (!existing) { res.status(404).json({ error: "Cost entry not found" }); return; }
|
||||||
|
|
||||||
const { licenseType, cost, currency, renewalDate, notes } = req.body;
|
const { licenseType, billingPeriod, cost, currency, renewalDate, notes } = req.body;
|
||||||
const updateData: Record<string, unknown> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
if (licenseType !== undefined) updateData.licenseType = licenseType;
|
if (licenseType !== undefined) updateData.licenseType = licenseType;
|
||||||
|
if (billingPeriod !== undefined) updateData.billingPeriod = billingPeriod;
|
||||||
if (cost !== undefined) updateData.cost = cost;
|
if (cost !== undefined) updateData.cost = cost;
|
||||||
if (currency !== undefined) updateData.currency = currency;
|
if (currency !== undefined) updateData.currency = currency;
|
||||||
if (renewalDate !== undefined) updateData.renewalDate = renewalDate ? new Date(renewalDate) : null;
|
if (renewalDate !== undefined) updateData.renewalDate = renewalDate ? new Date(renewalDate) : null;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Star, AlertTriangle } from "lucide-react";
|
import { Star, AlertTriangle, DollarSign } from "lucide-react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { customFetch } from "@workspace/api-client-react";
|
import { customFetch } from "@workspace/api-client-react";
|
||||||
|
|
||||||
@@ -38,16 +38,24 @@ export default function RedundancyPage() {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{data.map((group) => (
|
{data.map((group) => (
|
||||||
<div key={group.category}>
|
<div key={group.category}>
|
||||||
<h2 className="text-xl font-semibold mb-4 capitalize">{group.category}</h2>
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-semibold capitalize">{group.category}</h2>
|
||||||
|
{group.totalMonthlyCost > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<DollarSign className="w-3 h-3" />
|
||||||
|
{group.totalMonthlyCost.toFixed(2)}/mo total
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
||||||
{group.tools.map((tool: any) => (
|
{group.tools.map((tool: any) => (
|
||||||
<Link key={tool.id} href={`/tools/${tool.id}`}>
|
<Link key={tool.id} href={`/tools/${tool.id}`}>
|
||||||
<Card className="hover-elevate transition-all cursor-pointer hover:border-primary/50">
|
<Card className="hover-elevate transition-all cursor-pointer hover:border-primary/50">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex items-start gap-2 justify-between">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<span className="font-medium">{tool.name}</span>
|
<span className="font-medium">{tool.name}</span>
|
||||||
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground flex-wrap">
|
||||||
<span>{tool.ratingCount} reviews</span>
|
<span>{tool.ratingCount} reviews</span>
|
||||||
{tool.avgCombined != null && (
|
{tool.avgCombined != null && (
|
||||||
<>
|
<>
|
||||||
@@ -58,9 +66,27 @@ export default function RedundancyPage() {
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{tool.totalMonthly > 0 && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="flex items-center gap-0.5 text-amber-600">
|
||||||
|
<DollarSign className="w-3 h-3" />
|
||||||
|
{tool.totalMonthly.toFixed(2)}/mo
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{tool.costs?.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{tool.costs.map((c: any, i: number) => (
|
||||||
|
<Badge key={i} variant="outline" className="text-[10px] px-1 py-0">
|
||||||
|
{c.licenseType}{c.billingPeriod ? ` (${c.billingPeriod})` : ""}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">{tool.features.length} features</Badge>
|
)}
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="shrink-0">{tool.features.length} features</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -79,7 +105,7 @@ export default function RedundancyPage() {
|
|||||||
<span className="text-muted-foreground text-xs shrink-0">vs</span>
|
<span className="text-muted-foreground text-xs shrink-0">vs</span>
|
||||||
<span className="font-medium text-sm truncate">{pair.b.name}</span>
|
<span className="font-medium text-sm truncate">{pair.b.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 shrink-0">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Progress value={pair.overlap} className="w-16 h-2" />
|
<Progress value={pair.overlap} className="w-16 h-2" />
|
||||||
<span className="text-xs text-muted-foreground w-8">{pair.overlap}%</span>
|
<span className="text-xs text-muted-foreground w-8">{pair.overlap}%</span>
|
||||||
@@ -89,6 +115,13 @@ export default function RedundancyPage() {
|
|||||||
{pair.scoreDiff > 0 ? `${pair.b.name} +${pair.scoreDiff.toFixed(1)}` : `${pair.a.name} +${Math.abs(pair.scoreDiff).toFixed(1)}`}
|
{pair.scoreDiff > 0 ? `${pair.b.name} +${pair.scoreDiff.toFixed(1)}` : `${pair.a.name} +${Math.abs(pair.scoreDiff).toFixed(1)}`}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{pair.costDiff !== 0 && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{pair.costDiff > 0
|
||||||
|
? `${pair.b.name} +${pair.costDiff.toFixed(2)}/mo`
|
||||||
|
: `${pair.a.name} +${Math.abs(pair.costDiff).toFixed(2)}/mo`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export default function ToolDetail() {
|
|||||||
const [costDialogOpen, setCostDialogOpen] = useState(false);
|
const [costDialogOpen, setCostDialogOpen] = useState(false);
|
||||||
const [editCost, setEditCost] = useState<any | null>(null);
|
const [editCost, setEditCost] = useState<any | null>(null);
|
||||||
const [costLicenseType, setCostLicenseType] = useState("subscription");
|
const [costLicenseType, setCostLicenseType] = useState("subscription");
|
||||||
|
const [costBillingPeriod, setCostBillingPeriod] = useState("monthly");
|
||||||
const [costAmount, setCostAmount] = useState("");
|
const [costAmount, setCostAmount] = useState("");
|
||||||
const [costCurrency, setCostCurrency] = useState("EUR");
|
const [costCurrency, setCostCurrency] = useState("EUR");
|
||||||
const [costRenewal, setCostRenewal] = useState("");
|
const [costRenewal, setCostRenewal] = useState("");
|
||||||
@@ -140,6 +141,7 @@ export default function ToolDetail() {
|
|||||||
const method = editCost ? "PATCH" : "POST";
|
const method = editCost ? "PATCH" : "POST";
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
licenseType: costLicenseType,
|
licenseType: costLicenseType,
|
||||||
|
billingPeriod: costLicenseType === "subscription" ? costBillingPeriod : null,
|
||||||
currency: costCurrency,
|
currency: costCurrency,
|
||||||
notes: costNotes || null,
|
notes: costNotes || null,
|
||||||
};
|
};
|
||||||
@@ -159,6 +161,7 @@ export default function ToolDetail() {
|
|||||||
function resetCostForm() {
|
function resetCostForm() {
|
||||||
setEditCost(null);
|
setEditCost(null);
|
||||||
setCostLicenseType("subscription");
|
setCostLicenseType("subscription");
|
||||||
|
setCostBillingPeriod("monthly");
|
||||||
setCostAmount("");
|
setCostAmount("");
|
||||||
setCostCurrency("EUR");
|
setCostCurrency("EUR");
|
||||||
setCostRenewal("");
|
setCostRenewal("");
|
||||||
@@ -168,6 +171,7 @@ export default function ToolDetail() {
|
|||||||
function openEditCost(c: any) {
|
function openEditCost(c: any) {
|
||||||
setEditCost(c);
|
setEditCost(c);
|
||||||
setCostLicenseType(c.licenseType);
|
setCostLicenseType(c.licenseType);
|
||||||
|
setCostBillingPeriod(c.billingPeriod ?? "monthly");
|
||||||
setCostAmount(c.cost ?? "");
|
setCostAmount(c.cost ?? "");
|
||||||
setCostCurrency(c.currency ?? "EUR");
|
setCostCurrency(c.currency ?? "EUR");
|
||||||
setCostRenewal(c.renewalDate ?? "");
|
setCostRenewal(c.renewalDate ?? "");
|
||||||
@@ -550,7 +554,10 @@ export default function ToolDetail() {
|
|||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<Badge variant="outline" className="text-xs mb-1">{c.licenseType}</Badge>
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<Badge variant="outline" className="text-xs">{c.licenseType}</Badge>
|
||||||
|
{c.billingPeriod && <span className="text-[10px] text-muted-foreground uppercase">{c.billingPeriod}</span>}
|
||||||
|
</div>
|
||||||
<div className="text-lg font-bold">
|
<div className="text-lg font-bold">
|
||||||
{c.cost != null ? `${c.cost} ${c.currency ?? ""}` : "Free"}
|
{c.cost != null ? `${c.cost} ${c.currency ?? ""}` : "Free"}
|
||||||
</div>
|
</div>
|
||||||
@@ -602,6 +609,19 @@ export default function ToolDetail() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
{costLicenseType === "subscription" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Billing Period</label>
|
||||||
|
<Select value={costBillingPeriod} onValueChange={setCostBillingPeriod}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="monthly">Monthly</SelectItem>
|
||||||
|
<SelectItem value="quarterly">Quarterly</SelectItem>
|
||||||
|
<SelectItem value="yearly">Yearly</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Cost</label>
|
<label className="text-sm font-medium">Cost</label>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const toolCostsTable = pgTable("tool_costs", {
|
|||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
toolId: integer("tool_id").notNull().references(() => toolsTable.id, { onDelete: "cascade" }),
|
toolId: integer("tool_id").notNull().references(() => toolsTable.id, { onDelete: "cascade" }),
|
||||||
licenseType: text("license_type", { enum: ["free", "subscription", "one_time", "usage_based"] }).notNull().default("free"),
|
licenseType: text("license_type", { enum: ["free", "subscription", "one_time", "usage_based"] }).notNull().default("free"),
|
||||||
|
billingPeriod: text("billing_period", { enum: ["monthly", "quarterly", "yearly"] }),
|
||||||
cost: numeric("cost", { precision: 10, scale: 2 }),
|
cost: numeric("cost", { precision: 10, scale: 2 }),
|
||||||
currency: text("currency").default("EUR"),
|
currency: text("currency").default("EUR"),
|
||||||
renewalDate: timestamp("renewal_date", { withTimezone: true }),
|
renewalDate: timestamp("renewal_date", { withTimezone: true }),
|
||||||
|
|||||||
Reference in New Issue
Block a user