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:
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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() !== '')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user