Attaching package: 'dplyr'
The following objects are masked from 'package:stats':
filter, lag
The following objects are masked from 'package:base':
intersect, setdiff, setequal, union
This site is under development. Please contact us with any feedback.
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.
The prices shown reflect the cost of completing a transaction equal to a country-specific reference value. Reference values approximate the median transaction value for each market. To compute, we started with World Bank data on daily mean income per capita for the bottom 40 percent of the population. This was converted to local currency using 2017 Purchasing Power Parity (PPP) rates, then adjusted to current values using local CPI. Finally, we multiplied the result by 15 to approximate a typical transaction size following the multiplier based on IPA’s consumer protection surveys.
Countries without data are due to either unavailable listed fees from provider or technical issues encountered during scraping for the selected transaction type. These are indicated with a text note. In the latter case, fees may have been listed, but were not captured due to technical issues.
Attaching package: 'dplyr'
The following objects are masked from 'package:stats':
filter, lag
The following objects are masked from 'package:base':
intersect, setdiff, setequal, union
summary_data <- read_csv("data/summary_table.csv") |>
bind_rows() |>
filter(transaction_value == "high") |>
distinct()
Rows: 104 Columns: 71
── Column specification ────────────────────────────────────────────────────────
Delimiter: ","
chr (3): country, provider, transaction_value
dbl (66): local_currency_amount, date_collection, total_cost_cash-in via atm...
lgl (2): total_tax_cash-in via agent, pct_tax_cash-in via agent
ℹ Use `spec()` to retrieve the full column specification for this data.
ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
summary_stats <- summary_data |>
group_by(country, month) |>
summarise(
across(
starts_with("pct_"),
~ case_when(
all(is.na(.x), na.rm = TRUE) ~ NA_real_,
all(.x == -55 | is.na(.x), na.rm = TRUE) ~ -55, # keep -55 values
TRUE ~ 100 * mean(.x[!is.na(.x) & .x != -55], na.rm = TRUE)
),
.names = "avg_{str_replace_all(sub('pct_', '', .col), '[-[:space:]]', '_')}"
),
.groups = "drop"
)
import {prettyName} from "/assets/js/utilities.js"
import {primaryPalette as palette} from "/assets/js/colors.js"
data = transpose(prepped_data).map(d => ({
...d,
country: prettyName(d.country)
}))
// Extract unique month values from the data and sort in reverse chronological order
uniqueMonth = [...new Set(data.map(prices => prices.month))].sort()
//| panel: input
viewof month = Inputs.select(
uniqueMonth,
{ value: uniqueMonth[0],
label: "Month:",
multiple: false,
sort: false,
unique: true
}
)
// 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
}
)
filtered = data.filter(function(prices) {
return (month.includes(prices.month)
&&
country.includes(prices.country)
);
})
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: {
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: {
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 => (d.value === 'N/A' | 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: "Costs (%)",
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: {
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: {
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 => (d.value === 'N/A' || 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: "Costs (%)",
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>`
orderered_columns = [
"On-net transfer",
"Off-net transfer",
"Transfer to unregistered user",
"Bank-to-wallet transfer",
"Wallet-to-bank transfer",
"Merchant payment",
"Utility payment",
]
// 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: false,
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: "category",
fy: "country",
channels: {
Country: "country",
"Transaction type": "category",
"Fee (%)": "value",
},
tip: {
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: "category",
fillOpacity: 0.7,
channels: {
Country: "country",
"Transaction type": "category",
"Fee (%)": "value",
},
tip: {
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 => (d.value === 'N/A' || 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: "category",
fillOpacity: 0.8,
textAnchor:"start",
dx:5
}
)
],
marginLeft: 80,
x: {
tickFormat: d => `${d}%`,
grid: false,
label: "Costs (%)",
domain:[0,8]
},
y: {
// Only include the selected transaction types in the domain
domain: orderered_columns.filter(category => transactionTypeMoving.includes(category)),
axis: null,
},
fy: {
label: "",
padding: 0.3, // Increase padding between rows of countries
},
color: {
range: palette.slice(4,),
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;
}