feat(billing): P1b pure Stripe dues logic + 10 tests

lib/stripe-logic.ts (no SDK/Supabase/React): pinned STRIPE_API_VERSION;
stripeStatusIsPaid; duesMemberStatus (active/trialing->current; past_due
-> current within grace else late; canceled/unpaid/incomplete->late;
NEVER inactive; unknown->null no-op); graceExceeded (fail-safe on
missing/invalid period); priceIdForTier; isStripeConfigured. Suite 479.
This commit is contained in:
Moheeb Zara
2026-05-17 02:59:02 -07:00
parent 521df696a2
commit 3d7cf87f0f
2 changed files with 151 additions and 0 deletions
+78
View File
@@ -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)
})
})
+73
View File
@@ -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<string, string | null | undefined>
} | 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() !== '')
}