From cd7c4383d0dd26594ea808e184feab87b203203d Mon Sep 17 00:00:00 2001 From: Corey Blais Date: Thu, 30 Apr 2026 21:54:13 -0400 Subject: [PATCH] cleaned up stripe config --- .env.example | 2 +- README.md | 26 ++- backend/src/app.ts | 150 ++++++++++++++- backend/src/db/schema.ts | 17 +- backend/src/repositories/birdRepository.ts | 45 ++++- backend/src/types.ts | 2 +- docker-compose.prod.yml | 2 +- docker-compose.yml | 2 +- frontend/src/App.tsx | 211 ++++++++++++++++----- 9 files changed, 384 insertions(+), 73 deletions(-) diff --git a/.env.example b/.env.example index 2383c59..e7526ef 100644 --- a/.env.example +++ b/.env.example @@ -23,4 +23,4 @@ STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY= STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY= STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/?billing=success STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled -STRIPE_PORTAL_RETURN_URL=http://localhost:3000/ +STRIPE_PORTAL_RETURN_URL=http://localhost:3000/?billing=portal diff --git a/README.md b/README.md index 57a0cc6..13c6fb6 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,6 @@ FlockPal is a Dockerized TypeScript app for tracking flock health with a clean, - Medication and care reminders - 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 - 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_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 -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. diff --git a/backend/src/app.ts b/backend/src/app.ts index d5dc186..2516bcc 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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, Partial>>> = { 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: `

A possible found bird report was submitted for ${escapeHtml(bird.name)}.

    -
  • Band ID: ${escapeHtml(bird.tag_id)}
  • +
  • Band ID: ${escapeHtml(bird.tag_id ?? 'Not recorded')}
  • Species: ${escapeHtml(bird.species)}
  • Flock: ${escapeHtml(bird.workspace_name)}
  • Finder name: ${escapeHtml(finderName)}
  • @@ -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); diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 9bca7ef..b16ed63 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -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(), diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts index 51f1dfc..400fcb7 100644 --- a/backend/src/repositories/birdRepository.ts +++ b/backend/src/repositories/birdRepository.ts @@ -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( + `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 diff --git a/backend/src/types.ts b/backend/src/types.ts index 9bfc7a4..72e9b2b 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -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; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 93fe810..91cff9f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -54,7 +54,7 @@ services: STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY:-} STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-${FRONTEND_URL}/?billing=success} STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-${FRONTEND_URL}/?billing=cancelled} - STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-${FRONTEND_URL}/} + STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-${FRONTEND_URL}/?billing=portal} SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} SMTP_SECURE: ${SMTP_SECURE:-false} diff --git a/docker-compose.yml b/docker-compose.yml index 14f5987..08f65b1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,7 +53,7 @@ services: STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY:-} STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-http://localhost:3000/?billing=success} STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-http://localhost:3000/?billing=cancelled} - STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-http://localhost:3000/} + STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-http://localhost:3000/?billing=portal} SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} SMTP_SECURE: ${SMTP_SECURE:-false} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6a1a154..b9289c7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,7 +17,7 @@ type Bird = { id: string; workspaceId?: number; name: string; - tagId: string; + tagId: string | null; species: string; gender: BirdGender; dateOfBirth: string | null; @@ -231,6 +231,11 @@ type AuthNotice = { previewUrl?: string | null; }; +type BillingNotice = { + kind: 'success' | 'info' | 'error'; + message: string; +}; + type BulkWeightRowState = { weightGrams: string; }; @@ -310,7 +315,7 @@ const emptyBirdForm: BirdFormState = { const emptyMemorializeBirdForm = (): MemorializeBirdFormState => ({ memorializedOn: new Date().toISOString().slice(0, 10), memorialNote: '', - notifyOnMemorialDay: true, + notifyOnMemorialDay: false, }); const emptyWorkspaceForm: WorkspaceFormState = { @@ -408,7 +413,7 @@ const sortBirdsByName = (nextBirds: Bird[]) => [...nextBirds].sort((left, right) const toBirdForm = (bird: Bird): BirdFormState => ({ name: bird.name, - tagId: bird.tagId, + tagId: bird.tagId ?? '', species: bird.species, gender: bird.gender, dateOfBirth: bird.dateOfBirth ?? '', @@ -1072,6 +1077,7 @@ function App() { const [authProviders, setAuthProviders] = useState([]); const [authForm, setAuthForm] = useState(emptyAuthForm); const [authNotice, setAuthNotice] = useState(null); + const [billingNotice, setBillingNotice] = useState(null); const [authLoading, setAuthLoading] = useState(true); const [authSubmitting, setAuthSubmitting] = useState(false); const [lostBirdReportForm, setLostBirdReportForm] = useState(emptyLostBirdReportForm); @@ -1157,6 +1163,7 @@ function App() { } | null>(null); const [deletingBird, setDeletingBird] = useState(false); const [memorializingBird, setMemorializingBird] = useState(false); + const [savingMemorialReminderBirdId, setSavingMemorialReminderBirdId] = useState(''); const [editingVetVisitId, setEditingVetVisitId] = useState(''); const [deletingVetVisitId, setDeletingVetVisitId] = useState(''); const [editingMedicationId, setEditingMedicationId] = useState(''); @@ -1572,6 +1579,7 @@ function App() { setAuthSession(session); setAuthProviders(session.providers); setAuthNotice(null); + setBillingNotice(null); setNewIntegrationTokenSecret(''); setWorkspace(session.activeWorkspace); setActiveMembership({ @@ -1616,6 +1624,34 @@ function App() { setIntegrationTokenForm(emptyIntegrationTokenForm); setNewIntegrationTokenSecret(''); 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(() => { @@ -1646,30 +1682,61 @@ function App() { const url = new URL(window.location.href); const callbackToken = url.searchParams.get('auth_token') ?? ''; + const billingState = url.searchParams.get('billing'); const token = callbackToken || readStoredSessionToken(); if (callbackToken) { persistSessionToken(callbackToken); url.searchParams.delete('auth_token'); - window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`); } if (!token) { return; } - const response = await apiFetch('/auth/session', token); + const { session, token: sessionToken } = await refreshAuthSession(token); - if (!response.ok) { - clearAppSession(); - return; + if (billingState === 'success' || billingState === 'portal') { + try { + 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 (data.session && (data.token || token)) { - persistSessionToken(data.token || token); - applySession(data.session, data.token || token); + if (session && (callbackToken || billingState)) { + url.searchParams.delete('billing'); + window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`); setError(''); } } 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) => { event.preventDefault(); if (transferringBird) { @@ -3172,6 +3271,7 @@ function App() { } setError(''); + setBillingNotice(null); setBillingRedirecting(true); setSavingWorkspace(true); @@ -3210,6 +3310,7 @@ function App() { } setError(''); + setBillingNotice(null); setBillingRedirecting(true); try { @@ -4104,7 +4205,7 @@ function App() {

    - {selectedBird.species} • Band {selectedBird.tagId} + {selectedBird.species} • {selectedBird.tagId ? `Band ${selectedBird.tagId}` : 'Band ID not recorded'}

    Added {formatDate(selectedBird.createdAt.slice(0, 10))}

    @@ -4117,7 +4218,7 @@ function App() {
    Band ID - {selectedBird.tagId} + {selectedBird.tagId || 'Not recorded'}
    Hatch Day @@ -4489,6 +4590,7 @@ function App() { {workspace?.workspaceType !== 'rescue' ? (
    + {billingNotice ?

    {billingNotice.message}

    : null} -