diff --git a/packages/shared/src/db/migrations/0002_confused_lionheart.sql b/packages/shared/src/db/migrations/0002_confused_lionheart.sql new file mode 100644 index 0000000..5aed73f --- /dev/null +++ b/packages/shared/src/db/migrations/0002_confused_lionheart.sql @@ -0,0 +1,11 @@ +/* + SQLite does not support "Set default to column" out of the box, we do not generate automatic migration for that, so it has to be done manually + Please refer to: https://www.techonthenet.com/sqlite/tables/alter_table.php + https://www.sqlite.org/lang_altertable.html + https://stackoverflow.com/questions/2083543/modify-a-columns-type-in-sqlite3 + + Due to that we don't generate migration automatically and it has to be done manually +*/--> statement-breakpoint +ALTER TABLE orgs ADD `stripe_subscription_id` text(191);--> statement-breakpoint +ALTER TABLE orgs ADD `stripe_current_period_end` integer;--> statement-breakpoint +CREATE UNIQUE INDEX `orgs_stripe_subscription_id_key` ON `orgs` (`stripe_subscription_id`); \ No newline at end of file diff --git a/packages/shared/src/db/migrations/meta/0002_snapshot.json b/packages/shared/src/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..c996bc5 --- /dev/null +++ b/packages/shared/src/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,317 @@ +{ + "version": "5", + "dialect": "sqlite", + "id": "781d35e2-75fe-4cf4-83fe-67431c7c067f", + "prevId": "b5522eea-ae7b-4918-abb2-adb620f94b5c", + "tables": { + "org_invites": { + "name": "org_invites", + "columns": { + "id": { + "name": "id", + "type": "text(191)", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text(191)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text(191)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "text(191)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text(191)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'read'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "token_org_invites_id_key": { + "name": "token_org_invites_id_key", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "org_users": { + "name": "org_users", + "columns": { + "id": { + "name": "id", + "type": "text(191)", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'read'" + }, + "org_id": { + "name": "org_id", + "type": "text(191)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text(191)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "orgs": { + "name": "orgs", + "columns": { + "id": { + "name": "id", + "type": "text(191)", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(191)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'free'" + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text(191)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text(191)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_current_period_end": { + "name": "stripe_current_period_end", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "orgs_stripe_customer_id_key": { + "name": "orgs_stripe_customer_id_key", + "columns": [ + "stripe_customer_id" + ], + "isUnique": true + }, + "orgs_stripe_subscription_id_key": { + "name": "orgs_stripe_subscription_id_key", + "columns": [ + "stripe_subscription_id" + ], + "isUnique": true + }, + "orgs_name_id_key": { + "name": "orgs_name_id_key", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text(191)", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text(191)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text(191)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text(191)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_current_period_end": { + "name": "stripe_current_period_end", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "users_stripe_customer_id_key": { + "name": "users_stripe_customer_id_key", + "columns": [ + "stripe_customer_id" + ], + "isUnique": true + }, + "users_stripe_subscription_id_key": { + "name": "users_stripe_subscription_id_key", + "columns": [ + "stripe_subscription_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/packages/shared/src/db/migrations/meta/_journal.json b/packages/shared/src/db/migrations/meta/_journal.json index 9d6eeb3..809f3d0 100644 --- a/packages/shared/src/db/migrations/meta/_journal.json +++ b/packages/shared/src/db/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1710095002592, "tag": "0001_glorious_masked_marvel", "breakpoints": true + }, + { + "idx": 2, + "version": "5", + "when": 1769615790664, + "tag": "0002_confused_lionheart", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts index 6831e03..0311667 100644 --- a/packages/shared/src/db/schema.ts +++ b/packages/shared/src/db/schema.ts @@ -50,10 +50,14 @@ export const orgs = sqliteTable( { id: text("id", { length: 191 }).primaryKey().notNull(), name: text("name", { length: 191 }).notNull(), - plan: text("plan", { enum: [Plan.FREE, Plan.PAID] }) + plan: text("plan", { enum: [Plan.FREE, Plan.PRO] }) .default(Plan.FREE) .notNull(), stripeCustomerId: text("stripe_customer_id", { length: 191 }), + stripeSubscriptionId: text("stripe_subscription_id", { length: 191 }), + stripeCurrentPeriodEnd: integer("stripe_current_period_end", { + mode: "timestamp", + }), createdAt: integer("created_at", { mode: "timestamp", }) @@ -71,6 +75,9 @@ export const orgs = sqliteTable( stripeCustomerIdKey: uniqueIndex("orgs_stripe_customer_id_key").on( table.stripeCustomerId ), + stripeSubscriptionIdKey: uniqueIndex( + "orgs_stripe_subscription_id_key" + ).on(table.stripeSubscriptionId), nameIdKey: uniqueIndex("orgs_name_id_key").on(table.name), }; } diff --git a/packages/shared/src/types/plan.ts b/packages/shared/src/types/plan.ts index 6dc2a81..c911bd9 100644 --- a/packages/shared/src/types/plan.ts +++ b/packages/shared/src/types/plan.ts @@ -1,16 +1,71 @@ export enum Plan { FREE = "free", - PAID = "paid", + PRO = "pro", +} + +export interface PlanEnv { + STRIPE_PRO_MONTHLY_PLAN_ID: string; } export function stripePriceIdToPlan( stripePriceId: string, - env: { STRIPE_PRO_MONTHLY_PLAN_ID: string } + env: PlanEnv ): Plan | null { switch (stripePriceId) { case env.STRIPE_PRO_MONTHLY_PLAN_ID: - return Plan.PAID; + return Plan.PRO; default: return null; } } + +/** + * Plan feature limits for CalendarSync + * Free: 1 calendar, 1 destination, daily sync + * Pro ($8/month): unlimited calendars, unlimited destinations, 15-min sync, priority support + */ +export interface PlanLimits { + maxCalendars: number; + maxDestinations: number; + syncIntervalMinutes: number; + hasPrioritySupport: boolean; +} + +export function getPlanLimits(plan: Plan): PlanLimits { + switch (plan) { + case Plan.FREE: + return { + maxCalendars: 1, + maxDestinations: 1, + syncIntervalMinutes: 1440, // 24 hours (daily) + hasPrioritySupport: false, + }; + case Plan.PRO: + return { + maxCalendars: Infinity, + maxDestinations: Infinity, + syncIntervalMinutes: 15, + hasPrioritySupport: true, + }; + } +} + +export function canAddCalendar( + plan: Plan, + currentCalendarCount: number +): boolean { + const limits = getPlanLimits(plan); + return currentCalendarCount < limits.maxCalendars; +} + +export function canAddDestination( + plan: Plan, + currentDestinationCount: number +): boolean { + const limits = getPlanLimits(plan); + return currentDestinationCount < limits.maxDestinations; +} + +export function isPaidPlan(plan: Plan): boolean { + return plan === Plan.PRO; +} diff --git a/packages/web/app/(dashboard)/[name]/billing/page.tsx b/packages/web/app/(dashboard)/[name]/billing/page.tsx index 156a955..4404022 100644 --- a/packages/web/app/(dashboard)/[name]/billing/page.tsx +++ b/packages/web/app/(dashboard)/[name]/billing/page.tsx @@ -5,21 +5,11 @@ import { DashboardHeader } from "@/custom-components/header"; import { OrgBillingForm } from "@/custom-components/org-billing-form"; import { DashboardShell } from "@/custom-components/shell"; import { getOrgUserForOrgName } from "@/lib/org"; +import { getOrgUsageStats } from "@/lib/plan-gating"; import { getCurrentServerUser } from "@/lib/session"; import { cookies } from "next/headers"; import { Role, hasPermission } from "shared/src/types/role"; -// Cant do this in app dir: - -// export const config = { -// runtime: "edge", -// unstable_allowDynamic: [ -// // Stripe imports this, but does not use it, so tell build to ignore -// // use a glob to allow anything in the function-bind 3rd party module -// "**/node_modules/function-bind/**", -// ], -// }; - export const metadata = { title: "Billing", description: "Manage billing and your subscription plan.", @@ -41,6 +31,7 @@ export default async function BillingPage({ } const subscriptionPlan = planToSubscriptionPlan(userInOrg.orgPlan); + const usageStats = await getOrgUsageStats(userInOrg.orgId); return ( @@ -53,6 +44,9 @@ export default async function BillingPage({ orgName={params.name} orgPlan={userInOrg.orgPlan} subscriptionPlan={subscriptionPlan} + currentPeriodEnd={userInOrg.orgStripeCurrentPeriodEnd} + calendarsCount={usageStats.calendarsCount} + destinationsCount={usageStats.destinationsCount} /> diff --git a/packages/web/app/(marketing)/page.tsx b/packages/web/app/(marketing)/page.tsx index a21b7d9..3276891 100644 --- a/packages/web/app/(marketing)/page.tsx +++ b/packages/web/app/(marketing)/page.tsx @@ -274,7 +274,7 @@ export default async function IndexPage() {

Pro

- $9/month + $8/month

For professionals and teams