Overview

Transactions are the core data structure in FundKit, representing a payment request or money transfer. Understanding the transaction lifecycle is crucial for building robust payment integrations.
A transaction in FundKit represents a single payment operation - whether that’s collecting money from a customer, sending money to a recipient, or checking the status of a previous payment.

Transaction Structure

Basic Transaction

interface Transaction {
  amount: number; // Amount in smallest currency unit
  currency: string; // ISO currency code (UGX, KES, etc.)
  operator: string; // Mobile network operator
  accountNumber: string; // Phone number or account identifier
  externalId: string; // Your unique transaction ID
  reason?: string; // Description of the transaction
}

Example Transaction

const transaction = {
  amount: 10000, // 100.00 UGX (cents)
  currency: 'UGX', // Ugandan Shillings
  operator: 'mtn', // MTN Uganda
  accountNumber: '256779280949', // Customer's phone number
  externalId: 'order_12345', // Your order/invoice ID
  reason: 'Payment for order #12345', // Human-readable description
};

Transaction Types

Collections

Collect money from customers (charge them):
// Customer paying for an order
const collectionTx = {
  amount: 25000, // 250.00 UGX
  currency: 'UGX',
  operator: 'airtel',
  accountNumber: '256701234567',
  externalId: 'invoice_789',
  reason: 'Invoice payment',
};

const result = await client.collection(collectionTx);

Payouts (Coming Soon)

Send money to recipients:
// Paying a seller or refunding a customer
const payoutTx = {
  amount: 15000, // 150.00 UGX
  currency: 'UGX',
  operator: 'mtn',
  accountNumber: '256778123456',
  externalId: 'payout_456',
  reason: 'Seller commission payment',
};

const result = await client.payout(payoutTx);

Transaction Lifecycle

1

Created

Transaction is created and validated by FundKit typescript const transaction ={' '} {/* transaction data */}; // Status: Not yet submitted
2

Submitted

Transaction is sent to the selected payment provider typescript const result = await client.collection(transaction); // Status: 'pending' // Provider: Selected provider name // TransactionId: Provider's transaction ID
3

Processing

Provider processes the transaction with the mobile network typescript // Status: 'processing' // Customer receives SMS prompt to confirm payment
4

Completed or Failed

Final status is determined typescript // Success: Status: 'completed' // Failure: Status: 'failed' // Timeout: Status: 'timeout' // Cancelled: Status: 'cancelled'

Transaction States

Pending

Transaction has been submitted but not yet processed:
{
  id: 'txn_abc123',
  status: 'pending',
  amount: 10000,
  currency: 'UGX',
  externalId: 'order_12345',
  provider: 'honeycoin',
  createdAt: '2024-01-15T10:00:00Z'
}
What it means:
  • Provider has accepted the transaction
  • Waiting for mobile network to process
  • Customer may receive SMS prompt
  • Outcome is not yet determined

Processing

Transaction is actively being processed:
{
  id: 'txn_abc123',
  status: 'processing',
  amount: 10000,
  currency: 'UGX',
  externalId: 'order_12345',
  provider: 'honeycoin',
  processingStartedAt: '2024-01-15T10:01:30Z'
}
What it means:
  • Mobile network is processing payment
  • Customer is likely seeing payment prompt
  • Should complete within 1-3 minutes

Completed

Transaction was successful:
{
  id: 'txn_abc123',
  status: 'completed',
  amount: 10000,
  currency: 'UGX',
  externalId: 'order_12345',
  provider: 'honeycoin',
  completedAt: '2024-01-15T10:02:45Z',
  fees: 100,                         // Provider fees
  netAmount: 9900,                   // Amount after fees
  providerReference: 'HC_REF_789'    // Provider's reference
}
What it means:
  • Payment was successful
  • Money has been transferred
  • You can fulfill the order/service
  • Customer has been charged

Failed

Transaction was unsuccessful:
{
  id: 'txn_abc123',
  status: 'failed',
  amount: 10000,
  currency: 'UGX',
  externalId: 'order_12345',
  provider: 'honeycoin',
  failedAt: '2024-01-15T10:05:00Z',
  errorCode: 'INSUFFICIENT_FUNDS',
  errorMessage: 'Customer has insufficient balance',
  details: {
    providerError: 'Account balance too low',
    canRetry: true
  }
}
Common failure reasons:
  • INSUFFICIENT_FUNDS - Customer doesn’t have enough money
  • INVALID_PHONE_NUMBER - Phone number is incorrect
  • CUSTOMER_CANCELLED - Customer declined the payment
  • NETWORK_ERROR - Mobile network issue
  • ACCOUNT_BLOCKED - Customer’s account is blocked

