DEV Community

Cover image for Enabling Bank Transfer Payments with Live Reconciliation

Enabling Bank Transfer Payments with Live Reconciliation

Customers expect payments to reflect instantly, even when traditional banking processes slow down transactions. It gets even trickier for companies handling a high volume of transactions across multiple payment methods (cards, bank transfers, and mobile money).

In addition to that, fraud and financial discrepancies are common challenges. Many issues come from irregularities in transaction records, making it harder to detect errors or suspicious activity. With payment reconciliation, you can automatically match transactions, identify inconsistencies, make informed decisions, and comply with financial regulations.

However, reconciling bank transfers in real-time requires a blend of robust API integrations, automated webhooks, and smart validation logic.

In this guide, you’ll learn how to implement live reconciliation for bank transfers using Flutterwave’s APIs and webhooks. We’ll cover validating transaction references, updating user balances automatically, and handling edge cases like partial payments.

Before diving into the implementation details, let’s cover the basics of payment reconciliation.

What is Payment Reconciliation?

Payment reconciliation is the process of comparing financial records to confirm that payments made or received match what is recorded in bank statements or a business’s accounting books. For example, an accounting department might reconcile payments by comparing its official bank statement with recorded costs and payments from the past two weeks.

This process helps keep accurate financial records and improve cash flow management. It often involves managing multiple bank accounts, various payment methods, different currencies, and even cross-border transactions. Depending on the business, reconciliation can be done daily, weekly, or monthly. Small businesses with just a few bank records and low transaction volumes can typically handle this using standard accounting software. In contrast, larger organizations with multiple outlets and numerous transactions often face greater challenges in aligning their internal records.

Types of payment reconciliation depend on factors such as the payment method and account type. However, the payment reconciliation process generally falls into the following categories:

  • Bank Reconciliation: Compares the transactions recorded in an organization’s cash account with those shown on the bank statement. Businesses use this to verify their available cash balance.
  • Intercompany Reconciliation: A type of reconciliation performed by businesses with multiple subsidiaries or divisions. It involves matching and reconciling transactions across different entities within the same corporate group.
  • Account Payable Reconciliation: Involves matching supplier invoices and payments with transaction records in the accounts used to make payments. This helps businesses track amounts owed to suppliers and make timely payments.
  • Payroll Reconciliation: Compares wages, taxes, deductions, and other payroll-related transactions with bank statements and supporting documents to confirm accuracy.
  • Account Receivable Reconciliation: Involves matching customer payments with issued invoices. This helps businesses identify and address discrepancies, such as underpayments or overpayments.

How does Payment Reconciliation Work?

The process of payment reconciliation varies from business to business but generally involves the following steps:

How payment reconciliation works

  1. Collect relevant financial reporting documents and records*.*
  2. Compare transactions in the accounting system with those on the bank financial statements.
  3. Identify discrepancies caused by errors, timing differences, or potential fraud.
  4. Investigate discrepancies to determine their causes and resolve them.
  5. Record adjustments based on the resolved discrepancies.
  6. Verify the adjusted records to confirm they align with the bank statement.
  7. Document the reconciliation process for audit and compliance purposes.
  8. Review and approve the final reconciliation to ensure completeness and accuracy.

While the reconciliation process may seem straightforward, reconciling bank transfers can be challenging because it falls outside your direct control. Bank transfers require customers to send funds from their bank account to your designated business account, which limits your ability to track payment transactions in real time. As a result, real-time reconciliation becomes difficult.

In addition to the lack of control, bank transfer payments present several challenges:

  • Delayed payment reflections: Bank processing times can cause delays before payments appear in your account.
  • Incorrect payment references: Customers may enter the wrong payment reference, making it harder to match payments to invoices.
  • Installment payments: Customers might pay in installments, complicating the reconciliation process.

To address these challenges, you can use Flutterwave to automate the reconciliation process.

How to Automate Live Reconciliation with Flutterwave

A major reason why reconciling bank transfers is challenging is that it is often beyond your control. From the customer’s perspective, uploading proof of payment should immediately grant them value for their money. This raises important questions. What happens when payment confirmation is delayed, especially if your system is programmed to release value only after receiving confirmation from your bank endpoint? What if the transfer is reversed just seconds after the customer submits their payment confirmation?

