88 lines
3.6 KiB
TypeScript
88 lines
3.6 KiB
TypeScript
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
|
|||
|
|
}
|
|||
|
|
}
|