diff --git a/.env.example b/.env.example index 255d57e..ef366b5 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,11 @@ NODE_ENV=development TRUST_PROXY= ADMIN_EMAILS=corey@blaishome.online RESCUE_STATUS_NOTIFICATION_EMAIL=appadmin@flockpal.app +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_PRICE_HOUSEHOLD_CONURE= +STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK= +STRIPE_PRICE_HOUSEHOLD_MACAW= +STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/?billing=success +STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled +STRIPE_PORTAL_RETURN_URL=http://localhost:3000/ diff --git a/FlockPal-transparent.png b/FlockPal-transparent.png index 31283c0..ae21a42 100644 Binary files a/FlockPal-transparent.png and b/FlockPal-transparent.png differ diff --git a/README.md b/README.md index a12791f..ddae69d 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ FlockPal is a Dockerized TypeScript app for tracking flock health with a clean, - Multi-flock model with `standard` household and `rescue` modes - Shared flock member management for both households and rescues - Separate per-flock billing plan foundation with `rescue_free`, `household_basic`, `household_plus`, and `household_macaw` +- Stripe-ready per-flock billing identifiers so one account can manage multiple paid flock subscriptions - Bird profiles with name, tag ID, and species - Bird DOB and gotcha day fields - Daily weight recordings @@ -94,8 +95,23 @@ Set these if you want magic links delivered by email instead of logged as a prev - `SMTP_FROM_EMAIL` - `SMTP_FROM_NAME` +## Stripe billing environment + +Set these when the Stripe Checkout, Customer Portal, and webhook flow is enabled: + +- `STRIPE_SECRET_KEY` +- `STRIPE_WEBHOOK_SECRET` +- `STRIPE_PRICE_HOUSEHOLD_CONURE` +- `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK` +- `STRIPE_PRICE_HOUSEHOLD_MACAW` +- `STRIPE_CHECKOUT_SUCCESS_URL` +- `STRIPE_CHECKOUT_CANCEL_URL` +- `STRIPE_PORTAL_RETURN_URL` + ## Notes for monetization and security -This starter now includes the account and flock foundation for monetization, but it still needs production-grade session hardening, invitation verification, billing integration, audit logging, and background reminder delivery before launch. +This starter now includes the account and flock foundation for monetization, but it still needs production-grade session hardening, invitation verification, Stripe checkout/customer portal/webhook flows, audit logging, and background reminder delivery before launch. + +Stripe billing should be attached to `workspaces`, not `users`. Each flock has its own billing plan, subscription status, Stripe customer ID, and Stripe subscription ID, which lets one person own multiple household flocks with separate subscriptions while rescue flocks can stay on the free rescue path. For account design, `standard` vs `rescue` is best treated as a flock type, not as a user role. If paid plans are added later, a separate `admin account mode` is usually less flexible than flock roles such as `owner`, `assistant`, `caregiver`, and `viewer`. That lets the same underlying account system work for both households and rescues without splitting product logic into unrelated account classes. diff --git a/backend/package-lock.json b/backend/package-lock.json index 4b7ddd0..f080828 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -17,6 +17,7 @@ "morgan": "1.10.0", "nodemailer": "^8.0.5", "pg": "8.13.1", + "stripe": "^22.0.2", "zod": "3.24.1" }, "devDependencies": { @@ -1767,6 +1768,23 @@ "node": ">= 0.8" } }, + "node_modules/stripe": { + "version": "22.0.2", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.0.2.tgz", + "integrity": "sha512-2/BLrQ3oB1zlNfeL/LfHFjTGx6EQn0j+ztrrTJHuDjV5VVIpk92oSDaxyKLUr3pG3dnee2LZqhFUv2Bf0G1/3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index 874b4a0..3dfee60 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,6 +19,7 @@ "morgan": "1.10.0", "nodemailer": "^8.0.5", "pg": "8.13.1", + "stripe": "^22.0.2", "zod": "3.24.1" }, "devDependencies": { diff --git a/backend/src/app.ts b/backend/src/app.ts index 1449972..fdd31d9 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -6,6 +6,7 @@ import rateLimit from 'express-rate-limit'; import helmet from 'helmet'; import morgan from 'morgan'; import nodemailer from 'nodemailer'; +import Stripe from 'stripe'; import { z } from 'zod'; import { ensureSchema } from './db/schema.js'; @@ -60,6 +61,9 @@ import { listRescueWorkspacesForAdmin, listMembershipsForUser, listWorkspaceMembers, + setWorkspaceStripeCustomerId, + setWorkspaceStripeSubscription, + setWorkspaceSubscriptionStatusByStripeSubscriptionId, updateRescueVerificationStatus, updateWorkspace, upsertWorkspaceMember, @@ -72,6 +76,7 @@ import type { IntegrationTokenRow, ProviderKey, RescueVerificationStatus, + SubscriptionStatus, UserRow, VetVisitRow, WeightRow, @@ -231,6 +236,17 @@ const smtpPass = process.env.SMTP_PASS?.trim() ?? ''; const smtpFromEmail = process.env.SMTP_FROM_EMAIL?.trim() ?? ''; const smtpFromName = process.env.SMTP_FROM_NAME?.trim() || 'FlockPal'; const rescueStatusNotificationEmail = process.env.RESCUE_STATUS_NOTIFICATION_EMAIL?.trim() || 'appadmin@flockpal.app'; +const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim() ?? ''; +const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET?.trim() ?? ''; +const stripeCheckoutSuccessUrl = process.env.STRIPE_CHECKOUT_SUCCESS_URL?.trim() || `${frontendBaseUrl}/?billing=success`; +const stripeCheckoutCancelUrl = process.env.STRIPE_CHECKOUT_CANCEL_URL?.trim() || `${frontendBaseUrl}/?billing=cancelled`; +const stripePortalReturnUrl = process.env.STRIPE_PORTAL_RETURN_URL?.trim() || frontendBaseUrl; +const stripePriceByBillingPlan: Partial> = { + household_basic: process.env.STRIPE_PRICE_HOUSEHOLD_CONURE?.trim() ?? '', + household_plus: process.env.STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK?.trim() ?? '', + household_macaw: process.env.STRIPE_PRICE_HOUSEHOLD_MACAW?.trim() ?? '', +}; +const stripe = stripeSecretKey ? new Stripe(stripeSecretKey) : null; const adminEmails = new Set( (process.env.ADMIN_EMAILS ?? '') .split(',') @@ -271,6 +287,8 @@ const normalizeWorkspace = (row: WorkspaceRow) => ({ billingEmail: row.billing_email, billingPlan: row.billing_plan, subscriptionStatus: row.subscription_status, + stripeCustomerId: row.stripe_customer_id, + stripeSubscriptionId: row.stripe_subscription_id, rescueVerificationStatus: row.rescue_verification_status, createdAt: row.created_at, updatedAt: row.updated_at, @@ -402,6 +420,74 @@ app.use( legacyHeaders: false, }), ); +app.post('/api/billing/stripe/webhook', express.raw({ type: 'application/json' }), async (req: Request, res: Response) => { + if (!stripeWebhookSecret) { + res.status(503).json({ error: 'Stripe webhook is not configured.' }); + return; + } + + const signature = req.headers['stripe-signature']; + + if (!signature) { + res.status(400).json({ error: 'Missing Stripe signature.' }); + return; + } + + let event: Stripe.Event; + + try { + event = getStripeClient().webhooks.constructEvent(req.body, signature, stripeWebhookSecret); + } catch (error) { + res.status(400).json({ error: error instanceof Error ? error.message : 'Invalid Stripe webhook signature.' }); + return; + } + + try { + if (event.type === 'checkout.session.completed') { + const session = event.data.object as Stripe.Checkout.Session; + const workspaceId = Number(session.metadata?.workspaceId ?? session.client_reference_id ?? 0); + const subscriptionId = typeof session.subscription === 'string' ? session.subscription : session.subscription?.id; + const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id; + + if (workspaceId && subscriptionId) { + const subscription = await getStripeClient().subscriptions.retrieve(subscriptionId); + await setWorkspaceStripeSubscription({ + workspaceId, + stripeCustomerId: customerId ?? (typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id), + stripeSubscriptionId: subscription.id, + subscriptionStatus: mapStripeSubscriptionStatus(subscription.status), + }); + } + } + + if ( + event.type === 'customer.subscription.created' || + event.type === 'customer.subscription.updated' || + event.type === 'customer.subscription.deleted' + ) { + const subscription = event.data.object as Stripe.Subscription; + const workspaceId = Number(subscription.metadata?.workspaceId ?? 0); + const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id; + const subscriptionStatus = mapStripeSubscriptionStatus(subscription.status); + + if (workspaceId) { + await setWorkspaceStripeSubscription({ + workspaceId, + stripeCustomerId: customerId, + stripeSubscriptionId: subscription.id, + subscriptionStatus, + }); + } else { + await setWorkspaceSubscriptionStatusByStripeSubscriptionId(subscription.id, subscriptionStatus); + } + } + + res.json({ received: true }); + } catch (error) { + console.error('Stripe webhook handling failed', error); + res.status(500).json({ error: 'Unable to process Stripe webhook.' }); + } +}); app.use(express.json({ limit: '2mb' })); app.use(express.urlencoded({ extended: false })); app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev')); @@ -416,6 +502,8 @@ const normalizeWorkspaceMembershipList = async (userId: string) => billing_email: row.billing_email, billing_plan: row.billing_plan, subscription_status: row.subscription_status, + stripe_customer_id: row.stripe_customer_id, + stripe_subscription_id: row.stripe_subscription_id, rescue_verification_status: row.rescue_verification_status, created_at: row.workspace_created_at, updated_at: row.workspace_updated_at, @@ -432,6 +520,32 @@ const subscriptionAllowsWrite = (workspace: WorkspaceRow) => { return workspace.subscription_status === 'active' || workspace.subscription_status === 'trialing'; }; +const mapStripeSubscriptionStatus = (status: Stripe.Subscription.Status): SubscriptionStatus => { + if (status === 'active' || status === 'trialing' || status === 'past_due' || status === 'canceled' || status === 'unpaid') { + return status; + } + + return 'none'; +}; + +const getStripeClient = () => { + if (!stripe) { + throw new Error('Stripe is not configured.'); + } + + return stripe; +}; + +const getStripePriceIdForBillingPlan = (billingPlan: BillingPlan) => { + const priceId = stripePriceByBillingPlan[billingPlan]?.trim() ?? ''; + + if (!priceId) { + throw new Error(`Stripe price is not configured for ${billingPlan}.`); + } + + return priceId; +}; + const createAuthSession = async (userId: string, activeWorkspaceId: number) => { const token = createSessionToken(); const tokenHash = hashToken(token); @@ -1113,6 +1227,106 @@ app.patch('/api/admin/rescue-workspaces/:workspaceId', requireAuth, requireSessi } }); +app.post( + '/api/billing/checkout-session', + requireAuth, + requireSessionAuth, + requireWorkspaceRole(['owner', 'assistant']), + async (req: Request, res: Response, next: NextFunction) => { + const parsed = z.object({ billingPlan: billingPlanSchema.optional() }).safeParse(req.body ?? {}); + + if (!parsed.success) { + res.status(400).json({ error: 'Invalid billing payload', details: parsed.error.flatten() }); + return; + } + + try { + const workspace = req.auth!.workspace; + + if (workspace.workspace_type === 'rescue') { + res.status(400).json({ error: 'Rescue flocks do not use Stripe billing.' }); + return; + } + + const billingPlan = parsed.data.billingPlan ?? workspace.billing_plan; + const priceId = getStripePriceIdForBillingPlan(billingPlan); + let stripeCustomerId = workspace.stripe_customer_id; + + if (!stripeCustomerId) { + const customer = await getStripeClient().customers.create({ + email: workspace.billing_email ?? req.auth!.user.email, + name: workspace.name, + metadata: { + workspaceId: String(workspace.id), + userId: req.auth!.user.id, + }, + }); + stripeCustomerId = customer.id; + await setWorkspaceStripeCustomerId(workspace.id, stripeCustomerId); + } + + const checkoutSession = await getStripeClient().checkout.sessions.create({ + mode: 'subscription', + customer: stripeCustomerId, + client_reference_id: String(workspace.id), + line_items: [{ price: priceId, quantity: 1 }], + success_url: stripeCheckoutSuccessUrl, + cancel_url: stripeCheckoutCancelUrl, + allow_promotion_codes: true, + metadata: { + workspaceId: String(workspace.id), + billingPlan, + }, + subscription_data: { + metadata: { + workspaceId: String(workspace.id), + billingPlan, + }, + }, + }); + + if (!checkoutSession.url) { + throw new Error('Stripe did not return a checkout URL.'); + } + + res.json({ url: checkoutSession.url }); + } catch (error) { + next(error); + } + }, +); + +app.post( + '/api/billing/portal-session', + requireAuth, + requireSessionAuth, + requireWorkspaceRole(['owner', 'assistant']), + async (req: Request, res: Response, next: NextFunction) => { + try { + const workspace = req.auth!.workspace; + + if (workspace.workspace_type === 'rescue') { + res.status(400).json({ error: 'Rescue flocks do not use Stripe billing.' }); + return; + } + + if (!workspace.stripe_customer_id) { + res.status(409).json({ error: 'Start a subscription before opening the billing portal.' }); + return; + } + + const portalSession = await getStripeClient().billingPortal.sessions.create({ + customer: workspace.stripe_customer_id, + return_url: stripePortalReturnUrl, + }); + + res.json({ url: portalSession.url }); + } catch (error) { + next(error); + } + }, +); + app.get('/api/integration-tokens', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => { try { const tokens = await listIntegrationTokens(req.auth!.user.id, req.auth!.workspace.id); diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 9887e7b..02f16d0 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -19,6 +19,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => { billing_email VARCHAR(255), billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic', subscription_status VARCHAR(32) NOT NULL DEFAULT 'active', + stripe_customer_id VARCHAR(255), + stripe_subscription_id VARCHAR(255), rescue_verification_status VARCHAR(32) NOT NULL DEFAULT 'not_required', created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP @@ -31,8 +33,18 @@ export const ensureSchema = async (database: DatabaseClient = db) => { ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255), ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic', ADD COLUMN IF NOT EXISTS subscription_status VARCHAR(32) NOT NULL DEFAULT 'active', + ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255), + ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255), ADD COLUMN IF NOT EXISTS rescue_verification_status VARCHAR(32) NOT NULL DEFAULT 'not_required'; + CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_stripe_subscription_id + ON workspaces (stripe_subscription_id) + WHERE stripe_subscription_id IS NOT NULL; + + CREATE INDEX IF NOT EXISTS idx_workspaces_stripe_customer_id + ON workspaces (stripe_customer_id) + WHERE stripe_customer_id IS NOT NULL; + UPDATE workspaces SET rescue_verification_status = 'pending' WHERE workspace_type = 'rescue' diff --git a/backend/src/repositories/authRepository.ts b/backend/src/repositories/authRepository.ts index 920fdd6..99c99d0 100644 --- a/backend/src/repositories/authRepository.ts +++ b/backend/src/repositories/authRepository.ts @@ -39,6 +39,8 @@ const mapSessionAuthRow = ( workspace_billing_email: string | null; workspace_billing_plan: BillingPlan; workspace_subscription_status: SubscriptionStatus; + workspace_stripe_customer_id: string | null; + workspace_stripe_subscription_id: string | null; workspace_rescue_verification_status: RescueVerificationStatus; workspace_created_at: string; workspace_updated_at: string; @@ -75,6 +77,8 @@ const mapSessionAuthRow = ( billing_email: row.workspace_billing_email, billing_plan: row.workspace_billing_plan, subscription_status: row.workspace_subscription_status, + stripe_customer_id: row.workspace_stripe_customer_id, + stripe_subscription_id: row.workspace_stripe_subscription_id, rescue_verification_status: row.workspace_rescue_verification_status, created_at: row.workspace_created_at, updated_at: row.workspace_updated_at, @@ -120,6 +124,8 @@ const mapIntegrationTokenAuthRow = ( workspace_billing_email: string | null; workspace_billing_plan: BillingPlan; workspace_subscription_status: SubscriptionStatus; + workspace_stripe_customer_id: string | null; + workspace_stripe_subscription_id: string | null; workspace_rescue_verification_status: RescueVerificationStatus; workspace_created_at: string; workspace_updated_at: string; @@ -156,6 +162,8 @@ const mapIntegrationTokenAuthRow = ( billing_email: row.workspace_billing_email, billing_plan: row.workspace_billing_plan, subscription_status: row.workspace_subscription_status, + stripe_customer_id: row.workspace_stripe_customer_id, + stripe_subscription_id: row.workspace_stripe_subscription_id, rescue_verification_status: row.workspace_rescue_verification_status, created_at: row.workspace_created_at, updated_at: row.workspace_updated_at, @@ -338,6 +346,8 @@ export const resolveAuth = async (tokenHash: string, token: string) => { workspace_billing_email: string | null; workspace_billing_plan: BillingPlan; workspace_subscription_status: SubscriptionStatus; + workspace_stripe_customer_id: string | null; + workspace_stripe_subscription_id: string | null; workspace_rescue_verification_status: RescueVerificationStatus; workspace_created_at: string; workspace_updated_at: string; @@ -369,6 +379,8 @@ export const resolveAuth = async (tokenHash: string, token: string) => { workspaces.billing_email AS workspace_billing_email, workspaces.billing_plan AS workspace_billing_plan, workspaces.subscription_status AS workspace_subscription_status, + workspaces.stripe_customer_id AS workspace_stripe_customer_id, + workspaces.stripe_subscription_id AS workspace_stripe_subscription_id, workspaces.rescue_verification_status AS workspace_rescue_verification_status, workspaces.created_at AS workspace_created_at, workspaces.updated_at AS workspace_updated_at, @@ -422,6 +434,8 @@ export const resolveIntegrationTokenAuth = async (tokenHash: string, token: stri workspace_billing_email: string | null; workspace_billing_plan: BillingPlan; workspace_subscription_status: SubscriptionStatus; + workspace_stripe_customer_id: string | null; + workspace_stripe_subscription_id: string | null; workspace_rescue_verification_status: RescueVerificationStatus; workspace_created_at: string; workspace_updated_at: string; @@ -458,6 +472,8 @@ export const resolveIntegrationTokenAuth = async (tokenHash: string, token: stri workspaces.billing_email AS workspace_billing_email, workspaces.billing_plan AS workspace_billing_plan, workspaces.subscription_status AS workspace_subscription_status, + workspaces.stripe_customer_id AS workspace_stripe_customer_id, + workspaces.stripe_subscription_id AS workspace_stripe_subscription_id, workspaces.rescue_verification_status AS workspace_rescue_verification_status, workspaces.created_at AS workspace_created_at, workspaces.updated_at AS workspace_updated_at, diff --git a/backend/src/repositories/workspaceRepository.ts b/backend/src/repositories/workspaceRepository.ts index 7fc0394..9688ecb 100644 --- a/backend/src/repositories/workspaceRepository.ts +++ b/backend/src/repositories/workspaceRepository.ts @@ -1,5 +1,5 @@ import { db } from '../db/client.js'; -import type { BillingPlan, RescueVerificationStatus, UserRow, WorkspaceMemberRow, WorkspaceRow, WorkspaceType } from '../types.js'; +import type { BillingPlan, RescueVerificationStatus, SubscriptionStatus, UserRow, WorkspaceMemberRow, WorkspaceRow, WorkspaceType } from '../types.js'; export const getNextWorkspaceId = async () => { const result = await db.query<{ next_id: number }>('SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM workspaces'); @@ -8,7 +8,7 @@ export const getNextWorkspaceId = async () => { export const getWorkspaceById = async (workspaceId: number) => { const result = await db.query( - `SELECT id, name, workspace_type, billing_email, billing_plan, subscription_status, rescue_verification_status, created_at, updated_at + `SELECT id, name, workspace_type, billing_email, billing_plan, subscription_status, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at FROM workspaces WHERE id = $1`, [workspaceId], @@ -37,6 +37,8 @@ export const listMembershipsForUser = async (userId: string) => { billing_email: string | null; billing_plan: BillingPlan; subscription_status: WorkspaceRow['subscription_status']; + stripe_customer_id: string | null; + stripe_subscription_id: string | null; rescue_verification_status: RescueVerificationStatus; workspace_created_at: string; workspace_updated_at: string; @@ -56,6 +58,8 @@ export const listMembershipsForUser = async (userId: string) => { workspaces.billing_email, workspaces.billing_plan, workspaces.subscription_status, + workspaces.stripe_customer_id, + workspaces.stripe_subscription_id, workspaces.rescue_verification_status, workspaces.created_at AS workspace_created_at, workspaces.updated_at AS workspace_updated_at @@ -217,7 +221,7 @@ export const updateWorkspace = async ({ updated_at = CURRENT_TIMESTAMP FROM input WHERE workspaces.id = input.workspace_id - RETURNING workspaces.id, workspaces.name, workspaces.workspace_type, workspaces.billing_email, workspaces.billing_plan, workspaces.subscription_status, workspaces.rescue_verification_status, workspaces.created_at, workspaces.updated_at`, + RETURNING workspaces.id, workspaces.name, workspaces.workspace_type, workspaces.billing_email, workspaces.billing_plan, workspaces.subscription_status, workspaces.stripe_customer_id, workspaces.stripe_subscription_id, workspaces.rescue_verification_status, workspaces.created_at, workspaces.updated_at`, [workspaceId, name, workspaceType, billingEmail, billingPlan], ); @@ -252,7 +256,7 @@ export const findAlternateWorkspaceForUser = async (userId: string, excludeWorks export const listOwnedWorkspacesByOwnerEmail = async (ownerEmail: string, excludeWorkspaceId: number) => { const result = await db.query( - `SELECT workspaces.id, workspaces.name, workspaces.workspace_type, workspaces.billing_email, workspaces.billing_plan, workspaces.subscription_status, workspaces.rescue_verification_status, workspaces.created_at, workspaces.updated_at + `SELECT workspaces.id, workspaces.name, workspaces.workspace_type, workspaces.billing_email, workspaces.billing_plan, workspaces.subscription_status, workspaces.stripe_customer_id, workspaces.stripe_subscription_id, workspaces.rescue_verification_status, workspaces.created_at, workspaces.updated_at FROM workspace_members INNER JOIN workspaces ON workspaces.id = workspace_members.workspace_id WHERE LOWER(COALESCE(workspace_members.invite_email, workspace_members.email)) = LOWER($1) @@ -353,6 +357,8 @@ export const listRescueWorkspacesForAdmin = async () => { workspaces.billing_email, workspaces.billing_plan, workspaces.subscription_status, + workspaces.stripe_customer_id, + workspaces.stripe_subscription_id, workspaces.rescue_verification_status, workspaces.created_at, workspaces.updated_at, @@ -398,7 +404,7 @@ export const updateRescueVerificationStatus = async (workspaceId: number, status updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND workspace_type = 'rescue' - RETURNING id, name, workspace_type, billing_email, billing_plan, subscription_status, rescue_verification_status, created_at, updated_at`, + RETURNING id, name, workspace_type, billing_email, billing_plan, subscription_status, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at`, [workspaceId, status], ); @@ -415,13 +421,67 @@ export const cancelRescueVerificationRequest = async (workspaceId: number) => { WHERE id = $1 AND workspace_type = 'rescue' AND rescue_verification_status = 'pending' - RETURNING id, name, workspace_type, billing_email, billing_plan, subscription_status, rescue_verification_status, created_at, updated_at`, + RETURNING id, name, workspace_type, billing_email, billing_plan, subscription_status, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at`, [workspaceId], ); return result.rows[0] ?? null; }; +export const setWorkspaceStripeCustomerId = async (workspaceId: number, stripeCustomerId: string) => { + const result = await db.query( + `UPDATE workspaces + SET stripe_customer_id = $2, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING id, name, workspace_type, billing_email, billing_plan, subscription_status, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at`, + [workspaceId, stripeCustomerId], + ); + + return result.rows[0] ?? null; +}; + +export const setWorkspaceStripeSubscription = async ({ + workspaceId, + stripeCustomerId, + stripeSubscriptionId, + subscriptionStatus, +}: { + workspaceId: number; + stripeCustomerId: string | null; + stripeSubscriptionId: string; + subscriptionStatus: SubscriptionStatus; +}) => { + const result = await db.query( + `UPDATE workspaces + SET stripe_customer_id = COALESCE($2, stripe_customer_id), + stripe_subscription_id = $3, + subscription_status = $4, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING id, name, workspace_type, billing_email, billing_plan, subscription_status, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at`, + [workspaceId, stripeCustomerId, stripeSubscriptionId, subscriptionStatus], + ); + + return result.rows[0] ?? null; +}; + +export const setWorkspaceSubscriptionStatusByStripeSubscriptionId = async ( + stripeSubscriptionId: string, + subscriptionStatus: SubscriptionStatus, +) => { + const result = await db.query( + `UPDATE workspaces + SET subscription_status = $2, + updated_at = CURRENT_TIMESTAMP + WHERE stripe_subscription_id = $1 + RETURNING id, name, workspace_type, billing_email, billing_plan, subscription_status, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at`, + [stripeSubscriptionId, subscriptionStatus], + ); + + return result.rows[0] ?? null; +}; + export const getPlatformAdminSummary = async () => { const result = await db.query<{ total_birds: number; diff --git a/backend/src/types.ts b/backend/src/types.ts index 543c11e..2fd6891 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -22,6 +22,8 @@ export type WorkspaceRow = { billing_email: string | null; billing_plan: BillingPlan; subscription_status: SubscriptionStatus; + stripe_customer_id: string | null; + stripe_subscription_id: string | null; rescue_verification_status: RescueVerificationStatus; created_at: string; updated_at: string; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index dc1b002..e3e686a 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -39,6 +39,14 @@ services: MICROSOFT_CLIENT_SECRET: ${MICROSOFT_CLIENT_SECRET:-} APPLE_CLIENT_ID: ${APPLE_CLIENT_ID:-} APPLE_CLIENT_SECRET: ${APPLE_CLIENT_SECRET:-} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + STRIPE_PRICE_HOUSEHOLD_CONURE: ${STRIPE_PRICE_HOUSEHOLD_CONURE:-} + STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK:-} + STRIPE_PRICE_HOUSEHOLD_MACAW: ${STRIPE_PRICE_HOUSEHOLD_MACAW:-} + STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-${FRONTEND_URL}/?billing=success} + STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-${FRONTEND_URL}/?billing=cancelled} + STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-${FRONTEND_URL}/} SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} SMTP_SECURE: ${SMTP_SECURE:-false} diff --git a/docker-compose.yml b/docker-compose.yml index ecd1373..9756683 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,14 @@ services: MICROSOFT_CLIENT_SECRET: ${MICROSOFT_CLIENT_SECRET:-} APPLE_CLIENT_ID: ${APPLE_CLIENT_ID:-} APPLE_CLIENT_SECRET: ${APPLE_CLIENT_SECRET:-} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + STRIPE_PRICE_HOUSEHOLD_CONURE: ${STRIPE_PRICE_HOUSEHOLD_CONURE:-} + STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK:-} + STRIPE_PRICE_HOUSEHOLD_MACAW: ${STRIPE_PRICE_HOUSEHOLD_MACAW:-} + STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-http://localhost:3000/?billing=success} + STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-http://localhost:3000/?billing=cancelled} + STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-http://localhost:3000/} SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} SMTP_SECURE: ${SMTP_SECURE:-false} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8acb861..a6547a8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -53,6 +53,8 @@ type Workspace = { billingEmail: string | null; billingPlan: BillingPlan; subscriptionStatus: SubscriptionStatus; + stripeCustomerId: string | null; + stripeSubscriptionId: string | null; rescueVerificationStatus: RescueVerificationStatus; createdAt: string; updatedAt: string; @@ -835,6 +837,7 @@ function App() { const [savingBird, setSavingBird] = useState(false); const [savingWorkspace, setSavingWorkspace] = useState(false); const [cancelingRescueRequest, setCancelingRescueRequest] = useState(false); + const [billingRedirecting, setBillingRedirecting] = useState(false); const [savingWorkspaceMember, setSavingWorkspaceMember] = useState(false); const [creatingWorkspace, setCreatingWorkspace] = useState(false); const [deletingWorkspace, setDeletingWorkspace] = useState(false); @@ -2344,6 +2347,68 @@ function App() { } }; + const handleStartBillingCheckout = async () => { + if (!authToken || !workspace) { + return; + } + + setError(''); + setBillingRedirecting(true); + + try { + const response = await apiFetch('/billing/checkout-session', authToken, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ billingPlan: workspace.billingPlan }), + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to start Stripe checkout.')); + } + + const data = (await readJsonSafely<{ url?: string }>(response)) ?? {}; + + if (!data.url) { + throw new Error('Unable to start Stripe checkout.'); + } + + window.location.assign(data.url); + } catch (billingError) { + setError(billingError instanceof Error ? billingError.message : 'Unable to start Stripe checkout.'); + setBillingRedirecting(false); + } + }; + + const handleOpenBillingPortal = async () => { + if (!authToken) { + return; + } + + setError(''); + setBillingRedirecting(true); + + try { + const response = await apiFetch('/billing/portal-session', authToken, { + method: 'POST', + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to open Stripe billing portal.')); + } + + const data = (await readJsonSafely<{ url?: string }>(response)) ?? {}; + + if (!data.url) { + throw new Error('Unable to open Stripe billing portal.'); + } + + window.location.assign(data.url); + } catch (billingError) { + setError(billingError instanceof Error ? billingError.message : 'Unable to open Stripe billing portal.'); + setBillingRedirecting(false); + } + }; + const handleWorkspaceMemberSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(''); @@ -3375,10 +3440,32 @@ function App() { : 'Current bird count in this flock.'} -
- Stripe integration coming soon - Customer portal, payment method management, invoices, and renewal status will appear here. -
+ {workspace?.workspaceType !== 'rescue' ? ( +
+ {workspace?.stripeSubscriptionId ? 'Manage household billing' : 'Start household subscription'} + + {workspace?.stripeSubscriptionId + ? 'Open Stripe to update payment methods, invoices, cancellation, or plan changes for this flock.' + : 'Start Stripe Checkout for this flock. Billing is tracked separately for each household flock.'} + + {activeMembership?.role === 'owner' || activeMembership?.role === 'assistant' ? ( + + ) : ( + Ask a flock owner or assistant to manage billing for this flock. + )} +
+ ) : null} @@ -3582,7 +3669,7 @@ function App() {

Separate flock

-

Add another flock space

+

Add an additional flock