Add stripe integration
This commit is contained in:
@@ -8,3 +8,11 @@ NODE_ENV=development
|
|||||||
TRUST_PROXY=
|
TRUST_PROXY=
|
||||||
ADMIN_EMAILS=corey@blaishome.online
|
ADMIN_EMAILS=corey@blaishome.online
|
||||||
RESCUE_STATUS_NOTIFICATION_EMAIL=appadmin@flockpal.app
|
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 |
@@ -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
|
- Multi-flock model with `standard` household and `rescue` modes
|
||||||
- Shared flock member management for both households and rescues
|
- 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`
|
- 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 profiles with name, tag ID, and species
|
||||||
- Bird DOB and gotcha day fields
|
- Bird DOB and gotcha day fields
|
||||||
- Daily weight recordings
|
- 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_EMAIL`
|
||||||
- `SMTP_FROM_NAME`
|
- `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
|
## 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.
|
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.
|
||||||
|
|||||||
Generated
+18
@@ -17,6 +17,7 @@
|
|||||||
"morgan": "1.10.0",
|
"morgan": "1.10.0",
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.5",
|
||||||
"pg": "8.13.1",
|
"pg": "8.13.1",
|
||||||
|
"stripe": "^22.0.2",
|
||||||
"zod": "3.24.1"
|
"zod": "3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1767,6 +1768,23 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/toidentifier": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"morgan": "1.10.0",
|
"morgan": "1.10.0",
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.5",
|
||||||
"pg": "8.13.1",
|
"pg": "8.13.1",
|
||||||
|
"stripe": "^22.0.2",
|
||||||
"zod": "3.24.1"
|
"zod": "3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import rateLimit from 'express-rate-limit';
|
|||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import morgan from 'morgan';
|
import morgan from 'morgan';
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
|
import Stripe from 'stripe';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { ensureSchema } from './db/schema.js';
|
import { ensureSchema } from './db/schema.js';
|
||||||
@@ -60,6 +61,9 @@ import {
|
|||||||
listRescueWorkspacesForAdmin,
|
listRescueWorkspacesForAdmin,
|
||||||
listMembershipsForUser,
|
listMembershipsForUser,
|
||||||
listWorkspaceMembers,
|
listWorkspaceMembers,
|
||||||
|
setWorkspaceStripeCustomerId,
|
||||||
|
setWorkspaceStripeSubscription,
|
||||||
|
setWorkspaceSubscriptionStatusByStripeSubscriptionId,
|
||||||
updateRescueVerificationStatus,
|
updateRescueVerificationStatus,
|
||||||
updateWorkspace,
|
updateWorkspace,
|
||||||
upsertWorkspaceMember,
|
upsertWorkspaceMember,
|
||||||
@@ -72,6 +76,7 @@ import type {
|
|||||||
IntegrationTokenRow,
|
IntegrationTokenRow,
|
||||||
ProviderKey,
|
ProviderKey,
|
||||||
RescueVerificationStatus,
|
RescueVerificationStatus,
|
||||||
|
SubscriptionStatus,
|
||||||
UserRow,
|
UserRow,
|
||||||
VetVisitRow,
|
VetVisitRow,
|
||||||
WeightRow,
|
WeightRow,
|
||||||
@@ -231,6 +236,17 @@ const smtpPass = process.env.SMTP_PASS?.trim() ?? '';
|
|||||||
const smtpFromEmail = process.env.SMTP_FROM_EMAIL?.trim() ?? '';
|
const smtpFromEmail = process.env.SMTP_FROM_EMAIL?.trim() ?? '';
|
||||||
const smtpFromName = process.env.SMTP_FROM_NAME?.trim() || 'FlockPal';
|
const smtpFromName = process.env.SMTP_FROM_NAME?.trim() || 'FlockPal';
|
||||||
const rescueStatusNotificationEmail = process.env.RESCUE_STATUS_NOTIFICATION_EMAIL?.trim() || 'appadmin@flockpal.app';
|
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(
|
const adminEmails = new Set(
|
||||||
(process.env.ADMIN_EMAILS ?? '')
|
(process.env.ADMIN_EMAILS ?? '')
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -271,6 +287,8 @@ const normalizeWorkspace = (row: WorkspaceRow) => ({
|
|||||||
billingEmail: row.billing_email,
|
billingEmail: row.billing_email,
|
||||||
billingPlan: row.billing_plan,
|
billingPlan: row.billing_plan,
|
||||||
subscriptionStatus: row.subscription_status,
|
subscriptionStatus: row.subscription_status,
|
||||||
|
stripeCustomerId: row.stripe_customer_id,
|
||||||
|
stripeSubscriptionId: row.stripe_subscription_id,
|
||||||
rescueVerificationStatus: row.rescue_verification_status,
|
rescueVerificationStatus: row.rescue_verification_status,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
@@ -402,6 +420,74 @@ app.use(
|
|||||||
legacyHeaders: false,
|
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.json({ limit: '2mb' }));
|
||||||
app.use(express.urlencoded({ extended: false }));
|
app.use(express.urlencoded({ extended: false }));
|
||||||
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
|
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_email: row.billing_email,
|
||||||
billing_plan: row.billing_plan,
|
billing_plan: row.billing_plan,
|
||||||
subscription_status: row.subscription_status,
|
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,
|
rescue_verification_status: row.rescue_verification_status,
|
||||||
created_at: row.workspace_created_at,
|
created_at: row.workspace_created_at,
|
||||||
updated_at: row.workspace_updated_at,
|
updated_at: row.workspace_updated_at,
|
||||||
@@ -432,6 +520,32 @@ const subscriptionAllowsWrite = (workspace: WorkspaceRow) => {
|
|||||||
return workspace.subscription_status === 'active' || workspace.subscription_status === 'trialing';
|
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 createAuthSession = async (userId: string, activeWorkspaceId: number) => {
|
||||||
const token = createSessionToken();
|
const token = createSessionToken();
|
||||||
const tokenHash = hashToken(token);
|
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) => {
|
app.get('/api/integration-tokens', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const tokens = await listIntegrationTokens(req.auth!.user.id, req.auth!.workspace.id);
|
const tokens = await listIntegrationTokens(req.auth!.user.id, req.auth!.workspace.id);
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
billing_email VARCHAR(255),
|
billing_email VARCHAR(255),
|
||||||
billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
|
billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
|
||||||
subscription_status VARCHAR(32) NOT NULL DEFAULT 'active',
|
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',
|
rescue_verification_status VARCHAR(32) NOT NULL DEFAULT 'not_required',
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_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_email VARCHAR(255),
|
||||||
ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
|
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 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';
|
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
|
UPDATE workspaces
|
||||||
SET rescue_verification_status = 'pending'
|
SET rescue_verification_status = 'pending'
|
||||||
WHERE workspace_type = 'rescue'
|
WHERE workspace_type = 'rescue'
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ const mapSessionAuthRow = (
|
|||||||
workspace_billing_email: string | null;
|
workspace_billing_email: string | null;
|
||||||
workspace_billing_plan: BillingPlan;
|
workspace_billing_plan: BillingPlan;
|
||||||
workspace_subscription_status: SubscriptionStatus;
|
workspace_subscription_status: SubscriptionStatus;
|
||||||
|
workspace_stripe_customer_id: string | null;
|
||||||
|
workspace_stripe_subscription_id: string | null;
|
||||||
workspace_rescue_verification_status: RescueVerificationStatus;
|
workspace_rescue_verification_status: RescueVerificationStatus;
|
||||||
workspace_created_at: string;
|
workspace_created_at: string;
|
||||||
workspace_updated_at: string;
|
workspace_updated_at: string;
|
||||||
@@ -75,6 +77,8 @@ const mapSessionAuthRow = (
|
|||||||
billing_email: row.workspace_billing_email,
|
billing_email: row.workspace_billing_email,
|
||||||
billing_plan: row.workspace_billing_plan,
|
billing_plan: row.workspace_billing_plan,
|
||||||
subscription_status: row.workspace_subscription_status,
|
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,
|
rescue_verification_status: row.workspace_rescue_verification_status,
|
||||||
created_at: row.workspace_created_at,
|
created_at: row.workspace_created_at,
|
||||||
updated_at: row.workspace_updated_at,
|
updated_at: row.workspace_updated_at,
|
||||||
@@ -120,6 +124,8 @@ const mapIntegrationTokenAuthRow = (
|
|||||||
workspace_billing_email: string | null;
|
workspace_billing_email: string | null;
|
||||||
workspace_billing_plan: BillingPlan;
|
workspace_billing_plan: BillingPlan;
|
||||||
workspace_subscription_status: SubscriptionStatus;
|
workspace_subscription_status: SubscriptionStatus;
|
||||||
|
workspace_stripe_customer_id: string | null;
|
||||||
|
workspace_stripe_subscription_id: string | null;
|
||||||
workspace_rescue_verification_status: RescueVerificationStatus;
|
workspace_rescue_verification_status: RescueVerificationStatus;
|
||||||
workspace_created_at: string;
|
workspace_created_at: string;
|
||||||
workspace_updated_at: string;
|
workspace_updated_at: string;
|
||||||
@@ -156,6 +162,8 @@ const mapIntegrationTokenAuthRow = (
|
|||||||
billing_email: row.workspace_billing_email,
|
billing_email: row.workspace_billing_email,
|
||||||
billing_plan: row.workspace_billing_plan,
|
billing_plan: row.workspace_billing_plan,
|
||||||
subscription_status: row.workspace_subscription_status,
|
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,
|
rescue_verification_status: row.workspace_rescue_verification_status,
|
||||||
created_at: row.workspace_created_at,
|
created_at: row.workspace_created_at,
|
||||||
updated_at: row.workspace_updated_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_email: string | null;
|
||||||
workspace_billing_plan: BillingPlan;
|
workspace_billing_plan: BillingPlan;
|
||||||
workspace_subscription_status: SubscriptionStatus;
|
workspace_subscription_status: SubscriptionStatus;
|
||||||
|
workspace_stripe_customer_id: string | null;
|
||||||
|
workspace_stripe_subscription_id: string | null;
|
||||||
workspace_rescue_verification_status: RescueVerificationStatus;
|
workspace_rescue_verification_status: RescueVerificationStatus;
|
||||||
workspace_created_at: string;
|
workspace_created_at: string;
|
||||||
workspace_updated_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_email AS workspace_billing_email,
|
||||||
workspaces.billing_plan AS workspace_billing_plan,
|
workspaces.billing_plan AS workspace_billing_plan,
|
||||||
workspaces.subscription_status AS workspace_subscription_status,
|
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.rescue_verification_status AS workspace_rescue_verification_status,
|
||||||
workspaces.created_at AS workspace_created_at,
|
workspaces.created_at AS workspace_created_at,
|
||||||
workspaces.updated_at AS workspace_updated_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_email: string | null;
|
||||||
workspace_billing_plan: BillingPlan;
|
workspace_billing_plan: BillingPlan;
|
||||||
workspace_subscription_status: SubscriptionStatus;
|
workspace_subscription_status: SubscriptionStatus;
|
||||||
|
workspace_stripe_customer_id: string | null;
|
||||||
|
workspace_stripe_subscription_id: string | null;
|
||||||
workspace_rescue_verification_status: RescueVerificationStatus;
|
workspace_rescue_verification_status: RescueVerificationStatus;
|
||||||
workspace_created_at: string;
|
workspace_created_at: string;
|
||||||
workspace_updated_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_email AS workspace_billing_email,
|
||||||
workspaces.billing_plan AS workspace_billing_plan,
|
workspaces.billing_plan AS workspace_billing_plan,
|
||||||
workspaces.subscription_status AS workspace_subscription_status,
|
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.rescue_verification_status AS workspace_rescue_verification_status,
|
||||||
workspaces.created_at AS workspace_created_at,
|
workspaces.created_at AS workspace_created_at,
|
||||||
workspaces.updated_at AS workspace_updated_at,
|
workspaces.updated_at AS workspace_updated_at,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from '../db/client.js';
|
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 () => {
|
export const getNextWorkspaceId = async () => {
|
||||||
const result = await db.query<{ next_id: number }>('SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM workspaces');
|
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) => {
|
export const getWorkspaceById = async (workspaceId: number) => {
|
||||||
const result = await db.query<WorkspaceRow>(
|
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
|
FROM workspaces
|
||||||
WHERE id = $1`,
|
WHERE id = $1`,
|
||||||
[workspaceId],
|
[workspaceId],
|
||||||
@@ -37,6 +37,8 @@ export const listMembershipsForUser = async (userId: string) => {
|
|||||||
billing_email: string | null;
|
billing_email: string | null;
|
||||||
billing_plan: BillingPlan;
|
billing_plan: BillingPlan;
|
||||||
subscription_status: WorkspaceRow['subscription_status'];
|
subscription_status: WorkspaceRow['subscription_status'];
|
||||||
|
stripe_customer_id: string | null;
|
||||||
|
stripe_subscription_id: string | null;
|
||||||
rescue_verification_status: RescueVerificationStatus;
|
rescue_verification_status: RescueVerificationStatus;
|
||||||
workspace_created_at: string;
|
workspace_created_at: string;
|
||||||
workspace_updated_at: string;
|
workspace_updated_at: string;
|
||||||
@@ -56,6 +58,8 @@ export const listMembershipsForUser = async (userId: string) => {
|
|||||||
workspaces.billing_email,
|
workspaces.billing_email,
|
||||||
workspaces.billing_plan,
|
workspaces.billing_plan,
|
||||||
workspaces.subscription_status,
|
workspaces.subscription_status,
|
||||||
|
workspaces.stripe_customer_id,
|
||||||
|
workspaces.stripe_subscription_id,
|
||||||
workspaces.rescue_verification_status,
|
workspaces.rescue_verification_status,
|
||||||
workspaces.created_at AS workspace_created_at,
|
workspaces.created_at AS workspace_created_at,
|
||||||
workspaces.updated_at AS workspace_updated_at
|
workspaces.updated_at AS workspace_updated_at
|
||||||
@@ -217,7 +221,7 @@ export const updateWorkspace = async ({
|
|||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
FROM input
|
FROM input
|
||||||
WHERE workspaces.id = input.workspace_id
|
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],
|
[workspaceId, name, workspaceType, billingEmail, billingPlan],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -252,7 +256,7 @@ export const findAlternateWorkspaceForUser = async (userId: string, excludeWorks
|
|||||||
|
|
||||||
export const listOwnedWorkspacesByOwnerEmail = async (ownerEmail: string, excludeWorkspaceId: number) => {
|
export const listOwnedWorkspacesByOwnerEmail = async (ownerEmail: string, excludeWorkspaceId: number) => {
|
||||||
const result = await db.query<WorkspaceRow>(
|
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
|
FROM workspace_members
|
||||||
INNER JOIN workspaces ON workspaces.id = workspace_members.workspace_id
|
INNER JOIN workspaces ON workspaces.id = workspace_members.workspace_id
|
||||||
WHERE LOWER(COALESCE(workspace_members.invite_email, workspace_members.email)) = LOWER($1)
|
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_email,
|
||||||
workspaces.billing_plan,
|
workspaces.billing_plan,
|
||||||
workspaces.subscription_status,
|
workspaces.subscription_status,
|
||||||
|
workspaces.stripe_customer_id,
|
||||||
|
workspaces.stripe_subscription_id,
|
||||||
workspaces.rescue_verification_status,
|
workspaces.rescue_verification_status,
|
||||||
workspaces.created_at,
|
workspaces.created_at,
|
||||||
workspaces.updated_at,
|
workspaces.updated_at,
|
||||||
@@ -398,7 +404,7 @@ export const updateRescueVerificationStatus = async (workspaceId: number, status
|
|||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_type = 'rescue'
|
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],
|
[workspaceId, status],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -415,13 +421,67 @@ export const cancelRescueVerificationRequest = async (workspaceId: number) => {
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_type = 'rescue'
|
AND workspace_type = 'rescue'
|
||||||
AND rescue_verification_status = 'pending'
|
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],
|
[workspaceId],
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
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 () => {
|
export const getPlatformAdminSummary = async () => {
|
||||||
const result = await db.query<{
|
const result = await db.query<{
|
||||||
total_birds: number;
|
total_birds: number;
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export type WorkspaceRow = {
|
|||||||
billing_email: string | null;
|
billing_email: string | null;
|
||||||
billing_plan: BillingPlan;
|
billing_plan: BillingPlan;
|
||||||
subscription_status: SubscriptionStatus;
|
subscription_status: SubscriptionStatus;
|
||||||
|
stripe_customer_id: string | null;
|
||||||
|
stripe_subscription_id: string | null;
|
||||||
rescue_verification_status: RescueVerificationStatus;
|
rescue_verification_status: RescueVerificationStatus;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ services:
|
|||||||
MICROSOFT_CLIENT_SECRET: ${MICROSOFT_CLIENT_SECRET:-}
|
MICROSOFT_CLIENT_SECRET: ${MICROSOFT_CLIENT_SECRET:-}
|
||||||
APPLE_CLIENT_ID: ${APPLE_CLIENT_ID:-}
|
APPLE_CLIENT_ID: ${APPLE_CLIENT_ID:-}
|
||||||
APPLE_CLIENT_SECRET: ${APPLE_CLIENT_SECRET:-}
|
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_HOST: ${SMTP_HOST:-}
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
SMTP_SECURE: ${SMTP_SECURE:-false}
|
SMTP_SECURE: ${SMTP_SECURE:-false}
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ services:
|
|||||||
MICROSOFT_CLIENT_SECRET: ${MICROSOFT_CLIENT_SECRET:-}
|
MICROSOFT_CLIENT_SECRET: ${MICROSOFT_CLIENT_SECRET:-}
|
||||||
APPLE_CLIENT_ID: ${APPLE_CLIENT_ID:-}
|
APPLE_CLIENT_ID: ${APPLE_CLIENT_ID:-}
|
||||||
APPLE_CLIENT_SECRET: ${APPLE_CLIENT_SECRET:-}
|
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_HOST: ${SMTP_HOST:-}
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
SMTP_SECURE: ${SMTP_SECURE:-false}
|
SMTP_SECURE: ${SMTP_SECURE:-false}
|
||||||
|
|||||||
+90
-3
@@ -53,6 +53,8 @@ type Workspace = {
|
|||||||
billingEmail: string | null;
|
billingEmail: string | null;
|
||||||
billingPlan: BillingPlan;
|
billingPlan: BillingPlan;
|
||||||
subscriptionStatus: SubscriptionStatus;
|
subscriptionStatus: SubscriptionStatus;
|
||||||
|
stripeCustomerId: string | null;
|
||||||
|
stripeSubscriptionId: string | null;
|
||||||
rescueVerificationStatus: RescueVerificationStatus;
|
rescueVerificationStatus: RescueVerificationStatus;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -835,6 +837,7 @@ function App() {
|
|||||||
const [savingBird, setSavingBird] = useState(false);
|
const [savingBird, setSavingBird] = useState(false);
|
||||||
const [savingWorkspace, setSavingWorkspace] = useState(false);
|
const [savingWorkspace, setSavingWorkspace] = useState(false);
|
||||||
const [cancelingRescueRequest, setCancelingRescueRequest] = useState(false);
|
const [cancelingRescueRequest, setCancelingRescueRequest] = useState(false);
|
||||||
|
const [billingRedirecting, setBillingRedirecting] = useState(false);
|
||||||
const [savingWorkspaceMember, setSavingWorkspaceMember] = useState(false);
|
const [savingWorkspaceMember, setSavingWorkspaceMember] = useState(false);
|
||||||
const [creatingWorkspace, setCreatingWorkspace] = useState(false);
|
const [creatingWorkspace, setCreatingWorkspace] = useState(false);
|
||||||
const [deletingWorkspace, setDeletingWorkspace] = 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>) => {
|
const handleWorkspaceMemberSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
@@ -3375,10 +3440,32 @@ function App() {
|
|||||||
: 'Current bird count in this flock.'}
|
: 'Current bird count in this flock.'}
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
|
{workspace?.workspaceType !== 'rescue' ? (
|
||||||
<article className="summary-card">
|
<article className="summary-card">
|
||||||
<strong>Stripe integration coming soon</strong>
|
<strong>{workspace?.stripeSubscriptionId ? 'Manage household billing' : 'Start household subscription'}</strong>
|
||||||
<span>Customer portal, payment method management, invoices, and renewal status will appear here.</span>
|
<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>
|
</article>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -3582,7 +3669,7 @@ function App() {
|
|||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Separate flock</p>
|
<p className="eyebrow">Separate flock</p>
|
||||||
<h2>Add another flock space</h2>
|
<h2>Add an additional flock</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
|
|||||||
Reference in New Issue
Block a user