cleaned up stripe config
This commit is contained in:
+140
-10
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user