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
+1 -1
View File
@@ -23,4 +23,4 @@ STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY=
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY= STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY=
STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/?billing=success STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/?billing=success
STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled
STRIPE_PORTAL_RETURN_URL=http://localhost:3000/ STRIPE_PORTAL_RETURN_URL=http://localhost:3000/?billing=portal
+24 -2
View File
@@ -24,7 +24,6 @@ FlockPal is a Dockerized TypeScript app for tracking flock health with a clean,
- Medication and care reminders - Medication and care reminders
- Invitation acceptance and onboarding polish for flock members - Invitation acceptance and onboarding polish for flock members
- Stripe or equivalent billing integration for paid household tiers
- Scheduled reminder delivery for birthdays, gotcha days, and care events - Scheduled reminder delivery for birthdays, gotcha days, and care events
- Audit logging for flock access changes and bird transfers - Audit logging for flock access changes and bird transfers
@@ -111,9 +110,32 @@ Set these when the Stripe Checkout, Customer Portal, and webhook flow is enabled
- `STRIPE_CHECKOUT_CANCEL_URL` - `STRIPE_CHECKOUT_CANCEL_URL`
- `STRIPE_PORTAL_RETURN_URL` - `STRIPE_PORTAL_RETURN_URL`
Recommended defaults:
- `STRIPE_CHECKOUT_SUCCESS_URL=https://your-frontend-host/?billing=success`
- `STRIPE_CHECKOUT_CANCEL_URL=https://your-frontend-host/?billing=cancelled`
- `STRIPE_PORTAL_RETURN_URL=https://your-frontend-host/?billing=portal`
## Stripe operations
- Configure the Stripe Customer Portal to allow subscription plan changes for the household products.
- Enable the proration behavior you want in the Customer Portal configuration. FlockPal treats Stripe as the source of truth for upgrade timing and proration outcomes.
- Point the Stripe webhook endpoint at `https://your-backend-host/api/billing/stripe/webhook`.
- Subscribe the webhook endpoint to at least `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, and `customer.subscription.deleted`.
- Use one Stripe Price per plan and billing interval. FlockPal maps Stripe price IDs back to `household_basic`, `household_plus`, and `household_macaw`.
- After Stripe redirects back to the app, FlockPal now performs a direct billing sync against Stripe and then refreshes the active session. Webhooks are still required so asynchronous subscription changes stay in sync later.
For local development with the Stripe CLI:
```bash
stripe listen --forward-to http://localhost:5000/api/billing/stripe/webhook
```
Copy the signing secret printed by `stripe listen` into `STRIPE_WEBHOOK_SECRET`.
## Notes for monetization and security ## Notes for monetization and security
This starter now includes the account and flock foundation for monetization, but it still needs production-grade session hardening, invitation verification, Stripe checkout/customer portal/webhook flows, audit logging, and background reminder delivery before launch. This starter now includes the account and flock foundation for monetization, plus Stripe checkout, Customer Portal, and webhook synchronization. It still needs production-grade session hardening, invitation verification, audit logging, and background reminder delivery before launch.
Stripe billing should be attached to `workspaces`, not `users`. Each flock has its own billing plan, subscription status, Stripe customer ID, and Stripe subscription ID, which lets one person own multiple household flocks with separate subscriptions while rescue flocks can stay on the free rescue path. Stripe billing should be attached to `workspaces`, not `users`. Each flock has its own billing plan, subscription status, Stripe customer ID, and Stripe subscription ID, which lets one person own multiple household flocks with separate subscriptions while rescue flocks can stay on the free rescue path.
+140 -10
View File
@@ -52,6 +52,7 @@ import {
memorializeBird, memorializeBird,
transferBirdToWorkspace, transferBirdToWorkspace,
updateBird, updateBird,
updateMemorialReminderPreference,
updateMedicationForBird, updateMedicationForBird,
upsertMedicationAdministrationForBird, upsertMedicationAdministrationForBird,
updateVetVisitForBird, updateVetVisitForBird,
@@ -212,7 +213,7 @@ const lostBirdReportSchema = z.object({
const birdSchema = z.object({ const birdSchema = z.object({
name: z.string().trim().min(1).max(120), 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), species: z.string().trim().min(1).max(120),
gender: birdGenderSchema.optional(), gender: birdGenderSchema.optional(),
dateOfBirth: dateStringSchema.optional().or(z.literal('')), dateOfBirth: dateStringSchema.optional().or(z.literal('')),
@@ -229,6 +230,10 @@ const memorializeBirdSchema = z.object({
notifyOnMemorialDay: z.boolean().optional(), notifyOnMemorialDay: z.boolean().optional(),
}); });
const memorialReminderPreferenceSchema = z.object({
notifyOnMemorialDay: z.boolean(),
});
const weightSchema = z.object({ const weightSchema = z.object({
weightGrams: z.coerce.number().positive().max(10000), weightGrams: z.coerce.number().positive().max(10000),
recordedOn: dateStringSchema, recordedOn: dateStringSchema,
@@ -285,6 +290,13 @@ const emptyToNull = (value?: string) => {
return trimmed ? trimmed : null; 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 normalizeEmail = (value: string) => value.trim().toLowerCase();
const hashToken = (token: string) => crypto.createHash('sha256').update(token).digest('hex'); const hashToken = (token: string) => crypto.createHash('sha256').update(token).digest('hex');
const createSessionToken = () => crypto.randomBytes(32).toString('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 rescueStatusNotificationEmail = process.env.RESCUE_STATUS_NOTIFICATION_EMAIL?.trim() || 'appadmin@flockpal.app';
const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim() ?? ''; const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim() ?? '';
const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET?.trim() ?? ''; const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET?.trim() ?? '';
const stripeCheckoutSuccessUrl = process.env.STRIPE_CHECKOUT_SUCCESS_URL?.trim() || `${frontendBaseUrl}/?billing=success`; const withBillingRedirectState = (url: string, billingState: 'success' | 'cancelled' | 'portal') => {
const stripeCheckoutCancelUrl = process.env.STRIPE_CHECKOUT_CANCEL_URL?.trim() || `${frontendBaseUrl}/?billing=cancelled`; const nextUrl = new URL(url);
const stripePortalReturnUrl = process.env.STRIPE_PORTAL_RETURN_URL?.trim() || frontendBaseUrl;
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>>>> = { const stripePriceByBillingPlanAndInterval: Partial<Record<Exclude<BillingPlan, 'rescue_free'>, Partial<Record<BillingInterval, string>>>> = {
household_basic: { household_basic: {
monthly: process.env.STRIPE_PRICE_HOUSEHOLD_CONURE_MONTHLY?.trim() || process.env.STRIPE_PRICE_HOUSEHOLD_CONURE?.trim() || '', 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, id: row.id,
workspaceId: row.workspace_id, workspaceId: row.workspace_id,
name: row.name, name: row.name,
tagId: row.tag_id, tagId: normalizeBandId(row.tag_id),
species: row.species, species: row.species,
gender: row.gender, gender: row.gender,
dateOfBirth: row.date_of_birth, dateOfBirth: row.date_of_birth,
@@ -697,6 +725,61 @@ const getStripeClient = () => {
return stripe; 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) => { const getStripePriceIdForBillingPlan = (billingPlan: BillingPlan, billingInterval: BillingInterval) => {
if (billingPlan === 'rescue_free') { if (billingPlan === 'rescue_free') {
throw new Error('Rescue flocks do not use Stripe billing.'); throw new Error('Rescue flocks do not use Stripe billing.');
@@ -1073,7 +1156,7 @@ const sendLostBirdReportNotification = async ({
const lines = [ const lines = [
`A possible found bird report was submitted for ${bird.name}.`, `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}`, `Species: ${bird.species}`,
`Flock: ${bird.workspace_name}`, `Flock: ${bird.workspace_name}`,
'', '',
@@ -1100,7 +1183,7 @@ const sendLostBirdReportNotification = async ({
html: ` html: `
<p>A possible found bird report was submitted for <strong>${escapeHtml(bird.name)}</strong>.</p> <p>A possible found bird report was submitted for <strong>${escapeHtml(bird.name)}</strong>.</p>
<ul> <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>Species:</strong> ${escapeHtml(bird.species)}</li>
<li><strong>Flock:</strong> ${escapeHtml(bird.workspace_name)}</li> <li><strong>Flock:</strong> ${escapeHtml(bird.workspace_name)}</li>
<li><strong>Finder name:</strong> ${escapeHtml(finderName)}</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) => { app.get('/api/integration-tokens', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
try { try {
const tokens = await listIntegrationTokens(req.auth!.user.id, req.auth!.workspace.id); 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({ const bird = await createBird({
workspaceId: req.auth!.workspace.id, workspaceId: req.auth!.workspace.id,
name: parsed.data.name, name: parsed.data.name,
tagId: parsed.data.tagId, tagId: normalizeBandId(parsed.data.tagId),
species: parsed.data.species, species: parsed.data.species,
gender: (parsed.data.gender ?? 'unknown') as BirdGender, gender: (parsed.data.gender ?? 'unknown') as BirdGender,
dateOfBirth: emptyToNull(parsed.data.dateOfBirth), dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
@@ -2413,7 +2517,7 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
birdId: req.params.birdId, birdId: req.params.birdId,
workspaceId: req.auth!.workspace.id, workspaceId: req.auth!.workspace.id,
name: parsed.data.name, name: parsed.data.name,
tagId: parsed.data.tagId, tagId: normalizeBandId(parsed.data.tagId),
species: parsed.data.species, species: parsed.data.species,
gender: (parsed.data.gender ?? 'unknown') as BirdGender, gender: (parsed.data.gender ?? 'unknown') as BirdGender,
dateOfBirth: emptyToNull(parsed.data.dateOfBirth), dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
@@ -2491,7 +2595,7 @@ app.post('/api/birds/:birdId/memorialize', requireAuth, requireWriteAccess, requ
workspaceId: req.auth!.workspace.id, workspaceId: req.auth!.workspace.id,
memorializedOn: parsed.data.memorializedOn, memorializedOn: parsed.data.memorializedOn,
memorialNote: emptyToNull(parsed.data.memorialNote), memorialNote: emptyToNull(parsed.data.memorialNote),
notifyOnMemorialDay: parsed.data.notifyOnMemorialDay ?? true, notifyOnMemorialDay: parsed.data.notifyOnMemorialDay ?? false,
}); });
if (!bird) { 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) => { app.get('/api/birds/:birdId/weights', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
try { try {
const days = Math.min(Math.max(Number(req.query.days ?? 30), 1), 425); 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(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER NOT NULL DEFAULT 1, workspace_id INTEGER NOT NULL DEFAULT 1,
name VARCHAR(120) NOT NULL, name VARCHAR(120) NOT NULL,
tag_id VARCHAR(80) NOT NULL, tag_id VARCHAR(80),
species VARCHAR(120) NOT NULL, species VARCHAR(120) NOT NULL,
gender VARCHAR(16) NOT NULL DEFAULT 'unknown', gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
date_of_birth DATE, 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 memorial_note VARCHAR(1000),
ADD COLUMN IF NOT EXISTS notify_on_memorial_day BOOLEAN NOT NULL DEFAULT FALSE; 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 $$ DO $$
BEGIN BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'birds_workspace_fk') THEN 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 ALTER TABLE birds
DROP CONSTRAINT IF EXISTS birds_tag_id_key; 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 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 ( CREATE TABLE IF NOT EXISTS pending_bird_transfers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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 ORDER BY recorded_on DESC
LIMIT 1 LIMIT 1
) latest ON TRUE ) 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 AND birds.memorialized_at IS NULL
ORDER BY birds.created_at ASC ORDER BY birds.created_at ASC
LIMIT 10`, LIMIT 10`,
@@ -261,7 +264,7 @@ export const createBird = async ({
}: { }: {
workspaceId: number; workspaceId: number;
name: string; name: string;
tagId: string; tagId: string | null;
species: string; species: string;
gender: BirdGender; gender: BirdGender;
dateOfBirth: string | null; dateOfBirth: string | null;
@@ -298,7 +301,7 @@ export const updateBird = async ({
birdId: string; birdId: string;
workspaceId: number; workspaceId: number;
name: string; name: string;
tagId: string; tagId: string | null;
species: string; species: string;
gender: BirdGender; gender: BirdGender;
dateOfBirth: string | null; dateOfBirth: string | null;
@@ -387,6 +390,42 @@ export const memorializeBird = async ({
return result.rows[0] ?? null; 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) => { export const deleteBird = async (birdId: string, workspaceId: number) => {
const result = await db.query<{ id: string }>( const result = await db.query<{ id: string }>(
`DELETE FROM birds `DELETE FROM birds
+1 -1
View File
@@ -96,7 +96,7 @@ export type BirdRow = {
id: string; id: string;
workspace_id: number; workspace_id: number;
name: string; name: string;
tag_id: string; tag_id: string | null;
species: string; species: string;
gender: BirdGender; gender: BirdGender;
date_of_birth: string | null; date_of_birth: string | null;
+1 -1
View File
@@ -54,7 +54,7 @@ services:
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY:-} STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY:-}
STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-${FRONTEND_URL}/?billing=success} STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-${FRONTEND_URL}/?billing=success}
STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-${FRONTEND_URL}/?billing=cancelled} STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-${FRONTEND_URL}/?billing=cancelled}
STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-${FRONTEND_URL}/} STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-${FRONTEND_URL}/?billing=portal}
SMTP_HOST: ${SMTP_HOST:-} SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587} SMTP_PORT: ${SMTP_PORT:-587}
SMTP_SECURE: ${SMTP_SECURE:-false} SMTP_SECURE: ${SMTP_SECURE:-false}
+1 -1
View File
@@ -53,7 +53,7 @@ services:
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY:-} STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY:-}
STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-http://localhost:3000/?billing=success} STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-http://localhost:3000/?billing=success}
STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-http://localhost:3000/?billing=cancelled} STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-http://localhost:3000/?billing=cancelled}
STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-http://localhost:3000/} STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-http://localhost:3000/?billing=portal}
SMTP_HOST: ${SMTP_HOST:-} SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587} SMTP_PORT: ${SMTP_PORT:-587}
SMTP_SECURE: ${SMTP_SECURE:-false} SMTP_SECURE: ${SMTP_SECURE:-false}
+159 -52
View File
@@ -17,7 +17,7 @@ type Bird = {
id: string; id: string;
workspaceId?: number; workspaceId?: number;
name: string; name: string;
tagId: string; tagId: string | null;
species: string; species: string;
gender: BirdGender; gender: BirdGender;
dateOfBirth: string | null; dateOfBirth: string | null;
@@ -231,6 +231,11 @@ type AuthNotice = {
previewUrl?: string | null; previewUrl?: string | null;
}; };
type BillingNotice = {
kind: 'success' | 'info' | 'error';
message: string;
};
type BulkWeightRowState = { type BulkWeightRowState = {
weightGrams: string; weightGrams: string;
}; };
@@ -310,7 +315,7 @@ const emptyBirdForm: BirdFormState = {
const emptyMemorializeBirdForm = (): MemorializeBirdFormState => ({ const emptyMemorializeBirdForm = (): MemorializeBirdFormState => ({
memorializedOn: new Date().toISOString().slice(0, 10), memorializedOn: new Date().toISOString().slice(0, 10),
memorialNote: '', memorialNote: '',
notifyOnMemorialDay: true, notifyOnMemorialDay: false,
}); });
const emptyWorkspaceForm: WorkspaceFormState = { const emptyWorkspaceForm: WorkspaceFormState = {
@@ -408,7 +413,7 @@ const sortBirdsByName = (nextBirds: Bird[]) => [...nextBirds].sort((left, right)
const toBirdForm = (bird: Bird): BirdFormState => ({ const toBirdForm = (bird: Bird): BirdFormState => ({
name: bird.name, name: bird.name,
tagId: bird.tagId, tagId: bird.tagId ?? '',
species: bird.species, species: bird.species,
gender: bird.gender, gender: bird.gender,
dateOfBirth: bird.dateOfBirth ?? '', dateOfBirth: bird.dateOfBirth ?? '',
@@ -1072,6 +1077,7 @@ function App() {
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]); const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
const [authForm, setAuthForm] = useState<AuthFormState>(emptyAuthForm); const [authForm, setAuthForm] = useState<AuthFormState>(emptyAuthForm);
const [authNotice, setAuthNotice] = useState<AuthNotice | null>(null); const [authNotice, setAuthNotice] = useState<AuthNotice | null>(null);
const [billingNotice, setBillingNotice] = useState<BillingNotice | null>(null);
const [authLoading, setAuthLoading] = useState(true); const [authLoading, setAuthLoading] = useState(true);
const [authSubmitting, setAuthSubmitting] = useState(false); const [authSubmitting, setAuthSubmitting] = useState(false);
const [lostBirdReportForm, setLostBirdReportForm] = useState<LostBirdReportFormState>(emptyLostBirdReportForm); const [lostBirdReportForm, setLostBirdReportForm] = useState<LostBirdReportFormState>(emptyLostBirdReportForm);
@@ -1157,6 +1163,7 @@ function App() {
} | null>(null); } | null>(null);
const [deletingBird, setDeletingBird] = useState(false); const [deletingBird, setDeletingBird] = useState(false);
const [memorializingBird, setMemorializingBird] = useState(false); const [memorializingBird, setMemorializingBird] = useState(false);
const [savingMemorialReminderBirdId, setSavingMemorialReminderBirdId] = useState('');
const [editingVetVisitId, setEditingVetVisitId] = useState(''); const [editingVetVisitId, setEditingVetVisitId] = useState('');
const [deletingVetVisitId, setDeletingVetVisitId] = useState(''); const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
const [editingMedicationId, setEditingMedicationId] = useState(''); const [editingMedicationId, setEditingMedicationId] = useState('');
@@ -1572,6 +1579,7 @@ function App() {
setAuthSession(session); setAuthSession(session);
setAuthProviders(session.providers); setAuthProviders(session.providers);
setAuthNotice(null); setAuthNotice(null);
setBillingNotice(null);
setNewIntegrationTokenSecret(''); setNewIntegrationTokenSecret('');
setWorkspace(session.activeWorkspace); setWorkspace(session.activeWorkspace);
setActiveMembership({ setActiveMembership({
@@ -1616,6 +1624,34 @@ function App() {
setIntegrationTokenForm(emptyIntegrationTokenForm); setIntegrationTokenForm(emptyIntegrationTokenForm);
setNewIntegrationTokenSecret(''); setNewIntegrationTokenSecret('');
setAuthNotice(null); setAuthNotice(null);
setBillingNotice(null);
};
const refreshAuthSession = async (token: string) => {
const response = await apiFetch('/auth/session', token);
if (!response.ok) {
if (response.status === 401) {
clearAppSession();
}
throw new Error(await readErrorMessage(response, 'Unable to refresh your billing status.'));
}
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {};
if (!data.session) {
throw new Error('Unable to refresh your billing status.');
}
const nextToken = data.token || token;
persistSessionToken(nextToken);
applySession(data.session, nextToken);
return {
session: data.session,
token: nextToken,
};
}; };
useEffect(() => { useEffect(() => {
@@ -1646,30 +1682,61 @@ function App() {
const url = new URL(window.location.href); const url = new URL(window.location.href);
const callbackToken = url.searchParams.get('auth_token') ?? ''; const callbackToken = url.searchParams.get('auth_token') ?? '';
const billingState = url.searchParams.get('billing');
const token = callbackToken || readStoredSessionToken(); const token = callbackToken || readStoredSessionToken();
if (callbackToken) { if (callbackToken) {
persistSessionToken(callbackToken); persistSessionToken(callbackToken);
url.searchParams.delete('auth_token'); url.searchParams.delete('auth_token');
window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`);
} }
if (!token) { if (!token) {
return; return;
} }
const response = await apiFetch('/auth/session', token); const { session, token: sessionToken } = await refreshAuthSession(token);
if (!response.ok) { if (billingState === 'success' || billingState === 'portal') {
clearAppSession(); try {
return; const syncResponse = await apiFetch('/billing/sync', sessionToken, { method: 'POST' });
if (!syncResponse.ok) {
throw new Error(await readErrorMessage(syncResponse, 'Returned from Stripe, but billing could not be refreshed yet.'));
}
const { session: refreshedSession } = await refreshAuthSession(sessionToken);
const syncedWorkspace = refreshedSession.activeWorkspace;
const planName = formatBillingPlanName(syncedWorkspace.billingPlan);
const intervalName = formatBillingIntervalName(syncedWorkspace.billingInterval);
setBillingNotice({
kind: 'success',
message:
billingState === 'success'
? `Stripe checkout completed. Billing is now ${planName} on ${intervalName}.`
: `Stripe billing changes synced. Current plan: ${planName} on ${intervalName}.`,
});
} catch (billingSyncError) {
setBillingNotice({
kind: 'info',
message:
billingSyncError instanceof Error
? billingSyncError.message
: 'Returned from Stripe. Billing changes may still be syncing.',
});
}
} else if (billingState === 'cancelled') {
setBillingNotice({
kind: 'info',
message: 'Stripe checkout was cancelled. No billing changes were applied.',
});
} else {
setBillingNotice(null);
} }
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {}; if (session && (callbackToken || billingState)) {
url.searchParams.delete('billing');
if (data.session && (data.token || token)) { window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`);
persistSessionToken(data.token || token);
applySession(data.session, data.token || token);
setError(''); setError('');
} }
} catch (loadError) { } catch (loadError) {
@@ -2931,6 +2998,38 @@ function App() {
} }
}; };
const handleMemorialReminderPreferenceChange = async (bird: Bird, notifyOnMemorialDay: boolean) => {
if (savingMemorialReminderBirdId) {
return;
}
setSavingMemorialReminderBirdId(bird.id);
setError('');
try {
const response = await apiFetch(`/birds/${bird.id}/memorial-reminders`, authToken, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notifyOnMemorialDay }),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to update memorial reminder setting.'));
}
const data = await readJsonSafely<{ bird: Bird }>(response);
if (!data?.bird) {
throw new Error('Unable to update memorial reminder setting.');
}
setMemorializedBirds((current) => current.map((currentBird) => (currentBird.id === data.bird.id ? data.bird : currentBird)));
} catch (preferenceError) {
setError(preferenceError instanceof Error ? preferenceError.message : 'Unable to update memorial reminder setting.');
} finally {
setSavingMemorialReminderBirdId('');
}
};
const handleFlockTransferSubmit = async (event: React.FormEvent<HTMLFormElement>) => { const handleFlockTransferSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
if (transferringBird) { if (transferringBird) {
@@ -3172,6 +3271,7 @@ function App() {
} }
setError(''); setError('');
setBillingNotice(null);
setBillingRedirecting(true); setBillingRedirecting(true);
setSavingWorkspace(true); setSavingWorkspace(true);
@@ -3210,6 +3310,7 @@ function App() {
} }
setError(''); setError('');
setBillingNotice(null);
setBillingRedirecting(true); setBillingRedirecting(true);
try { try {
@@ -4104,7 +4205,7 @@ function App() {
</span> </span>
</h3> </h3>
<p className="muted"> <p className="muted">
{selectedBird.species} Band {selectedBird.tagId} {selectedBird.species} {selectedBird.tagId ? `Band ${selectedBird.tagId}` : 'Band ID not recorded'}
</p> </p>
<p className="muted">Added {formatDate(selectedBird.createdAt.slice(0, 10))}</p> <p className="muted">Added {formatDate(selectedBird.createdAt.slice(0, 10))}</p>
</div> </div>
@@ -4117,7 +4218,7 @@ function App() {
</article> </article>
<article className="detail-card"> <article className="detail-card">
<span>Band ID</span> <span>Band ID</span>
<strong>{selectedBird.tagId}</strong> <strong>{selectedBird.tagId || 'Not recorded'}</strong>
</article> </article>
<article className="detail-card"> <article className="detail-card">
<span>Hatch Day</span> <span>Hatch Day</span>
@@ -4489,6 +4590,7 @@ function App() {
</div> </div>
{workspace?.workspaceType !== 'rescue' ? ( {workspace?.workspaceType !== 'rescue' ? (
<div className="form-panel"> <div className="form-panel">
{billingNotice ? <p className={billingNotice.kind === 'error' ? 'error-banner' : 'success-banner'}>{billingNotice.message}</p> : null}
<label> <label>
Household plan Household plan
<select <select
@@ -4964,32 +5066,6 @@ function App() {
))} ))}
</div> </div>
{memorializedBirds.length ? (
<section className="settings-subsection settings-nested-card">
<div className="panel-header">
<div>
<p className="eyebrow">Memorials</p>
<h3>Memorialized birds</h3>
<p className="muted">
These profiles are read-only, hidden from the standard flock view, and excluded from household plan bird counts.
</p>
</div>
</div>
<div className="recent-list">
{memorializedBirds.map((bird) => (
<article className="vet-visit-card" key={bird.id}>
<strong>{bird.name}</strong>
<small>
{bird.species} Memorialized {formatDate(bird.memorializedOn)}
</small>
{bird.notifyOnMemorialDay ? <small>Memorial day reminders enabled.</small> : <small>Memorial day reminders off.</small>}
{bird.memorialNote ? <p className="muted">{bird.memorialNote}</p> : null}
</article>
))}
</div>
</section>
) : null}
<form className="form-panel settings-nested-stack" onSubmit={handleBirdSubmit}> <form className="form-panel settings-nested-stack" onSubmit={handleBirdSubmit}>
<section className="settings-nested-card"> <section className="settings-nested-card">
<div className="settings-nested-header"> <div className="settings-nested-header">
@@ -5003,7 +5079,11 @@ function App() {
</label> </label>
<label> <label>
Band ID Band ID
<input value={birdForm.tagId} onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })} required /> <input
value={birdForm.tagId}
onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })}
placeholder="Optional if unknown"
/>
</label> </label>
<label className="species-picker-field wide-field"> <label className="species-picker-field wide-field">
Species Species
@@ -5398,15 +5478,6 @@ function App() {
required required
/> />
</label> </label>
<label className="toggle-card">
<span>Send memorial day reminders</span>
<input
type="checkbox"
checked={memorializeBirdForm.notifyOnMemorialDay}
onChange={(event) => setMemorializeBirdForm({ ...memorializeBirdForm, notifyOnMemorialDay: event.target.checked })}
/>
<small className="muted">Send an annual remembrance notification using the same delivery workflow as Hatch Day reminders.</small>
</label>
<label className="wide-field"> <label className="wide-field">
Memorial note Memorial note
<textarea <textarea
@@ -5433,6 +5504,42 @@ function App() {
</div> </div>
</section> </section>
) : null} ) : null}
{memorializedBirds.length ? (
<section className="settings-subsection settings-nested-card">
<div className="panel-header">
<div>
<p className="eyebrow">Memorials</p>
<h3>Memorialized birds</h3>
<p className="muted">
These profiles are read-only, hidden from the standard flock view, and excluded from household plan bird counts.
</p>
</div>
</div>
<div className="recent-list">
{memorializedBirds.map((bird) => (
<article className="vet-visit-card" key={bird.id}>
<strong>{bird.name}</strong>
<small>
{bird.species} Memorialized {formatDate(bird.memorializedOn)}
</small>
{bird.memorialNote ? <p className="muted">{bird.memorialNote}</p> : null}
<label className="toggle-card">
<span>Send memorial reminders</span>
<input
type="checkbox"
checked={bird.notifyOnMemorialDay}
disabled={savingMemorialReminderBirdId === bird.id || activeMembership?.role !== 'owner'}
onChange={(event) => handleMemorialReminderPreferenceChange(bird, event.target.checked)}
/>
<small className="muted">Send an annual rememberance notificaiton.</small>
</label>
{activeMembership?.role !== 'owner' ? <small>Only flock owners can change memorial reminder settings.</small> : null}
</article>
))}
</div>
</section>
) : null}
</> </>
) : null} ) : null}
</article> </article>
@@ -5475,7 +5582,7 @@ function App() {
<option value="">Select a bird from this flock</option> <option value="">Select a bird from this flock</option>
{birds.map((bird) => ( {birds.map((bird) => (
<option key={bird.id} value={bird.id}> <option key={bird.id} value={bird.id}>
{bird.name} {bird.species} Band {bird.tagId} {bird.name} {bird.species} {bird.tagId ? `Band ${bird.tagId}` : 'Band ID not recorded'}
</option> </option>
))} ))}
</select> </select>