Timeout

Transaction took too long to complete:
{
  id: 'txn_abc123',
  status: 'timeout',
  amount: 10000,
  currency: 'UGX',
  externalId: 'order_12345',
  provider: 'honeycoin',
  timeoutAt: '2024-01-15T10:10:00Z',
  details: {
    reason: 'Customer did not respond to payment prompt',
    canRetry: true
  }
}
What it means:
  • Transaction exceeded maximum processing time
  • Customer may not have seen or responded to SMS
  • Transaction can usually be retried

Cancelled

Transaction was cancelled before completion:
{
  id: 'txn_abc123',
  status: 'cancelled',
  amount: 10000,
  currency: 'UGX',
  externalId: 'order_12345',
  provider: 'honeycoin',
  cancelledAt: '2024-01-15T10:03:00Z',
  cancelReason: 'Customer requested cancellation'
}

Checking Transaction Status

Single Status Check

const status = await client.getTransaction({
  provider: 'honeycoin',
  txId: 'txn_abc123',
});

console.log(`Transaction ${status.externalId} is ${status.status}`);

Polling for Updates

async function waitForCompletion(provider: string, txId: string) {
  const maxAttempts = 20; // Maximum polling attempts
  const pollInterval = 5000; // 5 seconds between checks

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const status = await client.getTransaction({ provider, txId });

    console.log(`Attempt ${attempt}: Status is ${status.status}`);

    // Check if transaction is in final state
    if (['completed', 'failed', 'timeout', 'cancelled'].includes(status.status)) {
      return status;
    }

    // Wait before next check
    if (attempt < maxAttempts) {
      await new Promise(resolve => setTimeout(resolve, pollInterval));
    }
  }

  throw new Error('Transaction status check timed out');
}

// Usage
try {
  const finalStatus = await waitForCompletion('honeycoin', 'txn_abc123');
  console.log('Final status:', finalStatus);
} catch (error) {
  console.error('Status check failed:', error.message);
}
Instead of polling, use webhooks for real-time updates:
// Configure webhook URL in provider settings
const honeycoin = new HoneyCoin({
  apiKey: process.env.HONEYCOIN_API_KEY!,
  publicKey: process.env.HONEYCOIN_PUBLIC_KEY!,
  webhookUrl: 'https://api.myapp.com/webhooks/honeycoin',
});

// Handle webhook in your API
app.post('/webhooks/honeycoin', (req, res) => {
  const event = req.body;

  if (event.type === 'transaction.completed') {
    const transaction = event.data;
    console.log(`Transaction ${transaction.externalId} completed!`);

    // Update your database
    // Send confirmation to customer
    // Fulfill the order
  }

  res.status(200).json({ received: true });
});

Transaction Validation

Input Validation

FundKit validates all transaction data before processing:
// Valid transaction
const validTx = {
  amount: 5000, // Positive number
  currency: 'UGX', // Supported currency
  operator: 'mtn', // Valid operator
  accountNumber: '256779280949', // Valid phone format
  externalId: 'order_123', // Unique identifier
  reason: 'Test payment', // Optional description
};

// Invalid transaction examples
const invalidTx = {
  amount: -1000, // Negative amount
  currency: 'USD', // Unsupported currency
  operator: 'vodafone', // Unsupported operator
  accountNumber: '123', // Invalid phone number
  externalId: '', // Empty external ID
};

Custom Validation

Add your own validation before sending transactions:
function validateTransaction(tx: Transaction): void {
  // Check amount limits
  if (tx.amount < 1000) {
    throw new Error('Minimum transaction amount is 1,000 UGX');
  }

  if (tx.amount > 5000000) {
    throw new Error('Maximum transaction amount is 5,000,000 UGX');
  }

  // Validate phone number format
  const phoneRegex = /^256[0-9]{9}$/;
  if (!phoneRegex.test(tx.accountNumber)) {
    throw new Error('Phone number must be in format 256XXXXXXXXX');
  }

  // Check external ID format
  if (!tx.externalId.startsWith('order_')) {
    throw new Error('External ID must start with "order_"');
  }
}

// Use validation before processing
try {
  validateTransaction(transaction);
  const result = await client.collection(transaction);
} catch (error) {
  console.error('Validation failed:', error.message);
}

Transaction Metadata

Adding Metadata

