Payments (Paystack)
@doc-version: 1.0.0
@last-updated: 2026-01-10
Visita uses Paystack as the primary payment provider for marketplace transactions, community project pledges, and Shukrands wallet top-ups.
Overview
Core Principle: Payments power civic action. When a resident pledges to a project or buys from a local seller, money flows directly to where it’s needed.
Payment Use Cases
| Use Case | Flow | Split |
|---|
| Marketplace Purchase | Customer → Seller | 95% Seller, 5% Platform |
| Project Pledge | Resident → Project Fund | 100% to Project |
| Shukrands Top-up | User → Wallet | 1 ZAR = 1 Shukrand |
Architecture
Integration Pattern
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Visita Client │────▶│ Visita Server │────▶│ Paystack │
│ (Frontend) │ │ (Actions/API) │ │ (API) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
▲ │
│ │
└───────── Redirect with reference ─────────────┘
│
▼
┌─────────────────┐
│ Webhook │
│ /api/webhooks/ │
│ paystack │
└─────────────────┘
Key Files
| File | Purpose |
|---|
data/business.ts | Payment initialization and verification |
app/actions/seller.ts | Order creation with payment |
app/actions/community/pledges.ts | Pledge payments |
app/api/webhooks/paystack/route.ts | Webhook handler |
components/wallet/BuyShukrandsModal.tsx | Wallet top-up UI |
Marketplace Payments
Creating an Order with Payment
// app/actions/seller.ts
export async function createOrderAndInitializePaymentAction(
items: { productId: string, quantity: number }[],
storeId: string,
shippingAddress?: {
street: string;
city: string;
province: string;
postalCode: string;
phone: string;
}
) {
const user = await getCurrentUser();
if (!user) return { success: false, error: "Unauthorized" };
const result = await createOrderAndInitializePayment({
items,
storeId,
userId: user.id,
shippingAddress,
});
return result;
}
Payment Initialization Response
// Successful initialization returns:
{
success: true,
orderId: "uuid",
authorization_url: "https://checkout.paystack.com/...",
reference: "VIS_123456789"
}
Split Payments (Subaccounts)
Sellers configure a Paystack subaccount to receive direct payouts:
// In store settings
const StoreProfileSchema = z.object({
// ...
paystackSubaccountCode: z.string().optional(), // "ACCT_xxxxx"
// ...
});
Split Configuration:
- Seller receives: 95% of transaction
- Platform receives: 5% as fee
- Configured via Paystack Dashboard → Settings → Subaccounts
Pledge Payment Flow
// app/actions/community/pledges.ts
export async function pledgeToProject(
projectId: string,
amount: number,
isAnonymous: boolean
) {
// 1. Create pledge record
// 2. Initialize Paystack payment
// 3. Return authorization URL
// 4. Webhook confirms payment → pledge.status = 'fulfilled'
}
Pledge States
| Status | Description |
|---|
pending | Payment initiated, awaiting completion |
fulfilled | Payment confirmed, funds available |
failed | Payment failed or cancelled |
refunded | Payment refunded to user |
Shukrands Wallet
Shukrands are the internal civic currency:
1 ZAR = 1 Shukrand (1:1 peg)
Top-up Flow
// components/wallet/BuyShukrandsModal.tsx
const handlePurchase = async (amount: number) => {
const response = await fetch('/api/wallet/topup', {
method: 'POST',
body: JSON.stringify({ amount })
});
const { authorization_url } = await response.json();
window.location.href = authorization_url;
};
Escrow Release
When a milestone is completed, Shukrands are released from escrow:
-- pay_with_shukrands RPC
BEGIN;
-- Deduct from escrow
UPDATE project_escrow
SET amount = amount - payment_amount
WHERE project_id = $1;
-- Credit recipient
UPDATE wallets
SET balance = balance + payment_amount
WHERE user_id = $2;
COMMIT;
Webhook Handler
TODO: The webhook handler is currently a stub. Full implementation is pending schema configuration.
// app/api/webhooks/paystack/route.ts
export async function POST(req: NextRequest) {
// Verify Paystack signature
const signature = req.headers.get('x-paystack-signature');
const body = await req.text();
const hash = crypto
.createHmac('sha512', process.env.PAYSTACK_SECRET_KEY!)
.update(body)
.digest('hex');
if (hash !== signature) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const event = JSON.parse(body);
switch (event.event) {
case 'charge.success':
await handleChargeSuccess(event.data);
break;
case 'transfer.success':
await handleTransferSuccess(event.data);
break;
}
return NextResponse.json({ received: true });
}
Payment Verification
After redirect from Paystack:
// app/actions/seller.ts
export async function verifyPaymentAction(reference: string) {
try {
return await verifyPayment(reference);
} catch (error) {
return { success: false, error: "Verification failed" };
}
}
The verification:
- Calls Paystack API to confirm payment status
- Updates order status to
paid
- Triggers notification to seller
Environment Variables
# Required in .env.local
PAYSTACK_SECRET_KEY=sk_live_xxxxx # or sk_test_xxxxx for sandbox
PAYSTACK_PUBLIC_KEY=pk_live_xxxxx # For frontend popup
Testing
Sandbox Mode
Use test keys and test card numbers:
- Success: 4084 0840 8408 4081
- Decline: 4000 0000 0000 0002
Testing Webhooks Locally
Use ngrok or similar to expose local server:
ngrok http 3000
# Then configure Paystack dashboard with the ngrok URL
Status: Partial Implementation
Last Updated: January 2026