This uncertainty makes the reconciliation and confirmation process frustrating for your customers and business. However, you can manage bank transfers in a controlled environment that allows customers to transact securely while enabling your business to reconcile payments in real-time.

You can use Flutterwave Virtual Accounts to generate account details (account number and bank) to securely receive payments into your Flutterwave balance via bank transfers. With this solution, you can create a general account for all customer payments or customer-specific accounts for more targeted reconciliation.

There are two types of virtual accounts you can create with Flutterwave:

  • Dynamic Virtual Account: A temporary account typically used for one-time transactions. These accounts are valid only within a set timeframe or until payment is completed. For example, if you operate an e-commerce platform, you can generate a unique dynamic virtual account when a customer selects the "Pay via Bank Transfer" option.
  • Static Virtual Account: A long-term account ideal for collecting recurring payments, such as subscriptions. If you run a software-as-a-service (SaaS) platform, you can assign each customer a permanent virtual account for monthly subscription payments. You can also create a static virtual account tied to a specific product that all customers can pay into.

Before diving into the technical implementation, let’s consider a scenario:

Imagine you have a customer named John Doe who subscribes to your monthly wellness program and prefers to pay via bank transfer. In this case, you want to use Flutterwave to create a static virtual account for John Doe to process his monthly subscription. To do this, follow the steps below.

Set up Webhook to Monitor Transfer

Before creating a virtual account, you need to set up your webhook endpoint on Flutterwave. This allows your system to receive alerts for any changes to the virtual account you create.

To set this up, log in to your Flutterwave dashboard and navigate to the Webhooks section under the Settings menu. Add your webhook endpoint, secret hash, and then save. The secret hash allows you to verify that incoming requests are from Flutterwave.

Set up Webhook

This implementation uses Go, but the approach applies to any other programming language.

Generate a Virtual Account

With your webhook setup, create a virtual account that automatically maps transactions to specific users.

package main

import (
        "bytes"
        "encoding/json"
        "fmt"
        "io"
        "net/http"
)

// Request payload matching Flutterwave's API spec
type VirtualAccountRequest struct {
        Email       string `json:"email"`
        TxRef       string `json:"tx_ref"`
        Phonenumber string `json:"phonenumber"`
        IsPermanent bool   `json:"is_permanent"` // Maps to "is_permanent" in JSON
        Firstname   string `json:"firstname"`
        Lastname    string `json:"lastname"`
        Narration   string `json:"narration"`
        BVN         string `json:"bvn"`
}

// Response structure (simplified example)
type VirtualAccountResponse struct {
        Status  string `json:"status"`
        Message string `json:"message"`
        Data    struct {
                AccountNumber string `json:"account_number"`
                BankName      string `json:"bank_name"`
                FlwRef         string `json:"flw_ref"`
        } `json:"data"`
}

func CreateVirtualAccount() (*VirtualAccountResponse, error) {
        // Construct request payload
        payload := VirtualAccountRequest{
                Email:       "john.doe@sample.com",
                TxRef:       "apex_tx_ref-002201",
                Phonenumber: "08100000000",
                IsPermanent: true,
                Firstname:   "John",
                Lastname:    "Doe",
                Narration:   "Wellness program - January",
                BVN:         "1234567890",
        }

        // Marshal payload to JSON
        jsonData, err := json.Marshal(payload)
        if err != nil {
                return nil, fmt.Errorf("failed to marshal payload: %v", err)
        }

        // Create HTTP request
        req, err := http.NewRequest(
                "POST",
                "https://api.flutterwave.com/v3/virtual-account-numbers",
                bytes.NewBuffer(jsonData),
        )
        if err != nil {
                return nil, fmt.Errorf("failed to create request: %v", err)
        }

        // Set headers
        req.Header.Set("Authorization", "Bearer YOUR_SECRET_KEY") // Replace with your key
        req.Header.Set("Content-Type", "application/json")

        // Send request
        client := &http.Client{}
        resp, err := client.Do(req)
        if err != nil {
                return nil, fmt.Errorf("request failed: %v", err)
        }
        defer resp.Body.Close()

        // Read response
        body, err := io.ReadAll(resp.Body)
        if err != nil {
                return nil, fmt.Errorf("failed to read response: %v", err)
        }

        // Unmarshal JSON response
        var result VirtualAccountResponse
        if err := json.Unmarshal(body, &result); err != nil {
                return nil, fmt.Errorf("failed to decode response: %v", err)
        }

        if resp.StatusCode != http.StatusOK {
                return nil, fmt.Errorf("API error: %s (HTTP %d)", result.Message, resp.StatusCode)
        }

        return &result, nil
}
Enter fullscreen mode Exit fullscreen mode

