Add stripe integration

This commit is contained in:
Corey Blais
2026-04-16 20:25:21 -04:00
parent 3a0e30085c
commit c757132cbd
13 changed files with 462 additions and 12 deletions
+8
View File
@@ -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/
Binary file not shown.

Before

Width:  |  Height:  |  Size: 695 KiB

After

Width:  |  Height:  |  Size: 679 KiB

+17 -1
View File
@@ -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.
+18
View File
@@ -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",
+1
View File
@@ -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": {
+214
View File
@@ -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<Record<BillingPlan, string>> = {
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);
+12
View File
@@ -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'
@@ -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,
@@ -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<WorkspaceRow>(
`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<WorkspaceRow>(
`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<WorkspaceRow>(
`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<WorkspaceRow>(
`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<WorkspaceRow>(
`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;
+2
View File
@@ -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;
+8
View File
@@ -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}
+8
View File
@@ -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}
+90 -3
View File
@@ -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<HTMLFormElement>) => {
event.preventDefault();
setError('');
@@ -3375,10 +3440,32 @@ function App() {
: 'Current bird count in this flock.'}
</span>
</article>
{workspace?.workspaceType !== 'rescue' ? (
<article className="summary-card">
<strong>Stripe integration coming soon</strong>
<span>Customer portal, payment method management, invoices, and renewal status will appear here.</span>
<strong>{workspace?.stripeSubscriptionId ? 'Manage household billing' : 'Start household subscription'}</strong>
<span>
{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.'}
</span>
{activeMembership?.role === 'owner' || activeMembership?.role === 'assistant' ? (
<button
className="secondary-button"
type="button"
onClick={workspace?.stripeSubscriptionId ? handleOpenBillingPortal : handleStartBillingCheckout}
disabled={billingRedirecting || !workspace}
>
{billingRedirecting
? 'Opening Stripe...'
: workspace?.stripeSubscriptionId
? 'Manage billing'
: 'Start subscription'}
</button>
) : (
<small className="muted">Ask a flock owner or assistant to manage billing for this flock.</small>
)}
</article>
) : null}
</div>
</article>
@@ -3582,7 +3669,7 @@ function App() {
<div className="panel-header">
<div>
<p className="eyebrow">Separate flock</p>
<h2>Add another flock space</h2>
<h2>Add an additional flock</h2>
</div>
<button
className="secondary-button"