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 (
- $9/month + $8/month
For professionals and teams
- Unlock all features including unlimited posts for your blog. + +
+ Start free and upgrade when you need more. No hidden fees, cancel + anytime.
+ Perfect for getting started +
+- Billed Monthly -
+ + {/* Pro Plan */} ++ For professionals and teams +
+- Taxonomy is a demo app.{" "} - You can test the upgrade and won't be charged. -
+ + {/* FAQ or Additional Info */} ++ Yes, you can cancel your subscription at any time. You'll + continue to have access until the end of your billing period. +
++ You'll immediately get access to all Pro features. Your + billing will be prorated for the remainder of the month. +
++ We accept all major credit cards through Stripe, including Visa, + Mastercard, and American Express. +
++ The Free plan lets you try CalendarSync with 1 calendar and 1 + destination. Upgrade to Pro when you're ready for more. +
+