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>
315 lines
11 KiB
TypeScript
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
|
|
}
|
|
}
|