Those who are starting to work in the financial sector may encounter the term "double-entry," which is a fundamental concept for managing financial transactions and records. In this post, I want to explain in detail how this concept works, when it should be used, and its importance in maintaining the integrity of financial transactions.
The best way to explain what double-entry is with an example. So, consider that you make a transfer of R$ 1,000 to pay a bill; this transfer process actually performs two operations:
- The first operation is a 1,000$ debit from your account;
- The second operation is a 1,000$ credit to the destination account.
It is essential that, for the transaction to be successfully completed, both operations are recorded correctly. If one of the operations fails, the entire process needs to be reversed to maintain account integrity. This concept is known as double entry and is fundamental to ensuring the integrity of financial transactions.
Importance in the Financial Sector
The double entry system is crucial in the financial sector for several reasons:
- Accounting accuracy: Ensures that all financial transactions are recorded accurately, reducing errors and discrepancies in accounts.
- Simplified auditing: Makes it easy to track transaction history and identify potential irregularities or fraud.
- Regulatory compliance: Meets legal and regulatory requirements of the financial sector, which demand accurate and transparent records.
- Bank reconciliation: Simplifies the reconciliation process, enabling efficient comparison between internal records and bank statements.
Applications Beyond Financial Systems
The double entry system, although traditionally associated with financial systems, can also be applied in other contexts, such as inventory management and logistics where it's necessary to record the inflow and outflow of products.
In all these cases, the fundamental principle of double entry — where each transaction affects two different records — helps maintain data integrity and traceability.
How to Develop
Let's create a simple implementation example of the double entry system in Go. In this example, we'll simulate a bank transfer:
package main
import (
"fmt"
"time"
)
type Account struct {
ID string
Balance float64
}
type Transaction struct {
ID string
FromID string
ToID string
Amount float64
Timestamp time.Time
}
func (a *Account) Debit(amount float64) error {
if a.Balance < amount {
return fmt.Errorf("Insufficient balance.")
}
a.Balance -= amount
return nil
}
func (a *Account) Credit(amount float64) {
a.Balance += amount
}
func Transfer(from, to *Account, amount float64) error {
// First entry: debit from the source account.
if err := from.Debit(amount); err != nil {
return err
}
// Second entry: credit to the destination account.
to.Credit(amount)
return nil
}
func main() {
// Creating example accounts
accountA := &Account{
ID: "conta_a",
Balance: 1000.0,
}
accountB := &Account{
ID: "conta_b",
Balance: 500.0,
}
fmt.Printf("Before the transfer:\nAccount A: %.2f\nAccount B: %.2f\n\n",
accountA.Balance, accountB.Balance)
// Performing a transfer.
err := Transfer(accountA, accountB, 300.0)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Printf("After the transfer:\nAccount A: %.2f\nAccount B: %.2f\n",
accountA.Balance, accountB.Balance)
}
In this example, we implemented:
- An
Account
structure to represent accounts with ID and balance - A
Transaction
structure to record transaction details - Methods
Debit
andCredit
to perform account operations - A
Transfer
function that implements the double entry concept, ensuring both operations are performed
When executed, this code demonstrates a transfer of $300.00 from account A to account B, showing the balances before and after the operation. If there is any error during the process (such as insufficient funds), the transaction is not completed.
The Said Operations
In the example we saw earlier, we created two fundamental operations that make up a transaction:
- A debit operation (Debit) that removes the value from the source account
- A credit operation (Credit) that adds the value to the destination account
These operations are the basic units of a financial transaction and must always occur in pairs to maintain the double-entry principle. Each operation is atomic, meaning it either happens completely or doesn't happen at all, with no intermediate states.
In the code, we implemented these operations as separate methods of the Account structure, but they are always called together through the Transfer function to ensure the double-entry principle is respected.
Encapsulation and Security
To ensure that the balance can only be modified through the transaction function, we can use encapsulation and make the Balance field private in the Account structure. See how we can modify the previous code:
type Account struct {
ID string
balance float64 // Note the lowercase 'b' making the field private.
}
// Getter method to access the balance.
func (a *Account) GetBalance() float64 {
return a.balance
}
// Debit and credit methods now work with the private field.
func (a *Account) debit(amount float64) error {
if a.balance < amount {
return fmt.Errorf("saldo insuficiente")
}
a.balance -= amount
return nil
}
func (a *Account) credit(amount float64) {
a.balance += amount
}
With this implementation, the balance field can only be modified through the package's own methods, ensuring that all balance changes go through the double entry system.
Of course, there are other important considerations when implementing a double entry system, such as ensuring transaction atomicity using a transactional database and implementing detailed logs of all operations for auditing purposes. These practices help maintain system integrity and traceability.
The Code in Production
As a practical example of a tool that implements the double entry system, we have Midaz, an open source ledger maintained by Lerian that uses this technique to ensure the integrity of financial transactions.
Unlike virtually all Brazilian financial systems which are closed, Midaz allows us to openly examine and discuss the use of double entry and study how this practice works in a production environment.
How an Operation is Created
Let's examine how Midaz creates an individual operation within a transaction. Within the creation of a transaction, the CreateOperation
method is called, which receives the transaction details and the accounts involved. Here's the commented code for creating an operation:
func (uc *UseCase) CreateOperation(ctx context.Context,
accounts []*account.Account,
transactionID string,
dsl *goldModel.Transaction,
validate goldModel.Responses,
result chan []*operation.Operation,
err chan error) {
// Declare a list to store the created operations.
var operations []*operation.Operation
// Create a fromTo list containing the source and destination accounts involved in the transaction.
var fromTo []goldModel.FromTo
fromTo = append(fromTo, dsl.Send.Source.From...) // Add origin accounts.
fromTo = append(fromTo, dsl.Send.Distribute.To...) // Add destination accounts.
// Iterate over all the accounts involved in the transaction.
for _, acc := range accounts {
// Check if the account is in the fromTo list.
for i := range fromTo {
// Check if the current account is involved in the transaction, either by ID or alias.
if fromTo[i].Account == acc.Id || fromTo[i].Account == acc.Alias {
// Set the account's current balance.
balance := operation.Balance{
Available: &acc.Balance.Available,
OnHold: &acc.Balance.OnHold,
Scale: &acc.Balance.Scale,
}
// Validate the operation and calculate the transaction values.
amt, bat, er := goldModel.ValidateFromToOperation(fromTo[i], validate, acc)
if er != nil {
logger.Errorf("Error creating operation: %v", er)
}
// Convert the transaction values to float64 and set the decimal scale.
v := float64(amt.Value)
s := float64(amt.Scale)
amount := operation.Amount{
Amount: &v,
Scale: &s,
}
// Set the account balance after the operation.
ba := float64(bat.Available)
boh := float64(bat.OnHold)
bs := float64(bat.Scale)
balanceAfter := operation.Balance{
Available: &ba,
OnHold: &boh,
Scale: &bs,
}
// Select whether the operation will be a debit or a credit.
var typeOperation string
if fromTo[i].IsFrom {
typeOperation = constant.DEBIT
} else {
typeOperation = constant.CREDIT
}
// Create a new operation with the processed data.
save := &operation.Operation{
ID: pkg.GenerateUUIDv7().String(),
TransactionID: transactionID,
Type: typeOperation,
AssetCode: dsl.Send.Asset,
Amount: amount,
Balance: balance,
BalanceAfter: balanceAfter,
AccountID: acc.Id,
AccountAlias: acc.Alias,
...
}
// Save the operation to the database.
op, er := uc.OperationRepo.Create(ctx, save)
if er != nil {
logger.Errorf("Error creating operation: %v", er)
}
// Add the created operation to the list of operations.
operations = append(operations, op)
break // Exit the loop to avoid multiple inclusions of the same account.
}
}
}
// Sends the list of created operations through the result channel
result <- operations
}
To make it easier to read, I removed loggers and tracers and some other details, but you can analyze the complete code on GitHub for creating a transaction and the operation. Leave a comment if you would like an article breaking down and explaining how the code for a complete financial transaction is done.
Top comments (0)