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)}.