From 77965e8e75ef6db06bdc10ba85b3e21197d1cf16 Mon Sep 17 00:00:00 2001 From: Alec Kriebel Date: Wed, 28 Jan 2026 07:57:04 -0800 Subject: [PATCH] feat: Implement Stripe billing with Free and Pro subscription plans - Add two subscription tiers: Free (1 calendar, 1 destination, daily sync) and Pro ($8/month with unlimited calendars, unlimited destinations, 15-min sync, priority support) - Update Plan enum from FREE/PAID to FREE/PRO with comprehensive plan limits module - Add database fields for tracking subscription (stripe_subscription_id, stripe_current_period_end) - Enhance Stripe webhook handler to handle full subscription lifecycle (created, updated, deleted, payment_succeeded, payment_failed) - Create plan-gating utility for checking feature access based on subscription - Update billing page UI to show current plan, usage stats, and upgrade CTA - Update pricing page with accurate Free and Pro plan features - Update landing page pricing section to reflect $8/month Pro plan Co-Authored-By: Claude Opus 4.5 --- .../db/migrations/0002_confused_lionheart.sql | 11 + .../src/db/migrations/meta/0002_snapshot.json | 317 ++++++++++++++++ .../src/db/migrations/meta/_journal.json | 9 +- packages/shared/src/db/schema.ts | 9 +- packages/shared/src/types/plan.ts | 61 +++- .../app/(dashboard)/[name]/billing/page.tsx | 16 +- packages/web/app/(marketing)/page.tsx | 8 +- packages/web/app/(marketing)/pricing/page.tsx | 160 ++++++--- packages/web/config/subscriptions.ts | 34 +- packages/web/custom-components/icons.tsx | 3 + .../custom-components/org-billing-form.tsx | 220 +++++++++++- packages/web/lib/org.ts | 8 +- packages/web/lib/plan-gating.ts | 87 +++++ .../web/pages/api/webhooks/stripe/index.ts | 339 ++++++++++++++++-- 14 files changed, 1164 insertions(+), 118 deletions(-) create mode 100644 packages/shared/src/db/migrations/0002_confused_lionheart.sql create mode 100644 packages/shared/src/db/migrations/meta/0002_snapshot.json create mode 100644 packages/web/lib/plan-gating.ts 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

    @@ -283,12 +283,12 @@ export default async function IndexPage() { calendar connections
  • - Real-time + 15-minute sync
  • - All - export destinations + Unlimited + destinations
  • Unlimited diff --git a/packages/web/app/(marketing)/pricing/page.tsx b/packages/web/app/(marketing)/pricing/page.tsx index 0cdf8a4..b3ceee0 100644 --- a/packages/web/app/(marketing)/pricing/page.tsx +++ b/packages/web/app/(marketing)/pricing/page.tsx @@ -1,68 +1,136 @@ import Link from "next/link"; +import { getPlanFeatures, PRO_PLAN_PRICE_MONTHLY } from "@/config/subscriptions"; import { Icons } from "@/custom-components/icons"; import { cn } from "components/lib/utils"; import { buttonVariants } from "components/ui/button"; +import { Plan } from "shared/src/types/plan"; export const metadata = { - title: "Pricing", + title: "Pricing - CalendarSync", + description: + "Simple, transparent pricing. Start free, upgrade when you need more.", }; export default function PricingPage() { + const freeFeatures = getPlanFeatures(Plan.FREE); + const proFeatures = getPlanFeatures(Plan.PRO); + return ( -
    -
    -

    +
    +
    +

    Simple, transparent pricing -

    -

    - Unlock all features including unlimited posts for your blog. +

    +

    + Start free and upgrade when you need more. No hidden fees, cancel + anytime.

    -
    -
    -

    - What's included in the PRO plan -

    -
      -
    • - Unlimited Posts -
    • -
    • - Unlimited Users -
    • -
    • - Custom domain -
    • -
    • - Dashboard Analytics -
    • -
    • - Access to Discord -
    • -
    • - Premium Support -
    • -
    +
    + {/* Free Plan */} +
    +
    +
    +

    Free

    +

    + Perfect for getting started +

    +
    +
    + $0 + /month +
    +
      + {freeFeatures.map((feature, i) => ( +
    • + + {feature} +
    • + ))} +
    + + Start Free + +
    -
    -
    -

    $19

    -

    - Billed Monthly -

    + + {/* Pro Plan */} +
    +
    + Popular +
    +
    +
    +

    Pro

    +

    + For professionals and teams +

    +
    +
    + + ${PRO_PLAN_PRICE_MONTHLY} + + /month +
    +
      + {proFeatures.map((feature, i) => ( +
    • + + {feature} +
    • + ))} +
    + + Start Free Trial +
    - - Get Started -
    -
    -

    - Taxonomy is a demo app.{" "} - You can test the upgrade and won't be charged. -

    + + {/* FAQ or Additional Info */} +
    +

    Frequently Asked Questions

    +
    +
    +

    Can I cancel anytime?

    +

    + Yes, you can cancel your subscription at any time. You'll + continue to have access until the end of your billing period. +

    +
    +
    +

    What happens when I upgrade?

    +

    + You'll immediately get access to all Pro features. Your + billing will be prorated for the remainder of the month. +

    +
    +
    +

    What payment methods do you accept?

    +

    + We accept all major credit cards through Stripe, including Visa, + Mastercard, and American Express. +

    +
    +
    +

    Is there a free trial?

    +

    + The Free plan lets you try CalendarSync with 1 calendar and 1 + destination. Upgrade to Pro when you're ready for more. +

    +
    +
    ); diff --git a/packages/web/config/subscriptions.ts b/packages/web/config/subscriptions.ts index f56dc50..51e30e9 100644 --- a/packages/web/config/subscriptions.ts +++ b/packages/web/config/subscriptions.ts @@ -1,28 +1,52 @@ import { env } from "@/web-env"; -import { Plan } from "shared/src/types/plan"; +import { Plan, getPlanLimits } from "shared/src/types/plan"; import { assertNever } from "shared/src/utils"; import { SubscriptionPlan } from "types"; export const freePlan: SubscriptionPlan = { name: "Free", description: - "The free plan is limited to 3 posts. Upgrade to the PRO plan for unlimited posts.", + "1 calendar, 1 destination, and daily sync. Perfect for getting started.", stripePriceId: "", }; export const proPlan: SubscriptionPlan = { - name: "PRO", - description: "The PRO plan has unlimited posts.", + name: "Pro", + description: + "Unlimited calendars, unlimited destinations, 15-minute sync, and priority support.", stripePriceId: env.STRIPE_PRO_MONTHLY_PLAN_ID || "", }; +export const PRO_PLAN_PRICE_MONTHLY = 8; // $8/month + export function planToSubscriptionPlan(plan: Plan): SubscriptionPlan { switch (plan) { case Plan.FREE: return freePlan; - case Plan.PAID: + case Plan.PRO: return proPlan; default: assertNever(plan); } } + +export function getPlanFeatures(plan: Plan): string[] { + const limits = getPlanLimits(plan); + + if (plan === Plan.FREE) { + return [ + `${limits.maxCalendars} calendar connection`, + `${limits.maxDestinations} destination`, + "Daily sync", + "30-day history", + ]; + } + + return [ + "Unlimited calendar connections", + "Unlimited destinations", + "15-minute sync interval", + "Unlimited history", + "Priority support", + ]; +} diff --git a/packages/web/custom-components/icons.tsx b/packages/web/custom-components/icons.tsx index 533eb64..838761d 100644 --- a/packages/web/custom-components/icons.tsx +++ b/packages/web/custom-components/icons.tsx @@ -18,6 +18,7 @@ import { Pizza, Plus, Settings, + Sparkles, SunMedium, Trash, Twitter, @@ -72,4 +73,6 @@ export const Icons = { ), twitter: Twitter, check: Check, + x: X, + sparkles: Sparkles, }; diff --git a/packages/web/custom-components/org-billing-form.tsx b/packages/web/custom-components/org-billing-form.tsx index 5b62bd9..c35d438 100644 --- a/packages/web/custom-components/org-billing-form.tsx +++ b/packages/web/custom-components/org-billing-form.tsx @@ -2,8 +2,11 @@ import * as React from "react"; +import { getPlanFeatures, PRO_PLAN_PRICE_MONTHLY } from "@/config/subscriptions"; +import { Icons } from "@/custom-components/icons"; import { clientFetch } from "@/lib/fetch"; import { cn } from "components/lib/utils"; +import { Badge } from "components/ui/badge"; import { Button } from "components/ui/button"; import { Card, @@ -13,56 +16,239 @@ import { CardHeader, CardTitle, } from "components/ui/card"; -import { Plan } from "shared/src/types/plan"; +import { Progress } from "components/ui/progress"; +import { Separator } from "components/ui/separator"; +import { Plan, isPaidPlan, getPlanLimits } from "shared/src/types/plan"; import { SubscriptionPlan } from "types"; interface BillingFormProps extends React.HTMLAttributes { orgName: string; orgPlan: Plan; subscriptionPlan: SubscriptionPlan; + currentPeriodEnd?: Date | null; + calendarsCount?: number; + destinationsCount?: number; +} + +function formatDate(date: Date): string { + return new Intl.DateTimeFormat("en-US", { + month: "long", + day: "numeric", + year: "numeric", + }).format(date); +} + +function formatSyncInterval(minutes: number): string { + if (minutes >= 1440) { + return "Daily"; + } else if (minutes >= 60) { + const hours = Math.floor(minutes / 60); + return `Every ${hours} hour${hours === 1 ? "" : "s"}`; + } else { + return `Every ${minutes} minute${minutes === 1 ? "" : "s"}`; + } } export function OrgBillingForm({ orgName, subscriptionPlan, orgPlan, + currentPeriodEnd, + calendarsCount = 0, + destinationsCount = 0, className, ...props }: BillingFormProps) { const [isLoading, setIsLoading] = React.useState(false); + const isPro = isPaidPlan(orgPlan); + const limits = getPlanLimits(orgPlan); + const features = getPlanFeatures(orgPlan); - async function onSubmit(event) { + async function onSubmit(event: React.FormEvent) { event.preventDefault(); clientFetch<{ url: string }>(`/api/orgs/${orgName}/stripe`, undefined, { - beforeRequestStart: () => setIsLoading(!isLoading), + beforeRequestStart: () => setIsLoading(true), afterRequestFinish: () => setIsLoading(false), onRequestSuccess: (response) => { - // Redirect to the Stripe session. - // This could be a checkout page for initial upgrade. - // Or portal to manage existing subscription. window.location.href = response.url; }, defaultErrorMessage: "Please refresh the page and try again.", }); } + const calendarsProgress = + limits.maxCalendars === Infinity + ? 0 + : (calendarsCount / limits.maxCalendars) * 100; + const destinationsProgress = + limits.maxDestinations === Infinity + ? 0 + : (destinationsCount / limits.maxDestinations) * 100; + return ( -
    +
    + {/* Current Plan Card */} + + +
    +
    + + Current Plan + + {subscriptionPlan.name} + + + + {subscriptionPlan.description} + +
    + {isPro && ( +
    +

    ${PRO_PLAN_PRICE_MONTHLY}

    +

    per month

    +
    + )} +
    +
    + +
    +
    +

    Plan Features

    +
      + {features.map((feature, i) => ( +
    • + + {feature} +
    • + ))} +
    +
    + + {isPro && currentPeriodEnd && ( +
    +

    + Your subscription renews on{" "} + {formatDate(currentPeriodEnd)} +

    +
    + )} +
    +
    + + + + + +
    + + {/* Usage Card */} - Subscription Plan + Usage - You are currently on the {subscriptionPlan.name}{" "} - plan. + Your current usage against plan limits - {subscriptionPlan.description} - - - + + {/* Calendars */} +
    +
    + Connected Calendars + + {calendarsCount} /{" "} + {limits.maxCalendars === Infinity + ? "Unlimited" + : limits.maxCalendars} + +
    + {limits.maxCalendars !== Infinity && ( + + )} +
    + + {/* Destinations */} +
    +
    + Destinations + + {destinationsCount} /{" "} + {limits.maxDestinations === Infinity + ? "Unlimited" + : limits.maxDestinations} + +
    + {limits.maxDestinations !== Infinity && ( + + )} +
    + + {/* Sync Interval */} +
    +
    + Sync Frequency + + {formatSyncInterval(limits.syncIntervalMinutes)} + +
    +
    + + {/* Priority Support */} +
    +
    + Priority Support + + {limits.hasPrioritySupport ? ( + + ) : ( + + )} + +
    +
    +
    - + + {/* Upgrade CTA for Free users */} + {!isPro && ( + + + + + Upgrade to Pro + + + Unlock the full power of CalendarSync + + + +
    +
    + + ${PRO_PLAN_PRICE_MONTHLY} + + /month +
    +
      + {getPlanFeatures(Plan.PRO).map((feature, i) => ( +
    • + + {feature} +
    • + ))} +
    +
    +
    + +
    + +
    +
    +
    + )} +
    ); } diff --git a/packages/web/lib/org.ts b/packages/web/lib/org.ts index bb80e44..8ff70c5 100644 --- a/packages/web/lib/org.ts +++ b/packages/web/lib/org.ts @@ -28,11 +28,13 @@ export async function verifyUserHasPermissionForOrgId( return hasPermission(roleRecord.role, role); } -interface OrgUserResult { +export interface OrgUserResult { orgId: string; orgPlan: Plan; role: Role; orgStripeCustomerId: string | null; + orgStripeSubscriptionId: string | null; + orgStripeCurrentPeriodEnd: Date | null; } export async function getOrgUserForOrgName( @@ -45,6 +47,8 @@ export async function getOrgUserForOrgName( orgPlan: orgs.plan, role: orgUsers.role, orgStripeCustomerId: orgs.stripeCustomerId, + orgStripeSubscriptionId: orgs.stripeSubscriptionId, + orgStripeCurrentPeriodEnd: orgs.stripeCurrentPeriodEnd, }) .from(orgs) .innerJoin(orgUsers, eq(orgs.id, orgUsers.orgId)) @@ -174,6 +178,8 @@ export async function getOrgsForUser(userId: string): Promise { name: orgs.name, plan: orgs.plan, stripeCustomerId: orgs.stripeCustomerId, + stripeSubscriptionId: orgs.stripeSubscriptionId, + stripeCurrentPeriodEnd: orgs.stripeCurrentPeriodEnd, createdAt: orgs.createdAt, updatedAt: orgs.updatedAt, }) diff --git a/packages/web/lib/plan-gating.ts b/packages/web/lib/plan-gating.ts new file mode 100644 index 0000000..df11085 --- /dev/null +++ b/packages/web/lib/plan-gating.ts @@ -0,0 +1,87 @@ +import { db } from "@/db"; +import { eq, sql } from "drizzle-orm"; +import { orgs } from "shared/src/db/schema"; +import { + Plan, + getPlanLimits, + canAddCalendar, + canAddDestination, +} from "shared/src/types/plan"; + +export interface UsageStats { + calendarsCount: number; + destinationsCount: number; + maxCalendars: number; + maxDestinations: number; + syncIntervalMinutes: number; + hasPrioritySupport: boolean; +} + +export async function getOrgUsageStats(orgId: string): Promise { + // Get org plan + const [orgRecord] = await db() + .select({ plan: orgs.plan }) + .from(orgs) + .where(eq(orgs.id, orgId)); + + const plan = orgRecord?.plan || Plan.FREE; + const limits = getPlanLimits(plan); + + // TODO: Once calendars and destinations tables are created, query actual counts + // For now, return mock counts of 0 + const calendarsCount = 0; + const destinationsCount = 0; + + return { + calendarsCount, + destinationsCount, + maxCalendars: limits.maxCalendars, + maxDestinations: limits.maxDestinations, + syncIntervalMinutes: limits.syncIntervalMinutes, + hasPrioritySupport: limits.hasPrioritySupport, + }; +} + +export async function checkCanAddCalendar( + orgId: string +): Promise<{ allowed: boolean; reason?: string }> { + const stats = await getOrgUsageStats(orgId); + + if (stats.calendarsCount >= stats.maxCalendars) { + return { + allowed: false, + reason: `You've reached the maximum of ${stats.maxCalendars} calendar${stats.maxCalendars === 1 ? "" : "s"} on your current plan. Upgrade to Pro for unlimited calendars.`, + }; + } + + return { allowed: true }; +} + +export async function checkCanAddDestination( + orgId: string +): Promise<{ allowed: boolean; reason?: string }> { + const stats = await getOrgUsageStats(orgId); + + if (stats.destinationsCount >= stats.maxDestinations) { + return { + allowed: false, + reason: `You've reached the maximum of ${stats.maxDestinations} destination${stats.maxDestinations === 1 ? "" : "s"} on your current plan. Upgrade to Pro for unlimited destinations.`, + }; + } + + return { allowed: true }; +} + +export function formatSyncInterval(minutes: number): string { + if (minutes >= 1440) { + const days = Math.floor(minutes / 1440); + return `${days} day${days === 1 ? "" : "s"}`; + } else if (minutes >= 60) { + const hours = Math.floor(minutes / 60); + return `${hours} hour${hours === 1 ? "" : "s"}`; + } else { + return `${minutes} minute${minutes === 1 ? "" : "s"}`; + } +} + +export { canAddCalendar, canAddDestination, getPlanLimits } from "shared/src/types/plan"; diff --git a/packages/web/pages/api/webhooks/stripe/index.ts b/packages/web/pages/api/webhooks/stripe/index.ts index 6ab2b82..2ff93dc 100644 --- a/packages/web/pages/api/webhooks/stripe/index.ts +++ b/packages/web/pages/api/webhooks/stripe/index.ts @@ -21,9 +21,180 @@ export const config = { ], }; +async function handleSubscriptionCreated(event: Stripe.Event) { + const subscription = event.data.object as Stripe.Subscription; + const orgId = subscription.metadata.org_id; + + if (!orgId) { + console.warn("Subscription created without org_id in metadata"); + return; + } + + const newPlan = stripePriceIdToPlan( + subscription.items.data[0].price.id, + env + ); + + if (!newPlan) { + console.error( + "No matching plan for Stripe Price Id: ", + subscription.items.data[0].price.id + ); + return; + } + + // Update org with subscription details + await db() + .update(orgs) + .set({ + plan: newPlan, + stripeCustomerId: subscription.customer as string, + stripeSubscriptionId: subscription.id, + stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000), + }) + .where(eq(orgs.id, orgId)); + + // Update customer metadata with org_id for future reference + await stripe.customers.update(subscription.customer as string, { + metadata: { org_id: orgId }, + }); + + track(EventNames.ORG_UPGRADED, orgId, { + "org id": orgId, + "stripe customer id": subscription.customer as string, + "subscription id": subscription.id, + "subscription status": subscription.status, + "subscription period end": new Date( + subscription.current_period_end * 1000 + ).toISOString(), + "price id": subscription.items.data[0].price.id, + plan: newPlan, + }); + + console.log(`Subscription created for org ${orgId}, plan: ${newPlan}`); +} + +async function handleSubscriptionUpdated(event: Stripe.Event) { + const subscription = event.data.object as Stripe.Subscription; + const orgId = subscription.metadata.org_id; + + if (!orgId) { + // Check if this is a user subscription + const userId = subscription.metadata.userId; + if (userId) { + await handleUserSubscriptionUpdated(subscription, userId); + return; + } + console.warn("Subscription updated without org_id or userId in metadata"); + return; + } + + // Only process active or trialing subscriptions + if ( + subscription.status !== "active" && + subscription.status !== "trialing" + ) { + console.log( + `Ignoring subscription update with status: ${subscription.status}` + ); + return; + } + + const newPlan = stripePriceIdToPlan( + subscription.items.data[0].price.id, + env + ); + + if (!newPlan) { + console.error( + "No matching plan for Stripe Price Id: ", + subscription.items.data[0].price.id + ); + return; + } + + // Get current org plan to determine if upgrade or downgrade + const [orgRecord] = await db() + .select({ plan: orgs.plan }) + .from(orgs) + .where(eq(orgs.id, orgId)); + + const previousPlan = orgRecord?.plan || Plan.FREE; + + // Update org with new subscription details + await db() + .update(orgs) + .set({ + plan: newPlan, + stripeSubscriptionId: subscription.id, + stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000), + }) + .where(eq(orgs.id, orgId)); + + const isUpgrade = previousPlan === Plan.FREE && newPlan === Plan.PRO; + const eventName = isUpgrade + ? EventNames.ORG_UPGRADED + : EventNames.ORG_SUBSCRIPTION_RENEWED; + + track(eventName, orgId, { + "org id": orgId, + "previous plan": previousPlan, + "new plan": newPlan, + "stripe customer id": subscription.customer as string, + "subscription id": subscription.id, + "subscription status": subscription.status, + "subscription period end": new Date( + subscription.current_period_end * 1000 + ).toISOString(), + "price id": subscription.items.data[0].price.id, + }); + + console.log( + `Subscription updated for org ${orgId}, plan: ${previousPlan} -> ${newPlan}` + ); +} + +async function handleUserSubscriptionUpdated( + subscription: Stripe.Subscription, + userId: string +) { + if ( + subscription.status !== "active" && + subscription.status !== "trialing" + ) { + return; + } + + await db() + .update(users) + .set({ + stripeSubscriptionId: subscription.id, + stripeCustomerId: subscription.customer as string, + stripePriceId: subscription.items.data[0].price.id, + stripeCurrentPeriodEnd: new Date( + subscription.current_period_end * 1000 + ), + }) + .where(eq(users.id, userId)); + + track(EventNames.USER_SUBSCRIPTION_RENEWED, userId, { + "stripe customer id": subscription.customer as string, + "subscription id": subscription.id, + "subscription period end": new Date( + subscription.current_period_end * 1000 + ).toISOString(), + "price id": subscription.items.data[0].price.id, + }); +} + async function handleInvoicePaymentSucceeded(event: Stripe.Event) { const invoice = event.data.object as Stripe.Invoice; + if (!invoice.subscription) { + console.log("Invoice without subscription, skipping"); + return; + } + // Retrieve the subscription details from Stripe. const subscription = await stripe.subscriptions.retrieve( invoice.subscription as string @@ -59,10 +230,20 @@ async function handleInvoicePaymentSucceeded(event: Stripe.Event) { "No matching plan for Stripe Price Id: ", subscription.items.data[0].price.id ); - throw new Error("No ID for subscription given"); + throw new Error("No matching plan found"); } if (isRenewal) { + // Update period end on renewal + await db() + .update(orgs) + .set({ + stripeCurrentPeriodEnd: new Date( + subscription.current_period_end * 1000 + ), + }) + .where(eq(orgs.id, orgId)); + track(EventNames.ORG_SUBSCRIPTION_RENEWED, orgId, { "org id": orgId, "stripe customer id": subscription.customer as string, @@ -74,7 +255,6 @@ async function handleInvoicePaymentSucceeded(event: Stripe.Event) { plan: newPlan, }); - // Nothing to do on org renews since plan value is already written to DB return; } @@ -83,10 +263,13 @@ async function handleInvoicePaymentSucceeded(event: Stripe.Event) { .set({ plan: newPlan, stripeCustomerId: subscription.customer as string, + stripeSubscriptionId: subscription.id, + stripeCurrentPeriodEnd: new Date( + subscription.current_period_end * 1000 + ), }) .where(eq(orgs.id, orgId)); - // TODO: get actual user id track(EventNames.ORG_UPGRADED, orgId, { "org id": orgId, "stripe customer id": subscription.customer as string, @@ -100,9 +283,7 @@ async function handleInvoicePaymentSucceeded(event: Stripe.Event) { } if (userId) { - // Update the user stripe into in our database. - // Since this is the initial subscription, we need to update - // the subscription id and customer id. + // Update the user stripe info in our database. await db() .update(users) .set({ @@ -130,16 +311,81 @@ async function handleInvoicePaymentSucceeded(event: Stripe.Event) { } } +async function handleInvoicePaymentFailed(event: Stripe.Event) { + const invoice = event.data.object as Stripe.Invoice; + + if (!invoice.subscription) { + return; + } + + const subscription = await stripe.subscriptions.retrieve( + invoice.subscription as string + ); + + const orgId = subscription.metadata.org_id; + const userId = subscription.metadata.userId; + + if (orgId) { + // Get org name and admin emails for notification + const [allAdminUserRecords, orgName] = await Promise.all([ + db() + .select({ userId: orgUsers.userId }) + .from(orgUsers) + .where(and(eq(orgUsers.orgId, orgId), eq(orgUsers.role, Role.ADMIN))), + getOrgNameForOrgId(orgId), + ]); + + const adminUsers = await getUserDetails( + env, + allAdminUserRecords.map((member) => member.userId) + ); + + // Send payment failed email to admins + await Promise.all( + Object.keys(adminUsers).map((adminUserId) => + Email.send(env, { + to: adminUsers[adminUserId].email, + from: env.SMTP_FROM, + subject: `[${siteConfig.name}] Payment failed for ${orgName}`, + html: `

    Hello,

    +

    We were unable to process payment for your ${siteConfig.name} subscription for the organization "${orgName}".

    +

    Please update your payment method to continue using Pro features. You can manage your billing here.

    +

    If payment continues to fail, your organization will be downgraded to the Free plan.

    +

    Thanks,

    +

    ${siteConfig.name} team

    `, + }) + ) + ); + + track(EventNames.ORG_DOWNGRADED, orgId, { + "org id": orgId, + reason: "payment_failed", + "stripe customer id": subscription.customer as string, + "subscription id": subscription.id, + }); + } + + if (userId) { + track(EventNames.USER_DOWNGRADED, userId, { + reason: "payment_failed", + "stripe customer id": subscription.customer as string, + "subscription id": subscription.id, + }); + } + + console.log( + `Payment failed for subscription ${subscription.id}, org: ${orgId}, user: ${userId}` + ); +} + async function handleCustomerSubscriptionDeleted(event: Stripe.Event) { const subscription = event.data.object as Stripe.Subscription; const orgId = subscription.metadata.org_id; - if (!orgId) { - if (!!subscription.metadata.user_id) { - // If user_id is set, and orgId is not, then this must be an individual subscription - // Individual subscriptions simply timeout (date is checked upon checking plan type) - // so updating our DB is not necessary for individual users - track(EventNames.USER_DOWNGRADED, subscription.metadata.user_id, { + if (!orgId) { + if (subscription.metadata.userId) { + // Individual user subscription + track(EventNames.USER_DOWNGRADED, subscription.metadata.userId, { "stripe customer id": subscription.customer as string, "subscription id": subscription.id, "subscription period end": new Date( @@ -147,26 +393,35 @@ async function handleCustomerSubscriptionDeleted(event: Stripe.Event) { ).toISOString(), "price id": subscription.items.data[0].price.id, }); - return; } - throw new Error("Subscription for deleted event had no Org Id"); + console.warn("Subscription deleted without org_id or userId in metadata"); + return; } - // TODO when needed: query for different plan type when there are more than 2 const downGradedPlan = Plan.FREE; + // Get current plan before downgrading + const [orgRecord] = await db() + .select({ plan: orgs.plan }) + .from(orgs) + .where(eq(orgs.id, orgId)); + + const previousPlan = orgRecord?.plan || Plan.PRO; + await db() .update(orgs) .set({ plan: downGradedPlan, + stripeSubscriptionId: null, + stripeCurrentPeriodEnd: null, }) .where(eq(orgs.id, orgId)); track(EventNames.ORG_DOWNGRADED, orgId, { "org id": orgId, - "previous plan": Plan.PAID, // TODO: if there are more than 2 plan types, query for this + "previous plan": previousPlan, "new plan": downGradedPlan, "stripe customer id": subscription.customer as string, "subscription id": subscription.id, @@ -176,15 +431,16 @@ async function handleCustomerSubscriptionDeleted(event: Stripe.Event) { "price id": subscription.items.data[0].price.id, }); - // TODO: send a downgrade email + // Send downgrade email to admins const allAdminUserRecords = await db() - .select({ id: orgUsers.id }) + .select({ userId: orgUsers.userId }) .from(orgUsers) .where(and(eq(orgUsers.orgId, orgId), eq(orgUsers.role, Role.ADMIN))); + const [adminUsers, orgName] = await Promise.all([ getUserDetails( env, - allAdminUserRecords.map((member) => member.id) + allAdminUserRecords.map((member) => member.userId) ), getOrgNameForOrgId(orgId), ]); @@ -196,7 +452,13 @@ async function handleCustomerSubscriptionDeleted(event: Stripe.Event) { from: env.SMTP_FROM, subject: `[${siteConfig.name}] ${orgName} organization has been downgraded`, html: `

    Hello,

    -

    The organization, ${orgName}, on ${siteConfig.name}, has been downgraded to the ${downGradedPlan} plan due to it's subscription ending.

    +

    The organization "${orgName}" on ${siteConfig.name} has been downgraded to the Free plan because your subscription has ended.

    +

    With the Free plan, you're limited to:

    +
      +
    • 1 calendar connection
    • +
    • 1 destination
    • +
    • Daily sync only
    • +

    If you'd like to review billing details or subscribe again, you can view billing here.

    You are receiving this email because you are an Admin member of this organization.

    Thanks & we hope you'll consider upgrading again,

    @@ -204,6 +466,8 @@ async function handleCustomerSubscriptionDeleted(event: Stripe.Event) { }) ) ); + + console.log(`Subscription deleted for org ${orgId}, downgraded to Free`); } const webCrypto = Stripe.createSubtleCryptoProvider(); @@ -230,16 +494,33 @@ export default async function handler(req: NextRequest) { return new NextResponse(`Webhook Error: ${error.message}`, { status: 400 }); } - console.log(`Recevied Stripe Webhook event: ${event.type}`); + console.log(`Received Stripe Webhook event: ${event.type}`); - if (event.type === "invoice.payment_succeeded") { - await handleInvoicePaymentSucceeded(event); - } else if (event.type === "customer.subscription.deleted") { - await handleCustomerSubscriptionDeleted(event); - } else { - console.warn( - `Received unexpected Stripe Webhook event type: ${event.type}` - ); + try { + switch (event.type) { + case "customer.subscription.created": + await handleSubscriptionCreated(event); + break; + case "customer.subscription.updated": + await handleSubscriptionUpdated(event); + break; + case "customer.subscription.deleted": + await handleCustomerSubscriptionDeleted(event); + break; + case "invoice.payment_succeeded": + await handleInvoicePaymentSucceeded(event); + break; + case "invoice.payment_failed": + await handleInvoicePaymentFailed(event); + break; + default: + console.log(`Unhandled Stripe Webhook event type: ${event.type}`); + } + } catch (error) { + console.error(`Error handling Stripe webhook ${event.type}:`, error); + return new NextResponse(`Webhook handler error: ${error.message}`, { + status: 500, + }); } return new NextResponse(null);