Files
property-management-network/lib/stripe/prices.ts
T
Leon SerfatyandClaude Opus 4.8 c9968531e4 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>
2026-07-02 13:42:34 -04:00

88 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { stripe } from "./client"
import { PLAN_AMOUNTS } from "./plans"
import type { Plan } from "@/types"
// Resolve Stripe prices by STABLE LOOKUP KEYS instead of hardcoded price IDs.
//
// The same lookup keys exist in both test and live mode, so the app finds the
// right price for whichever API key is configured — making go-live a pure
// key swap with NO price IDs to copy. If a price doesn't exist yet in the
// current mode, it's auto-created from PLAN_AMOUNTS on first use, so even the
// very first live checkout just works.
export const PRICE_LOOKUP_KEYS = {
pro_month: "pmn_pro_monthly",
pro_year: "pmn_pro_yearly",
landlord_month: "pmn_landlord_monthly",
landlord_year: "pmn_landlord_yearly",
lifetime: "pmn_lifetime",
} as const
type LookupKey = (typeof PRICE_LOOKUP_KEYS)[keyof typeof PRICE_LOOKUP_KEYS]
// How to create each price if it's missing in the current mode (amounts mirror
// the single source of truth in plans.ts; yearly = 10× monthly, ~2 months free).
const PRICE_SPEC: Record<LookupKey, { product: string; dollars: number; interval: "month" | "year" | null }> = {
[PRICE_LOOKUP_KEYS.pro_month]: { product: "Pro", dollars: PLAN_AMOUNTS.pro, interval: "month" },
[PRICE_LOOKUP_KEYS.pro_year]: { product: "Pro", dollars: PLAN_AMOUNTS.pro * 10, interval: "year" },
[PRICE_LOOKUP_KEYS.landlord_month]: { product: "Landlord", dollars: PLAN_AMOUNTS.landlord, interval: "month" },
[PRICE_LOOKUP_KEYS.landlord_year]: { product: "Landlord", dollars: PLAN_AMOUNTS.landlord * 10, interval: "year" },
[PRICE_LOOKUP_KEYS.lifetime]: { product: "Lifetime", dollars: PLAN_AMOUNTS.lifetime, interval: null },
}
// Per-process cache. A deployment runs with a single API key, so test and live
// never share a process — no cross-mode leakage.
const cache = new Map<LookupKey, string>()
async function resolveByLookup(key: LookupKey): Promise<string | undefined> {
const cached = cache.get(key)
if (cached) return cached
const existing = await stripe.prices.list({ lookup_keys: [key], active: true, limit: 1 })
if (existing.data[0]) {
cache.set(key, existing.data[0].id)
return existing.data[0].id
}
// Not present in this mode yet — create the product + price on the fly so the
// first checkout after a key swap succeeds without any manual setup.
const spec = PRICE_SPEC[key]
try {
const product = await stripe.products.create({
name: `Property Management Network — ${spec.product}`,
})
const price = await stripe.prices.create({
product: product.id,
currency: "usd",
unit_amount: Math.round(spec.dollars * 100),
lookup_key: key,
transfer_lookup_key: true,
...(spec.interval ? { recurring: { interval: spec.interval } } : {}),
})
cache.set(key, price.id)
return price.id
} catch {
// Lost a race with a concurrent checkout — re-read and use the winner.
const retry = await stripe.prices.list({ lookup_keys: [key], active: true, limit: 1 })
if (retry.data[0]) {
cache.set(key, retry.data[0].id)
return retry.data[0].id
}
return undefined
}
}
/** Resolve the Stripe price id for a plan + interval (auto-provisions if needed). */
export async function resolvePriceId(plan: Plan, interval: "month" | "year"): Promise<string | undefined> {
switch (plan) {
case "lifetime":
return resolveByLookup(PRICE_LOOKUP_KEYS.lifetime)
case "pro":
return resolveByLookup(interval === "year" ? PRICE_LOOKUP_KEYS.pro_year : PRICE_LOOKUP_KEYS.pro_month)
case "landlord":
return resolveByLookup(interval === "year" ? PRICE_LOOKUP_KEYS.landlord_year : PRICE_LOOKUP_KEYS.landlord_month)
default:
return undefined
}
}