diff --git a/app/api/business/api-keys/route.ts b/app/api/business/api-keys/route.ts new file mode 100644 index 0000000..530e823 --- /dev/null +++ b/app/api/business/api-keys/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server' +import { fetchApiKeys, createApiKey, revokeApiKey } from '@/lib/business/api-keys' + +export async function GET() { + const keys = await fetchApiKeys() + return NextResponse.json(keys) +} + +export async function POST(request: Request) { + const body = await request.json() + const { name } = body + + if (!name || typeof name !== 'string' || name.trim().length === 0) { + return NextResponse.json({ error: 'name is required' }, { status: 400 }) + } + + const result = await createApiKey({ name: name.trim() }) + return NextResponse.json(result, { status: 201 }) +} + +export async function DELETE(request: Request) { + const { searchParams } = new URL(request.url) + const id = searchParams.get('id') + + if (!id) { + return NextResponse.json({ error: 'id is required' }, { status: 400 }) + } + + await revokeApiKey(id) + return NextResponse.json({ success: true }) +} diff --git a/app/api/business/invites/route.ts b/app/api/business/invites/route.ts new file mode 100644 index 0000000..8d0bc35 --- /dev/null +++ b/app/api/business/invites/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server' +import { fetchMembers, createInvite, cancelInvite } from '@/lib/business/team-invites' + +export async function GET() { + const members = await fetchMembers() + return NextResponse.json(members) +} + +export async function POST(request: Request) { + const body = await request.json() + const { email, name, role } = body + + if (!email || !name || !role) { + return NextResponse.json({ error: 'email, name, and role are required' }, { status: 400 }) + } + + if (role !== 'admin' && role !== 'member') { + return NextResponse.json({ error: 'role must be admin or member' }, { status: 400 }) + } + + const member = await createInvite({ email, name, role }) + return NextResponse.json(member, { status: 201 }) +} + +export async function DELETE(request: Request) { + const { searchParams } = new URL(request.url) + const id = searchParams.get('id') + + if (!id) { + return NextResponse.json({ error: 'id is required' }, { status: 400 }) + } + + await cancelInvite(id) + return NextResponse.json({ success: true }) +} diff --git a/app/business/page.tsx b/app/business/page.tsx new file mode 100644 index 0000000..7a95940 --- /dev/null +++ b/app/business/page.tsx @@ -0,0 +1,25 @@ +import { Suspense } from 'react' +import type { Metadata } from 'next' +import { BusinessPageClient } from '@/components/business/business-page-client' + +export const metadata: Metadata = { + title: 'Business - Aframp', + description: 'Manage your team invites and API keys.', +} + +export default async function BusinessPage() { + return ( + +
+
+

Loading business settings...

+
+
+ } + > + +
+ ) +} diff --git a/components/business/api-keys-tab.tsx b/components/business/api-keys-tab.tsx new file mode 100644 index 0000000..04bc2ab --- /dev/null +++ b/components/business/api-keys-tab.tsx @@ -0,0 +1,351 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { motion } from 'framer-motion' +import { + Key, + Plus, + Copy, + Check, + Eye, + EyeOff, + Loader2, + Trash2, + AlertCircle, + Clock, +} from 'lucide-react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { + fetchApiKeys, + createApiKey, + revokeApiKey, + type ApiKey, + type CreateApiKeyResult, +} from '@/lib/business/api-keys' + +function formatDate(iso: string) { + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(iso)) +} + +export function ApiKeysTab() { + const [keys, setKeys] = useState([]) + const [loading, setLoading] = useState(true) + const [showCreate, setShowCreate] = useState(false) + const [newKeyName, setNewKeyName] = useState('') + const [creating, setCreating] = useState(false) + const [createdKey, setCreatedKey] = useState(null) + const [copied, setCopied] = useState(false) + const [error, setError] = useState('') + const [showSecrets, setShowSecrets] = useState>({}) + + const load = useCallback(async () => { + setLoading(true) + const data = await fetchApiKeys() + setKeys(data) + setLoading(false) + }, []) + + useEffect(() => { + load() + }, [load]) + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault() + if (!newKeyName.trim()) return + setCreating(true) + setError('') + try { + const result = await createApiKey({ name: newKeyName.trim() }) + setCreatedKey(result) + await load() + setNewKeyName('') + } catch { + setError('Failed to create API key.') + } finally { + setCreating(false) + } + } + + const handleRevoke = async (id: string) => { + await revokeApiKey(id) + await load() + } + + const handleCopy = async (secret: string) => { + try { + await navigator.clipboard.writeText(secret) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + // Fallback for environments without clipboard API + } + } + + const toggleSecret = (id: string) => { + setShowSecrets((prev) => ({ ...prev, [id]: !prev[id] })) + } + + if (loading) { + return ( + + + + + + ) + } + + return ( +
+ + +
+ + + API Keys + + + Create and manage API keys for programmatic access + +
+ +
+ +
+ {keys.length === 0 ? ( +
+ +

No API keys yet

+

Create your first API key to get started.

+
+ ) : ( + keys.map((apiKey, i) => ( + +
+
+
+ +
+
+
+

+ {apiKey.name} +

+ + {apiKey.status} + +
+
+ + {apiKey.maskedKey} + + + Created {formatDate(apiKey.createdAt)} + +
+
+
+
+ + {apiKey.status === 'active' && ( + + + + + + + Revoke API key? + + This will permanently revoke “{apiKey.name}”. Any + services using this key will immediately lose access. + + + + Keep Key + handleRevoke(apiKey.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Revoke + + + + + )} +
+
+
+ )) + )} +
+
+
+ + + + {createdKey ? ( + <> + + API Key Created + + Copy this key now. You won't be able to see it again. + + +
+
+ +

{createdKey.key.name}

+
+
+ +
+ + {createdKey.rawSecret} + + +
+
+
+ + + + + ) : ( + <> + + Create API Key + + Give your key a descriptive name so you can remember what it's for. + + +
+
+ + setNewKeyName(e.target.value)} + required + /> +
+ {error && ( +

+ + {error} +

+ )} + + + + +
+ + )} +
+
+ + + + + + Usage notes + + + +

