diff --git a/__tests__/stripe-logic.test.ts b/__tests__/stripe-logic.test.ts new file mode 100644 index 0000000..48fc1db --- /dev/null +++ b/__tests__/stripe-logic.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest' +import { + STRIPE_API_VERSION, + stripeStatusIsPaid, + duesMemberStatus, + graceExceeded, + priceIdForTier, + isStripeConfigured, +} from '@/lib/stripe-logic' + +describe('STRIPE_API_VERSION', () => { + it('is pinned (conscious upgrades only)', () => { + expect(STRIPE_API_VERSION).toBe('2026-04-22.dahlia') + }) +}) + +describe('stripeStatusIsPaid', () => { + it('active/trialing are paid; everything else is not', () => { + expect(stripeStatusIsPaid('active')).toBe(true) + expect(stripeStatusIsPaid('trialing')).toBe(true) + for (const s of ['past_due', 'canceled', 'unpaid', 'incomplete', 'incomplete_expired', '', null, undefined]) { + expect(stripeStatusIsPaid(s as string)).toBe(false) + } + }) +}) + +describe('duesMemberStatus', () => { + it('paid statuses -> current', () => { + expect(duesMemberStatus('active', false)).toBe('current') + expect(duesMemberStatus('trialing', true)).toBe('current') + }) + it('past_due respects the grace window', () => { + expect(duesMemberStatus('past_due', false)).toBe('current') + expect(duesMemberStatus('past_due', true)).toBe('late') + }) + it('terminal/failed statuses -> late, NEVER inactive', () => { + for (const s of ['canceled', 'unpaid', 'incomplete', 'incomplete_expired']) { + expect(duesMemberStatus(s, false)).toBe('late') + } + }) + it('unknown/empty -> null (no change, never blind overwrite)', () => { + expect(duesMemberStatus(undefined, true)).toBeNull() + expect(duesMemberStatus('something_new', true)).toBeNull() + }) +}) + +describe('graceExceeded', () => { + const end = '2026-05-01T00:00:00Z' + it('false within the window, true past it', () => { + expect(graceExceeded(end, 7, '2026-05-05T00:00:00Z')).toBe(false) + expect(graceExceeded(end, 7, '2026-05-08T00:01:00Z')).toBe(true) + expect(graceExceeded(end, 0, '2026-05-01T00:00:01Z')).toBe(true) + }) + it('missing/invalid period end is never "exceeded" (fail safe)', () => { + expect(graceExceeded(null, 7, '2030-01-01T00:00:00Z')).toBe(false) + expect(graceExceeded('nope', 7, '2030-01-01T00:00:00Z')).toBe(false) + }) +}) + +describe('priceIdForTier', () => { + const cfg = { prices: { plus: 'price_1', basic: ' ', associate: undefined } } + it('returns the mapped non-empty price or null', () => { + expect(priceIdForTier(cfg, 'plus')).toBe('price_1') + expect(priceIdForTier(cfg, 'basic')).toBeNull() + expect(priceIdForTier(cfg, 'associate')).toBeNull() + expect(priceIdForTier(cfg, 'missing')).toBeNull() + expect(priceIdForTier(null, 'plus')).toBeNull() + }) +}) + +describe('isStripeConfigured', () => { + it('needs publishable key + secret ref + >=1 mapped price', () => { + expect(isStripeConfigured(null)).toBe(false) + expect(isStripeConfigured({ publishable_key: 'pk', secret_key_ref: 's', prices: {} })).toBe(false) + expect(isStripeConfigured({ publishable_key: 'pk', prices: { plus: 'price_1' } })).toBe(false) + expect(isStripeConfigured({ publishable_key: 'pk', secret_key_ref: 's', prices: { plus: 'price_1' } })).toBe(true) + }) +}) diff --git a/lib/stripe-logic.ts b/lib/stripe-logic.ts new file mode 100644 index 0000000..63b539b --- /dev/null +++ b/lib/stripe-logic.ts @@ -0,0 +1,73 @@ +// Pure, dependency-free decision logic for Stripe recurring dues. No Stripe +// SDK, no Supabase, no React. Unit-tested directly so the money-critical +// status mapping cannot silently drift. The locked rules (owner, 2026-05): +// per-space OWN keys; subscriptions; lapse = grace -> `late`, NEVER auto- +// inactive. + +// Pin deliberately so an SDK/API bump is a conscious change (see +// integration-api-facts memory). Consumed by the server Stripe client. +export const STRIPE_API_VERSION = '2026-04-22.dahlia' + +// Stripe subscription.status values that mean "the member has paid access". +const PAID = new Set(['active', 'trialing']) +// Terminal/failed states: access is gone. `past_due` is handled separately +// (grace window) and is deliberately NOT in either set here. +const LAPSED = new Set(['canceled', 'unpaid', 'incomplete', 'incomplete_expired']) + +export function stripeStatusIsPaid(status: string | null | undefined): boolean { + return !!status && PAID.has(status) +} + +// Map a Stripe subscription status to the member status we should set. +// Returns `null` when no change is warranted (unknown/irrelevant status), so +// the caller never blindly overwrites. NEVER returns 'inactive' — lapse tops +// out at 'late' and an admin decides inactivation manually (locked decision). +// `graceExceeded` is computed by the caller (now past current_period_end + +// grace days); only relevant while `past_due`. +export function duesMemberStatus( + stripeStatus: string | null | undefined, + graceExceeded: boolean, +): 'current' | 'late' | null { + if (!stripeStatus) return null + if (PAID.has(stripeStatus)) return 'current' + if (stripeStatus === 'past_due') return graceExceeded ? 'late' : 'current' + if (LAPSED.has(stripeStatus)) return 'late' + return null +} + +// Whether `now` is past the grace window after the period end. +export function graceExceeded( + currentPeriodEnd: string | null | undefined, + graceDays: number, + now?: Date | string, +): boolean { + if (!currentPeriodEnd) return false + const end = new Date(currentPeriodEnd).getTime() + if (Number.isNaN(end)) return false + const t = now == null ? Date.now() : new Date(now).getTime() + return t > end + Math.max(0, graceDays) * 86_400_000 +} + +type StripeConfig = { + mode?: string + publishable_key?: string + secret_key_ref?: string + webhook_secret_ref?: string + prices?: Record +} | null | undefined + +// The Stripe Price id mapped to a membership tier, or null if unmapped. +export function priceIdForTier(config: StripeConfig, tier: string | null | undefined): string | null { + const p = config?.prices + if (!p || !tier) return null + const v = p[tier] + return typeof v === 'string' && v.trim() !== '' ? v : null +} + +// Enough config to actually run Checkout: a publishable key, a secret-key +// vault ref, and at least one tier price mapped. +export function isStripeConfigured(config: StripeConfig): boolean { + if (!config?.publishable_key || !config?.secret_key_ref) return false + const prices = config.prices ?? {} + return Object.values(prices).some(v => typeof v === 'string' && v.trim() !== '') +}