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
|
||||
}
|
||||
Reference in New Issue
Block a user