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
+123
View File
@@ -0,0 +1,123 @@
import { paypalFetch } from "./client"
// We encode the app user id + target plan into PayPal's `custom_id` so webhooks
// and the return handler can resolve who/what a subscription or order is for,
// without trusting query params. Format: "<userId>:<plan>".
export function encodeCustomId(userId: string, plan: string): string {
return `${userId}:${plan}`
}
export function decodeCustomId(customId: string | null | undefined): { userId: string; plan: string } | null {
if (!customId) return null
const idx = customId.lastIndexOf(":")
if (idx <= 0) return null
return { userId: customId.slice(0, idx), plan: customId.slice(idx + 1) }
}
function approveUrl(links: Array<{ rel: string; href: string }> | undefined): string | undefined {
return links?.find((l) => l.rel === "approve" || l.rel === "payer-action")?.href
}
const BRAND = "Property Management Network"
/** Create a recurring subscription; returns its id + the PayPal approval URL. */
export async function createSubscription(params: {
planId: string
userId: string
plan: string
email?: string | null
returnUrl: string
cancelUrl: string
}): Promise<{ id: string; approveUrl?: string }> {
const res = await paypalFetch("/v1/billing/subscriptions", {
method: "POST",
body: JSON.stringify({
plan_id: params.planId,
custom_id: encodeCustomId(params.userId, params.plan),
subscriber: params.email ? { email_address: params.email } : undefined,
application_context: {
brand_name: BRAND,
user_action: "SUBSCRIBE_NOW",
shipping_preference: "NO_SHIPPING",
return_url: params.returnUrl,
cancel_url: params.cancelUrl,
},
}),
})
if (!res.ok) throw new Error(`PayPal createSubscription failed: ${res.status} ${await res.text().catch(() => "")}`)
const json = (await res.json()) as { id: string; links?: Array<{ rel: string; href: string }> }
return { id: json.id, approveUrl: approveUrl(json.links) }
}
/** Create a one-time order (used for the Lifetime plan). */
export async function createOrder(params: {
amount: number
userId: string
plan: string
returnUrl: string
cancelUrl: string
}): Promise<{ id: string; approveUrl?: string }> {
const res = await paypalFetch("/v2/checkout/orders", {
method: "POST",
body: JSON.stringify({
intent: "CAPTURE",
purchase_units: [
{
amount: { currency_code: "USD", value: params.amount.toFixed(2) },
custom_id: encodeCustomId(params.userId, params.plan),
description: `${BRAND} — Lifetime`,
},
],
application_context: {
brand_name: BRAND,
user_action: "PAY_NOW",
shipping_preference: "NO_SHIPPING",
return_url: params.returnUrl,
cancel_url: params.cancelUrl,
},
}),
})
if (!res.ok) throw new Error(`PayPal createOrder failed: ${res.status} ${await res.text().catch(() => "")}`)
const json = (await res.json()) as { id: string; links?: Array<{ rel: string; href: string }> }
return { id: json.id, approveUrl: approveUrl(json.links) }
}
/** Capture an approved order. Returns the captured order (status COMPLETED). */
export async function captureOrder(orderId: string): Promise<{
status: string
custom_id?: string
} | null> {
const res = await paypalFetch(`/v2/checkout/orders/${orderId}/capture`, {
method: "POST",
body: "{}",
})
if (!res.ok) return null
const json = (await res.json()) as {
status: string
purchase_units?: Array<{ custom_id?: string; payments?: { captures?: Array<{ custom_id?: string }> } }>
}
const unit = json.purchase_units?.[0]
const custom_id = unit?.custom_id ?? unit?.payments?.captures?.[0]?.custom_id
return { status: json.status, custom_id }
}
export type PaypalSubscription = {
id: string
status: string
custom_id?: string
billing_info?: { next_billing_time?: string }
}
export async function getSubscription(id: string): Promise<PaypalSubscription | null> {
const res = await paypalFetch(`/v1/billing/subscriptions/${id}`, { method: "GET" })
if (!res.ok) return null
return (await res.json()) as PaypalSubscription
}
export async function cancelSubscription(id: string, reason = "Cancelled by subscriber"): Promise<boolean> {
const res = await paypalFetch(`/v1/billing/subscriptions/${id}/cancel`, {
method: "POST",
body: JSON.stringify({ reason }),
})
// 204 = cancelled; 422 = already inactive (treat as success so the UI settles).
return res.ok || res.status === 204 || res.status === 422
}
+57
View File
@@ -0,0 +1,57 @@
// PayPal REST API client — OAuth2 client-credentials + a thin fetch helper.
//
// Enabled only when PAYPAL_CLIENT_ID and PAYPAL_SECRET are set (mirrors the
// gating used for the other optional integrations). PAYPAL_ENVIRONMENT selects
// the sandbox (default) or live host.
const ENVIRONMENT = process.env.PAYPAL_ENVIRONMENT === "live" ? "live" : "sandbox"
const BASE_URL =
ENVIRONMENT === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"
export function paypalConfigured(): boolean {
return Boolean(process.env.PAYPAL_CLIENT_ID && process.env.PAYPAL_SECRET)
}
export function paypalEnvironment() {
return ENVIRONMENT
}
// Access tokens live ~9h; cache in-process (the app runs a persistent Node
// server, so this survives across requests) and refresh a minute early.
let cachedToken: { token: string; expiresAt: number } | null = null
async function getAccessToken(): Promise<string> {
if (cachedToken && cachedToken.expiresAt > Date.now() + 60_000) return cachedToken.token
const id = process.env.PAYPAL_CLIENT_ID
const secret = process.env.PAYPAL_SECRET
if (!id || !secret) throw new Error("PayPal is not configured")
const res = await fetch(`${BASE_URL}/v1/oauth2/token`, {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(`${id}:${secret}`).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
})
if (!res.ok) throw new Error(`PayPal auth failed: ${res.status} ${await res.text().catch(() => "")}`)
const json = (await res.json()) as { access_token: string; expires_in: number }
cachedToken = { token: json.access_token, expiresAt: Date.now() + json.expires_in * 1000 }
return cachedToken.token
}
/** Authenticated fetch against the PayPal REST API. Path is relative (e.g. "/v1/..."). */
export async function paypalFetch(path: string, init: RequestInit = {}): Promise<Response> {
const token = await getAccessToken()
return fetch(`${BASE_URL}${path}`, {
...init,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...(init.headers ?? {}),
},
})
}
+53
View File
@@ -0,0 +1,53 @@
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import type { Plan } from "@/types"
// Applies PayPal subscription/order outcomes to a profile. Shared by the return
// handler (synchronous, on approval redirect) and the webhook (async, for
// renewals/cancellations). Both are idempotent.
const RECURRING: ReadonlyArray<Plan> = ["pro", "landlord"]
export async function fulfillSubscription(
userId: string,
plan: string,
subscriptionId: string,
nextBillingTime?: string | null,
status = "active",
): Promise<void> {
if (!RECURRING.includes(plan as Plan)) return
await db
.update(profiles)
.set({
plan: plan as Plan,
subscription_status: status,
paypal_subscription_id: subscriptionId,
billing_provider: "paypal",
plan_expires_at: nextBillingTime ?? null,
})
.where(eq(profiles.id, userId))
}
export async function fulfillLifetime(userId: string): Promise<void> {
await db
.update(profiles)
.set({ plan: "lifetime", subscription_status: "active", billing_provider: "paypal" })
.where(eq(profiles.id, userId))
}
/** Downgrade/mark a profile by its PayPal subscription id (cancel/expire/suspend). */
export async function markPaypalSubscriptionInactive(
subscriptionId: string,
status: string,
downgrade: boolean,
): Promise<void> {
await db
.update(profiles)
.set(
downgrade
? { subscription_status: status, plan: "starter", paypal_subscription_id: null, plan_expires_at: null }
: { subscription_status: status },
)
.where(eq(profiles.paypal_subscription_id, subscriptionId))
}
+28
View File
@@ -0,0 +1,28 @@
import type { Plan } from "@/types"
// PayPal billing-plan IDs, one per (plan, interval). Create them once with
// `node scripts/paypal-setup-plans.mjs` and paste the printed IDs into the
// environment. A plan/interval with no configured ID simply isn't offered.
const PAYPAL_PLAN_IDS: Record<string, string | undefined> = {
"pro:month": process.env.PAYPAL_PRO_MONTHLY_PLAN_ID,
"pro:year": process.env.PAYPAL_PRO_YEARLY_PLAN_ID,
"landlord:month": process.env.PAYPAL_LANDLORD_MONTHLY_PLAN_ID,
"landlord:year": process.env.PAYPAL_LANDLORD_YEARLY_PLAN_ID,
}
/** Recurring plans PayPal can bill (lifetime is a one-time order, not a plan). */
export const PAYPAL_RECURRING_PLANS = ["pro", "landlord"] as const
export function getPaypalPlanId(plan: Plan, interval: "month" | "year"): string | undefined {
return PAYPAL_PLAN_IDS[`${plan}:${interval}`] || undefined
}
/** True when at least one PayPal-billable plan is configured. */
export function anyPaypalPlanConfigured(): boolean {
return Object.values(PAYPAL_PLAN_IDS).some(Boolean)
}
/** Annual PayPal billing is offered only when both yearly plan IDs exist. */
export function paypalAnnualEnabled(): boolean {
return Boolean(PAYPAL_PLAN_IDS["pro:year"] && PAYPAL_PLAN_IDS["landlord:year"])
}
+36
View File
@@ -0,0 +1,36 @@
import { paypalFetch } from "./client"
// Verify an inbound PayPal webhook using PayPal's verify-webhook-signature API.
// Requires PAYPAL_WEBHOOK_ID (from the webhook you create in the PayPal app).
// Returns false (reject) when the id is missing or verification doesn't succeed.
export async function verifyPaypalWebhook(headers: Headers, rawBody: string): Promise<boolean> {
const webhookId = process.env.PAYPAL_WEBHOOK_ID
if (!webhookId) return false
let event: unknown
try {
event = JSON.parse(rawBody)
} catch {
return false
}
try {
const res = await paypalFetch("/v1/notifications/verify-webhook-signature", {
method: "POST",
body: JSON.stringify({
auth_algo: headers.get("paypal-auth-algo"),
cert_url: headers.get("paypal-cert-url"),
transmission_id: headers.get("paypal-transmission-id"),
transmission_sig: headers.get("paypal-transmission-sig"),
transmission_time: headers.get("paypal-transmission-time"),
webhook_id: webhookId,
webhook_event: event,
}),
})
if (!res.ok) return false
const json = (await res.json()) as { verification_status?: string }
return json.verification_status === "SUCCESS"
} catch {
return false
}
}