Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import calendarRouter from './routes/calendar';
import userPreferencesRoutes from './routes/user-preferences';
import reminderSettingsRoutes from './routes/reminder-settings';
import { blockchainReconciliationService } from './services/blockchain-reconciliation-service';
import paymentsRoutes from './routes/payments';
import { errorHandler } from './middleware/errorHandler';
import { swaggerSpec } from './swagger';

Expand Down Expand Up @@ -158,6 +159,7 @@ app.use('/api/wallet', walletRoutes);
app.use('/api/notifications/dead-letter', notificationDeadLetterRoutes);
app.use('/api/exchange-rates', createExchangeRatesRouter(exchangeRateService));
app.use('/api/gift-card-ledger', giftCardLedgerRoutes);
app.use('/api/payments', authenticate, paymentsRoutes);
app.use('/api/telegram', telegramWebhookRoutes);
app.use('/api/calendar', calendarRouter);
app.use('/api/user-preferences', authenticate, userPreferencesRoutes);
Expand Down Expand Up @@ -461,4 +463,4 @@ const shutdown = () => {
};

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
process.on('SIGINT', shutdown);
81 changes: 81 additions & 0 deletions backend/src/routes/payments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Router, Request, Response } from 'express'
import {
initializeFunding,
verifyTransaction,
listBanks,
} from '../../services/paystack'

const router = Router()

/**
* POST /api/payments/paystack/initialize
*
* Initialize a Paystack wallet-funding transaction.
* Returns an authorization_url to redirect the user to.
*
* Body: { email, amountKobo, reference, metadata? }
*/
router.post(
'/paystack/initialize',
async (req: Request, res: Response) => {
try {
const { email, amountKobo, reference, callbackUrl, metadata } = req.body

if (!email || !amountKobo || !reference) {
return res.status(400).json({
error: 'email, amountKobo, and reference are required',
})
}

const result = await initializeFunding({
email,
amountKobo: Number(amountKobo),
reference,
callbackUrl,
metadata,
})

return res.status(200).json({ success: true, data: result })
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
return res.status(500).json({ error: message })
}
}
)

/**
* GET /api/payments/paystack/verify/:reference
*
* Verify a completed Paystack transaction by reference.
* Called after the user returns from the Paystack-hosted checkout page.
*/
router.get(
'/paystack/verify/:reference',
async (req: Request, res: Response) => {
try {
const result = await verifyTransaction(req.params.reference)
return res.status(200).json({ success: true, data: result })
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
return res.status(500).json({ error: message })
}
}
)

/**
* GET /api/payments/paystack/banks
*
* List supported Nigerian banks.
* Used by the sub-account setup UI.
*/
router.get('/paystack/banks', async (_req: Request, res: Response) => {
try {
const banks = await listBanks()
return res.status(200).json({ success: true, data: banks })
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
return res.status(500).json({ error: message })
}
})

export default router
4 changes: 2 additions & 2 deletions client/app/api/payments/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const paymentSchema = z.object({
.default("usd"),
token: z.string().min(1, "Payment token is required"),
planName: z.string().min(1, "Plan name is required"),
provider: z.enum(["stripe", "paypal", "mock"]).default("stripe"),
provider: z.enum(["stripe", "paypal", "mock", "paystack"]).default("stripe"),
}).refine(
(data) => isPaymentProviderEnabled(data.provider),
(data) => ({
Expand Down Expand Up @@ -66,4 +66,4 @@ export const POST = createAuthenticatedApiRoute(
rateLimit: RateLimiters.payment,
idempotent: true,
},
);
);
118 changes: 118 additions & 0 deletions client/lib/__tests__/payment-providers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, it, expect } from "vitest"
import {
getProvidersForRegion,
isProviderValidForRegion,
getCurrencyForRegion,
PROVIDER_MATRIX,
type Region,
} from "../payment-providers"

