diff --git a/.env.example b/.env.example index ef366b5..e7e779f 100644 --- a/.env.example +++ b/.env.example @@ -11,8 +11,14 @@ RESCUE_STATUS_NOTIFICATION_EMAIL=appadmin@flockpal.app STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= STRIPE_PRICE_HOUSEHOLD_CONURE= +STRIPE_PRICE_HOUSEHOLD_CONURE_MONTHLY= +STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY= STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK= +STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY= +STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY= 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_CANCEL_URL=http://localhost:3000/?billing=cancelled STRIPE_PORTAL_RETURN_URL=http://localhost:3000/ diff --git a/README.md b/README.md index ddae69d..57a0cc6 100644 --- a/README.md +++ b/README.md @@ -101,9 +101,12 @@ 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_PRICE_HOUSEHOLD_CONURE_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_CONURE` +- `STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY` +- `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_CANCEL_URL` - `STRIPE_PORTAL_RETURN_URL` diff --git a/backend/src/app.ts b/backend/src/app.ts index fdd31d9..f35b680 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -70,6 +70,7 @@ import { } from './repositories/workspaceRepository.js'; import type { AuthContext, + BillingInterval, BillingPlan, BirdGender, BirdRow, @@ -140,6 +141,7 @@ const switchWorkspaceSchema = z.object({ const workspaceTypeSchema = z.enum(['standard', 'rescue']); const workspaceRoleSchema = z.enum(['owner', 'assistant', 'caregiver', 'viewer']); 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 birdGenderSchema = z.enum(['unknown', 'male', 'female']); const rescueVerificationStatusSchema = z.enum(['pending', 'approved', 'rejected']); @@ -149,6 +151,7 @@ const workspaceSchema = z.object({ workspaceType: workspaceTypeSchema, billingEmail: z.string().trim().email().max(255).optional().or(z.literal('')), billingPlan: billingPlanSchema.optional(), + billingInterval: billingIntervalSchema.optional(), }); const createWorkspaceSchema = z.object({ @@ -156,6 +159,7 @@ const createWorkspaceSchema = z.object({ workspaceType: workspaceTypeSchema, billingEmail: z.string().trim().email().max(255).optional().or(z.literal('')), billingPlan: billingPlanSchema.optional(), + billingInterval: billingIntervalSchema.optional(), }); 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 stripeCheckoutCancelUrl = process.env.STRIPE_CHECKOUT_CANCEL_URL?.trim() || `${frontendBaseUrl}/?billing=cancelled`; const stripePortalReturnUrl = process.env.STRIPE_PORTAL_RETURN_URL?.trim() || frontendBaseUrl; -const stripePriceByBillingPlan: Partial> = { - household_basic: process.env.STRIPE_PRICE_HOUSEHOLD_CONURE?.trim() ?? '', - household_plus: process.env.STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK?.trim() ?? '', - household_macaw: process.env.STRIPE_PRICE_HOUSEHOLD_MACAW?.trim() ?? '', +const stripePriceByBillingPlanAndInterval: Partial, Partial>>> = { + household_basic: { + monthly: process.env.STRIPE_PRICE_HOUSEHOLD_CONURE_MONTHLY?.trim() || process.env.STRIPE_PRICE_HOUSEHOLD_CONURE?.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 adminEmails = new Set( @@ -286,6 +302,7 @@ const normalizeWorkspace = (row: WorkspaceRow) => ({ workspaceType: row.workspace_type, billingEmail: row.billing_email, billingPlan: row.billing_plan, + billingInterval: row.billing_interval, subscriptionStatus: row.subscription_status, stripeCustomerId: row.stripe_customer_id, stripeSubscriptionId: row.stripe_subscription_id, @@ -451,11 +468,14 @@ app.post('/api/billing/stripe/webhook', express.raw({ type: 'application/json' } if (workspaceId && subscriptionId) { const subscription = await getStripeClient().subscriptions.retrieve(subscriptionId); + const billingSelection = getBillingSelectionForStripeSubscription(subscription); await setWorkspaceStripeSubscription({ workspaceId, stripeCustomerId: customerId ?? (typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id), stripeSubscriptionId: subscription.id, 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 customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id; const subscriptionStatus = mapStripeSubscriptionStatus(subscription.status); + const billingSelection = getBillingSelectionForStripeSubscription(subscription); if (workspaceId) { await setWorkspaceStripeSubscription({ @@ -476,9 +497,16 @@ app.post('/api/billing/stripe/webhook', express.raw({ type: 'application/json' } stripeCustomerId: customerId, stripeSubscriptionId: subscription.id, subscriptionStatus, + billingPlan: billingSelection.billingPlan, + billingInterval: billingSelection.billingInterval, }); } 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, billing_email: row.billing_email, billing_plan: row.billing_plan, + billing_interval: row.billing_interval, subscription_status: row.subscription_status, stripe_customer_id: row.stripe_customer_id, stripe_subscription_id: row.stripe_subscription_id, @@ -536,16 +565,43 @@ const getStripeClient = () => { return stripe; }; -const getStripePriceIdForBillingPlan = (billingPlan: BillingPlan) => { - const priceId = stripePriceByBillingPlan[billingPlan]?.trim() ?? ''; +const getStripePriceIdForBillingPlan = (billingPlan: BillingPlan, billingInterval: BillingInterval) => { + if (billingPlan === 'rescue_free') { + throw new Error('Rescue flocks do not use Stripe billing.'); + } + + const priceId = stripePriceByBillingPlanAndInterval[billingPlan]?.[billingInterval]?.trim() ?? ''; 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; }; +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 token = createSessionToken(); const tokenHash = hashToken(token); @@ -1233,7 +1289,7 @@ app.post( requireSessionAuth, requireWorkspaceRole(['owner', 'assistant']), 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) { 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 priceId = getStripePriceIdForBillingPlan(billingPlan); + const billingInterval = parsed.data.billingInterval ?? workspace.billing_interval; + const priceId = getStripePriceIdForBillingPlan(billingPlan, billingInterval); let stripeCustomerId = workspace.stripe_customer_id; if (!stripeCustomerId) { @@ -1276,11 +1333,13 @@ app.post( metadata: { workspaceId: String(workspace.id), billingPlan, + billingInterval, }, subscription_data: { metadata: { workspaceId: String(workspace.id), billingPlan, + billingInterval, }, }, }); @@ -1410,6 +1469,7 @@ app.post('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request workspaceType: parsed.data.workspaceType, billingEmail: emptyToNull(parsed.data.billingEmail), billingPlan, + billingInterval: parsed.data.workspaceType === 'rescue' ? 'monthly' : (parsed.data.billingInterval ?? 'monthly'), owner: req.auth!.user, }); @@ -1447,6 +1507,7 @@ app.put('/api/workspace', requireAuth, requireWriteAccess, requireWorkspaceRole( workspaceType: parsed.data.workspaceType, billingEmail: emptyToNull(parsed.data.billingEmail), 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') { @@ -1480,6 +1541,7 @@ app.delete('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRo workspaceType: 'standard', billingEmail: req.auth!.user.email, billingPlan: 'household_basic', + billingInterval: 'monthly', owner: req.auth!.user, }); nextWorkspaceId = fallbackWorkspace?.id ?? fallbackWorkspaceId; diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 02f16d0..c3110e0 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -18,6 +18,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => { workspace_type VARCHAR(16) NOT NULL DEFAULT 'standard', billing_email VARCHAR(255), 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', stripe_customer_id VARCHAR(255), stripe_subscription_id VARCHAR(255), @@ -32,6 +33,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => { ALTER TABLE workspaces 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_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 stripe_customer_id VARCHAR(255), ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255), diff --git a/backend/src/repositories/authRepository.ts b/backend/src/repositories/authRepository.ts index 99c99d0..2640a06 100644 --- a/backend/src/repositories/authRepository.ts +++ b/backend/src/repositories/authRepository.ts @@ -2,6 +2,7 @@ import { db } from '../db/client.js'; import type { AuthContext, AuthSessionRow, + BillingInterval, BillingPlan, IntegrationTokenRow, IntegrationTokenScope, @@ -38,6 +39,7 @@ const mapSessionAuthRow = ( workspace_workspace_type: WorkspaceType; workspace_billing_email: string | null; workspace_billing_plan: BillingPlan; + workspace_billing_interval: BillingInterval; workspace_subscription_status: SubscriptionStatus; workspace_stripe_customer_id: string | null; workspace_stripe_subscription_id: string | null; @@ -76,6 +78,7 @@ const mapSessionAuthRow = ( workspace_type: row.workspace_workspace_type, billing_email: row.workspace_billing_email, billing_plan: row.workspace_billing_plan, + billing_interval: row.workspace_billing_interval, subscription_status: row.workspace_subscription_status, stripe_customer_id: row.workspace_stripe_customer_id, stripe_subscription_id: row.workspace_stripe_subscription_id, @@ -123,6 +126,7 @@ const mapIntegrationTokenAuthRow = ( workspace_workspace_type: WorkspaceType; workspace_billing_email: string | null; workspace_billing_plan: BillingPlan; + workspace_billing_interval: BillingInterval; workspace_subscription_status: SubscriptionStatus; workspace_stripe_customer_id: string | null; workspace_stripe_subscription_id: string | null; @@ -161,6 +165,7 @@ const mapIntegrationTokenAuthRow = ( workspace_type: row.workspace_workspace_type, billing_email: row.workspace_billing_email, billing_plan: row.workspace_billing_plan, + billing_interval: row.workspace_billing_interval, subscription_status: row.workspace_subscription_status, stripe_customer_id: row.workspace_stripe_customer_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_billing_email: string | null; workspace_billing_plan: BillingPlan; + workspace_billing_interval: BillingInterval; workspace_subscription_status: SubscriptionStatus; workspace_stripe_customer_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.billing_email AS workspace_billing_email, workspaces.billing_plan AS workspace_billing_plan, + workspaces.billing_interval AS workspace_billing_interval, 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, @@ -433,6 +440,7 @@ export const resolveIntegrationTokenAuth = async (tokenHash: string, token: stri workspace_workspace_type: WorkspaceType; workspace_billing_email: string | null; workspace_billing_plan: BillingPlan; + workspace_billing_interval: BillingInterval; workspace_subscription_status: SubscriptionStatus; workspace_stripe_customer_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.billing_email AS workspace_billing_email, workspaces.billing_plan AS workspace_billing_plan, + workspaces.billing_interval AS workspace_billing_interval, 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, diff --git a/backend/src/repositories/workspaceRepository.test.ts b/backend/src/repositories/workspaceRepository.test.ts index 91741db..e9ae239 100644 --- a/backend/src/repositories/workspaceRepository.test.ts +++ b/backend/src/repositories/workspaceRepository.test.ts @@ -46,6 +46,7 @@ test('createWorkspace inserts owner membership and returns the created workspace workspace_type: 'rescue', billing_email: 'billing@example.com', billing_plan: 'rescue_free', + billing_interval: 'monthly', created_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', billingEmail: 'billing@example.com', billingPlan: 'rescue_free', + billingInterval: 'monthly', owner: user, }); @@ -79,6 +81,7 @@ test('updateWorkspace converts an existing household flock to rescue without ins workspace_type: 'rescue', billing_email: 'billing@example.com', billing_plan: 'rescue_free', + billing_interval: 'monthly', subscription_status: 'active', rescue_verification_status: 'pending', 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', billingEmail: 'billing@example.com', billingPlan: 'rescue_free', + billingInterval: 'monthly', }); 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.match(calls[0].text, /UPDATE 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 () => { diff --git a/backend/src/repositories/workspaceRepository.ts b/backend/src/repositories/workspaceRepository.ts index 9688ecb..5739d33 100644 --- a/backend/src/repositories/workspaceRepository.ts +++ b/backend/src/repositories/workspaceRepository.ts @@ -1,5 +1,5 @@ import { db } from '../db/client.js'; -import type { BillingPlan, RescueVerificationStatus, 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 () => { const result = await db.query<{ next_id: number }>('SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM workspaces'); @@ -8,7 +8,7 @@ export const getNextWorkspaceId = async () => { export const getWorkspaceById = async (workspaceId: number) => { const result = await db.query( - `SELECT id, name, workspace_type, billing_email, billing_plan, subscription_status, 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 WHERE id = $1`, [workspaceId], @@ -36,6 +36,7 @@ export const listMembershipsForUser = async (userId: string) => { workspace_type: WorkspaceType; billing_email: string | null; billing_plan: BillingPlan; + billing_interval: BillingInterval; subscription_status: WorkspaceRow['subscription_status']; stripe_customer_id: string | null; stripe_subscription_id: string | null; @@ -57,6 +58,7 @@ export const listMembershipsForUser = async (userId: string) => { workspaces.workspace_type, workspaces.billing_email, workspaces.billing_plan, + workspaces.billing_interval, workspaces.subscription_status, workspaces.stripe_customer_id, workspaces.stripe_subscription_id, @@ -103,8 +105,8 @@ export const ensurePersonalWorkspaceForUser = async (user: UserRow) => { if (!unclaimed.rowCount) { await db.query( - `INSERT INTO workspaces (id, name, workspace_type, billing_plan, billing_email, subscription_status, rescue_verification_status) - VALUES ($1, $2, 'standard', 'household_basic', $3, 'active', 'not_required')`, + `INSERT INTO workspaces (id, name, workspace_type, billing_plan, billing_interval, billing_email, subscription_status, rescue_verification_status) + VALUES ($1, $2, 'standard', 'household_basic', 'monthly', $3, 'active', 'not_required')`, [workspaceId, `${user.name}'s Flock`, user.email], ); } else { @@ -113,6 +115,7 @@ export const ensurePersonalWorkspaceForUser = async (user: UserRow) => { SET name = $2, workspace_type = 'standard', billing_plan = 'household_basic', + billing_interval = 'monthly', billing_email = $3, subscription_status = 'active', rescue_verification_status = 'not_required', @@ -154,6 +157,7 @@ export const createWorkspace = async ({ workspaceType, billingEmail, billingPlan, + billingInterval, owner, }: { id: number; @@ -161,17 +165,19 @@ export const createWorkspace = async ({ workspaceType: WorkspaceType; billingEmail: string | null; billingPlan: BillingPlan; + billingInterval: BillingInterval; owner: UserRow; }) => { await db.query( - `INSERT INTO workspaces (id, name, workspace_type, billing_email, billing_plan, subscription_status, rescue_verification_status) - VALUES ($1, $2, $3, $4, $5, $6, $7)`, + `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, $8)`, [ id, name, workspaceType, billingEmail, billingPlan, + billingInterval, workspaceType === 'rescue' ? 'active' : 'active', workspaceType === 'rescue' ? 'pending' : 'not_required', ], @@ -192,12 +198,14 @@ export const updateWorkspace = async ({ workspaceType, billingEmail, billingPlan, + billingInterval, }: { workspaceId: number; name: string; workspaceType: WorkspaceType; billingEmail: string | null; billingPlan: BillingPlan; + billingInterval: BillingInterval; }) => { const result = await db.query( `WITH input AS ( @@ -206,13 +214,15 @@ export const updateWorkspace = async ({ $2::varchar AS name, $3::varchar AS workspace_type, $4::varchar AS billing_email, - $5::varchar AS billing_plan + $5::varchar AS billing_plan, + $6::varchar AS billing_interval ) UPDATE workspaces SET name = input.name, workspace_type = input.workspace_type, billing_email = input.billing_email, billing_plan = input.billing_plan, + billing_interval = input.billing_interval, rescue_verification_status = CASE WHEN input.workspace_type = 'rescue' AND workspaces.rescue_verification_status = 'not_required' THEN 'pending' WHEN input.workspace_type = 'standard' THEN 'not_required' @@ -221,8 +231,8 @@ export const updateWorkspace = async ({ updated_at = CURRENT_TIMESTAMP FROM input WHERE workspaces.id = input.workspace_id - RETURNING workspaces.id, workspaces.name, workspaces.workspace_type, workspaces.billing_email, workspaces.billing_plan, workspaces.subscription_status, workspaces.stripe_customer_id, workspaces.stripe_subscription_id, workspaces.rescue_verification_status, workspaces.created_at, workspaces.updated_at`, - [workspaceId, name, workspaceType, billingEmail, billingPlan], + 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, billingInterval], ); return result.rows[0] ?? null; @@ -256,7 +266,7 @@ export const findAlternateWorkspaceForUser = async (userId: string, excludeWorks export const listOwnedWorkspacesByOwnerEmail = async (ownerEmail: string, excludeWorkspaceId: number) => { const result = await db.query( - `SELECT workspaces.id, workspaces.name, workspaces.workspace_type, workspaces.billing_email, workspaces.billing_plan, workspaces.subscription_status, workspaces.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 INNER JOIN workspaces ON workspaces.id = workspace_members.workspace_id WHERE LOWER(COALESCE(workspace_members.invite_email, workspace_members.email)) = LOWER($1) @@ -356,6 +366,7 @@ export const listRescueWorkspacesForAdmin = async () => { workspaces.workspace_type, workspaces.billing_email, workspaces.billing_plan, + workspaces.billing_interval, workspaces.subscription_status, workspaces.stripe_customer_id, workspaces.stripe_subscription_id, @@ -397,6 +408,10 @@ export const updateRescueVerificationStatus = async (workspaceId: number, status WHEN $2 = 'rejected' THEN 'household_basic' ELSE billing_plan END, + billing_interval = CASE + WHEN $2 = 'rejected' THEN 'monthly' + ELSE billing_interval + END, rescue_verification_status = CASE WHEN $2 = 'rejected' THEN 'not_required' ELSE $2 @@ -404,7 +419,7 @@ export const updateRescueVerificationStatus = async (workspaceId: number, status updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND workspace_type = 'rescue' - RETURNING id, name, workspace_type, billing_email, billing_plan, subscription_status, 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], ); @@ -416,12 +431,13 @@ export const cancelRescueVerificationRequest = async (workspaceId: number) => { `UPDATE workspaces SET workspace_type = 'standard', billing_plan = 'household_basic', + billing_interval = 'monthly', rescue_verification_status = 'not_required', updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND workspace_type = 'rescue' 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], ); @@ -434,7 +450,7 @@ export const setWorkspaceStripeCustomerId = async (workspaceId: number, stripeCu 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`, + 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], ); @@ -446,21 +462,27 @@ export const setWorkspaceStripeSubscription = async ({ stripeCustomerId, stripeSubscriptionId, subscriptionStatus, + billingPlan, + billingInterval, }: { workspaceId: number; stripeCustomerId: string | null; stripeSubscriptionId: string; subscriptionStatus: SubscriptionStatus; + billingPlan?: BillingPlan | null; + billingInterval?: BillingInterval | null; }) => { const result = await db.query( `UPDATE workspaces SET stripe_customer_id = COALESCE($2, stripe_customer_id), stripe_subscription_id = $3, subscription_status = $4, + billing_plan = COALESCE($5, billing_plan), + billing_interval = COALESCE($6, billing_interval), 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], + 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, billingPlan ?? null, billingInterval ?? null], ); return result.rows[0] ?? null; @@ -469,14 +491,18 @@ export const setWorkspaceStripeSubscription = async ({ export const setWorkspaceSubscriptionStatusByStripeSubscriptionId = async ( stripeSubscriptionId: string, subscriptionStatus: SubscriptionStatus, + billingPlan?: BillingPlan | null, + billingInterval?: BillingInterval | null, ) => { const result = await db.query( `UPDATE workspaces SET subscription_status = $2, + billing_plan = COALESCE($3, billing_plan), + billing_interval = COALESCE($4, billing_interval), 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], + 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, billingPlan ?? null, billingInterval ?? null], ); return result.rows[0] ?? null; diff --git a/backend/src/types.ts b/backend/src/types.ts index 2fd6891..e01f79d 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -1,6 +1,7 @@ export type WorkspaceType = 'standard' | 'rescue'; export type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer'; 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 RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected'; export type ProviderKey = 'google' | 'microsoft' | 'apple'; @@ -21,6 +22,7 @@ export type WorkspaceRow = { workspace_type: WorkspaceType; billing_email: string | null; billing_plan: BillingPlan; + billing_interval: BillingInterval; subscription_status: SubscriptionStatus; stripe_customer_id: string | null; stripe_subscription_id: string | null; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e3e686a..13ec062 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -42,8 +42,14 @@ services: 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_MONTHLY:-} + STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY:-} 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_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_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-${FRONTEND_URL}/?billing=cancelled} STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-${FRONTEND_URL}/} diff --git a/docker-compose.yml b/docker-compose.yml index 9756683..86c0614 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,8 +41,14 @@ services: 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_MONTHLY:-} + STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY:-} 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_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_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-http://localhost:3000/?billing=cancelled} STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-http://localhost:3000/} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a6547a8..42b3b23 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightRefer type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw'; type HouseholdBillingPlan = Exclude; +type BillingInterval = 'monthly' | 'yearly'; type WorkspaceType = 'standard' | 'rescue'; type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer'; type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none'; @@ -52,6 +53,7 @@ type Workspace = { workspaceType: WorkspaceType; billingEmail: string | null; billingPlan: BillingPlan; + billingInterval: BillingInterval; subscriptionStatus: SubscriptionStatus; stripeCustomerId: string | null; stripeSubscriptionId: string | null; @@ -152,6 +154,7 @@ type WorkspaceFormState = { workspaceType: WorkspaceType; billingEmail: string; billingPlan: HouseholdBillingPlan; + billingInterval: BillingInterval; }; type WorkspaceMemberFormState = { @@ -165,6 +168,7 @@ type WorkspaceCreateFormState = { workspaceType: WorkspaceType; billingEmail: string; billingPlan: HouseholdBillingPlan; + billingInterval: BillingInterval; }; type AuthFormState = { @@ -247,6 +251,7 @@ const emptyWorkspaceForm: WorkspaceFormState = { workspaceType: 'standard', billingEmail: '', billingPlan: 'household_basic', + billingInterval: 'monthly', }; const emptyWorkspaceMemberForm: WorkspaceMemberFormState = { @@ -260,6 +265,7 @@ const emptyWorkspaceCreateForm: WorkspaceCreateFormState = { workspaceType: 'standard', billingEmail: '', billingPlan: 'household_basic', + billingInterval: 'monthly', }; const emptyAuthForm: AuthFormState = { @@ -481,6 +487,8 @@ const oauthStartUrl = (providerKey: AuthProvider['providerKey']) => { const isHouseholdPlan = (billingPlan: BillingPlan): billingPlan is HouseholdBillingPlan => billingPlan === 'household_basic' || billingPlan === 'household_plus' || billingPlan === 'household_macaw'; +const formatBillingIntervalName = (billingInterval: BillingInterval) => (billingInterval === 'yearly' ? 'Annual' : 'Monthly'); + const formatBillingPlanName = (billingPlan: BillingPlan) => { if (billingPlan === 'rescue_free') { return 'Rescue Free'; @@ -548,6 +556,15 @@ const formatSubscriptionStatus = (status: SubscriptionStatus) => { 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) => { if (status === 'approved') { return 'Active'; @@ -1122,6 +1139,7 @@ function App() { workspaceType: session.activeWorkspace.workspaceType, billingEmail: session.activeWorkspace.billingEmail ?? '', billingPlan: isHouseholdPlan(session.activeWorkspace.billingPlan) ? session.activeWorkspace.billingPlan : 'household_basic', + billingInterval: session.activeWorkspace.billingInterval, }); setWorkspaceCreateForm((current) => ({ ...current, @@ -1626,6 +1644,7 @@ function App() { workspaceType: workspaceCreateForm.workspaceType, billingEmail: workspaceCreateForm.billingEmail, billingPlan: workspaceCreateForm.workspaceType === 'rescue' ? undefined : workspaceCreateForm.billingPlan, + billingInterval: workspaceCreateForm.workspaceType === 'rescue' ? undefined : workspaceCreateForm.billingInterval, }), }); @@ -2216,6 +2235,7 @@ function App() { body: JSON.stringify({ ...workspaceForm, billingPlan: workspaceForm.workspaceType === 'rescue' ? undefined : workspaceForm.billingPlan, + billingInterval: workspaceForm.workspaceType === 'rescue' ? undefined : workspaceForm.billingInterval, }), }); @@ -2248,6 +2268,7 @@ function App() { workspaceType: savedWorkspace.workspaceType, billingEmail: savedWorkspace.billingEmail ?? '', billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic', + billingInterval: savedWorkspace.billingInterval, }); } catch (workspaceError) { setError(workspaceError instanceof Error ? workspaceError.message : 'Unable to save flock settings.'); @@ -2339,6 +2360,7 @@ function App() { workspaceType: savedWorkspace.workspaceType, billingEmail: savedWorkspace.billingEmail ?? '', billingPlan: isHouseholdPlan(savedWorkspace.billingPlan) ? savedWorkspace.billingPlan : 'household_basic', + billingInterval: savedWorkspace.billingInterval, }); } catch (workspaceError) { 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, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ billingPlan: workspace.billingPlan }), + body: JSON.stringify({ billingPlan: workspace.billingPlan, billingInterval: workspace.billingInterval }), }); if (!response.ok) { @@ -3291,12 +3313,11 @@ function App() {

Flock

-

Flock profile and billing

+

Flock profile

- Each flock carries its own billing and collaboration rules. That lets one person keep a personal household flock while also - participating in a rescue flock without mixing billing or bird ownership. + Manage this flock's name and type. Household billing details live in the Billing info card below.