Files
property-management-network/lib/admin/billing.ts
T
Leon SerfatyandClaude Opus 5 1d02598786 chore: sync in-progress work across marketing, admin, API and tests
Snapshot of uncommitted work that had accumulated in the tree alongside
the Turnstile changes:

- marketing pages, SEO helpers (lib/seo.ts, lib/marketing/) and
  structured data
- admin billing actions and a per-user portfolio view, plus an admin
  error boundary
- rate limiting (lib/rate-limit.ts) applied across the /api/v1 surface
- CSP and proxy adjustments, accounting/webhook lib updates
- Playwright config and an e2e/unit test suite
- next bumped to ^16.3.4 with the lockfile regenerated
- generated AGENTS.md / CLAUDE.md

Authored by other sessions working in this tree; committed here so the
Turnstile work could be pushed without leaving the tree dirty.
Typecheck passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:27:16 -04:00

315 lines
11 KiB
TypeScript

import { eq } from "drizzle-orm"
import type Stripe from "stripe"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import { stripe } from "@/lib/stripe/client"
import { resolvePriceId } from "@/lib/stripe/prices"
import type { Plan } from "@/types"
// ============================================================================
// Admin-side billing operations.
//
// Everything here TOUCHES REAL MONEY. Two rules the callers depend on:
//
// 1. Every function returns a discriminated result instead of throwing on an
// expected condition (no subscription, unsupported transition). The admin
// UI shows the message; only genuine Stripe/network faults throw.
// 2. When a subscription's price changes we ALSO rewrite its metadata.plan.
// The Stripe webhook (app/api/stripe/webhook/route.ts) derives the app plan
// from `subscription.metadata.plan` — leaving it stale would make the
// webhook immediately revert the change we just made.
// ============================================================================
export type BillingResult<T = undefined> =
| ({ ok: true } & (T extends undefined ? { detail?: string } : { data: T; detail?: string }))
| { ok: false; error: string }
const fail = (error: string): { ok: false; error: string } => ({ ok: false, error })
/** Plans that map to a recurring Stripe subscription. */
const RECURRING_PLANS: Plan[] = ["pro", "landlord"]
async function getProfile(userId: string) {
return db.query.profiles.findFirst({
where: eq(profiles.id, userId),
columns: {
id: true,
email: true,
plan: true,
stripe_customer_id: true,
stripe_subscription_id: true,
subscription_status: true,
},
})
}
/**
* Move a user to `plan` IN STRIPE, then mirror it locally.
*
* Supported transitions:
* paid → paid swap the subscription item's price (prorated)
* paid → starter cancel the subscription at period end
*
* Not supported (returns ok:false, never a silent no-op):
* → lifetime a one-time payment, not a subscription change
* no subscription nothing to modify — use a comp instead
*/
export async function changePlanInStripe(
userId: string,
plan: Plan,
interval: "month" | "year" = "month"
): Promise<BillingResult> {
const profile = await getProfile(userId)
if (!profile) return fail("User not found.")
if (plan === "lifetime") {
return fail(
"Lifetime is a one-time purchase, not a subscription. Grant it as a comp, " +
"or have the user complete lifetime checkout."
)
}
const subId = profile.stripe_subscription_id
if (!subId) {
return fail(
"This user has no active Stripe subscription to modify. Grant the plan as a comp instead."
)
}
let subscription: Stripe.Subscription
try {
subscription = await stripe.subscriptions.retrieve(subId)
} catch {
return fail("Could not load the subscription from Stripe — it may have been deleted.")
}
if (subscription.status === "canceled") {
return fail("That subscription is already canceled. Grant the plan as a comp instead.")
}
// ── downgrade to free: cancel at period end so they keep what they paid for ──
if (plan === "starter") {
await stripe.subscriptions.update(subId, { cancel_at_period_end: true })
await db
.update(profiles)
.set({ subscription_status: "canceling" })
.where(eq(profiles.id, userId))
return {
ok: true,
detail:
"Subscription set to cancel at the end of the current period. The plan stays active until then.",
}
}
// ── paid → paid: swap the price on the existing item ────────────────────────
if (!RECURRING_PLANS.includes(plan)) return fail(`Unsupported plan: ${plan}`)
const priceId = await resolvePriceId(plan, interval)
if (!priceId) return fail("Could not resolve the Stripe price for that plan.")
const item = subscription.items.data[0]
if (!item) return fail("That subscription has no line items to modify.")
await stripe.subscriptions.update(subId, {
items: [{ id: item.id, price: priceId }],
// Bill the difference now rather than silently absorbing it.
proration_behavior: "create_prorations",
cancel_at_period_end: false,
// MUST stay in sync — the webhook reads plan from here.
metadata: { ...subscription.metadata, user_id: userId, plan },
})
await db
.update(profiles)
.set({ plan, subscription_status: "active" })
.where(eq(profiles.id, userId))
return { ok: true, detail: `Stripe subscription moved to ${plan} (${interval}ly), prorated.` }
}
/**
* Cancel a subscription. `immediate` ends access now and is the option that can
* surprise a paying customer, so the UI confirms it separately from the
* cancel-at-period-end default.
*/
export async function cancelSubscription(
userId: string,
immediate = false
): Promise<BillingResult> {
const profile = await getProfile(userId)
if (!profile) return fail("User not found.")
const subId = profile.stripe_subscription_id
if (!subId) return fail("This user has no active Stripe subscription.")
try {
if (immediate) {
await stripe.subscriptions.cancel(subId)
await db
.update(profiles)
.set({
plan: "starter",
subscription_status: "canceled",
stripe_subscription_id: null,
plan_expires_at: null,
})
.where(eq(profiles.id, userId))
return { ok: true, detail: "Subscription canceled immediately and plan reset to Starter." }
}
await stripe.subscriptions.update(subId, { cancel_at_period_end: true })
await db
.update(profiles)
.set({ subscription_status: "canceling" })
.where(eq(profiles.id, userId))
return { ok: true, detail: "Subscription will cancel at the end of the current period." }
} catch (e) {
return fail((e as Error).message.slice(0, 300))
}
}
/** Undo a pending cancel-at-period-end. */
export async function resumeSubscription(userId: string): Promise<BillingResult> {
const profile = await getProfile(userId)
if (!profile) return fail("User not found.")
const subId = profile.stripe_subscription_id
if (!subId) return fail("This user has no subscription to resume.")
try {
await stripe.subscriptions.update(subId, { cancel_at_period_end: false })
await db
.update(profiles)
.set({ subscription_status: "active" })
.where(eq(profiles.id, userId))
return { ok: true, detail: "Scheduled cancellation removed — the subscription will renew." }
} catch (e) {
return fail((e as Error).message.slice(0, 300))
}
}
export type ChargeRow = {
id: string
amount: number
amountRefunded: number
currency: string
created: number
status: string
refunded: boolean
description: string | null
receiptUrl: string | null
}
/** Recent charges for a user, newest first — the list the refund UI works from. */
export async function listUserCharges(userId: string, limit = 10): Promise<ChargeRow[]> {
const profile = await getProfile(userId)
if (!profile?.stripe_customer_id) return []
try {
const charges = await stripe.charges.list({
customer: profile.stripe_customer_id,
limit,
})
return charges.data.map((c) => ({
id: c.id,
amount: c.amount,
amountRefunded: c.amount_refunded,
currency: c.currency,
created: c.created,
status: c.status,
refunded: c.refunded,
description: c.description,
receiptUrl: c.receipt_url,
}))
} catch {
// Stripe unreachable or key unset — the page still renders without billing.
return []
}
}
/**
* Refund a charge, fully or partially. `amountCents` omitted = full refund of
* whatever remains unrefunded.
*
* The charge is re-read and verified to belong to THIS user's Stripe customer
* before refunding: the charge id arrives from the client, and without that
* check an admin action could be replayed with any charge id in the account.
*/
export async function refundCharge(
userId: string,
chargeId: string,
amountCents?: number,
reason?: "duplicate" | "fraudulent" | "requested_by_customer"
): Promise<BillingResult<{ refundId: string; amount: number }>> {
const profile = await getProfile(userId)
if (!profile?.stripe_customer_id) return fail("This user has no Stripe customer record.")
let charge: Stripe.Charge
try {
charge = await stripe.charges.retrieve(chargeId)
} catch {
return fail("Could not load that charge from Stripe.")
}
const chargeCustomer = typeof charge.customer === "string" ? charge.customer : charge.customer?.id
if (chargeCustomer !== profile.stripe_customer_id) {
return fail("That charge does not belong to this user.")
}
if (charge.refunded) return fail("That charge is already fully refunded.")
const remaining = charge.amount - charge.amount_refunded
if (remaining <= 0) return fail("Nothing left to refund on that charge.")
if (amountCents !== undefined) {
if (!Number.isInteger(amountCents) || amountCents <= 0) {
return fail("Refund amount must be a positive number of cents.")
}
if (amountCents > remaining) {
return fail(`Refund exceeds the ${(remaining / 100).toFixed(2)} still available on that charge.`)
}
}
try {
const refund = await stripe.refunds.create({
charge: chargeId,
...(amountCents !== undefined ? { amount: amountCents } : {}),
...(reason ? { reason } : {}),
})
return {
ok: true,
data: { refundId: refund.id, amount: refund.amount },
detail: `Refunded ${(refund.amount / 100).toFixed(2)} ${charge.currency.toUpperCase()}.`,
}
} catch (e) {
return fail((e as Error).message.slice(0, 300))
}
}
export type SubscriptionSummary = {
id: string
status: string
cancelAtPeriodEnd: boolean
currentPeriodEnd: number | null
priceNickname: string | null
amount: number | null
interval: string | null
} | null
/** Live subscription state straight from Stripe, for the admin user page. */
export async function getSubscriptionSummary(userId: string): Promise<SubscriptionSummary> {
const profile = await getProfile(userId)
if (!profile?.stripe_subscription_id) return null
try {
const s = await stripe.subscriptions.retrieve(profile.stripe_subscription_id)
const item = s.items.data[0]
return {
id: s.id,
status: s.status,
cancelAtPeriodEnd: s.cancel_at_period_end,
currentPeriodEnd: (item as unknown as { current_period_end?: number })?.current_period_end ?? null,
priceNickname: item?.price?.nickname ?? null,
amount: item?.price?.unit_amount ?? null,
interval: item?.price?.recurring?.interval ?? null,
}
} catch {
return null
}
}