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.
DFS transactions are typically either free, or follow two pricing structures: (1) slab-based pricing, where transactions within a set range are charged a flat fee; or (2) percentage-based pricing, where fees are set as a consistent percentage of the transaction amount.
This visualization shows how providers in the selected country price the selected transaction type. Prices for all providers in the selected country are shown in orange while prices for other providers in different countries are shown in gray.
In the current beta version, we include data on two providers in Sierra Leone and Paraguay; we include data for just one provider for all other countries.
If no price structure is displayed in orange, this either indicates that no providers in the selected country list fees or technical issues occurred in the scraping for the selected transaction type.
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
# Read and clean data
listed_prices <- read_csv("data/all_listed_prices.csv") |>
# TODO: remove this filter when April 2025 data is available
filter(date_collection < "20250401" ) |>
# Convert -99 values to NA
mutate(across(
c(fee, fee_pct, tax, tax_pct, tax_type, value_max),
~ ifelse(. < 0 & !(. %in% c(-55, -99)), NA, as.numeric(.))
)) |>
mutate(across(
c(value_max),
~ ifelse(. < 0 & !(. %in% c(-55, -99)), 10000000, as.numeric(.))
)) |>
mutate(across(
c(value_min),
~ ifelse(. < 0 & !(. %in% c(-55, -99)), 0, as.numeric(.))
)) |>
# Format date to YYYY-MM
mutate(
month = paste0(
str_sub(date_collection, 1, 4),
"-",
str_sub(date_collection, 5, 6)
)
) |>
# Convert to USD
mutate(
value_min_usd = as.numeric(value_min) / as.numeric(exchange_rate),
value_max_usd = ifelse(
is.na(value_max),
NA,
as.numeric(value_max) / as.numeric(exchange_rate)
),
country = str_to_title(country)
)
Rows: 1728 Columns: 16
── Column specification ────────────────────────────────────────────────────────
Delimiter: ","
chr (7): country, fsp_type, provider, transaction_type, currency, web_addres...
dbl (9): exchange_rate, value_min, value_max, fee, fee_pct, tax_type, tax, t...
ℹ 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.
# Create a table to calculate fees at different transaction values in USD
# Generate sequence of values from 1 to 100 USD
transaction_values_usd <- data.frame(value_usd = 1:100)
# Function to calculate fee percentage for each provider's fee structure
calculate_fees <- function(df, transaction_values) {
# For each row in the dataset, calculate fee at each transaction value
all_fees <- list()
for (i in 1:nrow(df)) {
row <- df[i, ]
# Convert transaction values to local currency
local_values <- transaction_values$value_usd *
as.numeric(row$exchange_rate)
# Calculate which transaction values fall within this fee structure's range
value_min_usd <- as.numeric(row$value_min) /
as.numeric(row$exchange_rate)
value_max_usd <- ifelse(
is.na(row$value_max),
Inf,
as.numeric(row$value_max) / as.numeric(row$exchange_rate)
)
applicable_values <- transaction_values |>
filter(value_usd >= value_min_usd & value_usd <= value_max_usd)
if (nrow(applicable_values) > 0) {
# For each applicable value, calculate fee
fees <- applicable_values |>
mutate(
provider = row$provider,
transaction_type = row$transaction_type,
country = row$country,
month = row$month,
local_currency = local_values[match(
value_usd,
transaction_values$value_usd
)],
fixed_fee = as.numeric(row$fee),
pct_fee = as.numeric(row$fee_pct),
tax = as.numeric(ifelse(is.na(row$tax), 0, row$tax)),
tax_pct = as.numeric(ifelse(
is.na(row$tax_pct),
0,
row$tax_pct
)),
tax_type = row$tax_type,
# Calculate total fee depending on tax_type
total_fee = case_when(
tax_type == 1 ~
fixed_fee +
(pct_fee / 100 * local_currency) +
tax +
(tax_pct / 100 * local_currency),
TRUE ~ fixed_fee + (pct_fee / 100 * local_currency)
),
# Calculate as percentage
fee_percentage = (total_fee / local_currency) * 100
)
all_fees[[length(all_fees) + 1]] <- fees
}
}
# Combine all fee calculations
if (length(all_fees) > 0) {
result <- bind_rows(all_fees)
return(result)
} else {
return(data.frame())
}
}
# Calculate fees for all providers and transaction types
fee_data <- calculate_fees(listed_prices, transaction_values_usd)
# Format transaction type names to be more readable
formatted_fee_data <- fee_data |>
mutate(
transaction_type = case_when(
transaction_type == "cash-in via agent" ~ "Cash-in via agent",
transaction_type == "cash-in via atm" ~ "Cash-in via ATM",
transaction_type == "p2p on-network transfer" ~ "On-net transfer",
transaction_type == "p2p off-network transfer" ~ "Off-net transfer",
transaction_type == "p2p to unregistered user" ~
"Transfer to unregistered user",
transaction_type == "cash-out via agent" ~ "Cash-out via agent",
transaction_type == "cash-out via atm" ~ "Cash-out via ATM",
transaction_type == "wallet to bank" ~ "Wallet-to-bank transfer",
transaction_type == "bank to wallet" ~ "Bank-to-wallet transfer",
transaction_type == "payment at merchant" ~ "Merchant payment",
transaction_type == "utility payment" ~ "Utility payment",
TRUE ~ transaction_type # Default case for any other types
)
)
import {prettyName} from "/assets/js/utilities.js"
import {primaryPalette as palette} from "/assets/js/colors.js"
// Import data from R
pricesData = transpose(prices_data)
feeData = transpose(fee_data)
//| panel: input
// Extract category values from the data and sort in reverse chronological order
uniqueCountry = [...new Set(feeData.map(prices => prices.country))].sort()
//| panel: input
viewof selectedCountry = Inputs.select(
uniqueCountry,
{ value: "Bangladesh",
label: "Country:",
multiple: false,
sort: true,
unique: true
}
)
// Extract unique month values from the data and sort in reverse chronological order
uniqueMonth = [...new Set(feeData.map(prices => prices.month))].sort().reverse()
viewof selectedMonth = Inputs.select(
uniqueMonth,
{ value: uniqueMonth[0],
label: "Month:",
multiple: false,
sort: true,
unique: true
}
)
// Define the desired order for transaction types
orderedCategories = [
"Cash-in via agent",
"Cash-in via ATM",
"Cash-out via agent",
"Cash-out via ATM",
"On-net transfer",
"Off-net transfer",
"Transfer to unregistered user",
"Utility payment",
"Merchant payment",
"Wallet-to-bank transfer",
"Bank-to-wallet transfer"
]
// Get all available transaction types
availableCategories = [...new Set(feeData.map(d => d.transaction_type))]
// Filter orderedCategories to include only types that exist in the data
uniqueCategory = orderedCategories.filter(type => availableCategories.includes(type))
//| panel: input
viewof selectedTransactionType = Inputs.select(
uniqueCategory,
{ value: "Cash-out via agent",
label: "Transaction Types:",
multiple: false,
sort: false,
unique: true
}
)
filteredData = feeData.filter(d =>
d.month === selectedMonth &&
d.transaction_type === selectedTransactionType
)
// Create a unique ID for each provider+country combination to ensure we draw one line per combination
filteredData.forEach(d => {
d.lineId = d.provider + "_" + d.country;
// Add display name for tooltips - include provider name for all countries
if (filteredData.filter(item => item.country === d.country).length > 1) {
// Multiple providers for this country
d.displayName = d.provider + " (" + d.country + ")";
} else {
// Single provider for this country
d.displayName = d.country;
}
// Add tooltipPriority to ensure selected country takes precedence in overlapping cases
d.tooltipPriority = d.country === selectedCountry ? 1 : 0;
})
sortedByValue = filteredData.sort((a, b) => a.value_usd - b.value_usd);
// For rendering, specifically in case of overlaps, we want to prepare our data in two sets
// First, we'll have all non-selected countries, then selected country on top
// This way, the tooltips for selected country will take precedence when there's overlap
plotData = [
...sortedByValue.filter(d => d.country !== selectedCountry),
...sortedByValue.filter(d => d.country === selectedCountry)
]
hasSelectedCountryData = plotData.some(d => d.country === selectedCountry)
// First get unique countries from the raw data
dataCountries = [...new Set(pricesData.map(d => d.country))]
// Get countries with plotted data - these have entries in plotData
countriesWithPlottedData = [...new Set(plotData.map(d => d.country))]
// Create footnote text for various scenarios
footnoteText = html`
<div style="margin-top: 15px; font-size: 14px; max-width: 800px;">
<p><strong>Notes:</strong></p>
<ol style="padding-left: 20px; margin-top: 5px;">
<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>`
// Identify countries with technical issues (-55 values) not already in plotData
countriesWithTechnicalIssues = [...new Set(pricesData
.filter(d =>
d.transaction_type.toLowerCase() === selectedTransactionType.toLowerCase() &&
d.month === selectedMonth &&
(d.fee == -55 || d.fee_pct == -55) && // Using == instead of === for numeric comparison
!countriesWithPlottedData.includes(d.country) // Make sure this country doesn't already have plotted data
)
.map(d => d.country)
)]
// Get countries that are in the dataset but don't have plotted data and don't have technical issues
// These are countries with "not listed" fees
countriesWithNoListedFees = dataCountries.filter(c =>
!countriesWithPlottedData.includes(c) &&
!countriesWithTechnicalIssues.includes(c) &&
pricesData.some(d =>
d.country === c &&
d.transaction_type.toLowerCase() === selectedTransactionType.toLowerCase() &&
d.month === selectedMonth
)
)
// Special message for the selected country if it has no data
selectedCountryMessage = !hasSelectedCountryData ?
html`<div style="margin-top: 10px; font-size: 14px;">
<strong>Note:</strong> ${selectedCountry} has no listed fees for ${selectedTransactionType} in ${selectedMonth}.
</div>` :
html`<div></div>`;
// Tech issues section if any countries have technical issues
technicalIssuesSection = countriesWithTechnicalIssues.length > 0 ?
html`<div style="margin-top: 10px; font-size: 14px;">
Countries with unavailable fees for ${selectedTransactionType} due to technical issues:
<span style="font-style: italic;">${countriesWithTechnicalIssues.join(", ")}</span>
</div>` :
html``;
// Not listed section if any countries have fees not listed
notListedSection = countriesWithNoListedFees.length > 0 ?
html`<div style="margin-top: ${countriesWithTechnicalIssues.length > 0 ? "5" : "10"}px; font-size: 14px;">
Countries with unlisted fees for ${selectedTransactionType}:
<span style="font-style: italic;">${countriesWithNoListedFees.join(", ")}</span>
</div>` :
html``;
html`
<div>
${Plot.plot({
title: `${prettyName(selectedTransactionType)} Pricing in ${prettyName(selectedCountry)} (${selectedMonth})`,
width: 800,
height: 500,
x: {
label: "Transaction Value (USD)",
domain: [0,100]
},
y: {
label: "Fee (% of Transaction Value)",
nice: true,
grid: true,
},
color: {
legend: false,
domain: [false, true],
range: ["#c9c9c8", "#f26529"]
},
marks: [
Plot.ruleY([0]),
// Draw all lines in one plot mark to leverage plot.js's own tooltip priority
Plot.line(
plotData, // All the data, but ordered with selected country last
{
x: "value_usd",
y: "fee_percentage",
stroke: d => d.country === selectedCountry, // Orange for selected, gray for others
strokeWidth: d => d.country === selectedCountry ? 3 : 2,
opacity: d => d.country === selectedCountry ? 1 : 0.4,
curve: "linear",
z: "lineId",
sort: {y: "x"},
channels: {
"Country": "country",
"Provider": "provider",
"Fee (%)": d => d.fee_percentage != null ? d.fee_percentage.toFixed(2) + "%" : "N/A"
},
tip: {
format: {
y: false,
x: false,
stroke: false,
opacity: false,
z: false,
strokeWidth: false
}
}
}
),
]
})}
${selectedCountryMessage}
${technicalIssuesSection}
${notListedSection}
${footnoteText}
</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;
}