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.
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") |>
# 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: 2207 Columns: 15
── Column specification ────────────────────────────────────────────────────────
Delimiter: ","
chr (6): country, fsp_type, provider, transaction_type, currency, web_address
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, ]
# Skip rows with -55 values in fee or fee_pct, ensuring NA values are handled
if (
!is.na(row$fee) &&
row$fee == -55 ||
!is.na(row$fee_pct) && row$fee_pct == -55
) {
next
}
# Convert transaction values to local currency
local_values <- transaction_values$value_usd *
as.numeric(row$exchange_rate)
# Handle missing or invalid value_min and value_max
value_min_usd <- ifelse(
is.na(row$value_min) | row$value_min < 0,
0,
as.numeric(row$value_min) / as.numeric(row$exchange_rate)
)
value_max_usd <- ifelse(
is.na(row$value_max) | row$value_max < 0,
Inf,
as.numeric(row$value_max) / as.numeric(row$exchange_rate)
)
# Calculate which transaction values fall within this fee structure's range
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))]
.map(m => {
// Convert YYYY-MM to 'YYYY-MMM' (e.g., 2025-01 to 2025-Jan)
const [year, month] = m.split('-');
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
return `${year}-${monthNames[parseInt(month, 10) - 1]}`;
})
.sort((a, b) => b.localeCompare(a));
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))]
// 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
)
)
// Create combined footnote text with both notes and country listings
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>
${countriesWithNoListedFees.length > 0 ? html`<span style="font-style: italic;">Countries with unlisted fees for ${selectedTransactionType}: ${countriesWithNoListedFees.join(", ")}</span><br/>` : ''}
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>
${countriesWithTechnicalIssues.length > 0 ? html`<span style="font-style: italic;">Countries with unavailable fees for ${selectedTransactionType} due to technical issues: ${countriesWithTechnicalIssues.join(", ")}</span><br/>` : ''}
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>`
// 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>`;
// Empty placeholders for sections that will no longer be used
technicalIssuesSection = html``;
notListedSection = html``;
// Get providers for the selected country and sort them alphabetically for consistent coloring
selectedCountryProviders = [...new Set(plotData
.filter(d => d.country === selectedCountry)
.map(d => d.provider))].sort()
// Create a special color scheme for the selected country's providers
hasMultipleProvidersForSelectedCountry = selectedCountryProviders.length > 1;
// Define a consistent color palette for providers
providerColorPalette = ["#f26529", "#9b59b6"];
// Create a color mapping function that handles multiple providers in selected country
function getStrokeColor(d) {
// If not the selected country, always gray
if (d.country !== selectedCountry) return "#c9c9c8";
// If only one provider in selected country, use orange
if (!hasMultipleProvidersForSelectedCountry) return "#f26529";
// Otherwise, return a provider-specific color from palette
const providerIndex = selectedCountryProviders.indexOf(d.provider);
if (providerIndex === -1) return "#c9c9c8"; // Fallback for unexpected cases
return providerColorPalette[providerIndex % providerColorPalette.length];
}
// Create a legend specifically for providers in the selected country
providerLegendItems = hasMultipleProvidersForSelectedCountry ?
selectedCountryProviders.map((provider, i) => {
return {
color: providerColorPalette[i % providerColorPalette.length],
label: provider
};
}) : [];
// Create the legend HTML if there are multiple providers
providerLegend = hasMultipleProvidersForSelectedCountry ?
html`<div style="margin-top: 10px; margin-bottom: 10px;">
<strong>Providers in ${selectedCountry}:</strong>
<div style="display: flex; gap: 15px; margin-top: 5px;">
${providerLegendItems.map(item => html`
<div style="display: flex; align-items: center;">
<div style="width: 12px; height: 12px; background-color: ${item.color}; margin-right: 5px;"></div>
<span>${item.label}</span>
</div>
`)}
</div>
</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,
tickFormat: d => d + "%"
},
color: {
legend: false
},
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 => getStrokeColor(d),
strokeWidth: d => d.country === selectedCountry ? 3 : 2,
opacity: d => d.country === selectedCountry ? 1 : 0.4,
curve: "linear",
// IMPORTANT: Use provider name in the z grouping for multiple providers to get separate lines
z: d => d.country === selectedCountry && hasMultipleProvidersForSelectedCountry ? d.provider : d.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
}
}
}
),
]
})}
${providerLegend}
${selectedCountryMessage}
${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;
}