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:
co-authored by
Claude Opus 4.8
parent
969d5d4c8a
commit
c9968531e4
@@ -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
|
||||
}
|
||||
@@ -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 ?? {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user