Prices by Country and Transaction Type
Consumers face costs when moving money domestically. Primarily, this entails exchanging cash for e-money and vice versa (e.g. cash-in, cash-out), moving money between personal accounts (e.g. on-network transfer, off-network transfer) and paying for goods or services (e.g. merchant payments, utility payments).
The visualization shows the price of various transaction types across countries. Prices are expressed as percentages of the transaction amount. For each country, the values represent a simple average of all providers from whom we collected data.
The prices shown reflect the cost of completing a transaction equal to a country-specific reference value. For more information on reference values, see our methodology.
summary_data_raws = dfsdb.query("SELECT * FROM summary_table WHERE transaction_value = 'high'")
// Replace "NA" string values in summary_data_raw with JS NaN
isNumeric = (str) => /^[+-]?\d+(\.\d+)?$/.test(str);
summary_data_processed = summary_data_raws.map(row => {
Object.keys(row).forEach(key => {
if (row[key] === "NA")
row[key] = NaN;
else if (isNumeric(row[key]))
row[key]=parseFloat(row[key]);
});
return row;
});
// Add month column using plain JavaScript
summary_data_objects = summary_data_processed.map(d => ({
...d,
month: d.date_collection ?
d.date_collection.toString().slice(0, 4) + "-" + d.date_collection.toString().slice(4, 6) :
null
}))
// Group and calculate averages manually
prepped_data = (() => {
const groups = {};
// Group by country and month
for (const row of summary_data_objects) {
const key = `${row.country}|${row.month}`;
if (!groups[key]) {
groups[key] = {
country: row.country,
month: row.month,
rows: []
};
}
groups[key].rows.push(row);
}
// Calculate averages for each group
const results = [];
for (const group of Object.values(groups)) {
const result = {
country: group.country,
month: group.month
};
// Process each fee column (using actual CSV column names)
const columns = [
'pct_cost_cash-in via agent',
'pct_cost_cash-in via atm',
'pct_cost_p2p on-network transfer',
'pct_cost_p2p off-network transfer',
'pct_cost_p2p to unregistered user',
'pct_cost_bank to wallet',
'pct_cost_wallet to bank',
'pct_cost_payment at merchant',
'pct_cost_utility payment',
'pct_cost_cash-out via agent',
'pct_cost_cash-out via atm'
];
for (const col of columns) {
const values = group.rows.map(r => r[col]).filter(v => v != null);
const validVals = values.filter(v => v !== -55);
const allNegativeFive = values.every(v => v === -55);
let avgValue;
if (validVals.length === 0) {
avgValue = allNegativeFive ? -55 : null;
} else {
avgValue = 100 * (validVals.reduce((a, b) => {
if (isNaN(a) || isNaN(b)) return isNaN(a) ? b : a;
return a + b;
}) / validVals.length);
}
// Convert column name to expected format for visualization
const newColName = col.replace('pct_cost_', 'avg_cost_').replace(/[\s-]/g, '_');
result[newColName] = avgValue;
}
results.push(result);
}
return results;
})()
import {prettyName} from "/assets/js/utilities.js"
import {primaryPalette as palette} from "/assets/js/colors.js"
data = prepped_data.map(d => ({
...d,
country: prettyName(d.country)
}))
// Extract unique month values from the data and sort in reverse chronological order
monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
uniqueMonth = [...new Set(data.map(prices => prices.month))].sort();
uniqueMonthLabels = uniqueMonth.map(m => {
const [year, month] = m.split("-");
return `${year}-${monthNames[parseInt(month, 10) - 1]}`;
});
//| panel: input
viewof month = Inputs.select(
uniqueMonth,
{ value: uniqueMonth[uniqueMonth.length - 1], // Default to most recent month
label: "Month:",
multiple: false,
sort: false,
unique: true,
format: m => uniqueMonthLabels[uniqueMonth.indexOf(m)]
}
)
// Extract category values from the data and sort in reverse chronological order
uniqueCountry = [...new Set(data.map(prices => prices.country))].sort()
//| panel: input
viewof country = Inputs.checkbox(
uniqueCountry,
{
value: uniqueCountry,
label: "Countries:",
multiple: true,
sort: true,
unique: true
}
)
all_transaction_data = data.flatMap(row => [
// Money Into System
{
country: prettyName(row.country),
month: row.month,
category: "Cash-in via agent",
value: row.avg_cost_cash_in_via_agent,
group: "Depositing Money"
},
{
country: prettyName(row.country),
month: row.month,
category: "Cash-in via ATM",
value: row.avg_cost_cash_in_via_atm,
group: "Depositing Money"
},
// Moving Money Around
{
country: prettyName(row.country),
month: row.month,
category: "On-net transfer",
value: row.avg_cost_p2p_on_network_transfer,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Off-net transfer",
value: row.avg_cost_p2p_off_network_transfer,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Transfer to unregistered user",
value: row.avg_cost_p2p_to_unregistered_user,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Bank-to-wallet transfer",
value: row.avg_cost_bank_to_wallet,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Wallet-to-bank transfer",
value: row.avg_cost_wallet_to_bank,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Merchant payment",
value: row.avg_cost_payment_at_merchant,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Utility payment",
value: row.avg_cost_utility_payment,
group: "Moving Money Around"
},
// Taking Money Out
{
country: prettyName(row.country),
month: row.month,
category: "Cash-out via agent",
value: row.avg_cost_cash_out_via_agent,
group: "Taking Money Out"
},
{
country: prettyName(row.country),
month: row.month,
category: "Cash-out via ATM",
value: row.avg_cost_cash_out_via_atm,
group: "Taking Money Out"
}
])
// Process all transaction data first
all_transaction_data_processed = all_transaction_data.map(d => ({
...d,
value: d.value === null || d.value === undefined ? 'N/A' : d.value === -55 ? -55 : Number(d.value),
numericValue: d.value === null || d.value === undefined ? -1 : d.value === -55 ? -55 : Number(d.value)
}));
// Define unique transaction types for each group from the full dataset
uniqueTransactionTypesIn = [...new Set(all_transaction_data_processed.filter(d => d.group === "Depositing Money").map(d => d.category))].sort()
uniqueTransactionTypesOut = [...new Set(all_transaction_data_processed.filter(d => d.group === "Taking Money Out").map(d => d.category))].sort()
uniqueTransactionTypesMoving = [...new Set(all_transaction_data_processed.filter(d => d.group === "Moving Money Around").map(d => d.category))].sort()
// Now filter based on country and month selection
filtered_long = filtered.flatMap(row => [
// Money Into System
{
country: prettyName(row.country),
month: row.month,
category: "Cash-in via agent",
value: row.avg_cost_cash_in_via_agent,
group: "Depositing Money"
},
{
country: prettyName(row.country),
month: row.month,
category: "Cash-in via ATM",
value: row.avg_cost_cash_in_via_atm,
group: "Depositing Money"
},
// Moving Money Around
{
country: prettyName(row.country),
month: row.month,
category: "On-net transfer",
value: row.avg_cost_p2p_on_network_transfer,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Off-net transfer",
value: row.avg_cost_p2p_off_network_transfer,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Transfer to unregistered user",
value: row.avg_cost_p2p_to_unregistered_user,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Bank-to-wallet transfer",
value: row.avg_cost_bank_to_wallet,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Wallet-to-bank transfer",
value: row.avg_cost_wallet_to_bank,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Merchant payment",
value: row.avg_cost_payment_at_merchant,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Utility payment",
value: row.avg_cost_utility_payment,
group: "Moving Money Around"
},
// Taking Money Out
{
country: prettyName(row.country),
month: row.month,
category: "Cash-out via agent",
value: row.avg_cost_cash_out_via_agent,
group: "Taking Money Out"
},
{
country: prettyName(row.country),
month: row.month,
category: "Cash-out via ATM",
value: row.avg_cost_cash_out_via_atm,
group: "Taking Money Out"
}
])
filtered_long2 = filtered_long.map(d => ({
...d,
value: d.value === null || d.value === undefined ? 'N/A' : d.value === -55 ? -55 : Number(d.value),
numericValue: d.value === null || d.value === undefined ? -1 : d.value === -55 ? -55 : Number(d.value)
}));
filtered_long_in = filtered_long2.filter(d => d.group === "Depositing Money" )
filtered_long_out = filtered_long2.filter(d => d.group === "Taking Money Out")
filtered_long_moving = filtered_long2.filter(d => d.group === "Moving Money Around")
viewof transactionTypeIn = Inputs.checkbox(
uniqueTransactionTypesIn,
{
label: "Transaction:",
value: uniqueTransactionTypesIn[0], // Default to the first transaction type
multiple: false,
sort: false,
}
)
// Generate the plot for depositing money
depositPlot = transactionTypeIn.length > 0 ? Plot.plot({
marks: [
// Regular bars for values > 0
Plot.barX(filtered_long_in.filter(d => d.value !== 'N/A' && d.value > 0 && transactionTypeIn.includes(d.category)), {
x: "value",
y: "category",
fill: "category",
fy: "country",
channels: {
Country: "country",
"Transaction type": "category",
"Fee (%)": "value",
},
tip: {
maxRadius: 5,
format: {
y: false,
x: false,
fill: false,
fy: false,
}
}
}),
// Bar for zero values
Plot.barX(filtered_long_in.filter(d => d.value === 0 && transactionTypeIn.includes(d.category)), {
x: -0.05, // Small fixed width for visibility
y: "category",
fy: "country",
fill: "category",
fillOpacity: 0.7,
channels: {
Country: "country",
"Transaction type": "category",
"Fee (%)": "value",
},
tip: {
maxRadius: 5,
format: {
y: false,
x: false,
stroke: false,
fy: false,
fill: false
}
}
}),
// Add text label for N/A bars
Plot.text(
filtered_long_in.filter(d => (Number.isNaN(d.value) || d.value == -55) && transactionTypeIn.includes(d.category)),
{
x: 0,
y: "category",
fy: "country",
text: d => d.value === -55 ? `${d.category} fees unavailable due to technical issues` : `${d.category} fees not listed`,
fontSize: 12,
fill: "category",
fillOpacity: 0.8,
textAnchor:"start",
dx:5
}
)
],
marginLeft: 80,
x: {
tickFormat: d => `${d}%`,
grid: true,
label: "Cost (% of transaction amount)",
domain: [0, 8]
},
y: {
// Only include the selected transaction types in the domain
domain: uniqueTransactionTypesIn.filter(category => transactionTypeIn.includes(category)),
axis: null,
},
fy: {
label: "",
padding: 0.3, // Increase padding between rows of countries
},
color: {
range: palette.slice(0,3),
legend: true
}
}) : html`<div></div>`
// Add explanatory note about missing data only when transaction types are selected
transactionTypeIn.length > 0 ? html`<div>
${depositPlot}
<div style="font-size: 0.85em; margin-top: 10px; color: #555; max-width: 800px;">
<p><strong>Notes:</strong></p>
<ol>
<li>Fees labeled "not listed" are not clearly available on provider websites. This may indicate the transaction is not offered by the provider, or the transaction is offered but lacks clear pricing on the website. In these cases, prices may be missing, ambiguous, inconsistent, or described only as "varies."</li>
<li>Fees labeled as "unavailable due to technical issues" are available on provider websites, but IPA was unable to collect this information due to technical difficulties with our data collection process.</li>
</ol>
</div>
</div>` : html`<div></div>`
viewof transactionTypeOut = Inputs.checkbox(
uniqueTransactionTypesOut,
{
label: "Transaction:",
value: uniqueTransactionTypesOut[0], // Default to the first transaction type
multiple: false,
sort: false
}
)
// Generate the plot for withdrawing money
withdrawPlot = transactionTypeOut.length > 0 ? Plot.plot({
marks: [
// Regular bars for values > 0
Plot.barX(filtered_long_out.filter(d => d.value !== 'N/A' && d.value > 0 && transactionTypeOut.includes(d.category)), {
x: "value",
y: "category",
fill: "category",
fy: "country",
channels: {
Country: "country",
"Transaction type": "category",
"Fee (%)": "value",
},
tip: {
maxRadius: 5,
format: {
y: false,
x: false,
fill: false,
fy: false,
}
}
}),
// Bar for zero values
Plot.barX(filtered_long_out.filter(d => d.value === 0 && transactionTypeOut.includes(d.category)), {
x: -0.04, // Small fixed width for visibility
y: "category",
fy: "country",
fill: "category",
fillOpacity: 0.7,
channels: {
Country: "country",
"Transaction type": "category",
"Fee (%)": "value",
},
tip: {
maxRadius: 5,
format: {
y: false,
x: false,
stroke: false,
fy: false,
fill: false
}
}
}),
// Add text label for N/A bars
Plot.text(
filtered_long_out.filter(d => ( Number.isNaN(d.value) || d.value === -55) && transactionTypeOut.includes(d.category)),
{
x: 0,
y: "category",
fy: "country",
text: d => d.value === -55 ? `${d.category} fees unavailable due to technical issues` : `${d.category} fees not listed`,
fontSize: 10,
fill: "category",
fillOpacity: 0.8,
textAnchor: "start",
dx: 5
}
)
],
marginLeft: 80,
x: {
tickFormat: d => `${d}%`,
grid: true,
label: "Cost (% of transaction amount)",
domain: [0, 8]
},
y: {
// Only include the selected transaction types in the domain
domain: uniqueTransactionTypesOut.filter(category => transactionTypeOut.includes(category)),
axis: null,
},
fy: {
label: "",
padding: 0.3, // Increase padding between rows of countries
},
color: {
range: palette.slice(3,5),
legend: true
}
}): html`<div></div>`
// Add explanatory note about missing data only when transaction types are selected
transactionTypeOut.length > 0 ? html`<div>
${withdrawPlot}
<div style="font-size: 0.85em; margin-top: 10px; color: #555; max-width: 800px;">
<p><strong>Notes:</strong></p>
<ol>
<li>Fees labeled "not listed" are not clearly available on provider websites. This may indicate the transaction is not offered by the provider, or the transaction is offered but lacks clear pricing on the website. In these cases, prices may be missing, ambiguous, inconsistent, or described only as "varies."</li>
<li>Fees labeled as "unavailable due to technical issues" are available on provider websites, but IPA was unable to collect this information due to technical difficulties with our data collection process.</li>
</ol>
</div>
</div>` : html`<div></div>`
orderedColumns = [
"On-net transfer",
"Off-net transfer",
"Transfer to unregistered user",
"Bank-to-wallet transfer",
"Wallet-to-bank transfer",
"Merchant payment",
"Utility payment",
]
// Create a consistent color mapping for all transaction types
movingColorMap = new Map(
uniqueTransactionTypesMoving.map((type, i) => [type, palette[i + 5]])
)
// Add a dropdown select for Transfers & Payments transaction types
viewof transactionTypeMoving = Inputs.checkbox(
uniqueTransactionTypesMoving,
{
label: "Transaction:",
value: uniqueTransactionTypesMoving[0], // Default to the first transaction type
multiple: true,
sort: false
}
)
// Generate the plot for transfers and payments
transfersPlot = transactionTypeMoving.length > 0 ? Plot.plot({
marks: [
// Regular bars for values > 0
Plot.barX(filtered_long_moving.filter(d => d.value !== 'N/A' && d.value > 0 && transactionTypeMoving.includes(d.category)), {
x: "value",
y: "category",
fill: d => movingColorMap.get(d.category),
fy: "country",
channels: {
Country: "country",
"Transaction type": "category",
"Fee (%)": "value",
},
tip: {
maxRadius: 5,
format: {
y: false,
x: false,
fill: false,
fy: false,
}
}
}),
// Bar for zero values
Plot.barX(filtered_long_moving.filter(d => d.value === 0 && transactionTypeMoving.includes(d.category)), {
x: -0.04, // Small fixed width for visibility
y: "category",
fy: "country",
fill: d => movingColorMap.get(d.category),
fillOpacity: 0.7,
channels: {
Country: "country",
"Transaction type": "category",
"Fee (%)": "value",
},
tip: {
maxRadius: 5,
format: {
y: false,
x: false,
stroke: false,
fy: false,
fill: false
}
}
}),
// Add text label for N/A bars
Plot.text(
filtered_long_moving.filter(d => ( Number.isNaN(d.value) || d.value === -55) && transactionTypeMoving.includes(d.category)),
{
x: 0,
y: "category",
fy: "country",
text: d => d.value === -55 ? `${d.category} fees unavailable due to technical issues` : `${d.category} fees not listed`,
fontSize: 10,
fill: d => movingColorMap.get(d.category),
fillOpacity: 0.8,
textAnchor: "start",
dx: 5
}
)
],
marginLeft: 80,
x: {
tickFormat: d => `${d}%`,
grid: true,
label: "Cost (% of transaction amount)",
domain: [0, 8]
},
y: {
// Only include the selected transaction types in the domain
domain: orderedColumns.filter(category => transactionTypeMoving.includes(category)),
axis: null,
},
fy: {
label: "",
padding: 0.3, // Increase padding between rows of countries
},
color: {
// Create a legend that only shows selected transaction types
domain: transactionTypeMoving,
range: transactionTypeMoving.map(type => movingColorMap.get(type)),
legend: true
}
}): html`<div></div>`
// Add explanatory note about missing data only when transaction types are selected
transactionTypeMoving.length > 0 ? html`<div>
${transfersPlot}
<div style="font-size: 0.85em; margin-top: 10px; color: #555; max-width: 800px;">
<p><strong>Notes:</strong></p>
<ol>
<li>Fees labeled "not listed" are not clearly available on provider websites. This may indicate the transaction is not offered by the provider, or the transaction is offered but lacks clear pricing on the website. In these cases, prices may be missing, ambiguous, inconsistent, or described only as "varies."</li>
<li>Fees labeled as "unavailable due to technical issues" are available on provider websites, but IPA was unable to collect this information due to technical difficulties with our data collection process.</li>
</ol>
</div>
</div>` : html`<div></div>`
// from https://observablehq.com/@jeremiak/download-data-button
button = (data, filename = 'data.csv') => {
if (!data) throw new Error('Array of data required as first argument');
let downloadData;
if (filename.includes('.csv')) {
downloadData = new Blob([d3.csvFormat(data)], { type: "text/csv" });
} else {
downloadData = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json"
});
}
const size = (downloadData.size / 1024).toFixed(0);
const button = DOM.download(
downloadData,
filename,
`Download Data (~${size} KB)`
);
return button;
}