cleaned up stripe config

This commit is contained in:
Corey Blais
2026-04-30 21:54:13 -04:00
parent 646f895ed6
commit cd7c4383d0
9 changed files with 384 additions and 73 deletions
+140 -10
View File
@@ -52,6 +52,7 @@ import {
memorializeBird,
transferBirdToWorkspace,
updateBird,
updateMemorialReminderPreference,
updateMedicationForBird,
upsertMedicationAdministrationForBird,
updateVetVisitForBird,
@@ -212,7 +213,7 @@ const lostBirdReportSchema = z.object({
const birdSchema = z.object({
name: z.string().trim().min(1).max(120),
tagId: z.string().trim().min(1).max(80),
tagId: z.string().trim().max(80).optional().or(z.literal('')),
species: z.string().trim().min(1).max(120),
gender: birdGenderSchema.optional(),
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
@@ -229,6 +230,10 @@ const memorializeBirdSchema = z.object({
notifyOnMemorialDay: z.boolean().optional(),
});
const memorialReminderPreferenceSchema = z.object({
notifyOnMemorialDay: z.boolean(),
});
const weightSchema = z.object({
weightGrams: z.coerce.number().positive().max(10000),
recordedOn: dateStringSchema,
@@ -285,6 +290,13 @@ const emptyToNull = (value?: string) => {
return trimmed ? trimmed : null;
};
const unknownBandIdValues = new Set(['unknown', 'not recorded', 'n/a', 'na', 'none']);
const normalizeBandId = (value?: string | null) => {
const trimmed = value?.trim() ?? '';
return trimmed && !unknownBandIdValues.has(trimmed.toLowerCase()) ? trimmed : null;
};
const normalizeEmail = (value: string) => value.trim().toLowerCase();
const hashToken = (token: string) => crypto.createHash('sha256').update(token).digest('hex');
const createSessionToken = () => crypto.randomBytes(32).toString('hex');
@@ -318,9 +330,25 @@ const smtpFromName = process.env.SMTP_FROM_NAME?.trim() || 'FlockPal';
const rescueStatusNotificationEmail = process.env.RESCUE_STATUS_NOTIFICATION_EMAIL?.trim() || 'appadmin@flockpal.app';
const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim() ?? '';
const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET?.trim() ?? '';
const stripeCheckoutSuccessUrl = process.env.STRIPE_CHECKOUT_SUCCESS_URL?.trim() || `${frontendBaseUrl}/?billing=success`;
const stripeCheckoutCancelUrl = process.env.STRIPE_CHECKOUT_CANCEL_URL?.trim() || `${frontendBaseUrl}/?billing=cancelled`;
const stripePortalReturnUrl = process.env.STRIPE_PORTAL_RETURN_URL?.trim() || frontendBaseUrl;
const withBillingRedirectState = (url: string, billingState: 'success' | 'cancelled' | 'portal') => {
const nextUrl = new URL(url);
if (!nextUrl.searchParams.has('billing')) {
nextUrl.searchParams.set('billing', billingState);
}
return nextUrl.toString();
};
const stripeCheckoutSuccessUrl = withBillingRedirectState(
process.env.STRIPE_CHECKOUT_SUCCESS_URL?.trim() || `${frontendBaseUrl}/?billing=success`,
'success',
);
const stripeCheckoutCancelUrl = withBillingRedirectState(
process.env.STRIPE_CHECKOUT_CANCEL_URL?.trim() || `${frontendBaseUrl}/?billing=cancelled`,
'cancelled',
);
const stripePortalReturnUrl = withBillingRedirectState(process.env.STRIPE_PORTAL_RETURN_URL?.trim() || frontendBaseUrl, 'portal');
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() || '',
@@ -434,7 +462,7 @@ const normalizeBird = (row: BirdRow) => ({
id: row.id,
workspaceId: row.workspace_id,
name: row.name,
tagId: row.tag_id,
tagId: normalizeBandId(row.tag_id),
species: row.species,
gender: row.gender,
dateOfBirth: row.date_of_birth,
@@ -697,6 +725,61 @@ const getStripeClient = () => {
return stripe;
};
const getMostRelevantStripeSubscriptionForWorkspace = async (workspace: WorkspaceRow) => {
const stripeClient = getStripeClient();
if (workspace.stripe_subscription_id) {
return stripeClient.subscriptions.retrieve(workspace.stripe_subscription_id);
}
if (!workspace.stripe_customer_id) {
return null;
}
const subscriptions = await stripeClient.subscriptions.list({
customer: workspace.stripe_customer_id,
status: 'all',
limit: 20,
});
const matchingSubscription = [...subscriptions.data]
.filter((subscription) => String(subscription.metadata?.workspaceId ?? '') === String(workspace.id))
.sort((left, right) => right.created - left.created)[0];
return matchingSubscription ?? null;
};
const syncWorkspaceStripeBilling = async (workspaceId: number) => {
const workspace = await getWorkspaceById(workspaceId);
if (!workspace) {
return null;
}
if (workspace.workspace_type === 'rescue') {
throw new Error('Rescue flocks do not use Stripe billing.');
}
const subscription = await getMostRelevantStripeSubscriptionForWorkspace(workspace);
if (!subscription) {
return workspace;
}
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
const billingSelection = getBillingSelectionForStripeSubscription(subscription);
return (
(await setWorkspaceStripeSubscription({
workspaceId: workspace.id,
stripeCustomerId: customerId,
stripeSubscriptionId: subscription.id,
subscriptionStatus: mapStripeSubscriptionStatus(subscription.status),
billingPlan: billingSelection.billingPlan,
billingInterval: billingSelection.billingInterval,
})) ?? workspace
);
};
const getStripePriceIdForBillingPlan = (billingPlan: BillingPlan, billingInterval: BillingInterval) => {
if (billingPlan === 'rescue_free') {
throw new Error('Rescue flocks do not use Stripe billing.');
@@ -1073,7 +1156,7 @@ const sendLostBirdReportNotification = async ({
const lines = [
`A possible found bird report was submitted for ${bird.name}.`,
'',
`Band ID: ${bird.tag_id}`,
`Band ID: ${bird.tag_id ?? 'Not recorded'}`,
`Species: ${bird.species}`,
`Flock: ${bird.workspace_name}`,
'',
@@ -1100,7 +1183,7 @@ const sendLostBirdReportNotification = async ({
html: `
<p>A possible found bird report was submitted for <strong>${escapeHtml(bird.name)}</strong>.</p>
<ul>
<li><strong>Band ID:</strong> ${escapeHtml(bird.tag_id)}</li>
<li><strong>Band ID:</strong> ${escapeHtml(bird.tag_id ?? 'Not recorded')}</li>
<li><strong>Species:</strong> ${escapeHtml(bird.species)}</li>
<li><strong>Flock:</strong> ${escapeHtml(bird.workspace_name)}</li>
<li><strong>Finder name:</strong> ${escapeHtml(finderName)}</li>
@@ -1986,6 +2069,27 @@ app.post(
},
);
app.post(
'/api/billing/sync',
requireAuth,
requireSessionAuth,
requireWorkspaceRole(['owner', 'assistant']),
async (req: Request, res: Response, next: NextFunction) => {
try {
const syncedWorkspace = await syncWorkspaceStripeBilling(req.auth!.workspace.id);
if (!syncedWorkspace) {
res.status(404).json({ error: 'Workspace not found.' });
return;
}
res.json({ workspace: normalizeWorkspace(syncedWorkspace) });
} catch (error) {
next(error);
}
},
);
app.get('/api/integration-tokens', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const tokens = await listIntegrationTokens(req.auth!.user.id, req.auth!.workspace.id);
@@ -2292,7 +2396,7 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
const bird = await createBird({
workspaceId: req.auth!.workspace.id,
name: parsed.data.name,
tagId: parsed.data.tagId,
tagId: normalizeBandId(parsed.data.tagId),
species: parsed.data.species,
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
@@ -2413,7 +2517,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
birdId: req.params.birdId,
workspaceId: req.auth!.workspace.id,
name: parsed.data.name,
tagId: parsed.data.tagId,
tagId: normalizeBandId(parsed.data.tagId),
species: parsed.data.species,
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
@@ -2491,7 +2595,7 @@ app.post('/api/birds/:birdId/memorialize', requireAuth, requireWriteAccess, requ
workspaceId: req.auth!.workspace.id,
memorializedOn: parsed.data.memorializedOn,
memorialNote: emptyToNull(parsed.data.memorialNote),
notifyOnMemorialDay: parsed.data.notifyOnMemorialDay ?? true,
notifyOnMemorialDay: parsed.data.notifyOnMemorialDay ?? false,
});
if (!bird) {
@@ -2505,6 +2609,32 @@ app.post('/api/birds/:birdId/memorialize', requireAuth, requireWriteAccess, requ
}
});
app.patch('/api/birds/:birdId/memorial-reminders', requireAuth, requireWriteAccess, requireSessionAuth, requireWorkspaceRole(['owner']), async (req: Request, res: Response, next: NextFunction) => {
const parsed = memorialReminderPreferenceSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid memorial reminder payload', details: parsed.error.flatten() });
return;
}
try {
const bird = await updateMemorialReminderPreference({
birdId: req.params.birdId,
workspaceId: req.auth!.workspace.id,
notifyOnMemorialDay: parsed.data.notifyOnMemorialDay,
});
if (!bird) {
res.status(404).json({ error: 'Memorialized bird not found.' });
return;
}
res.json({ bird: normalizeBird(bird) });
} catch (error) {
next(error);
}
});
app.get('/api/birds/:birdId/weights', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const days = Math.min(Math.max(Number(req.query.days ?? 30), 1), 425);
+15 -2
View File
@@ -199,7 +199,7 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER NOT NULL DEFAULT 1,
name VARCHAR(120) NOT NULL,
tag_id VARCHAR(80) NOT NULL,
tag_id VARCHAR(80),
species VARCHAR(120) NOT NULL,
gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
date_of_birth DATE,
@@ -229,6 +229,14 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ADD COLUMN IF NOT EXISTS memorial_note VARCHAR(1000),
ADD COLUMN IF NOT EXISTS notify_on_memorial_day BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE birds
ALTER COLUMN tag_id DROP NOT NULL;
UPDATE birds
SET tag_id = NULL
WHERE tag_id IS NOT NULL
AND LOWER(BTRIM(tag_id)) IN ('unknown', 'not recorded', 'n/a', 'na', 'none');
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'birds_workspace_fk') THEN
@@ -241,8 +249,13 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ALTER TABLE birds
DROP CONSTRAINT IF EXISTS birds_tag_id_key;
DROP INDEX IF EXISTS idx_birds_workspace_tag_id;
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_workspace_tag_id
ON birds (workspace_id, tag_id);
ON birds (workspace_id, LOWER(tag_id))
WHERE tag_id IS NOT NULL
AND BTRIM(tag_id) <> ''
AND LOWER(BTRIM(tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none');
CREATE TABLE IF NOT EXISTS pending_bird_transfers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+42 -3
View File
@@ -113,7 +113,10 @@ export const findBirdsByBandId = async (tagId: string) => {
ORDER BY recorded_on DESC
LIMIT 1
) latest ON TRUE
WHERE LOWER(birds.tag_id) = LOWER($1)
WHERE birds.tag_id IS NOT NULL
AND BTRIM(birds.tag_id) <> ''
AND LOWER(BTRIM(birds.tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none')
AND LOWER(birds.tag_id) = LOWER($1)
AND birds.memorialized_at IS NULL
ORDER BY birds.created_at ASC
LIMIT 10`,
@@ -261,7 +264,7 @@ export const createBird = async ({
}: {
workspaceId: number;
name: string;
tagId: string;
tagId: string | null;
species: string;
gender: BirdGender;
dateOfBirth: string | null;
@@ -298,7 +301,7 @@ export const updateBird = async ({
birdId: string;
workspaceId: number;
name: string;
tagId: string;
tagId: string | null;
species: string;
gender: BirdGender;
dateOfBirth: string | null;
@@ -387,6 +390,42 @@ export const memorializeBird = async ({
return result.rows[0] ?? null;
};
export const updateMemorialReminderPreference = async ({
birdId,
workspaceId,
notifyOnMemorialDay,
}: {
birdId: string;
workspaceId: number;
notifyOnMemorialDay: boolean;
}) => {
const result = await db.query<BirdRow>(
`UPDATE birds
SET notify_on_memorial_day = $3
WHERE id = $1
AND workspace_id = $2
AND memorialized_at IS NOT NULL
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
(
SELECT weight_grams::text
FROM weight_records
WHERE bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) AS latest_weight_grams,
(
SELECT recorded_on::text
FROM weight_records
WHERE bird_id = birds.id
ORDER BY recorded_on DESC
LIMIT 1
) AS latest_recorded_on`,
[birdId, workspaceId, notifyOnMemorialDay],
);
return result.rows[0] ?? null;
};
export const deleteBird = async (birdId: string, workspaceId: number) => {
const result = await db.query<{ id: string }>(
`DELETE FROM birds
+1 -1
View File
@@ -96,7 +96,7 @@ export type BirdRow = {
id: string;
workspace_id: number;
name: string;
tag_id: string;
tag_id: string | null;
species: string;
gender: BirdGender;
date_of_birth: string | null;