124 lines
4.4 KiB
TypeScript
124 lines
4.4 KiB
TypeScript
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
|
||
|
|
}
|