additional stripe changes and Billing info cleanup

This commit is contained in:
Corey Blais
2026-04-16 20:44:53 -04:00
parent c757132cbd
commit 96e5694b01
11 changed files with 258 additions and 76 deletions
+72 -10
View File
@@ -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<Record<BillingPlan, string>> = {
household_basic: process.env.STRIPE_PRICE_HOUSEHOLD_CONURE?.trim() ?? '',
household_plus: process.env.STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK?.trim() ?? '',
household_macaw: process.env.STRIPE_PRICE_HOUSEHOLD_MACAW?.trim() ?? '',
const stripePriceByBillingPlanAndInterval: Partial<Record<Exclude<BillingPlan, 'rescue_free'>, Partial<Record<BillingInterval, string>>>> = {
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;
+2
View File
@@ -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),
@@ -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,
@@ -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 () => {
+43 -17
View File
@@ -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<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
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<WorkspaceRow>(
`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<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
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<WorkspaceRow>(
`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<WorkspaceRow>(
`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;
+2
View File
@@ -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;