additional stripe changes and Billing info cleanup
This commit is contained in:
@@ -11,8 +11,14 @@ RESCUE_STATUS_NOTIFICATION_EMAIL=appadmin@flockpal.app
|
|||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
STRIPE_PRICE_HOUSEHOLD_CONURE=
|
STRIPE_PRICE_HOUSEHOLD_CONURE=
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_CONURE_MONTHLY=
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY=
|
||||||
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK=
|
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK=
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY=
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY=
|
||||||
STRIPE_PRICE_HOUSEHOLD_MACAW=
|
STRIPE_PRICE_HOUSEHOLD_MACAW=
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY=
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY=
|
||||||
STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/?billing=success
|
STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/?billing=success
|
||||||
STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled
|
STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled
|
||||||
STRIPE_PORTAL_RETURN_URL=http://localhost:3000/
|
STRIPE_PORTAL_RETURN_URL=http://localhost:3000/
|
||||||
|
|||||||
@@ -101,9 +101,12 @@ Set these when the Stripe Checkout, Customer Portal, and webhook flow is enabled
|
|||||||
|
|
||||||
- `STRIPE_SECRET_KEY`
|
- `STRIPE_SECRET_KEY`
|
||||||
- `STRIPE_WEBHOOK_SECRET`
|
- `STRIPE_WEBHOOK_SECRET`
|
||||||
- `STRIPE_PRICE_HOUSEHOLD_CONURE`
|
- `STRIPE_PRICE_HOUSEHOLD_CONURE_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_CONURE`
|
||||||
- `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK`
|
- `STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY`
|
||||||
- `STRIPE_PRICE_HOUSEHOLD_MACAW`
|
- `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK`
|
||||||
|
- `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY`
|
||||||
|
- `STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_MACAW`
|
||||||
|
- `STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY`
|
||||||
- `STRIPE_CHECKOUT_SUCCESS_URL`
|
- `STRIPE_CHECKOUT_SUCCESS_URL`
|
||||||
- `STRIPE_CHECKOUT_CANCEL_URL`
|
- `STRIPE_CHECKOUT_CANCEL_URL`
|
||||||
- `STRIPE_PORTAL_RETURN_URL`
|
- `STRIPE_PORTAL_RETURN_URL`
|
||||||
|
|||||||
+72
-10
@@ -70,6 +70,7 @@ import {
|
|||||||
} from './repositories/workspaceRepository.js';
|
} from './repositories/workspaceRepository.js';
|
||||||
import type {
|
import type {
|
||||||
AuthContext,
|
AuthContext,
|
||||||
|
BillingInterval,
|
||||||
BillingPlan,
|
BillingPlan,
|
||||||
BirdGender,
|
BirdGender,
|
||||||
BirdRow,
|
BirdRow,
|
||||||
@@ -140,6 +141,7 @@ const switchWorkspaceSchema = z.object({
|
|||||||
const workspaceTypeSchema = z.enum(['standard', 'rescue']);
|
const workspaceTypeSchema = z.enum(['standard', 'rescue']);
|
||||||
const workspaceRoleSchema = z.enum(['owner', 'assistant', 'caregiver', 'viewer']);
|
const workspaceRoleSchema = z.enum(['owner', 'assistant', 'caregiver', 'viewer']);
|
||||||
const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw']);
|
const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw']);
|
||||||
|
const billingIntervalSchema = z.enum(['monthly', 'yearly']);
|
||||||
const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']);
|
const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']);
|
||||||
const birdGenderSchema = z.enum(['unknown', 'male', 'female']);
|
const birdGenderSchema = z.enum(['unknown', 'male', 'female']);
|
||||||
const rescueVerificationStatusSchema = z.enum(['pending', 'approved', 'rejected']);
|
const rescueVerificationStatusSchema = z.enum(['pending', 'approved', 'rejected']);
|
||||||
@@ -149,6 +151,7 @@ const workspaceSchema = z.object({
|
|||||||
workspaceType: workspaceTypeSchema,
|
workspaceType: workspaceTypeSchema,
|
||||||
billingEmail: z.string().trim().email().max(255).optional().or(z.literal('')),
|
billingEmail: z.string().trim().email().max(255).optional().or(z.literal('')),
|
||||||
billingPlan: billingPlanSchema.optional(),
|
billingPlan: billingPlanSchema.optional(),
|
||||||
|
billingInterval: billingIntervalSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createWorkspaceSchema = z.object({
|
const createWorkspaceSchema = z.object({
|
||||||
@@ -156,6 +159,7 @@ const createWorkspaceSchema = z.object({
|
|||||||
workspaceType: workspaceTypeSchema,
|
workspaceType: workspaceTypeSchema,
|
||||||
billingEmail: z.string().trim().email().max(255).optional().or(z.literal('')),
|
billingEmail: z.string().trim().email().max(255).optional().or(z.literal('')),
|
||||||
billingPlan: billingPlanSchema.optional(),
|
billingPlan: billingPlanSchema.optional(),
|
||||||
|
billingInterval: billingIntervalSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const workspaceMemberSchema = z.object({
|
const workspaceMemberSchema = z.object({
|
||||||
@@ -241,10 +245,22 @@ const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET?.trim() ?? '';
|
|||||||
const stripeCheckoutSuccessUrl = process.env.STRIPE_CHECKOUT_SUCCESS_URL?.trim() || `${frontendBaseUrl}/?billing=success`;
|
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 stripeCheckoutCancelUrl = process.env.STRIPE_CHECKOUT_CANCEL_URL?.trim() || `${frontendBaseUrl}/?billing=cancelled`;
|
||||||
const stripePortalReturnUrl = process.env.STRIPE_PORTAL_RETURN_URL?.trim() || frontendBaseUrl;
|
const stripePortalReturnUrl = process.env.STRIPE_PORTAL_RETURN_URL?.trim() || frontendBaseUrl;
|
||||||
const stripePriceByBillingPlan: Partial<Record<BillingPlan, string>> = {
|
const stripePriceByBillingPlanAndInterval: Partial<Record<Exclude<BillingPlan, 'rescue_free'>, Partial<Record<BillingInterval, string>>>> = {
|
||||||
household_basic: process.env.STRIPE_PRICE_HOUSEHOLD_CONURE?.trim() ?? '',
|
household_basic: {
|
||||||
household_plus: process.env.STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK?.trim() ?? '',
|
monthly: process.env.STRIPE_PRICE_HOUSEHOLD_CONURE_MONTHLY?.trim() || process.env.STRIPE_PRICE_HOUSEHOLD_CONURE?.trim() || '',
|
||||||
household_macaw: process.env.STRIPE_PRICE_HOUSEHOLD_MACAW?.trim() ?? '',
|
yearly: process.env.STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY?.trim() ?? '',
|
||||||
|
},
|
||||||
|
household_plus: {
|
||||||
|
monthly:
|
||||||
|
process.env.STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY?.trim() ||
|
||||||
|
process.env.STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK?.trim() ||
|
||||||
|
'',
|
||||||
|
yearly: process.env.STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY?.trim() ?? '',
|
||||||
|
},
|
||||||
|
household_macaw: {
|
||||||
|
monthly: process.env.STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY?.trim() || process.env.STRIPE_PRICE_HOUSEHOLD_MACAW?.trim() || '',
|
||||||
|
yearly: process.env.STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY?.trim() ?? '',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const stripe = stripeSecretKey ? new Stripe(stripeSecretKey) : null;
|
const stripe = stripeSecretKey ? new Stripe(stripeSecretKey) : null;
|
||||||
const adminEmails = new Set(
|
const adminEmails = new Set(
|
||||||
@@ -286,6 +302,7 @@ const normalizeWorkspace = (row: WorkspaceRow) => ({
|
|||||||
workspaceType: row.workspace_type,
|
workspaceType: row.workspace_type,
|
||||||
billingEmail: row.billing_email,
|
billingEmail: row.billing_email,
|
||||||
billingPlan: row.billing_plan,
|
billingPlan: row.billing_plan,
|
||||||
|
billingInterval: row.billing_interval,
|
||||||
subscriptionStatus: row.subscription_status,
|
subscriptionStatus: row.subscription_status,
|
||||||
stripeCustomerId: row.stripe_customer_id,
|
stripeCustomerId: row.stripe_customer_id,
|
||||||
stripeSubscriptionId: row.stripe_subscription_id,
|
stripeSubscriptionId: row.stripe_subscription_id,
|
||||||
@@ -451,11 +468,14 @@ app.post('/api/billing/stripe/webhook', express.raw({ type: 'application/json' }
|
|||||||
|
|
||||||
if (workspaceId && subscriptionId) {
|
if (workspaceId && subscriptionId) {
|
||||||
const subscription = await getStripeClient().subscriptions.retrieve(subscriptionId);
|
const subscription = await getStripeClient().subscriptions.retrieve(subscriptionId);
|
||||||
|
const billingSelection = getBillingSelectionForStripeSubscription(subscription);
|
||||||
await setWorkspaceStripeSubscription({
|
await setWorkspaceStripeSubscription({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
stripeCustomerId: customerId ?? (typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id),
|
stripeCustomerId: customerId ?? (typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id),
|
||||||
stripeSubscriptionId: subscription.id,
|
stripeSubscriptionId: subscription.id,
|
||||||
subscriptionStatus: mapStripeSubscriptionStatus(subscription.status),
|
subscriptionStatus: mapStripeSubscriptionStatus(subscription.status),
|
||||||
|
billingPlan: billingSelection.billingPlan,
|
||||||
|
billingInterval: billingSelection.billingInterval,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -469,6 +489,7 @@ app.post('/api/billing/stripe/webhook', express.raw({ type: 'application/json' }
|
|||||||
const workspaceId = Number(subscription.metadata?.workspaceId ?? 0);
|
const workspaceId = Number(subscription.metadata?.workspaceId ?? 0);
|
||||||
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
||||||
const subscriptionStatus = mapStripeSubscriptionStatus(subscription.status);
|
const subscriptionStatus = mapStripeSubscriptionStatus(subscription.status);
|
||||||
|
const billingSelection = getBillingSelectionForStripeSubscription(subscription);
|
||||||
|
|
||||||
if (workspaceId) {
|
if (workspaceId) {
|
||||||
await setWorkspaceStripeSubscription({
|
await setWorkspaceStripeSubscription({
|
||||||
@@ -476,9 +497,16 @@ app.post('/api/billing/stripe/webhook', express.raw({ type: 'application/json' }
|
|||||||
stripeCustomerId: customerId,
|
stripeCustomerId: customerId,
|
||||||
stripeSubscriptionId: subscription.id,
|
stripeSubscriptionId: subscription.id,
|
||||||
subscriptionStatus,
|
subscriptionStatus,
|
||||||
|
billingPlan: billingSelection.billingPlan,
|
||||||
|
billingInterval: billingSelection.billingInterval,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await setWorkspaceSubscriptionStatusByStripeSubscriptionId(subscription.id, subscriptionStatus);
|
await setWorkspaceSubscriptionStatusByStripeSubscriptionId(
|
||||||
|
subscription.id,
|
||||||
|
subscriptionStatus,
|
||||||
|
billingSelection.billingPlan,
|
||||||
|
billingSelection.billingInterval,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,6 +529,7 @@ const normalizeWorkspaceMembershipList = async (userId: string) =>
|
|||||||
workspace_type: row.workspace_type,
|
workspace_type: row.workspace_type,
|
||||||
billing_email: row.billing_email,
|
billing_email: row.billing_email,
|
||||||
billing_plan: row.billing_plan,
|
billing_plan: row.billing_plan,
|
||||||
|
billing_interval: row.billing_interval,
|
||||||
subscription_status: row.subscription_status,
|
subscription_status: row.subscription_status,
|
||||||
stripe_customer_id: row.stripe_customer_id,
|
stripe_customer_id: row.stripe_customer_id,
|
||||||
stripe_subscription_id: row.stripe_subscription_id,
|
stripe_subscription_id: row.stripe_subscription_id,
|
||||||
@@ -536,16 +565,43 @@ const getStripeClient = () => {
|
|||||||
return stripe;
|
return stripe;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStripePriceIdForBillingPlan = (billingPlan: BillingPlan) => {
|
const getStripePriceIdForBillingPlan = (billingPlan: BillingPlan, billingInterval: BillingInterval) => {
|
||||||
const priceId = stripePriceByBillingPlan[billingPlan]?.trim() ?? '';
|
if (billingPlan === 'rescue_free') {
|
||||||
|
throw new Error('Rescue flocks do not use Stripe billing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceId = stripePriceByBillingPlanAndInterval[billingPlan]?.[billingInterval]?.trim() ?? '';
|
||||||
|
|
||||||
if (!priceId) {
|
if (!priceId) {
|
||||||
throw new Error(`Stripe price is not configured for ${billingPlan}.`);
|
throw new Error(`Stripe price is not configured for ${billingPlan} (${billingInterval}).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return priceId;
|
return priceId;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getBillingSelectionForStripePrice = (priceId: string) => {
|
||||||
|
for (const [billingPlan, intervals] of Object.entries(stripePriceByBillingPlanAndInterval)) {
|
||||||
|
for (const [billingInterval, configuredPriceId] of Object.entries(intervals ?? {})) {
|
||||||
|
if (configuredPriceId && configuredPriceId === priceId) {
|
||||||
|
return {
|
||||||
|
billingPlan: billingPlan as BillingPlan,
|
||||||
|
billingInterval: billingInterval as BillingInterval,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
billingPlan: null,
|
||||||
|
billingInterval: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBillingSelectionForStripeSubscription = (subscription: Stripe.Subscription) => {
|
||||||
|
const priceId = subscription.items.data[0]?.price.id ?? '';
|
||||||
|
return getBillingSelectionForStripePrice(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);
|
||||||
@@ -1233,7 +1289,7 @@ app.post(
|
|||||||
requireSessionAuth,
|
requireSessionAuth,
|
||||||
requireWorkspaceRole(['owner', 'assistant']),
|
requireWorkspaceRole(['owner', 'assistant']),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const parsed = z.object({ billingPlan: billingPlanSchema.optional() }).safeParse(req.body ?? {});
|
const parsed = z.object({ billingPlan: billingPlanSchema.optional(), billingInterval: billingIntervalSchema.optional() }).safeParse(req.body ?? {});
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
res.status(400).json({ error: 'Invalid billing payload', details: parsed.error.flatten() });
|
res.status(400).json({ error: 'Invalid billing payload', details: parsed.error.flatten() });
|
||||||
@@ -1249,7 +1305,8 @@ app.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const billingPlan = parsed.data.billingPlan ?? workspace.billing_plan;
|
const billingPlan = parsed.data.billingPlan ?? workspace.billing_plan;
|
||||||
const priceId = getStripePriceIdForBillingPlan(billingPlan);
|
const billingInterval = parsed.data.billingInterval ?? workspace.billing_interval;
|
||||||
|
const priceId = getStripePriceIdForBillingPlan(billingPlan, billingInterval);
|
||||||
let stripeCustomerId = workspace.stripe_customer_id;
|
let stripeCustomerId = workspace.stripe_customer_id;
|
||||||
|
|
||||||
if (!stripeCustomerId) {
|
if (!stripeCustomerId) {
|
||||||
@@ -1276,11 +1333,13 @@ app.post(
|
|||||||
metadata: {
|
metadata: {
|
||||||
workspaceId: String(workspace.id),
|
workspaceId: String(workspace.id),
|
||||||
billingPlan,
|
billingPlan,
|
||||||
|
billingInterval,
|
||||||
},
|
},
|
||||||
subscription_data: {
|
subscription_data: {
|
||||||
metadata: {
|
metadata: {
|
||||||
workspaceId: String(workspace.id),
|
workspaceId: String(workspace.id),
|
||||||
billingPlan,
|
billingPlan,
|
||||||
|
billingInterval,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1410,6 +1469,7 @@ app.post('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request
|
|||||||
workspaceType: parsed.data.workspaceType,
|
workspaceType: parsed.data.workspaceType,
|
||||||
billingEmail: emptyToNull(parsed.data.billingEmail),
|
billingEmail: emptyToNull(parsed.data.billingEmail),
|
||||||
billingPlan,
|
billingPlan,
|
||||||
|
billingInterval: parsed.data.workspaceType === 'rescue' ? 'monthly' : (parsed.data.billingInterval ?? 'monthly'),
|
||||||
owner: req.auth!.user,
|
owner: req.auth!.user,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1447,6 +1507,7 @@ app.put('/api/workspace', requireAuth, requireWriteAccess, requireWorkspaceRole(
|
|||||||
workspaceType: parsed.data.workspaceType,
|
workspaceType: parsed.data.workspaceType,
|
||||||
billingEmail: emptyToNull(parsed.data.billingEmail),
|
billingEmail: emptyToNull(parsed.data.billingEmail),
|
||||||
billingPlan,
|
billingPlan,
|
||||||
|
billingInterval: parsed.data.workspaceType === 'rescue' ? 'monthly' : (parsed.data.billingInterval ?? req.auth!.workspace.billing_interval),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (workspace?.workspace_type === 'rescue' && req.auth!.workspace.workspace_type !== 'rescue') {
|
if (workspace?.workspace_type === 'rescue' && req.auth!.workspace.workspace_type !== 'rescue') {
|
||||||
@@ -1480,6 +1541,7 @@ app.delete('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRo
|
|||||||
workspaceType: 'standard',
|
workspaceType: 'standard',
|
||||||
billingEmail: req.auth!.user.email,
|
billingEmail: req.auth!.user.email,
|
||||||
billingPlan: 'household_basic',
|
billingPlan: 'household_basic',
|
||||||
|
billingInterval: 'monthly',
|
||||||
owner: req.auth!.user,
|
owner: req.auth!.user,
|
||||||
});
|
});
|
||||||
nextWorkspaceId = fallbackWorkspace?.id ?? fallbackWorkspaceId;
|
nextWorkspaceId = fallbackWorkspace?.id ?? fallbackWorkspaceId;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
workspace_type VARCHAR(16) NOT NULL DEFAULT 'standard',
|
workspace_type VARCHAR(16) NOT NULL DEFAULT 'standard',
|
||||||
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',
|
||||||
|
billing_interval VARCHAR(16) NOT NULL DEFAULT 'monthly',
|
||||||
subscription_status VARCHAR(32) NOT NULL DEFAULT 'active',
|
subscription_status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||||
stripe_customer_id VARCHAR(255),
|
stripe_customer_id VARCHAR(255),
|
||||||
stripe_subscription_id VARCHAR(255),
|
stripe_subscription_id VARCHAR(255),
|
||||||
@@ -32,6 +33,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ALTER TABLE workspaces
|
ALTER TABLE workspaces
|
||||||
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 billing_interval VARCHAR(16) NOT NULL DEFAULT 'monthly',
|
||||||
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_customer_id VARCHAR(255),
|
||||||
ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255),
|
ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { db } from '../db/client.js';
|
|||||||
import type {
|
import type {
|
||||||
AuthContext,
|
AuthContext,
|
||||||
AuthSessionRow,
|
AuthSessionRow,
|
||||||
|
BillingInterval,
|
||||||
BillingPlan,
|
BillingPlan,
|
||||||
IntegrationTokenRow,
|
IntegrationTokenRow,
|
||||||
IntegrationTokenScope,
|
IntegrationTokenScope,
|
||||||
@@ -38,6 +39,7 @@ const mapSessionAuthRow = (
|
|||||||
workspace_workspace_type: WorkspaceType;
|
workspace_workspace_type: WorkspaceType;
|
||||||
workspace_billing_email: string | null;
|
workspace_billing_email: string | null;
|
||||||
workspace_billing_plan: BillingPlan;
|
workspace_billing_plan: BillingPlan;
|
||||||
|
workspace_billing_interval: BillingInterval;
|
||||||
workspace_subscription_status: SubscriptionStatus;
|
workspace_subscription_status: SubscriptionStatus;
|
||||||
workspace_stripe_customer_id: string | null;
|
workspace_stripe_customer_id: string | null;
|
||||||
workspace_stripe_subscription_id: string | null;
|
workspace_stripe_subscription_id: string | null;
|
||||||
@@ -76,6 +78,7 @@ const mapSessionAuthRow = (
|
|||||||
workspace_type: row.workspace_workspace_type,
|
workspace_type: row.workspace_workspace_type,
|
||||||
billing_email: row.workspace_billing_email,
|
billing_email: row.workspace_billing_email,
|
||||||
billing_plan: row.workspace_billing_plan,
|
billing_plan: row.workspace_billing_plan,
|
||||||
|
billing_interval: row.workspace_billing_interval,
|
||||||
subscription_status: row.workspace_subscription_status,
|
subscription_status: row.workspace_subscription_status,
|
||||||
stripe_customer_id: row.workspace_stripe_customer_id,
|
stripe_customer_id: row.workspace_stripe_customer_id,
|
||||||
stripe_subscription_id: row.workspace_stripe_subscription_id,
|
stripe_subscription_id: row.workspace_stripe_subscription_id,
|
||||||
@@ -123,6 +126,7 @@ const mapIntegrationTokenAuthRow = (
|
|||||||
workspace_workspace_type: WorkspaceType;
|
workspace_workspace_type: WorkspaceType;
|
||||||
workspace_billing_email: string | null;
|
workspace_billing_email: string | null;
|
||||||
workspace_billing_plan: BillingPlan;
|
workspace_billing_plan: BillingPlan;
|
||||||
|
workspace_billing_interval: BillingInterval;
|
||||||
workspace_subscription_status: SubscriptionStatus;
|
workspace_subscription_status: SubscriptionStatus;
|
||||||
workspace_stripe_customer_id: string | null;
|
workspace_stripe_customer_id: string | null;
|
||||||
workspace_stripe_subscription_id: string | null;
|
workspace_stripe_subscription_id: string | null;
|
||||||
@@ -161,6 +165,7 @@ const mapIntegrationTokenAuthRow = (
|
|||||||
workspace_type: row.workspace_workspace_type,
|
workspace_type: row.workspace_workspace_type,
|
||||||
billing_email: row.workspace_billing_email,
|
billing_email: row.workspace_billing_email,
|
||||||
billing_plan: row.workspace_billing_plan,
|
billing_plan: row.workspace_billing_plan,
|
||||||
|
billing_interval: row.workspace_billing_interval,
|
||||||
subscription_status: row.workspace_subscription_status,
|
subscription_status: row.workspace_subscription_status,
|
||||||
stripe_customer_id: row.workspace_stripe_customer_id,
|
stripe_customer_id: row.workspace_stripe_customer_id,
|
||||||
stripe_subscription_id: row.workspace_stripe_subscription_id,
|
stripe_subscription_id: row.workspace_stripe_subscription_id,
|
||||||
@@ -345,6 +350,7 @@ export const resolveAuth = async (tokenHash: string, token: string) => {
|
|||||||
workspace_workspace_type: WorkspaceType;
|
workspace_workspace_type: WorkspaceType;
|
||||||
workspace_billing_email: string | null;
|
workspace_billing_email: string | null;
|
||||||
workspace_billing_plan: BillingPlan;
|
workspace_billing_plan: BillingPlan;
|
||||||
|
workspace_billing_interval: BillingInterval;
|
||||||
workspace_subscription_status: SubscriptionStatus;
|
workspace_subscription_status: SubscriptionStatus;
|
||||||
workspace_stripe_customer_id: string | null;
|
workspace_stripe_customer_id: string | null;
|
||||||
workspace_stripe_subscription_id: string | null;
|
workspace_stripe_subscription_id: string | null;
|
||||||
@@ -378,6 +384,7 @@ export const resolveAuth = async (tokenHash: string, token: string) => {
|
|||||||
workspaces.workspace_type AS workspace_workspace_type,
|
workspaces.workspace_type AS workspace_workspace_type,
|
||||||
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.billing_interval AS workspace_billing_interval,
|
||||||
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_customer_id AS workspace_stripe_customer_id,
|
||||||
workspaces.stripe_subscription_id AS workspace_stripe_subscription_id,
|
workspaces.stripe_subscription_id AS workspace_stripe_subscription_id,
|
||||||
@@ -433,6 +440,7 @@ export const resolveIntegrationTokenAuth = async (tokenHash: string, token: stri
|
|||||||
workspace_workspace_type: WorkspaceType;
|
workspace_workspace_type: WorkspaceType;
|
||||||
workspace_billing_email: string | null;
|
workspace_billing_email: string | null;
|
||||||
workspace_billing_plan: BillingPlan;
|
workspace_billing_plan: BillingPlan;
|
||||||
|
workspace_billing_interval: BillingInterval;
|
||||||
workspace_subscription_status: SubscriptionStatus;
|
workspace_subscription_status: SubscriptionStatus;
|
||||||
workspace_stripe_customer_id: string | null;
|
workspace_stripe_customer_id: string | null;
|
||||||
workspace_stripe_subscription_id: string | null;
|
workspace_stripe_subscription_id: string | null;
|
||||||
@@ -471,6 +479,7 @@ export const resolveIntegrationTokenAuth = async (tokenHash: string, token: stri
|
|||||||
workspaces.workspace_type AS workspace_workspace_type,
|
workspaces.workspace_type AS workspace_workspace_type,
|
||||||
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.billing_interval AS workspace_billing_interval,
|
||||||
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_customer_id AS workspace_stripe_customer_id,
|
||||||
workspaces.stripe_subscription_id AS workspace_stripe_subscription_id,
|
workspaces.stripe_subscription_id AS workspace_stripe_subscription_id,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ test('createWorkspace inserts owner membership and returns the created workspace
|
|||||||
workspace_type: 'rescue',
|
workspace_type: 'rescue',
|
||||||
billing_email: 'billing@example.com',
|
billing_email: 'billing@example.com',
|
||||||
billing_plan: 'rescue_free',
|
billing_plan: 'rescue_free',
|
||||||
|
billing_interval: 'monthly',
|
||||||
created_at: '2026-04-14T00:00:00.000Z',
|
created_at: '2026-04-14T00:00:00.000Z',
|
||||||
updated_at: '2026-04-14T00:00:00.000Z',
|
updated_at: '2026-04-14T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
@@ -59,6 +60,7 @@ test('createWorkspace inserts owner membership and returns the created workspace
|
|||||||
workspaceType: 'rescue',
|
workspaceType: 'rescue',
|
||||||
billingEmail: 'billing@example.com',
|
billingEmail: 'billing@example.com',
|
||||||
billingPlan: 'rescue_free',
|
billingPlan: 'rescue_free',
|
||||||
|
billingInterval: 'monthly',
|
||||||
owner: user,
|
owner: user,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,6 +81,7 @@ test('updateWorkspace converts an existing household flock to rescue without ins
|
|||||||
workspace_type: 'rescue',
|
workspace_type: 'rescue',
|
||||||
billing_email: 'billing@example.com',
|
billing_email: 'billing@example.com',
|
||||||
billing_plan: 'rescue_free',
|
billing_plan: 'rescue_free',
|
||||||
|
billing_interval: 'monthly',
|
||||||
subscription_status: 'active',
|
subscription_status: 'active',
|
||||||
rescue_verification_status: 'pending',
|
rescue_verification_status: 'pending',
|
||||||
created_at: '2026-04-14T00:00:00.000Z',
|
created_at: '2026-04-14T00:00:00.000Z',
|
||||||
@@ -93,6 +96,7 @@ test('updateWorkspace converts an existing household flock to rescue without ins
|
|||||||
workspaceType: 'rescue',
|
workspaceType: 'rescue',
|
||||||
billingEmail: 'billing@example.com',
|
billingEmail: 'billing@example.com',
|
||||||
billingPlan: 'rescue_free',
|
billingPlan: 'rescue_free',
|
||||||
|
billingInterval: 'monthly',
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(workspace?.id, 42);
|
assert.equal(workspace?.id, 42);
|
||||||
@@ -100,7 +104,7 @@ test('updateWorkspace converts an existing household flock to rescue without ins
|
|||||||
assert.equal(calls.length, 1);
|
assert.equal(calls.length, 1);
|
||||||
assert.match(calls[0].text, /UPDATE workspaces/);
|
assert.match(calls[0].text, /UPDATE workspaces/);
|
||||||
assert.doesNotMatch(calls[0].text, /INSERT INTO workspaces/);
|
assert.doesNotMatch(calls[0].text, /INSERT INTO workspaces/);
|
||||||
assert.deepEqual(calls[0].params, [42, 'Converted Rescue', 'rescue', 'billing@example.com', 'rescue_free']);
|
assert.deepEqual(calls[0].params, [42, 'Converted Rescue', 'rescue', 'billing@example.com', 'rescue_free', 'monthly']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deleteWorkspaceIfEmpty blocks deletion when birds are still assigned', async () => {
|
test('deleteWorkspaceIfEmpty blocks deletion when birds are still assigned', async () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from '../db/client.js';
|
import { db } from '../db/client.js';
|
||||||
import type { BillingPlan, RescueVerificationStatus, SubscriptionStatus, UserRow, WorkspaceMemberRow, WorkspaceRow, WorkspaceType } from '../types.js';
|
import type { BillingInterval, 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, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at
|
`SELECT id, name, workspace_type, billing_email, billing_plan, billing_interval, 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],
|
||||||
@@ -36,6 +36,7 @@ export const listMembershipsForUser = async (userId: string) => {
|
|||||||
workspace_type: WorkspaceType;
|
workspace_type: WorkspaceType;
|
||||||
billing_email: string | null;
|
billing_email: string | null;
|
||||||
billing_plan: BillingPlan;
|
billing_plan: BillingPlan;
|
||||||
|
billing_interval: BillingInterval;
|
||||||
subscription_status: WorkspaceRow['subscription_status'];
|
subscription_status: WorkspaceRow['subscription_status'];
|
||||||
stripe_customer_id: string | null;
|
stripe_customer_id: string | null;
|
||||||
stripe_subscription_id: string | null;
|
stripe_subscription_id: string | null;
|
||||||
@@ -57,6 +58,7 @@ export const listMembershipsForUser = async (userId: string) => {
|
|||||||
workspaces.workspace_type,
|
workspaces.workspace_type,
|
||||||
workspaces.billing_email,
|
workspaces.billing_email,
|
||||||
workspaces.billing_plan,
|
workspaces.billing_plan,
|
||||||
|
workspaces.billing_interval,
|
||||||
workspaces.subscription_status,
|
workspaces.subscription_status,
|
||||||
workspaces.stripe_customer_id,
|
workspaces.stripe_customer_id,
|
||||||
workspaces.stripe_subscription_id,
|
workspaces.stripe_subscription_id,
|
||||||
@@ -103,8 +105,8 @@ export const ensurePersonalWorkspaceForUser = async (user: UserRow) => {
|
|||||||
|
|
||||||
if (!unclaimed.rowCount) {
|
if (!unclaimed.rowCount) {
|
||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO workspaces (id, name, workspace_type, billing_plan, billing_email, subscription_status, rescue_verification_status)
|
`INSERT INTO workspaces (id, name, workspace_type, billing_plan, billing_interval, billing_email, subscription_status, rescue_verification_status)
|
||||||
VALUES ($1, $2, 'standard', 'household_basic', $3, 'active', 'not_required')`,
|
VALUES ($1, $2, 'standard', 'household_basic', 'monthly', $3, 'active', 'not_required')`,
|
||||||
[workspaceId, `${user.name}'s Flock`, user.email],
|
[workspaceId, `${user.name}'s Flock`, user.email],
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -113,6 +115,7 @@ export const ensurePersonalWorkspaceForUser = async (user: UserRow) => {
|
|||||||
SET name = $2,
|
SET name = $2,
|
||||||
workspace_type = 'standard',
|
workspace_type = 'standard',
|
||||||
billing_plan = 'household_basic',
|
billing_plan = 'household_basic',
|
||||||
|
billing_interval = 'monthly',
|
||||||
billing_email = $3,
|
billing_email = $3,
|
||||||
subscription_status = 'active',
|
subscription_status = 'active',
|
||||||
rescue_verification_status = 'not_required',
|
rescue_verification_status = 'not_required',
|
||||||
@@ -154,6 +157,7 @@ export const createWorkspace = async ({
|
|||||||
workspaceType,
|
workspaceType,
|
||||||
billingEmail,
|
billingEmail,
|
||||||
billingPlan,
|
billingPlan,
|
||||||
|
billingInterval,
|
||||||
owner,
|
owner,
|
||||||
}: {
|
}: {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -161,17 +165,19 @@ export const createWorkspace = async ({
|
|||||||
workspaceType: WorkspaceType;
|
workspaceType: WorkspaceType;
|
||||||
billingEmail: string | null;
|
billingEmail: string | null;
|
||||||
billingPlan: BillingPlan;
|
billingPlan: BillingPlan;
|
||||||
|
billingInterval: BillingInterval;
|
||||||
owner: UserRow;
|
owner: UserRow;
|
||||||
}) => {
|
}) => {
|
||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO workspaces (id, name, workspace_type, billing_email, billing_plan, subscription_status, rescue_verification_status)
|
`INSERT INTO workspaces (id, name, workspace_type, billing_email, billing_plan, billing_interval, subscription_status, rescue_verification_status)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
[
|
[
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
workspaceType,
|
workspaceType,
|
||||||
billingEmail,
|
billingEmail,
|
||||||
billingPlan,
|
billingPlan,
|
||||||
|
billingInterval,
|
||||||
workspaceType === 'rescue' ? 'active' : 'active',
|
workspaceType === 'rescue' ? 'active' : 'active',
|
||||||
workspaceType === 'rescue' ? 'pending' : 'not_required',
|
workspaceType === 'rescue' ? 'pending' : 'not_required',
|
||||||
],
|
],
|
||||||
@@ -192,12 +198,14 @@ export const updateWorkspace = async ({
|
|||||||
workspaceType,
|
workspaceType,
|
||||||
billingEmail,
|
billingEmail,
|
||||||
billingPlan,
|
billingPlan,
|
||||||
|
billingInterval,
|
||||||
}: {
|
}: {
|
||||||
workspaceId: number;
|
workspaceId: number;
|
||||||
name: string;
|
name: string;
|
||||||
workspaceType: WorkspaceType;
|
workspaceType: WorkspaceType;
|
||||||
billingEmail: string | null;
|
billingEmail: string | null;
|
||||||
billingPlan: BillingPlan;
|
billingPlan: BillingPlan;
|
||||||
|
billingInterval: BillingInterval;
|
||||||
}) => {
|
}) => {
|
||||||
const result = await db.query<WorkspaceRow>(
|
const result = await db.query<WorkspaceRow>(
|
||||||
`WITH input AS (
|
`WITH input AS (
|
||||||
@@ -206,13 +214,15 @@ export const updateWorkspace = async ({
|
|||||||
$2::varchar AS name,
|
$2::varchar AS name,
|
||||||
$3::varchar AS workspace_type,
|
$3::varchar AS workspace_type,
|
||||||
$4::varchar AS billing_email,
|
$4::varchar AS billing_email,
|
||||||
$5::varchar AS billing_plan
|
$5::varchar AS billing_plan,
|
||||||
|
$6::varchar AS billing_interval
|
||||||
)
|
)
|
||||||
UPDATE workspaces
|
UPDATE workspaces
|
||||||
SET name = input.name,
|
SET name = input.name,
|
||||||
workspace_type = input.workspace_type,
|
workspace_type = input.workspace_type,
|
||||||
billing_email = input.billing_email,
|
billing_email = input.billing_email,
|
||||||
billing_plan = input.billing_plan,
|
billing_plan = input.billing_plan,
|
||||||
|
billing_interval = input.billing_interval,
|
||||||
rescue_verification_status = CASE
|
rescue_verification_status = CASE
|
||||||
WHEN input.workspace_type = 'rescue' AND workspaces.rescue_verification_status = 'not_required' THEN 'pending'
|
WHEN input.workspace_type = 'rescue' AND workspaces.rescue_verification_status = 'not_required' THEN 'pending'
|
||||||
WHEN input.workspace_type = 'standard' THEN 'not_required'
|
WHEN input.workspace_type = 'standard' THEN 'not_required'
|
||||||
@@ -221,8 +231,8 @@ 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.stripe_customer_id, workspaces.stripe_subscription_id, 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.billing_interval, 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, billingInterval],
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
@@ -256,7 +266,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.stripe_customer_id, workspaces.stripe_subscription_id, 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.billing_interval, 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)
|
||||||
@@ -356,6 +366,7 @@ export const listRescueWorkspacesForAdmin = async () => {
|
|||||||
workspaces.workspace_type,
|
workspaces.workspace_type,
|
||||||
workspaces.billing_email,
|
workspaces.billing_email,
|
||||||
workspaces.billing_plan,
|
workspaces.billing_plan,
|
||||||
|
workspaces.billing_interval,
|
||||||
workspaces.subscription_status,
|
workspaces.subscription_status,
|
||||||
workspaces.stripe_customer_id,
|
workspaces.stripe_customer_id,
|
||||||
workspaces.stripe_subscription_id,
|
workspaces.stripe_subscription_id,
|
||||||
@@ -397,6 +408,10 @@ export const updateRescueVerificationStatus = async (workspaceId: number, status
|
|||||||
WHEN $2 = 'rejected' THEN 'household_basic'
|
WHEN $2 = 'rejected' THEN 'household_basic'
|
||||||
ELSE billing_plan
|
ELSE billing_plan
|
||||||
END,
|
END,
|
||||||
|
billing_interval = CASE
|
||||||
|
WHEN $2 = 'rejected' THEN 'monthly'
|
||||||
|
ELSE billing_interval
|
||||||
|
END,
|
||||||
rescue_verification_status = CASE
|
rescue_verification_status = CASE
|
||||||
WHEN $2 = 'rejected' THEN 'not_required'
|
WHEN $2 = 'rejected' THEN 'not_required'
|
||||||
ELSE $2
|
ELSE $2
|
||||||
@@ -404,7 +419,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, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at`,
|
RETURNING id, name, workspace_type, billing_email, billing_plan, billing_interval, subscription_status, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at`,
|
||||||
[workspaceId, status],
|
[workspaceId, status],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -416,12 +431,13 @@ export const cancelRescueVerificationRequest = async (workspaceId: number) => {
|
|||||||
`UPDATE workspaces
|
`UPDATE workspaces
|
||||||
SET workspace_type = 'standard',
|
SET workspace_type = 'standard',
|
||||||
billing_plan = 'household_basic',
|
billing_plan = 'household_basic',
|
||||||
|
billing_interval = 'monthly',
|
||||||
rescue_verification_status = 'not_required',
|
rescue_verification_status = 'not_required',
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
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, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at`,
|
RETURNING id, name, workspace_type, billing_email, billing_plan, billing_interval, subscription_status, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at`,
|
||||||
[workspaceId],
|
[workspaceId],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -434,7 +450,7 @@ export const setWorkspaceStripeCustomerId = async (workspaceId: number, stripeCu
|
|||||||
SET stripe_customer_id = $2,
|
SET stripe_customer_id = $2,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $1
|
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`,
|
RETURNING id, name, workspace_type, billing_email, billing_plan, billing_interval, subscription_status, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at`,
|
||||||
[workspaceId, stripeCustomerId],
|
[workspaceId, stripeCustomerId],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -446,21 +462,27 @@ export const setWorkspaceStripeSubscription = async ({
|
|||||||
stripeCustomerId,
|
stripeCustomerId,
|
||||||
stripeSubscriptionId,
|
stripeSubscriptionId,
|
||||||
subscriptionStatus,
|
subscriptionStatus,
|
||||||
|
billingPlan,
|
||||||
|
billingInterval,
|
||||||
}: {
|
}: {
|
||||||
workspaceId: number;
|
workspaceId: number;
|
||||||
stripeCustomerId: string | null;
|
stripeCustomerId: string | null;
|
||||||
stripeSubscriptionId: string;
|
stripeSubscriptionId: string;
|
||||||
subscriptionStatus: SubscriptionStatus;
|
subscriptionStatus: SubscriptionStatus;
|
||||||
|
billingPlan?: BillingPlan | null;
|
||||||
|
billingInterval?: BillingInterval | null;
|
||||||
}) => {
|
}) => {
|
||||||
const result = await db.query<WorkspaceRow>(
|
const result = await db.query<WorkspaceRow>(
|
||||||
`UPDATE workspaces
|
`UPDATE workspaces
|
||||||
SET stripe_customer_id = COALESCE($2, stripe_customer_id),
|
SET stripe_customer_id = COALESCE($2, stripe_customer_id),
|
||||||
stripe_subscription_id = $3,
|
stripe_subscription_id = $3,
|
||||||
subscription_status = $4,
|
subscription_status = $4,
|
||||||
|
billing_plan = COALESCE($5, billing_plan),
|
||||||
|
billing_interval = COALESCE($6, billing_interval),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $1
|
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`,
|
RETURNING id, name, workspace_type, billing_email, billing_plan, billing_interval, subscription_status, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at`,
|
||||||
[workspaceId, stripeCustomerId, stripeSubscriptionId, subscriptionStatus],
|
[workspaceId, stripeCustomerId, stripeSubscriptionId, subscriptionStatus, billingPlan ?? null, billingInterval ?? null],
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
@@ -469,14 +491,18 @@ export const setWorkspaceStripeSubscription = async ({
|
|||||||
export const setWorkspaceSubscriptionStatusByStripeSubscriptionId = async (
|
export const setWorkspaceSubscriptionStatusByStripeSubscriptionId = async (
|
||||||
stripeSubscriptionId: string,
|
stripeSubscriptionId: string,
|
||||||
subscriptionStatus: SubscriptionStatus,
|
subscriptionStatus: SubscriptionStatus,
|
||||||
|
billingPlan?: BillingPlan | null,
|
||||||
|
billingInterval?: BillingInterval | null,
|
||||||
) => {
|
) => {
|
||||||
const result = await db.query<WorkspaceRow>(
|
const result = await db.query<WorkspaceRow>(
|
||||||
`UPDATE workspaces
|
`UPDATE workspaces
|
||||||
SET subscription_status = $2,
|
SET subscription_status = $2,
|
||||||
|
billing_plan = COALESCE($3, billing_plan),
|
||||||
|
billing_interval = COALESCE($4, billing_interval),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE stripe_subscription_id = $1
|
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`,
|
RETURNING id, name, workspace_type, billing_email, billing_plan, billing_interval, subscription_status, stripe_customer_id, stripe_subscription_id, rescue_verification_status, created_at, updated_at`,
|
||||||
[stripeSubscriptionId, subscriptionStatus],
|
[stripeSubscriptionId, subscriptionStatus, billingPlan ?? null, billingInterval ?? null],
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export type WorkspaceType = 'standard' | 'rescue';
|
export type WorkspaceType = 'standard' | 'rescue';
|
||||||
export type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer';
|
export type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer';
|
||||||
export type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
|
export type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
|
||||||
|
export type BillingInterval = 'monthly' | 'yearly';
|
||||||
export type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none';
|
export type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none';
|
||||||
export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
|
export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
|
||||||
export type ProviderKey = 'google' | 'microsoft' | 'apple';
|
export type ProviderKey = 'google' | 'microsoft' | 'apple';
|
||||||
@@ -21,6 +22,7 @@ export type WorkspaceRow = {
|
|||||||
workspace_type: WorkspaceType;
|
workspace_type: WorkspaceType;
|
||||||
billing_email: string | null;
|
billing_email: string | null;
|
||||||
billing_plan: BillingPlan;
|
billing_plan: BillingPlan;
|
||||||
|
billing_interval: BillingInterval;
|
||||||
subscription_status: SubscriptionStatus;
|
subscription_status: SubscriptionStatus;
|
||||||
stripe_customer_id: string | null;
|
stripe_customer_id: string | null;
|
||||||
stripe_subscription_id: string | null;
|
stripe_subscription_id: string | null;
|
||||||
|
|||||||
@@ -42,8 +42,14 @@ services:
|
|||||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
||||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
||||||
STRIPE_PRICE_HOUSEHOLD_CONURE: ${STRIPE_PRICE_HOUSEHOLD_CONURE:-}
|
STRIPE_PRICE_HOUSEHOLD_CONURE: ${STRIPE_PRICE_HOUSEHOLD_CONURE:-}
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_CONURE_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_CONURE_MONTHLY:-}
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY:-}
|
||||||
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK:-}
|
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK:-}
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY:-}
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY:-}
|
||||||
STRIPE_PRICE_HOUSEHOLD_MACAW: ${STRIPE_PRICE_HOUSEHOLD_MACAW:-}
|
STRIPE_PRICE_HOUSEHOLD_MACAW: ${STRIPE_PRICE_HOUSEHOLD_MACAW:-}
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY:-}
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY:-}
|
||||||
STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-${FRONTEND_URL}/?billing=success}
|
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_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-${FRONTEND_URL}/?billing=cancelled}
|
||||||
STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-${FRONTEND_URL}/}
|
STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-${FRONTEND_URL}/}
|
||||||
|
|||||||
@@ -41,8 +41,14 @@ services:
|
|||||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
||||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
||||||
STRIPE_PRICE_HOUSEHOLD_CONURE: ${STRIPE_PRICE_HOUSEHOLD_CONURE:-}
|
STRIPE_PRICE_HOUSEHOLD_CONURE: ${STRIPE_PRICE_HOUSEHOLD_CONURE:-}
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_CONURE_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_CONURE_MONTHLY:-}
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY:-}
|
||||||
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK:-}
|
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK:-}
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY:-}
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY:-}
|
||||||
STRIPE_PRICE_HOUSEHOLD_MACAW: ${STRIPE_PRICE_HOUSEHOLD_MACAW:-}
|
STRIPE_PRICE_HOUSEHOLD_MACAW: ${STRIPE_PRICE_HOUSEHOLD_MACAW:-}
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY:-}
|
||||||
|
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY:-}
|
||||||
STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-http://localhost:3000/?billing=success}
|
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_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-http://localhost:3000/?billing=cancelled}
|
||||||
STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-http://localhost:3000/}
|
STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-http://localhost:3000/}
|
||||||
|
|||||||
+101
-45
@@ -4,6 +4,7 @@ import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightRefer
|
|||||||
|
|
||||||
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
|
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
|
||||||
type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>;
|
type HouseholdBillingPlan = Exclude<BillingPlan, 'rescue_free'>;
|
||||||
|
type BillingInterval = 'monthly' | 'yearly';
|
||||||
type WorkspaceType = 'standard' | 'rescue';
|
type WorkspaceType = 'standard' | 'rescue';
|
||||||
type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer';
|
type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer';
|
||||||
type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none';
|
type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none';
|
||||||
@@ -52,6 +53,7 @@ type Workspace = {
|
|||||||
workspaceType: WorkspaceType;
|
workspaceType: WorkspaceType;
|
||||||
billingEmail: string | null;
|
billingEmail: string | null;
|
||||||
billingPlan: BillingPlan;
|
billingPlan: BillingPlan;
|
||||||
|
billingInterval: BillingInterval;
|
||||||
subscriptionStatus: SubscriptionStatus;
|
subscriptionStatus: SubscriptionStatus;
|
||||||
stripeCustomerId: string | null;
|
stripeCustomerId: string | null;
|
||||||
stripeSubscriptionId: string | null;
|
stripeSubscriptionId: string | null;
|
||||||
@@ -152,6 +154,7 @@ type WorkspaceFormState = {
|
|||||||
workspaceType: WorkspaceType;
|
workspaceType: WorkspaceType;
|
||||||
billingEmail: string;
|
billingEmail: string;
|
||||||
billingPlan: HouseholdBillingPlan;
|
billingPlan: HouseholdBillingPlan;
|
||||||
|
billingInterval: BillingInterval;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WorkspaceMemberFormState = {
|
type WorkspaceMemberFormState = {
|
||||||
@@ -165,6 +168,7 @@ type WorkspaceCreateFormState = {
|
|||||||
workspaceType: WorkspaceType;
|
workspaceType: WorkspaceType;
|
||||||
billingEmail: string;
|
billingEmail: string;
|
||||||
billingPlan: HouseholdBillingPlan;
|
billingPlan: HouseholdBillingPlan;
|
||||||
|
billingInterval: BillingInterval;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AuthFormState = {
|
type AuthFormState = {
|
||||||
@@ -247,6 +251,7 @@ const emptyWorkspaceForm: WorkspaceFormState = {
|
|||||||
workspaceType: 'standard',
|
workspaceType: 'standard',
|
||||||
billingEmail: '',
|
billingEmail: '',
|
||||||
billingPlan: 'household_basic',
|
billingPlan: 'household_basic',
|
||||||
|
billingInterval: 'monthly',
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyWorkspaceMemberForm: WorkspaceMemberFormState = {
|
const emptyWorkspaceMemberForm: WorkspaceMemberFormState = {
|
||||||
@@ -260,6 +265,7 @@ const emptyWorkspaceCreateForm: WorkspaceCreateFormState = {
|
|||||||
workspaceType: 'standard',
|
workspaceType: 'standard',
|
||||||
billingEmail: '',
|
billingEmail: '',
|
||||||
billingPlan: 'household_basic',
|
billingPlan: 'household_basic',
|
||||||
|
billingInterval: 'monthly',
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyAuthForm: AuthFormState = {
|
const emptyAuthForm: AuthFormState = {
|
||||||
@@ -481,6 +487,8 @@ const oauthStartUrl = (providerKey: AuthProvider['providerKey']) => {
|
|||||||
const isHouseholdPlan = (billingPlan: BillingPlan): billingPlan is HouseholdBillingPlan =>
|
const isHouseholdPlan = (billingPlan: BillingPlan): billingPlan is HouseholdBillingPlan =>
|
||||||
billingPlan === 'household_basic' || billingPlan === 'household_plus' || billingPlan === 'household_macaw';
|
billingPlan === 'household_basic' || billingPlan === 'household_plus' || billingPlan === 'household_macaw';
|
||||||
|
|
||||||
|
const formatBillingIntervalName = (billingInterval: BillingInterval) => (billingInterval === 'yearly' ? 'Annual' : 'Monthly');
|
||||||
|
|
||||||
const formatBillingPlanName = (billingPlan: BillingPlan) => {
|
const formatBillingPlanName = (billingPlan: BillingPlan) => {
|
||||||
if (billingPlan === 'rescue_free') {
|
if (billingPlan === 'rescue_free') {
|
||||||
return 'Rescue Free';
|
return 'Rescue Free';
|
||||||
@@ -548,6 +556,15 @@ const formatSubscriptionStatus = (status: SubscriptionStatus) => {
|
|||||||
return 'Active';
|
return 'Active';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const subscriptionAllowsFlockWrites = (status: SubscriptionStatus) => status === 'active' || status === 'trialing';
|
||||||
|
|
||||||
|
const formatFlockAccessStatus = (status: SubscriptionStatus) => (subscriptionAllowsFlockWrites(status) ? formatSubscriptionStatus(status) : 'Read-only');
|
||||||
|
|
||||||
|
const formatFlockAccessDescription = (status: SubscriptionStatus) =>
|
||||||
|
subscriptionAllowsFlockWrites(status)
|
||||||
|
? 'This flock is writable while the subscription is active.'
|
||||||
|
: `This flock is read-only until billing is restored. Current subscription status: ${formatSubscriptionStatus(status)}.`;
|
||||||
|
|
||||||
const formatRescueVerificationStatus = (status: RescueVerificationStatus) => {
|
const formatRescueVerificationStatus = (status: RescueVerificationStatus) => {
|
||||||
if (status === 'approved') {
|
if (status === 'approved') {
|
||||||
return 'Active';
|
return 'Active';
|
||||||
@@ -1122,6 +1139,7 @@ function App() {
|
|||||||
workspaceType: session.activeWorkspace.workspaceType,
|
workspaceType: session.activeWorkspace.workspaceType,
|
||||||
billingEmail: session.activeWorkspace.billingEmail ?? '',
|
billingEmail: session.activeWorkspace.billingEmail ?? '',
|
||||||
billingPlan: isHouseholdPlan(session.activeWorkspace.billingPlan) ? session.activeWorkspace.billingPlan : 'household_basic',
|
billingPlan: isHouseholdPlan(session.activeWorkspace.billingPlan) ? session.activeWorkspace.billingPlan : 'household_basic',
|
||||||
|
billingInterval: session.activeWorkspace.billingInterval,
|
||||||
});
|
});
|
||||||
setWorkspaceCreateForm((current) => ({
|
setWorkspaceCreateForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -1626,6 +1644,7 @@ function App() {
|
|||||||
workspaceType: workspaceCreateForm.workspaceType,
|
workspaceType: workspaceCreateForm.workspaceType,
|
||||||
billingEmail: workspaceCreateForm.billingEmail,
|
billingEmail: workspaceCreateForm.billingEmail,
|
||||||
billingPlan: workspaceCreateForm.workspaceType === 'rescue' ? undefined : workspaceCreateForm.billingPlan,
|
billingPlan: workspaceCreateForm.workspaceType === 'rescue' ? undefined : workspaceCreateForm.billingPlan,
|
||||||
|
billingInterval: workspaceCreateForm.workspaceType === 'rescue' ? undefined : workspaceCreateForm.billingInterval,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2216,6 +2235,7 @@ function App() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
...workspaceForm,
|
...workspaceForm,
|
||||||
billingPlan: workspaceForm.workspaceType === 'rescue' ? undefined : workspaceForm.billingPlan,
|
billingPlan: workspaceForm.workspaceType === 'rescue' ? undefined : workspaceForm.billingPlan,
|
||||||
|
billingInterval: workspaceForm.workspaceType === 'rescue' ? undefined : workspaceForm.billingInterval,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2248,6 +2268,7 @@ function App() {
|
|||||||
workspaceType: savedWorkspace.workspaceType,
|
workspaceType: savedWorkspace.workspaceType,
|
||||||
billingEmail: savedWorkspace.billingEmail ?? '',
|
billingEmail: savedWorkspace.billingEmail ?? '',
|
||||||
billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic',
|
billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic',
|
||||||
|
billingInterval: savedWorkspace.billingInterval,
|
||||||
});
|
});
|
||||||
} catch (workspaceError) {
|
} catch (workspaceError) {
|
||||||
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to save flock settings.');
|
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to save flock settings.');
|
||||||
@@ -2339,6 +2360,7 @@ function App() {
|
|||||||
workspaceType: savedWorkspace.workspaceType,
|
workspaceType: savedWorkspace.workspaceType,
|
||||||
billingEmail: savedWorkspace.billingEmail ?? '',
|
billingEmail: savedWorkspace.billingEmail ?? '',
|
||||||
billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic',
|
billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic',
|
||||||
|
billingInterval: savedWorkspace.billingInterval,
|
||||||
});
|
});
|
||||||
} catch (workspaceError) {
|
} catch (workspaceError) {
|
||||||
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to cancel rescue status request.');
|
setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to cancel rescue status request.');
|
||||||
@@ -2359,7 +2381,7 @@ function App() {
|
|||||||
const response = await apiFetch('/billing/checkout-session', authToken, {
|
const response = await apiFetch('/billing/checkout-session', authToken, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ billingPlan: workspace.billingPlan }),
|
body: JSON.stringify({ billingPlan: workspace.billingPlan, billingInterval: workspace.billingInterval }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -3291,12 +3313,11 @@ function App() {
|
|||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Flock</p>
|
<p className="eyebrow">Flock</p>
|
||||||
<h2>Flock profile and billing</h2>
|
<h2>Flock profile</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Each flock carries its own billing and collaboration rules. That lets one person keep a personal household flock while also
|
Manage this flock's name and type. Household billing details live in the Billing info card below.
|
||||||
participating in a rescue flock without mixing billing or bird ownership.
|
|
||||||
</p>
|
</p>
|
||||||
<form className="form-panel" onSubmit={handleWorkspaceSubmit}>
|
<form className="form-panel" onSubmit={handleWorkspaceSubmit}>
|
||||||
<label>
|
<label>
|
||||||
@@ -3327,44 +3348,12 @@ function App() {
|
|||||||
</span>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
) : null}
|
) : null}
|
||||||
{workspaceForm.workspaceType === 'standard' ? (
|
{workspaceForm.workspaceType === 'rescue' ? (
|
||||||
<>
|
|
||||||
<label>
|
|
||||||
Household plan
|
|
||||||
<select
|
|
||||||
value={workspaceForm.billingPlan}
|
|
||||||
onChange={(event) =>
|
|
||||||
setWorkspaceForm({
|
|
||||||
...workspaceForm,
|
|
||||||
billingPlan: event.target.value as WorkspaceFormState['billingPlan'],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="household_basic">Conure</option>
|
|
||||||
<option value="household_plus">Indian Ringneck</option>
|
|
||||||
<option value="household_macaw">Macaw</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<article className="summary-card">
|
|
||||||
<strong>{formatBillingPlanName(workspaceForm.billingPlan)}</strong>
|
|
||||||
<span>{formatBillingPlanCapacity(workspaceForm.billingPlan)}</span>
|
|
||||||
</article>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<article className="summary-card">
|
<article className="summary-card">
|
||||||
<strong>{formatBillingPlanName('rescue_free')}</strong>
|
<strong>{formatBillingPlanName('rescue_free')}</strong>
|
||||||
<span>Rescue flocks stay free while still supporting shared team access.</span>
|
<span>Rescue flocks stay free while still supporting shared team access.</span>
|
||||||
</article>
|
</article>
|
||||||
)}
|
) : null}
|
||||||
<label>
|
|
||||||
Billing contact email
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={workspaceForm.billingEmail}
|
|
||||||
onChange={(event) => setWorkspaceForm({ ...workspaceForm, billingEmail: event.target.value })}
|
|
||||||
placeholder="Optional for later billing and account management"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button className="primary-button" type="submit" disabled={savingWorkspace}>
|
<button className="primary-button" type="submit" disabled={savingWorkspace}>
|
||||||
{savingWorkspace
|
{savingWorkspace
|
||||||
? 'Saving flock...'
|
? 'Saving flock...'
|
||||||
@@ -3390,15 +3379,64 @@ function App() {
|
|||||||
<h2>Billing info</h2>
|
<h2>Billing info</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{workspace?.workspaceType !== 'rescue' ? (
|
||||||
|
<form className="form-panel" onSubmit={handleWorkspaceSubmit}>
|
||||||
|
<label>
|
||||||
|
Household plan
|
||||||
|
<select
|
||||||
|
value={workspaceForm.billingPlan}
|
||||||
|
onChange={(event) =>
|
||||||
|
setWorkspaceForm({
|
||||||
|
...workspaceForm,
|
||||||
|
billingPlan: event.target.value as WorkspaceFormState['billingPlan'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="household_basic">Conure - up to 4 birds</option>
|
||||||
|
<option value="household_plus">Indian Ringneck - up to 10 birds</option>
|
||||||
|
<option value="household_macaw">Macaw - 11+ birds</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Billing frequency
|
||||||
|
<select
|
||||||
|
value={workspaceForm.billingInterval}
|
||||||
|
onChange={(event) =>
|
||||||
|
setWorkspaceForm({
|
||||||
|
...workspaceForm,
|
||||||
|
billingInterval: event.target.value as WorkspaceFormState['billingInterval'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
<option value="yearly">Annual</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Billing contact email
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={workspaceForm.billingEmail}
|
||||||
|
onChange={(event) => setWorkspaceForm({ ...workspaceForm, billingEmail: event.target.value })}
|
||||||
|
placeholder="Optional for billing and account management"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button className="primary-button" type="submit" disabled={savingWorkspace}>
|
||||||
|
{savingWorkspace ? 'Saving billing...' : 'Save billing settings'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
<div className="summary-grid">
|
<div className="summary-grid">
|
||||||
<article className="summary-card">
|
<article className="summary-card">
|
||||||
<strong>{workspace ? formatBillingPlanName(workspace.billingPlan) : 'No plan yet'}</strong>
|
<strong>
|
||||||
|
{workspace ? `${formatBillingPlanName(workspace.billingPlan)} • ${formatBillingIntervalName(workspace.billingInterval)}` : 'No plan yet'}
|
||||||
|
</strong>
|
||||||
<span>{workspace ? formatBillingPlanCapacity(workspace.billingPlan) : 'Pick a flock plan to see bird capacity.'}</span>
|
<span>{workspace ? formatBillingPlanCapacity(workspace.billingPlan) : 'Pick a flock plan to see bird capacity.'}</span>
|
||||||
</article>
|
</article>
|
||||||
{workspace?.workspaceType !== 'rescue' ? (
|
{workspace?.workspaceType !== 'rescue' ? (
|
||||||
<article className="summary-card">
|
<article className="summary-card">
|
||||||
<strong>{workspace ? formatSubscriptionStatus(workspace.subscriptionStatus) : 'Unknown'}</strong>
|
<strong>{workspace ? formatFlockAccessStatus(workspace.subscriptionStatus) : 'Unknown'}</strong>
|
||||||
<span>Flock write access will follow subscription health once billing is connected.</span>
|
<span>{workspace ? formatFlockAccessDescription(workspace.subscriptionStatus) : 'Subscription status controls flock write access.'}</span>
|
||||||
</article>
|
</article>
|
||||||
) : null}
|
) : null}
|
||||||
{workspace?.workspaceType === 'rescue' ? (
|
{workspace?.workspaceType === 'rescue' ? (
|
||||||
@@ -3725,13 +3763,31 @@ function App() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="household_basic">Conure</option>
|
<option value="household_basic">Conure - up to 4 birds</option>
|
||||||
<option value="household_plus">Indian Ringneck</option>
|
<option value="household_plus">Indian Ringneck - up to 10 birds</option>
|
||||||
<option value="household_macaw">Macaw</option>
|
<option value="household_macaw">Macaw - 11+ birds</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Billing frequency
|
||||||
|
<select
|
||||||
|
value={workspaceCreateForm.billingInterval}
|
||||||
|
onChange={(event) =>
|
||||||
|
setWorkspaceCreateForm({
|
||||||
|
...workspaceCreateForm,
|
||||||
|
billingInterval: event.target.value as WorkspaceCreateFormState['billingInterval'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
<option value="yearly">Annual</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<article className="summary-card">
|
<article className="summary-card">
|
||||||
<strong>{formatBillingPlanName(workspaceCreateForm.billingPlan)}</strong>
|
<strong>
|
||||||
|
{formatBillingPlanName(workspaceCreateForm.billingPlan)} •{' '}
|
||||||
|
{formatBillingIntervalName(workspaceCreateForm.billingInterval)}
|
||||||
|
</strong>
|
||||||
<span>{formatBillingPlanCapacity(workspaceCreateForm.billingPlan)}</span>
|
<span>{formatBillingPlanCapacity(workspaceCreateForm.billingPlan)}</span>
|
||||||
</article>
|
</article>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user