describe("Payment Provider Matrix", () => {

describe("getProvidersForRegion", () => {
it("NG production → only paystack", () => {
expect(getProvidersForRegion('NG', 'production')).toEqual(['paystack'])
})

it("GH production → only paystack", () => {
expect(getProvidersForRegion('GH', 'production')).toEqual(['paystack'])
})

it("ZA production → only paystack", () => {
expect(getProvidersForRegion('ZA', 'production')).toEqual(['paystack'])
})

it("KE production → only paystack", () => {
expect(getProvidersForRegion('KE', 'production')).toEqual(['paystack'])
})

it("INTL production → only paypal", () => {
expect(getProvidersForRegion('INTL', 'production')).toEqual(['paypal'])
})

it("NG development → paystack and mock", () => {
const providers = getProvidersForRegion('NG', 'development')
expect(providers).toContain('paystack')
expect(providers).toContain('mock')
expect(providers).not.toContain('paypal')
})

it("INTL development → paypal and mock", () => {
const providers = getProvidersForRegion('INTL', 'development')
expect(providers).toContain('paypal')
expect(providers).toContain('mock')
expect(providers).not.toContain('paystack')
})
})

describe("isProviderValidForRegion", () => {
it("mock is never valid in production for any region", () => {
const allRegions: Region[] = ['NG', 'GH', 'ZA', 'KE', 'INTL']
for (const region of allRegions) {
expect(isProviderValidForRegion('mock', region, 'production')).toBe(false)
}
})

it("paypal is not valid for African regions in production", () => {
expect(isProviderValidForRegion('paypal', 'NG', 'production')).toBe(false)
expect(isProviderValidForRegion('paypal', 'GH', 'production')).toBe(false)
expect(isProviderValidForRegion('paypal', 'ZA', 'production')).toBe(false)
expect(isProviderValidForRegion('paypal', 'KE', 'production')).toBe(false)
})

it("paystack is not valid for INTL in any environment", () => {
expect(isProviderValidForRegion('paystack', 'INTL', 'production')).toBe(false)
expect(isProviderValidForRegion('paystack', 'INTL', 'development')).toBe(false)
expect(isProviderValidForRegion('paystack', 'INTL', 'test')).toBe(false)
})

it("paystack is valid for all African regions in production", () => {
expect(isProviderValidForRegion('paystack', 'NG', 'production')).toBe(true)
expect(isProviderValidForRegion('paystack', 'GH', 'production')).toBe(true)
expect(isProviderValidForRegion('paystack', 'ZA', 'production')).toBe(true)
expect(isProviderValidForRegion('paystack', 'KE', 'production')).toBe(true)
})

it("paypal is valid for INTL in production", () => {
expect(isProviderValidForRegion('paypal', 'INTL', 'production')).toBe(true)
})
})

describe("getCurrencyForRegion", () => {
it("returns NGN for African regions", () => {
expect(getCurrencyForRegion('NG')).toBe('NGN')
expect(getCurrencyForRegion('GH')).toBe('NGN')
expect(getCurrencyForRegion('ZA')).toBe('NGN')
expect(getCurrencyForRegion('KE')).toBe('NGN')
})

it("returns USD for international", () => {
expect(getCurrencyForRegion('INTL')).toBe('USD')
})
})

describe("PROVIDER_MATRIX integrity", () => {
it("every rule has at least one region and one environment", () => {
for (const rule of PROVIDER_MATRIX) {
expect(rule.regions.length).toBeGreaterThan(0)
expect(rule.environments.length).toBeGreaterThan(0)
}
})

it("mock never appears in production environments", () => {
const mockRule = PROVIDER_MATRIX.find((r) => r.provider === 'mock')
expect(mockRule).toBeDefined()
expect(mockRule!.environments).not.toContain('production')
})

it("paystack and paypal never share a region", () => {
const paystackRegions = PROVIDER_MATRIX
.find((r) => r.provider === 'paystack')!.regions
const paypalRegions = PROVIDER_MATRIX
.find((r) => r.provider === 'paypal')!.regions

const overlap = paystackRegions.filter((r) => paypalRegions.includes(r))
expect(overlap).toHaveLength(0)
})
})
})
Loading
Loading