Consolidate audit-fixes branch: webhooks, integrations, and deploy hardening

Batch commit of the pending working tree on security/audit-fixes-2026-07.
Major areas:
- Outbound webhooks / Zapier: schema + signed delivery with retries, public
  v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain.
- Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS
  (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when
  Spaces is unconfigured instead of silently using ephemeral disk.
- Integrations & features (concurrent work): accounting (QuickBooks/Xero),
  e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding,
  expanded legal pages.
- DB migrations 0006–0009.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-07-02 13:42:34 -04:00
co-authored by Claude Opus 4.8
parent 969d5d4c8a
commit c9968531e4
282 changed files with 41530 additions and 4013 deletions
+19 -19
View File
@@ -35,25 +35,25 @@ export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
},
}
export const PLAN_PRICES: Record<string, { plan: Plan; priceId: string; amount: number; interval: string }> = {
pro: {
plan: "pro",
priceId: process.env.STRIPE_PRO_MONTHLY_PRICE_ID!,
amount: 29,
interval: "month",
},
landlord: {
plan: "landlord",
priceId: process.env.STRIPE_LANDLORD_MONTHLY_PRICE_ID!,
amount: 59,
interval: "month",
},
lifetime: {
plan: "lifetime",
priceId: process.env.STRIPE_LIFETIME_PRICE_ID!,
amount: 199,
interval: "one_time",
},
// Single source of truth for DISPLAYED prices (USD). Client-safe (plain numbers,
// no env). Billing always charges the Stripe Price ID, so a mismatch here only
// affects what the marketing/billing UI shows — keep these in sync with the
// amounts configured on the Stripe Prices referenced below.
export const PLAN_AMOUNTS = { starter: 0, pro: 29, landlord: 59, lifetime: 199 } as const
// Plan metadata (client-safe — no env, no price IDs). The actual Stripe price
// is resolved at runtime by lib/stripe/prices.ts using stable lookup keys, so
// the same code works in test and live with ONLY an API-key swap.
export const PLAN_PRICES: Record<string, { plan: Plan; amount: number; interval: string }> = {
pro: { plan: "pro", amount: PLAN_AMOUNTS.pro, interval: "month" },
landlord: { plan: "landlord", amount: PLAN_AMOUNTS.landlord, interval: "month" },
lifetime: { plan: "lifetime", amount: PLAN_AMOUNTS.lifetime, interval: "one_time" },
}
// Annual billing is always offered; the yearly price is resolved (and
// auto-provisioned if missing) at checkout time by lib/stripe/prices.ts.
export function annualEnabled(): boolean {
return true
}
export function checkLimit(
+87
View File
@@ -0,0 +1,87 @@
import { stripe } from "./client"
import { PLAN_AMOUNTS } from "./plans"
import type { Plan } from "@/types"
// Resolve Stripe prices by STABLE LOOKUP KEYS instead of hardcoded price IDs.
//
// The same lookup keys exist in both test and live mode, so the app finds the
// right price for whichever API key is configured — making go-live a pure
// key swap with NO price IDs to copy. If a price doesn't exist yet in the
// current mode, it's auto-created from PLAN_AMOUNTS on first use, so even the
// very first live checkout just works.
export const PRICE_LOOKUP_KEYS = {
pro_month: "pmn_pro_monthly",
pro_year: "pmn_pro_yearly",
landlord_month: "pmn_landlord_monthly",
landlord_year: "pmn_landlord_yearly",
lifetime: "pmn_lifetime",
} as const
type LookupKey = (typeof PRICE_LOOKUP_KEYS)[keyof typeof PRICE_LOOKUP_KEYS]
// How to create each price if it's missing in the current mode (amounts mirror
// the single source of truth in plans.ts; yearly = 10× monthly, ~2 months free).
const PRICE_SPEC: Record<LookupKey, { product: string; dollars: number; interval: "month" | "year" | null }> = {
[PRICE_LOOKUP_KEYS.pro_month]: { product: "Pro", dollars: PLAN_AMOUNTS.pro, interval: "month" },
[PRICE_LOOKUP_KEYS.pro_year]: { product: "Pro", dollars: PLAN_AMOUNTS.pro * 10, interval: "year" },
[PRICE_LOOKUP_KEYS.landlord_month]: { product: "Landlord", dollars: PLAN_AMOUNTS.landlord, interval: "month" },
[PRICE_LOOKUP_KEYS.landlord_year]: { product: "Landlord", dollars: PLAN_AMOUNTS.landlord * 10, interval: "year" },
[PRICE_LOOKUP_KEYS.lifetime]: { product: "Lifetime", dollars: PLAN_AMOUNTS.lifetime, interval: null },
}
// Per-process cache. A deployment runs with a single API key, so test and live
// never share a process — no cross-mode leakage.
const cache = new Map<LookupKey, string>()
async function resolveByLookup(key: LookupKey): Promise<string | undefined> {
const cached = cache.get(key)
if (cached) return cached
const existing = await stripe.prices.list({ lookup_keys: [key], active: true, limit: 1 })
if (existing.data[0]) {
cache.set(key, existing.data[0].id)
return existing.data[0].id
}
// Not present in this mode yet — create the product + price on the fly so the
// first checkout after a key swap succeeds without any manual setup.
const spec = PRICE_SPEC[key]
try {
const product = await stripe.products.create({
name: `Property Management Network — ${spec.product}`,
})
const price = await stripe.prices.create({
product: product.id,
currency: "usd",
unit_amount: Math.round(spec.dollars * 100),
lookup_key: key,
transfer_lookup_key: true,
...(spec.interval ? { recurring: { interval: spec.interval } } : {}),
})
cache.set(key, price.id)
return price.id
} catch {
// Lost a race with a concurrent checkout — re-read and use the winner.
const retry = await stripe.prices.list({ lookup_keys: [key], active: true, limit: 1 })
if (retry.data[0]) {
cache.set(key, retry.data[0].id)
return retry.data[0].id
}
return undefined
}
}
/** Resolve the Stripe price id for a plan + interval (auto-provisions if needed). */
export async function resolvePriceId(plan: Plan, interval: "month" | "year"): Promise<string | undefined> {
switch (plan) {
case "lifetime":
return resolveByLookup(PRICE_LOOKUP_KEYS.lifetime)
case "pro":
return resolveByLookup(interval === "year" ? PRICE_LOOKUP_KEYS.pro_year : PRICE_LOOKUP_KEYS.pro_month)
case "landlord":
return resolveByLookup(interval === "year" ? PRICE_LOOKUP_KEYS.landlord_year : PRICE_LOOKUP_KEYS.landlord_month)
default:
return undefined
}
}