The snippet above does the following:

  • Create structs to represent the request and response payloads.
  • Sends a request to the create virtual account endpoint with the required data. An important parameter to note in the payload is IsPermanent: true, which means the virtual account created is permanent.

On a successful request, Flutterwave returns a unique account number, bank details, and other information ready to receive payments.

{
    "status": "success",
    "message": "Virtual account created",
    "data": {
        "response_code": "02",
        "response_message": "Transaction in progress",
        "flw_ref": "FLW-d3f049dad861484a9e1504309e07f615",
        "order_ref": "URF_1726598678168_8608035",
        "account_number": "9949162075",
        "frequency": "N/A",
        "bank_name": "WEMA BANK",
        "created_at": "2024-09-17 18:44:38",
        "expiry_date": "2024-09-17 19:44:38",
        "note": "Please make a bank transfer to testing functions FLW",
        "amount": "101.40"
    }
}
Enter fullscreen mode Exit fullscreen mode

Automating Reconciliation with Webhooks

Now that John Doe can send payments, the next step is to detect and process financial transactions in real-time. Flutterwave provides webhooks that notify your backend when a payment is received.

To do this, first define a webhook struct as shown below:

type WebhookEvent struct {
        Event     string `json:"event"`       // e.g., "charge.completed"
        Data      struct {
                ID               int     `json:"id"`
                TxRef            string  `json:"tx_ref"`       // Your unique reference
                FlwRef           string  `json:"flw_ref"`      // Flutterwave's internal reference
                Amount           float64 `json:"amount"`       // Expected amount
                ChargedAmount    float64 `json:"charged_amount"`
                Currency         string  `json:"currency"`     // e.g., "NGN"
                Status           string  `json:"status"`       // "successful"
                PaymentType      string  `json:"payment_type"` // "bank_transfer"
                CreatedAt        string  `json:"created_at"`   // ISO 8601 timestamp
                AccountID        int     `json:"account_id"`   // Virtual account ID
                Customer         struct {
                        Email      string `json:"email"`
                        PhoneNumber string `json:"phone_number"`
                } `json:"customer"`
        } `json:"data"`
        EventType string `json:"event.type"` // e.g., "BANK_TRANSFER_TRANSACTION"
}
Enter fullscreen mode Exit fullscreen mode

Next, create a function to handle the incoming webhook:

func WebhookHandler(w http.ResponseWriter, r *http.Request) {
        defer r.Body.Close()

        // 1. Verify signature (critical for security)
        secretHash := os.Getenv("FLW_WEBHOOK_HASH")
        signature := r.Header.Get("x-flutterwave-signature")
        body, _ := io.ReadAll(r.Body)

        hash := hmac.New(sha256.New, []byte(secretHash))
        hash.Write(body)
        expectedSignature := hex.EncodeToString(hash.Sum(nil))

        if signature != expectedSignature {
                w.WriteHeader(http.StatusUnauthorized)
                return
        }

        // 2. Parse payload
        var event WebhookEvent
        if err := json.Unmarshal(body, &event); err != nil {
                w.WriteHeader(http.StatusBadRequest)
                log.Printf("Failed to parse webhook: %v", err)
                return
        }

        // 3. Handle events
        switch event.Event {
        case "charge.completed":
                if event.Data.Status == "successful" {
                        handleSuccessfulPayment(event.Data.TxRef, event.Data.Amount)
                }
        case "charge.failed":
                handleFailedPayment(event.Data.TxRef)
        }

        w.WriteHeader(http.StatusOK)
}

