Skip to main content

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 CaseFlowSplit
Marketplace PurchaseCustomer → Seller95% Seller, 5% Platform
Project PledgeResident → Project Fund100% to Project
Shukrands Top-upUser → Wallet1 ZAR = 1 Shukrand

Architecture

Integration Pattern

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  Visita Client  │────▶│  Visita Server  │────▶│    Paystack     │
│   (Frontend)    │     │  (Actions/API)  │     │     (API)       │
└─────────────────┘     └─────────────────┘     └─────────────────┘
        ▲                                               │
        │                                               │
        └───────── Redirect with reference ─────────────┘


                                               ┌─────────────────┐
                                               │    Webhook      │
                                               │ /api/webhooks/  │
                                               │   paystack      │
                                               └─────────────────┘

Key Files

FilePurpose
data/business.tsPayment initialization and verification
app/actions/seller.tsOrder creation with payment
app/actions/community/pledges.tsPledge payments
app/api/webhooks/paystack/route.tsWebhook handler
components/wallet/BuyShukrandsModal.tsxWallet 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

Community Pledges

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

StatusDescription
pendingPayment initiated, awaiting completion
fulfilledPayment confirmed, funds available
failedPayment failed or cancelled
refundedPayment 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:
  1. Calls Paystack API to confirm payment status
  2. Updates order status to paid
  3. 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