feat: add new subscription tier and update credit configurations
- Introduced a new "max" subscription tier with associated monthly credits and top-up limits. - Updated existing subscription tiers' monthly credits and top-up limits for "starter", "pro", and "business". - Enhanced credit display and overview components to reflect the new tier and its attributes. - Integrated Polar authentication features for improved subscription management and credit handling.
This commit is contained in:
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -18,6 +18,7 @@ import type * as helpers from "../helpers.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as nodes from "../nodes.js";
|
||||
import type * as openrouter from "../openrouter.js";
|
||||
import type * as polar from "../polar.js";
|
||||
import type * as storage from "../storage.js";
|
||||
|
||||
import type {
|
||||
@@ -37,6 +38,7 @@ declare const fullApi: ApiFromModules<{
|
||||
http: typeof http;
|
||||
nodes: typeof nodes;
|
||||
openrouter: typeof openrouter;
|
||||
polar: typeof polar;
|
||||
storage: typeof storage;
|
||||
}>;
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createClient, type GenericCtx } from "@convex-dev/better-auth";
|
||||
import { convex } from "@convex-dev/better-auth/plugins";
|
||||
import { requireRunMutationCtx } from "@convex-dev/better-auth/utils";
|
||||
import { checkout, polar, portal, webhooks } from "@polar-sh/better-auth";
|
||||
import { Polar } from "@polar-sh/sdk";
|
||||
import { components } from "./_generated/api";
|
||||
import { internal } from "./_generated/api";
|
||||
import { DataModel } from "./_generated/dataModel";
|
||||
import { query } from "./_generated/server";
|
||||
import { betterAuth } from "better-auth/minimal";
|
||||
@@ -10,6 +14,11 @@ import authConfig from "./auth.config";
|
||||
const siteUrl = process.env.SITE_URL!;
|
||||
const appUrl = process.env.APP_URL;
|
||||
|
||||
const polarClient = new Polar({
|
||||
accessToken: process.env.POLAR_ACCESS_TOKEN!,
|
||||
server: "production",
|
||||
});
|
||||
|
||||
// Component Client — stellt Adapter, Helper und Auth-Methoden bereit
|
||||
export const authComponent = createClient<DataModel>(components.betterAuth);
|
||||
|
||||
@@ -68,6 +77,92 @@ export const createAuth = (ctx: GenericCtx<DataModel>) => {
|
||||
},
|
||||
plugins: [
|
||||
convex({ authConfig }),
|
||||
polar({
|
||||
client: polarClient,
|
||||
createCustomerOnSignUp: true,
|
||||
use: [
|
||||
checkout({
|
||||
successUrl: `${siteUrl}/dashboard?checkout=success`,
|
||||
authenticatedUsersOnly: true,
|
||||
}),
|
||||
portal(),
|
||||
webhooks({
|
||||
secret: process.env.POLAR_WEBHOOK_SECRET!,
|
||||
onCustomerStateChanged: async (payload) => {
|
||||
const runMutationCtx = requireRunMutationCtx(ctx);
|
||||
const customerState = payload.data;
|
||||
const userId = customerState.externalId;
|
||||
|
||||
if (!userId) {
|
||||
console.error("Polar customer.state_changed payload without externalId", {
|
||||
customerId: customerState.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = customerState.activeSubscriptions?.[0];
|
||||
|
||||
if (!subscription) {
|
||||
await runMutationCtx.runMutation(internal.polar.handleSubscriptionRevoked, {
|
||||
userId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const tierMetadata = subscription.metadata.tier;
|
||||
const creditsMetadata = subscription.metadata.credits;
|
||||
const tier = tierMetadata === "starter" || tierMetadata === "pro" || tierMetadata === "max"
|
||||
? tierMetadata
|
||||
: undefined;
|
||||
const monthlyCredits = Number(creditsMetadata);
|
||||
|
||||
if (!tier || !Number.isFinite(monthlyCredits) || monthlyCredits <= 0) {
|
||||
console.error("Missing or invalid Polar subscription metadata", {
|
||||
subscriptionId: subscription.id,
|
||||
tier: tierMetadata,
|
||||
credits: creditsMetadata,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await runMutationCtx.runMutation(internal.polar.handleSubscriptionActivated, {
|
||||
userId,
|
||||
tier,
|
||||
polarSubscriptionId: subscription.id,
|
||||
currentPeriodStart: subscription.currentPeriodStart.getTime(),
|
||||
currentPeriodEnd: subscription.currentPeriodEnd.getTime(),
|
||||
monthlyCredits,
|
||||
});
|
||||
},
|
||||
onOrderPaid: async (payload) => {
|
||||
const runMutationCtx = requireRunMutationCtx(ctx);
|
||||
const order = payload.data;
|
||||
const metadata = order.product?.metadata;
|
||||
const type = metadata?.type;
|
||||
const credits = Number(metadata?.credits);
|
||||
|
||||
if (type !== "topup" || !Number.isFinite(credits) || credits <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = order.customer.externalId;
|
||||
if (!userId) {
|
||||
console.error("Polar order.paid payload without externalId", {
|
||||
orderId: order.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await runMutationCtx.runMutation(internal.polar.handleTopUpPaid, {
|
||||
userId,
|
||||
credits,
|
||||
polarOrderId: order.id,
|
||||
amountPaidEuroCents: order.totalAmount,
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,28 +8,35 @@ import { requireAuth } from "./helpers";
|
||||
|
||||
export const TIER_CONFIG = {
|
||||
free: {
|
||||
monthlyCredits: 50, // €0,50 in Cent
|
||||
monthlyCredits: 50,
|
||||
dailyGenerationCap: 10,
|
||||
concurrencyLimit: 1,
|
||||
premiumModels: false,
|
||||
topUpLimit: 0, // Kein Top-Up für Free
|
||||
topUpLimit: 50000,
|
||||
},
|
||||
starter: {
|
||||
monthlyCredits: 630, // €6,30 in Cent
|
||||
monthlyCredits: 400,
|
||||
dailyGenerationCap: 50,
|
||||
concurrencyLimit: 2,
|
||||
premiumModels: true,
|
||||
topUpLimit: 2000, // €20 pro Monat
|
||||
},
|
||||
pro: {
|
||||
monthlyCredits: 3602, // €36,02 in Cent (+5% Bonus)
|
||||
monthlyCredits: 3300,
|
||||
dailyGenerationCap: 200,
|
||||
concurrencyLimit: 2,
|
||||
premiumModels: true,
|
||||
topUpLimit: 10000, // €100 pro Monat
|
||||
},
|
||||
max: {
|
||||
monthlyCredits: 6700,
|
||||
dailyGenerationCap: 500,
|
||||
concurrencyLimit: 2,
|
||||
premiumModels: true,
|
||||
topUpLimit: 50000,
|
||||
},
|
||||
business: {
|
||||
monthlyCredits: 7623, // €76,23 in Cent (+10% Bonus)
|
||||
monthlyCredits: 6700,
|
||||
dailyGenerationCap: 500,
|
||||
concurrencyLimit: 2,
|
||||
premiumModels: true,
|
||||
@@ -516,6 +523,7 @@ export const activateSubscription = internalMutation({
|
||||
v.literal("free"),
|
||||
v.literal("starter"),
|
||||
v.literal("pro"),
|
||||
v.literal("max"),
|
||||
v.literal("business")
|
||||
),
|
||||
lemonSqueezySubscriptionId: v.string(),
|
||||
@@ -601,10 +609,6 @@ export const topUp = mutation({
|
||||
const tier = (subscription?.tier ?? "free") as Tier;
|
||||
const config = TIER_CONFIG[tier];
|
||||
|
||||
if (config.topUpLimit === 0) {
|
||||
throw new Error("Top-up not available for Free tier");
|
||||
}
|
||||
|
||||
// Monatliches Top-Up-Limit prüfen
|
||||
const monthStart = new Date();
|
||||
monthStart.setDate(1);
|
||||
|
||||
183
convex/polar.ts
Normal file
183
convex/polar.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
import { internalMutation, type MutationCtx } from "./_generated/server";
|
||||
|
||||
type DbCtx = Pick<MutationCtx, "db">;
|
||||
|
||||
type ActivatedArgs = {
|
||||
userId: string;
|
||||
tier: "starter" | "pro" | "max";
|
||||
polarSubscriptionId: string;
|
||||
currentPeriodStart: number;
|
||||
currentPeriodEnd: number;
|
||||
monthlyCredits: number;
|
||||
};
|
||||
|
||||
type RevokedArgs = {
|
||||
userId: string;
|
||||
polarSubscriptionId?: string;
|
||||
};
|
||||
|
||||
type TopUpArgs = {
|
||||
userId: string;
|
||||
credits: number;
|
||||
polarOrderId: string;
|
||||
amountPaidEuroCents: number;
|
||||
};
|
||||
|
||||
export async function applySubscriptionActivated(ctx: DbCtx, args: ActivatedArgs) {
|
||||
const existing = await ctx.db
|
||||
.query("subscriptions")
|
||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||||
.order("desc")
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
tier: args.tier,
|
||||
status: "active",
|
||||
currentPeriodStart: args.currentPeriodStart,
|
||||
currentPeriodEnd: args.currentPeriodEnd,
|
||||
polarSubscriptionId: args.polarSubscriptionId,
|
||||
});
|
||||
} else {
|
||||
await ctx.db.insert("subscriptions", {
|
||||
userId: args.userId,
|
||||
tier: args.tier,
|
||||
status: "active",
|
||||
currentPeriodStart: args.currentPeriodStart,
|
||||
currentPeriodEnd: args.currentPeriodEnd,
|
||||
polarSubscriptionId: args.polarSubscriptionId,
|
||||
});
|
||||
}
|
||||
|
||||
const balance = await ctx.db
|
||||
.query("creditBalances")
|
||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||||
.unique();
|
||||
|
||||
if (balance) {
|
||||
await ctx.db.patch(balance._id, {
|
||||
balance: balance.balance + args.monthlyCredits,
|
||||
monthlyAllocation: args.monthlyCredits,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
} else {
|
||||
await ctx.db.insert("creditBalances", {
|
||||
userId: args.userId,
|
||||
balance: args.monthlyCredits,
|
||||
reserved: 0,
|
||||
monthlyAllocation: args.monthlyCredits,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.insert("creditTransactions", {
|
||||
userId: args.userId,
|
||||
amount: args.monthlyCredits,
|
||||
type: "subscription",
|
||||
status: "committed",
|
||||
description: `${args.tier} plan - ${args.monthlyCredits} credits allocated`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function applySubscriptionRevoked(ctx: DbCtx, args: RevokedArgs) {
|
||||
const sub = await ctx.db
|
||||
.query("subscriptions")
|
||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||||
.order("desc")
|
||||
.first();
|
||||
|
||||
if (sub) {
|
||||
await ctx.db.patch(sub._id, {
|
||||
tier: "free",
|
||||
status: "cancelled",
|
||||
});
|
||||
}
|
||||
|
||||
const balance = await ctx.db
|
||||
.query("creditBalances")
|
||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||||
.unique();
|
||||
|
||||
if (balance) {
|
||||
await ctx.db.patch(balance._id, {
|
||||
monthlyAllocation: 50,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.insert("creditTransactions", {
|
||||
userId: args.userId,
|
||||
amount: 0,
|
||||
type: "subscription",
|
||||
status: "committed",
|
||||
description: args.polarSubscriptionId
|
||||
? `Subscription ${args.polarSubscriptionId} cancelled - downgraded to Free`
|
||||
: "Subscription cancelled - downgraded to Free",
|
||||
});
|
||||
}
|
||||
|
||||
export async function applyTopUpPaid(ctx: DbCtx, args: TopUpArgs) {
|
||||
const duplicate = await ctx.db
|
||||
.query("creditTransactions")
|
||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||||
.filter((q) => q.eq(q.field("description"), `Top-up order ${args.polarOrderId}`))
|
||||
.first();
|
||||
|
||||
if (duplicate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const balance = await ctx.db
|
||||
.query("creditBalances")
|
||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||||
.unique();
|
||||
|
||||
if (!balance) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.db.patch(balance._id, {
|
||||
balance: balance.balance + args.credits,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
await ctx.db.insert("creditTransactions", {
|
||||
userId: args.userId,
|
||||
amount: args.credits,
|
||||
type: "topup",
|
||||
status: "committed",
|
||||
description: `Top-up order ${args.polarOrderId} - ${args.credits} credits (EUR ${(args.amountPaidEuroCents / 100).toFixed(2)})`,
|
||||
});
|
||||
}
|
||||
|
||||
export const handleSubscriptionActivated = internalMutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
tier: v.union(v.literal("starter"), v.literal("pro"), v.literal("max")),
|
||||
polarSubscriptionId: v.string(),
|
||||
currentPeriodStart: v.number(),
|
||||
currentPeriodEnd: v.number(),
|
||||
monthlyCredits: v.number(),
|
||||
},
|
||||
handler: applySubscriptionActivated,
|
||||
});
|
||||
|
||||
export const handleSubscriptionRevoked = internalMutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
polarSubscriptionId: v.optional(v.string()),
|
||||
},
|
||||
handler: applySubscriptionRevoked,
|
||||
});
|
||||
|
||||
export const handleTopUpPaid = internalMutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
credits: v.number(),
|
||||
polarOrderId: v.string(),
|
||||
amountPaidEuroCents: v.number(),
|
||||
},
|
||||
handler: applyTopUpPaid,
|
||||
});
|
||||
@@ -258,6 +258,7 @@ export default defineSchema({
|
||||
v.literal("free"),
|
||||
v.literal("starter"),
|
||||
v.literal("pro"),
|
||||
v.literal("max"),
|
||||
v.literal("business")
|
||||
),
|
||||
status: v.union(
|
||||
@@ -268,11 +269,13 @@ export default defineSchema({
|
||||
),
|
||||
currentPeriodStart: v.number(), // Timestamp (ms)
|
||||
currentPeriodEnd: v.number(), // Timestamp (ms)
|
||||
polarSubscriptionId: v.optional(v.string()),
|
||||
lemonSqueezySubscriptionId: v.optional(v.string()),
|
||||
lemonSqueezyCustomerId: v.optional(v.string()),
|
||||
cancelAtPeriodEnd: v.optional(v.boolean()), // Kündigung zum Periodenende
|
||||
})
|
||||
.index("by_user", ["userId"])
|
||||
.index("by_polar", ["polarSubscriptionId"])
|
||||
.index("by_lemon_squeezy", ["lemonSqueezySubscriptionId"]),
|
||||
|
||||
// ==========================================================================
|
||||
|
||||
Reference in New Issue
Block a user