// Example reconciliation logic
func handleSuccessfulPayment(txRef string, amount float64) {
        userID := extractUserIDFromTxRef(txRef) // a helper function to retrieve user ID from the transaction reference

        // Atomic balance update (pseudo-code)
        err := db.Exec(
                "UPDATE users SET balance = balance + $1 WHERE id = $2",
                amount, userID,
        )
        if err != nil {
                log.Printf("Failed to update balance: %v", err)
        }
}

func handleFailedPayment(txRef string) {
        log.Printf("Payment failed for transaction %s. Notify user or retry logic here.", txRef)
}
Enter fullscreen mode Exit fullscreen mode

The snippet above does the following:

  • Creates a WebhookHandler function that verifies the authenticity of the webhook payload by checking the secret hash to confirm it is coming from Flutterwave. It also checks the payload event to determine if the charge.completed is present and treats it as a successful transaction.
  • Creates a handleSuccessfulPayment helper function that reconciles the database based on the amount and the matching user ID.
  • Creates a handleFailedPayment helper function that logs the failed transaction.

Note that in a production environment, you would notify the user, queue for retry, and update the status of the transaction (or any other financial data) in the database when the payment fails.

Handling Partial Payments

In an ideal world, you would expect users to pay the specified amount, but that isn’t the case most times. You may encounter users attempting to game the system by sending a partial amount, hoping to receive value.

To address this issue, first create a struct to track the subscription amount:

type Transaction struct {
        TxRef        string  `db:"tx_ref"`
        UserID       string  `db:"user_id"`
        ExpectedAmt  float64 `db:"expected_amount"`
        ReceivedAmt  float64 `db:"received_amount"`
        Status       string  `db:"status"` // "pending", "partial", "completed"
}
Enter fullscreen mode Exit fullscreen mode

Next, update the handleSuccessfulPayment helper function to compare the expected amount with the received amount:

func handleSuccessfulPayment(txRef string, receivedAmount float64) {
        // 1. Get transaction details from DB
        var tx Transaction
        err := db.Get(&tx, "SELECT * FROM transactions WHERE tx_ref = $1", txRef)
        if err != nil {
                log.Printf("Transaction not found: %s", txRef)
                return
        }

        // 2. Check if payment matches expected amount
        if receivedAmount < tx.ExpectedAmt {
                log.Printf("Partial payment for %s: Received %.2f (Expected %.2f)",
                        txRef, receivedAmount, tx.ExpectedAmt)

                // Update DB with partial amount (atomic operation)
                _, err := db.Exec(`
                        UPDATE transactions
                        SET received_amount = received_amount + $1,
                                status = 'partial'
                        WHERE tx_ref = $2`,
                        receivedAmount, txRef)
                if err != nil {
                        log.Printf("Failed to update partial payment: %v", err)
                        return
                }

                // Notify user (e.g., email/SMS)
                notifyPartialPayment(tx.UserID, receivedAmount)
                return
        }

        // 3. Full payment received
        _, err = db.Exec(`
                UPDATE transactions
                SET received_amount = $1,
                        status = 'completed'
                WHERE tx_ref = $2`,
                receivedAmount, txRef)
        if err != nil {
                log.Printf("Failed to mark payment complete: %v", err)
        }
}

func notifyPartialPayment(userID string, receivedAmount float64) {
        log.Printf("⚠️ Partial payment of %.2f received from user %s. Remaining balance pending. Notify user to complete payment.",
                receivedAmount,
                userID,
        )
}
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

Automating bank transfer reconciliation helps maintain smooth operations, reduces manual errors, and improves customer satisfaction. By using Flutterwave’s virtual accounts and webhooks, you can build a real-time reconciliation system that:

  • Instantly reflects payments in user accounts
  • Handles delayed or partial payments seamlessly
  • Prevents mismatches and fraud

Check out these resources to learn more:

Top comments (1)

Collapse
 
dafeumukoro_oghenerunor_ profile image
Dafe-umukoro Oghenerunor

👍