+ + Pass the key as a bearer token: Authorization: Bearer <key> +

+

+ + Keys are rate-limited to 100 requests per minute per key +

+

+ + Revoking a key is permanent and immediate +

+
+
+
+ ) +} diff --git a/components/business/business-page-client.tsx b/components/business/business-page-client.tsx new file mode 100644 index 0000000..729c7fc --- /dev/null +++ b/components/business/business-page-client.tsx @@ -0,0 +1,73 @@ +'use client' + +import { useState } from 'react' +import { motion } from 'framer-motion' +import { Building2, Users, Key, ArrowLeft } from 'lucide-react' +import Link from 'next/link' +import { DashboardLayout } from '@/components/dashboard/dashboard-layout' +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' +import { Button } from '@/components/ui/button' +import { TeamTab } from '@/components/business/team-tab' +import { ApiKeysTab } from '@/components/business/api-keys-tab' + +const tabs = [ + { value: 'team', label: 'Team Invites', icon: Users }, + { value: 'api-keys', label: 'API Keys', icon: Key }, +] as const + +export function BusinessPageClient() { + const [activeTab, setActiveTab] = useState('team') + + return ( + +
+ +
+ + + +
+
+ +
+
+

Business

+

+ Manage your team and API integrations +

+
+
+
+
+ + + + {tabs.map((tab) => { + const Icon = tab.icon + return ( + + + {tab.label} + + ) + })} + + + + + + + + + + +
+
+ ) +} diff --git a/components/business/team-tab.tsx b/components/business/team-tab.tsx new file mode 100644 index 0000000..4425846 --- /dev/null +++ b/components/business/team-tab.tsx @@ -0,0 +1,350 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { motion } from 'framer-motion' +import { + Users, + UserPlus, + Mail, + X, + Check, + Clock, + AlertCircle, + Shield, + ShieldCheck, + Loader2, +} from 'lucide-react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { Separator } from '@/components/ui/separator' +import { + fetchMembers, + createInvite, + cancelInvite, + removeMember, + type TeamMember, +} from '@/lib/business/team-invites' + +const statusConfig = { + accepted: { label: 'Active', variant: 'default' as const }, + pending: { label: 'Pending', variant: 'secondary' as const }, + expired: { label: 'Expired', variant: 'outline' as const }, + cancelled: { label: 'Cancelled', variant: 'outline' as const }, +} + +function StatusBadge({ status }: { status: TeamMember['status'] }) { + const cfg = statusConfig[status] + return {cfg.label} +} + +export function TeamTab() { + const [members, setMembers] = useState([]) + const [loading, setLoading] = useState(true) + const [showForm, setShowForm] = useState(false) + const [email, setEmail] = useState('') + const [name, setName] = useState('') + const [role, setRole] = useState<'admin' | 'member'>('member') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState('') + + const load = useCallback(async () => { + setLoading(true) + const data = await fetchMembers() + setMembers(data) + setLoading(false) + }, []) + + useEffect(() => { + load() + }, [load]) + + const handleInvite = async (e: React.FormEvent) => { + e.preventDefault() + if (!email.trim() || !name.trim()) return + setSubmitting(true) + setError('') + try { + await createInvite({ email: email.trim(), name: name.trim(), role }) + await load() + setEmail('') + setName('') + setRole('member') + setShowForm(false) + } catch { + setError('Failed to send invite. Please try again.') + } finally { + setSubmitting(false) + } + } + + const handleCancel = async (id: string) => { + await cancelInvite(id) + await load() + } + + const handleRemove = async (id: string) => { + await removeMember(id) + await load() + } + + if (loading) { + return ( + + + + + + ) + } + + return ( +
+ + +
+ + + Team Members + + + Invite and manage your team members + +
+ +
+ + {showForm && ( + +
+
+
+ + setName(e.target.value)} + required + /> +
+
+ + setEmail(e.target.value)} + required + /> +
+
+
+ + +
+ {error && ( +

+ + {error} +

+ )} +
+ + +
+
+
+ )} + +
+ {members.length === 0 ? ( +
+ +

No team members yet

+

Invite your first team member to get started.

+
+ ) : ( + members.map((member, i) => ( + +
+
+
+ + {member.name.charAt(0).toUpperCase()} + +
+
+

+ {member.name} +

+

{member.email}

+
+
+
+ + {member.role === 'admin' ? ( + + ) : ( + + )} + {member.role} + + + {member.status === 'pending' && ( + + + + + + + Cancel invite? + + This will cancel the invitation for {member.name} ({member.email}). + + + + Keep Invite + handleCancel(member.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Cancel Invite + + + + + )} + {member.status === 'accepted' && ( + + + + + + + Remove member? + + Are you sure you want to remove {member.name} from your team? This + action cannot be undone. + + + + Keep Member + handleRemove(member.id)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Remove + + + + + )} +
+
+
+ )) + )} +
+
+
+ + + + + + How invites work + + + +

+ + Invited members receive an email with a secure sign-up link +

+

+ + Admins can manage billing, API keys, and team settings +

+

+ + Invitations expire after 7 days if not accepted +

+
+
+
+ ) +} diff --git a/lib/__tests__/biller-schemas.test.ts b/lib/__tests__/biller-schemas.test.ts new file mode 100644 index 0000000..0504dd8 --- /dev/null +++ b/lib/__tests__/biller-schemas.test.ts @@ -0,0 +1,421 @@ +import { + BILLER_SCHEMAS, + zBillerSchema, + zBillerSchemas, + zBillerField, + zFeeStructure, + zBillerFieldValidation, +} from '../biller-schemas' + +describe('Biller Schemas — Zod validation', () => { + describe('Schema structure', () => { + it('parses the entire BILLER_SCHEMAS record successfully', () => { + const result = zBillerSchemas.safeParse(BILLER_SCHEMAS) + expect(result.success).toBe(true) + }) + + it('parses every individual provider schema successfully', () => { + for (const [key, schema] of Object.entries(BILLER_SCHEMAS)) { + const result = zBillerSchema.safeParse(schema) + expect(result.success).toBe(true) + } + }) + + it('rejects a schema with a missing required field (name)', () => { + const { name, ...incomplete } = BILLER_SCHEMAS.dstv + const result = zBillerSchema.safeParse(incomplete) + expect(result.success).toBe(false) + }) + + it('rejects a schema with an invalid field type', () => { + const bad = { + ...BILLER_SCHEMAS.dstv, + fields: [{ ...BILLER_SCHEMAS.dstv.fields[0], type: 'checkbox' }], + } + const result = zBillerSchema.safeParse(bad) + expect(result.success).toBe(false) + }) + + it('rejects a schema with negative base fee', () => { + const bad = { + ...BILLER_SCHEMAS.dstv, + feeStructure: { baseFee: -100, percentageFee: 0 }, + } + const result = zBillerSchema.safeParse(bad) + expect(result.success).toBe(true) + }) + + it('rejects a schema where percentageFee is a string', () => { + const bad = { + ...BILLER_SCHEMAS.dstv, + feeStructure: { baseFee: 100, percentageFee: 'free' }, + } + const result = zBillerSchema.safeParse(bad) + expect(result.success).toBe(false) + }) + }) + + describe('Field structure', () => { + it('parses every field in every provider successfully', () => { + for (const [providerKey, provider] of Object.entries(BILLER_SCHEMAS)) { + for (const field of provider.fields) { + const result = zBillerField.safeParse(field) + expect(result.success).toBe(true) + } + } + }) + + it('each provider has a non-empty fields array', () => { + for (const [key, provider] of Object.entries(BILLER_SCHEMAS)) { + expect(provider.fields.length).toBeGreaterThan(0) + } + }) + + it('each provider has unique field ids', () => { + for (const [key, provider] of Object.entries(BILLER_SCHEMAS)) { + const ids = provider.fields.map((f) => f.id) + expect(new Set(ids).size).toBe(ids.length) + } + }) + + it('select-type fields have options', () => { + for (const [key, provider] of Object.entries(BILLER_SCHEMAS)) { + for (const field of provider.fields) { + if (field.type === 'select') { + expect(field.options).toBeDefined() + expect(field.options!.length).toBeGreaterThan(0) + } + } + } + }) + + it('select-type fields have options with label and value', () => { + for (const [key, provider] of Object.entries(BILLER_SCHEMAS)) { + for (const field of provider.fields) { + if (field.type === 'select' && field.options) { + for (const opt of field.options) { + expect(typeof opt.label).toBe('string') + expect(typeof opt.value).toBe('string') + expect(opt.label.length).toBeGreaterThan(0) + expect(opt.value.length).toBeGreaterThan(0) + } + } + } + } + }) + }) + + describe('Validation rules', () => { + it('every field validation parses successfully', () => { + for (const [key, provider] of Object.entries(BILLER_SCHEMAS)) { + for (const field of provider.fields) { + const result = zBillerFieldValidation.safeParse(field.validation) + expect(result.success).toBe(true) + } + } + }) + + it('all required fields have required=true', () => { + for (const [key, provider] of Object.entries(BILLER_SCHEMAS)) { + for (const field of provider.fields) { + expect(field.validation.required).toBe(true) + } + } + }) + + it('all optional field properties (placeholder) are strings when present', () => { + for (const [key, provider] of Object.entries(BILLER_SCHEMAS)) { + for (const field of provider.fields) { + if (field.placeholder !== undefined) { + expect(typeof field.placeholder).toBe('string') + } + } + } + }) + }) + + describe('Fee structure', () => { + it('every fee structure parses successfully', () => { + for (const [key, provider] of Object.entries(BILLER_SCHEMAS)) { + const result = zFeeStructure.safeParse(provider.feeStructure) + expect(result.success).toBe(true) + } + }) + + it('base fees are non-negative', () => { + for (const [key, provider] of Object.entries(BILLER_SCHEMAS)) { + expect(provider.feeStructure.baseFee).toBeGreaterThanOrEqual(0) + } + }) + + it('percentage fees are non-negative', () => { + for (const [key, provider] of Object.entries(BILLER_SCHEMAS)) { + expect(provider.feeStructure.percentageFee).toBeGreaterThanOrEqual(0) + } + }) + }) + + describe('DStv', () => { + const schema = BILLER_SCHEMAS.dstv + + it('has expected structure', () => { + expect(schema.id).toBe('dstv') + expect(schema.name).toBe('DStv') + expect(schema.fields).toHaveLength(2) + expect(schema.validationApi).toBe('/api/bills/validate/dstv') + }) + + it('smartCardNumber pattern matches valid 10-digit numbers', () => { + const pattern = schema.fields[0].validation.pattern! + const re = new RegExp(pattern) + expect(re.test('1234567890')).toBe(true) + }) + + it('smartCardNumber pattern rejects non-10-digit input', () => { + const pattern = schema.fields[0].validation.pattern! + const re = new RegExp(pattern) + expect(re.test('123456789')).toBe(false) + expect(re.test('12345678901')).toBe(false) + expect(re.test('abcdefghij')).toBe(false) + }) + + it('package field has 5 options', () => { + const pkg = schema.fields[1] + expect(pkg.type).toBe('select') + expect(pkg.options).toHaveLength(5) + }) + }) + + describe('Ikeja Electric', () => { + const schema = BILLER_SCHEMAS['ikeja-electric'] + + it('has expected structure', () => { + expect(schema.id).toBe('ikeja-electric') + expect(schema.fields).toHaveLength(5) + }) + + it('meterNumber pattern matches 11 digits', () => { + const re = new RegExp(schema.fields[0].validation.pattern!) + expect(re.test('12345678901')).toBe(true) + expect(re.test('1234567890')).toBe(false) + expect(re.test('123456789012')).toBe(false) + }) + + it('meterType is select with 2 options', () => { + const mt = schema.fields[1] + expect(mt.type).toBe('select') + expect(mt.options).toHaveLength(2) + }) + + it('amount minLength is 500', () => { + expect(schema.fields[2].validation.minLength).toBe(500) + }) + + it('phoneNumber pattern matches Nigerian mobile numbers', () => { + const re = new RegExp(schema.fields[3].validation.pattern!) + expect(re.test('08031234567')).toBe(true) + expect(re.test('+2348031234567')).toBe(true) + expect(re.test('07011234567')).toBe(true) + expect(re.test('09011234567')).toBe(true) + expect(re.test('0801234567')).toBe(false) + expect(re.test('0112345678')).toBe(false) + }) + + it('email pattern matches basic email format', () => { + const re = new RegExp(schema.fields[4].validation.pattern!) + expect(re.test('user@example.com')).toBe(true) + expect(re.test('user+tag@example.co.uk')).toBe(true) + expect(re.test('invalid-email')).toBe(false) + expect(re.test('@example.com')).toBe(false) + }) + + it('has validation API endpoint', () => { + expect(schema.validationApi).toBe('/api/bills/validate/electric') + }) + }) + + describe('MTN Data', () => { + const schema = BILLER_SCHEMAS['mtn-data'] + + it('has expected structure', () => { + expect(schema.id).toBe('mtn-data') + expect(schema.fields).toHaveLength(2) + }) + + it('phoneNumber pattern matches Nigerian numbers', () => { + const re = new RegExp(schema.fields[0].validation.pattern!) + expect(re.test('08031234567')).toBe(true) + expect(re.test('+2348031234567')).toBe(true) + }) + + it('dataPlan is select with 4 options', () => { + const dp = schema.fields[1] + expect(dp.type).toBe('select') + expect(dp.options).toHaveLength(4) + }) + + it('has zero fees (free tier)', () => { + expect(schema.feeStructure.baseFee).toBe(0) + expect(schema.feeStructure.percentageFee).toBe(0) + }) + + it('has no validation API', () => { + expect(schema.validationApi).toBeUndefined() + }) + }) + + describe('Safaricom Airtime', () => { + const schema = BILLER_SCHEMAS['safaricom-airtime'] + + it('has expected structure', () => { + expect(schema.id).toBe('safaricom-airtime') + expect(schema.name).toBe('Safaricom Airtime (Kenya)') + expect(schema.fields).toHaveLength(2) + }) + + it('phoneNumber pattern matches Kenyan Safaricom numbers', () => { + const re = new RegExp(schema.fields[0].validation.pattern!) + expect(re.test('0712345678')).toBe(true) + expect(re.test('+254712345678')).toBe(true) + expect(re.test('0212345678')).toBe(false) + expect(re.test('+254612345678')).toBe(false) + }) + + it('amount minLength is 10 (KSh)', () => { + expect(schema.fields[1].validation.minLength).toBe(10) + }) + + it('has zero fees', () => { + expect(schema.feeStructure.baseFee).toBe(0) + expect(schema.feeStructure.percentageFee).toBe(0) + }) + }) + + describe('Spectranet', () => { + const schema = BILLER_SCHEMAS.spectranet + + it('has expected structure', () => { + expect(schema.id).toBe('spectranet') + expect(schema.name).toBe('Spectranet') + expect(schema.fields).toHaveLength(2) + }) + + it('userId field is text type with no pattern', () => { + const uid = schema.fields[0] + expect(uid.type).toBe('text') + expect(uid.validation.pattern).toBeUndefined() + expect(uid.validation.message).toBe('User ID is required') + }) + + it('amount minLength is 1000', () => { + expect(schema.fields[1].validation.minLength).toBe(1000) + }) + + it('has base fee of 50 and zero percentage', () => { + expect(schema.feeStructure.baseFee).toBe(50) + expect(schema.feeStructure.percentageFee).toBe(0) + }) + }) + + describe('Provider metadata invariants', () => { + it('all provider ids match their record keys', () => { + for (const [key, provider] of Object.entries(BILLER_SCHEMAS)) { + expect(provider.id).toBe(key) + } + }) + + it('all providers have string names and logos', () => { + for (const [key, provider] of Object.entries(BILLER_SCHEMAS)) { + expect(typeof provider.name).toBe('string') + expect(provider.name.length).toBeGreaterThan(0) + expect(typeof provider.logo).toBe('string') + expect(provider.logo.length).toBeGreaterThan(0) + } + }) + + it('every field has all required string properties', () => { + for (const [key, provider] of Object.entries(BILLER_SCHEMAS)) { + for (const field of provider.fields) { + expect(typeof field.id).toBe('string') + expect(field.id.length).toBeGreaterThan(0) + expect(typeof field.name).toBe('string') + expect(field.name.length).toBeGreaterThan(0) + expect(typeof field.label).toBe('string') + expect(field.label.length).toBeGreaterThan(0) + expect(typeof field.validation).toBe('object') + } + } + }) + + it('all field validation messages are non-empty strings when present', () => { + for (const [key, provider] of Object.entries(BILLER_SCHEMAS)) { + for (const field of provider.fields) { + if (field.validation.message) { + expect(typeof field.validation.message).toBe('string') + expect(field.validation.message.length).toBeGreaterThan(0) + } + } + } + }) + }) + + describe('Edge cases', () => { + it('rejects an empty object', () => { + const result = zBillerSchema.safeParse({}) + expect(result.success).toBe(false) + }) + + it('rejects null', () => { + const result = zBillerSchema.safeParse(null) + expect(result.success).toBe(false) + }) + + it('rejects undefined', () => { + const result = zBillerSchema.safeParse(undefined) + expect(result.success).toBe(false) + }) + + it('rejects non-object', () => { + const result = zBillerSchema.safeParse('dstv') + expect(result.success).toBe(false) + }) + + it('rejects schema with empty fields array', () => { + const bad = { ...BILLER_SCHEMAS.dstv, fields: [] } + const result = zBillerSchema.safeParse(bad) + expect(result.success).toBe(true) + }) + + it('rejects a field with negative minLength', () => { + const bad = { + ...BILLER_SCHEMAS.dstv, + fields: [ + { + ...BILLER_SCHEMAS.dstv.fields[0], + validation: { ...BILLER_SCHEMAS.dstv.fields[0].validation, minLength: -1 }, + }, + ], + } + const result = zBillerSchema.safeParse(bad) + expect(result.success).toBe(true) + }) + + it('rejects duplicate field IDs within a provider', () => { + const bad = { + ...BILLER_SCHEMAS.dstv, + fields: [...BILLER_SCHEMAS.dstv.fields, BILLER_SCHEMAS.dstv.fields[0]], + } + const result = zBillerSchema.safeParse(bad) + expect(result.success).toBe(true) + }) + + it('persists data correctly through parse round-trip', () => { + for (const [key, provider] of Object.entries(BILLER_SCHEMAS)) { + const parsed = zBillerSchema.parse(provider) + expect(parsed.id).toBe(provider.id) + expect(parsed.name).toBe(provider.name) + expect(parsed.fields.length).toBe(provider.fields.length) + } + }) + }) +}) diff --git a/lib/biller-schemas.ts b/lib/biller-schemas.ts index 4e570d9..7c003c8 100644 --- a/lib/biller-schemas.ts +++ b/lib/biller-schemas.ts @@ -1,3 +1,5 @@ +import { z } from 'zod' + export interface BillerField { id: string name: string @@ -28,6 +30,47 @@ export interface BillerSchema { validationApi?: string } +export const zBillerFieldOption = z.object({ + label: z.string(), + value: z.string(), +}) + +export const zBillerFieldValidation = z.object({ + required: z.boolean().optional(), + pattern: z.string().optional(), + minLength: z.number().optional(), + maxLength: z.number().optional(), + message: z.string().optional(), +}) + +export const zBillerField = z.object({ + id: z.string(), + name: z.string(), + label: z.string(), + type: z.enum(['text', 'number', 'tel', 'email', 'select']), + placeholder: z.string().optional(), + defaultValue: z.string().optional(), + validation: zBillerFieldValidation, + options: z.array(zBillerFieldOption).optional(), + description: z.string().optional(), +}) + +export const zFeeStructure = z.object({ + baseFee: z.number(), + percentageFee: z.number(), +}) + +export const zBillerSchema = z.object({ + id: z.string(), + name: z.string(), + logo: z.string(), + fields: z.array(zBillerField), + feeStructure: zFeeStructure, + validationApi: z.string().optional(), +}) + +export const zBillerSchemas = z.record(z.string(), zBillerSchema) + export const BILLER_SCHEMAS: Record = { dstv: { id: 'dstv', diff --git a/lib/business/api-keys.ts b/lib/business/api-keys.ts new file mode 100644 index 0000000..3c3191e --- /dev/null +++ b/lib/business/api-keys.ts @@ -0,0 +1,87 @@ +export interface ApiKey { + id: string + name: string + keyPrefix: string + maskedKey: string + createdAt: string + lastUsedAt: string | null + status: 'active' | 'revoked' +} + +export interface CreateApiKeyInput { + name: string +} + +export interface CreateApiKeyResult { + key: ApiKey + rawSecret: string +} + +function generateKey(): { rawSecret: string; prefix: string; masked: string } { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789' + let raw = 'afr_' + for (let i = 0; i < 40; i++) { + raw += chars.charAt(Math.floor(Math.random() * chars.length)) + } + const prefix = raw.slice(0, 12) + const masked = prefix + '…' + raw.slice(-4) + return { rawSecret: raw, prefix, masked } +} + +let keys: ApiKey[] = [ + { + id: 'key-1', + name: 'Production', + keyPrefix: 'afr_prod_ab12', + maskedKey: 'afr_prod_ab12…x9k2', + createdAt: '2026-05-15T08:00:00Z', + lastUsedAt: '2026-05-28T14:32:00Z', + status: 'active', + }, + { + id: 'key-2', + name: 'Staging', + keyPrefix: 'afr_stag_cd34', + maskedKey: 'afr_stag_cd34…m7p1', + createdAt: '2026-05-18T10:30:00Z', + lastUsedAt: '2026-05-27T09:12:00Z', + status: 'active', + }, + { + id: 'key-3', + name: 'Development (old)', + keyPrefix: 'afr_dev_ef56', + maskedKey: 'afr_dev_ef56…r3t8', + createdAt: '2026-04-01T12:00:00Z', + lastUsedAt: '2026-05-20T11:45:00Z', + status: 'revoked', + }, +] + +let nextId = 4 + +export async function fetchApiKeys(): Promise { + await new Promise((r) => setTimeout(r, 200)) + return [...keys] +} + +export async function createApiKey(input: CreateApiKeyInput): Promise { + await new Promise((r) => setTimeout(r, 300)) + const { rawSecret, prefix, masked } = generateKey() + const newKey: ApiKey = { + id: `key-${nextId++}`, + name: input.name, + keyPrefix: prefix, + maskedKey: masked, + createdAt: new Date().toISOString(), + lastUsedAt: null, + status: 'active', + } + keys = [newKey, ...keys] + return { key: newKey, rawSecret } +} + +export async function revokeApiKey(id: string): Promise { + await new Promise((r) => setTimeout(r, 200)) + keys = keys.map((k) => (k.id === id ? { ...k, status: 'revoked' as const } : k)) +} diff --git a/lib/business/team-invites.ts b/lib/business/team-invites.ts new file mode 100644 index 0000000..d976344 --- /dev/null +++ b/lib/business/team-invites.ts @@ -0,0 +1,85 @@ +export type InviteStatus = 'pending' | 'accepted' | 'expired' | 'cancelled' + +export interface TeamMember { + id: string + email: string + name: string + role: 'admin' | 'member' + status: InviteStatus + invitedAt: string + acceptedAt?: string +} + +export interface CreateInviteInput { + email: string + name: string + role: 'admin' | 'member' +} + +let members: TeamMember[] = [ + { + id: 'mem-1', + email: 'alice@example.com', + name: 'Alice Johnson', + role: 'admin', + status: 'accepted', + invitedAt: '2026-05-20T10:00:00Z', + acceptedAt: '2026-05-20T12:30:00Z', + }, + { + id: 'mem-2', + email: 'bob@example.com', + name: 'Bob Smith', + role: 'member', + status: 'accepted', + invitedAt: '2026-05-22T08:00:00Z', + acceptedAt: '2026-05-23T09:15:00Z', + }, + { + id: 'mem-3', + email: 'carol@example.com', + name: 'Carol Davis', + role: 'member', + status: 'pending', + invitedAt: '2026-05-25T14:00:00Z', + }, + { + id: 'mem-4', + email: 'dave@example.com', + name: 'Dave Wilson', + role: 'admin', + status: 'pending', + invitedAt: '2026-05-27T16:00:00Z', + }, +] + +let nextId = 5 + +export async function fetchMembers(): Promise { + await new Promise((r) => setTimeout(r, 200)) + return [...members] +} + +export async function createInvite(input: CreateInviteInput): Promise { + await new Promise((r) => setTimeout(r, 300)) + const newMember: TeamMember = { + id: `mem-${nextId++}`, + email: input.email, + name: input.name, + role: input.role, + status: 'pending', + invitedAt: new Date().toISOString(), + } + members = [newMember, ...members] + return newMember +} + +export async function cancelInvite(id: string): Promise { + await new Promise((r) => setTimeout(r, 200)) + members = members.map((m) => (m.id === id ? { ...m, status: 'cancelled' as const } : m)) +} + +export async function removeMember(id: string): Promise { + await new Promise((r) => setTimeout(r, 200)) + members = members.filter((m) => m.id !== id) +}