Store additional information with transactions:
const transaction = {
  amount: 10000,
  currency: 'UGX',
  operator: 'mtn',
  accountNumber: '256779280949',
  externalId: 'order_12345',
  reason: 'Product purchase',

  // Additional metadata
  metadata: {
    customerId: 'cust_789',
    productId: 'prod_456',
    campaignId: 'campaign_123',
    userAgent: 'MyApp/1.0',
    ipAddress: '192.168.1.1',
    orderItems: [
      { id: 'item_1', quantity: 2, price: 3000 },
      { id: 'item_2', quantity: 1, price: 4000 },
    ],
  },
};

Metadata Best Practices

Use Consistent Keys

// Good: Consistent naming
metadata: {
  customer_id: 'cust_123',
  order_id: 'order_456',
  product_id: 'prod_789'
}

Avoid Sensitive Data

// Good: Safe metadata
metadata: {
  customer_id: 'cust_123',
  order_total: 10000
}

// Bad: Sensitive data
metadata: {
  credit_card: '4111-1111-1111-1111',
  password: 'secret123'
}

Keep It Small

// Good: Compact metadata
metadata: {
  customer: 'c123',
  product: 'p456'
}

// Bad: Large objects
metadata: {
  full_customer_profile: { /* huge object */ }
}

Make It Searchable

// Good: Searchable fields
metadata: {
  campaign_id: 'holiday2024',
  category: 'electronics',
  region: 'kampala'
}

Transaction Reporting

Basic Reporting

// Get transactions for a date range
const transactions = await client.getTransactions({
  startDate: '2024-01-01',
  endDate: '2024-01-31',
  status: 'completed',
  limit: 100,
});

// Calculate totals
const total = transactions.reduce((sum, tx) => sum + tx.amount, 0);
const fees = transactions.reduce((sum, tx) => sum + (tx.fees || 0), 0);

console.log(`Total volume: ${total} UGX`);
console.log(`Total fees: ${fees} UGX`);
console.log(`Net amount: ${total - fees} UGX`);

Advanced Analytics

// Group by provider
const byProvider = transactions.reduce((acc, tx) => {
  acc[tx.provider] = acc[tx.provider] || { count: 0, volume: 0 };
  acc[tx.provider].count++;
  acc[tx.provider].volume += tx.amount;
  return acc;
}, {});

// Success rates
const successRate =
  transactions.filter(tx => tx.status === 'completed').length / transactions.length;

// Average transaction amount
const avgAmount = total / transactions.length;

console.log('Analytics:', {
  successRate: `${(successRate * 100).toFixed(2)}%`,
  avgAmount: `${avgAmount.toFixed(0)} UGX`,
  byProvider,
});

Error Handling

Transaction-Specific Errors

try {
  const result = await client.collection(transaction);
} catch (error) {
  // Handle validation errors
  if (error.code === 'VALIDATION_ERROR') {
    console.error('Invalid transaction data:', error.details);
    // Show user-friendly validation message
    return;
  }

  // Handle provider errors
  if (error.code === 'PROVIDER_ERROR') {
    console.error('Provider issue:', error.message);
    // Try different provider or retry later
    return;
  }

  // Handle specific transaction errors
  switch (error.code) {
    case 'INSUFFICIENT_FUNDS':
      // Customer doesn't have enough money
      showMessage('Insufficient funds. Please top up and try again.');
      break;

    case 'INVALID_PHONE_NUMBER':
      // Phone number is wrong
      showMessage('Invalid phone number. Please check and try again.');
      break;

    case 'CUSTOMER_CANCELLED':
      // Customer declined payment
      showMessage('Payment was cancelled.');
      break;

    default:
      // Unknown error
      showMessage('Payment failed. Please try again.');
      logError(error);
  }
}

Best Practices

Use Meaningful External IDs

// Good: Descriptive external IDs
externalId: `order_${orderId}_${timestamp}`
externalId: `invoice_${invoiceNumber}`
externalId: `subscription_${userId}_${month}`

Handle Timeouts Gracefully

// Good: Proper timeout handling
try {
  const result = await client.collection(transaction);
} catch (error) {
  if (error.code === 'TIMEOUT') {
    // Check status instead of immediate retry
    const status = await client.getTransaction({
      provider: error.provider,
      txId: error.transactionId
    });
  }
}

Implement Idempotency

// Good: Check for existing transaction
const existing = await getTransactionByExternalId(externalId);
if (existing) {
  return existing; // Don't create duplicate
}

const result = await client.collection(transaction);

Use Webhooks

// Good: Real-time updates via webhooks
app.post('/webhook', (req, res) => {
  const event = req.body;
  updateTransactionStatus(event.data);
  res.status(200).json({ ok: true });
});

Next Steps