From 55af65226394adf18a0c88803abe399da68cb6ed Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 19 Apr 2026 18:12:44 +0100 Subject: [PATCH] feat(contributions): rewrite server config as preset-based helpers --- server/config/contributions.js | 47 ++++++------ tests/server/config/contributions.test.js | 90 +++++++++++++++++++---- 2 files changed, 98 insertions(+), 39 deletions(-) diff --git a/server/config/contributions.js b/server/config/contributions.js index d871bd5..64f98ee 100644 --- a/server/config/contributions.js +++ b/server/config/contributions.js @@ -1,28 +1,29 @@ -// Server-side contribution config -export const CONTRIBUTION_TIERS = { - FREE: { value: "0", amount: 0, label: "$0 - I need support right now", tier: "free" }, - SUPPORTER: { value: "5", amount: 5, label: "$5 - I can contribute", tier: "supporter" }, - MEMBER: { value: "15", amount: 15, label: "$15 - I can sustain the community", tier: "member" }, - ADVOCATE: { value: "30", amount: 30, label: "$30 - I can support others too", tier: "advocate" }, - CHAMPION: { value: "50", amount: 50, label: "$50 - I want to sponsor multiple members", tier: "champion" }, +// Server-side contribution config. +// Parallel to app/config/contributions.js but also hosts getHelcimPlanId, +// which is server-only (depends on runtime config). + +export const CONTRIBUTION_PRESETS = [ + { amount: 0, label: "I need support right now" }, + { amount: 5, label: "I can contribute" }, + { amount: 15, label: "I can sustain the community" }, + { amount: 30, label: "I can support others too" }, + { amount: 50, label: "I want to sponsor multiple members" }, +] + +export const requiresPayment = (amount) => amount > 0 + +export const isValidContributionAmount = (amount) => + Number.isInteger(amount) && amount >= 0 + +export const getGuidanceLabel = (amount) => { + if (amount === null || amount === undefined) return null + const n = Number(amount) + if (!Number.isFinite(n) || n < 0) return null + const match = CONTRIBUTION_PRESETS.findLast(p => p.amount <= n) + return match?.label ?? null } -export const getContributionOptions = () => Object.values(CONTRIBUTION_TIERS) -export const getValidContributionValues = () => Object.values(CONTRIBUTION_TIERS).map(t => t.value) -export const getContributionTierByValue = (value) => - Object.values(CONTRIBUTION_TIERS).find(t => t.value === value) -export const requiresPayment = (value) => (getContributionTierByValue(value)?.amount ?? 0) > 0 -export const isValidContributionValue = (value) => getValidContributionValues().includes(value) -export const getPaidContributionTiers = () => - Object.values(CONTRIBUTION_TIERS).filter(t => t.amount > 0) - -export function getTierAmount(tier, cadence = 'monthly') { - const base = parseFloat(tier.amount) - return cadence === 'annual' ? base * 10 : base -} - -// Cadence-keyed plan-id lookup. Throws via createError at call site if env missing; -// this helper just returns the raw runtime-config value. +// Cadence-keyed plan-id lookup. Unrelated to the tier concept. export function getHelcimPlanId(cadence) { const config = useRuntimeConfig() if (cadence === 'annual') return config.helcimAnnualPlanId || null diff --git a/tests/server/config/contributions.test.js b/tests/server/config/contributions.test.js index b0e916b..085a9c4 100644 --- a/tests/server/config/contributions.test.js +++ b/tests/server/config/contributions.test.js @@ -1,20 +1,78 @@ import { describe, it, expect } from 'vitest' -import { getTierAmount } from '../../../server/config/contributions.js' +import { + CONTRIBUTION_PRESETS, + requiresPayment, + isValidContributionAmount, + getGuidanceLabel, +} from '../../../server/config/contributions.js' -describe('getTierAmount', () => { - it('returns monthly amount as-is', () => { - expect(getTierAmount({ amount: 5 }, 'monthly')).toBe(5) - }) - - it('returns annual amount as base * 10', () => { - expect(getTierAmount({ amount: 5 }, 'annual')).toBe(50) - }) - - it('returns annual amount for $50 tier', () => { - expect(getTierAmount({ amount: 50 }, 'annual')).toBe(500) - }) - - it('defaults cadence to monthly', () => { - expect(getTierAmount({ amount: 15 })).toBe(15) +describe('CONTRIBUTION_PRESETS', () => { + it('exposes the five suggested amounts with guidance labels', () => { + expect(CONTRIBUTION_PRESETS).toEqual([ + { amount: 0, label: 'I need support right now' }, + { amount: 5, label: 'I can contribute' }, + { amount: 15, label: 'I can sustain the community' }, + { amount: 30, label: 'I can support others too' }, + { amount: 50, label: 'I want to sponsor multiple members' }, + ]) + }) +}) + +describe('requiresPayment', () => { + it('returns false for 0', () => { + expect(requiresPayment(0)).toBe(false) + }) + it('returns true for any positive number', () => { + expect(requiresPayment(1)).toBe(true) + expect(requiresPayment(15)).toBe(true) + }) + it('returns false for null, undefined, NaN', () => { + expect(requiresPayment(null)).toBe(false) + expect(requiresPayment(undefined)).toBe(false) + expect(requiresPayment(NaN)).toBe(false) + }) +}) + +describe('isValidContributionAmount', () => { + it('accepts non-negative integers', () => { + expect(isValidContributionAmount(0)).toBe(true) + expect(isValidContributionAmount(15)).toBe(true) + expect(isValidContributionAmount(9999)).toBe(true) + }) + it('rejects negatives', () => { + expect(isValidContributionAmount(-1)).toBe(false) + }) + it('rejects non-integers and non-numbers', () => { + expect(isValidContributionAmount(1.5)).toBe(false) + expect(isValidContributionAmount('15')).toBe(false) + expect(isValidContributionAmount(NaN)).toBe(false) + expect(isValidContributionAmount(null)).toBe(false) + expect(isValidContributionAmount(undefined)).toBe(false) + }) +}) + +describe('getGuidanceLabel', () => { + it('returns the exact preset label when amount matches a preset', () => { + expect(getGuidanceLabel(0)).toBe('I need support right now') + expect(getGuidanceLabel(5)).toBe('I can contribute') + expect(getGuidanceLabel(15)).toBe('I can sustain the community') + expect(getGuidanceLabel(30)).toBe('I can support others too') + expect(getGuidanceLabel(50)).toBe('I want to sponsor multiple members') + }) + it('returns the nearest-lower preset label for between-preset values', () => { + expect(getGuidanceLabel(7)).toBe('I can contribute') + expect(getGuidanceLabel(14)).toBe('I can contribute') + expect(getGuidanceLabel(40)).toBe('I can support others too') + expect(getGuidanceLabel(100)).toBe('I want to sponsor multiple members') + }) + it('coerces string input', () => { + expect(getGuidanceLabel('15')).toBe('I can sustain the community') + }) + it('returns null for invalid input', () => { + expect(getGuidanceLabel(-1)).toBeNull() + expect(getGuidanceLabel(NaN)).toBeNull() + expect(getGuidanceLabel('abc')).toBeNull() + expect(getGuidanceLabel(null)).toBeNull() + expect(getGuidanceLabel(undefined)).toBeNull() }) })