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.
Changes in DFS prices are influenced by several key factors, including regulatory and policy changes, market competition and operational costs.
The visualization shows the changes in average prices by country and transaction type. 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 with similar fees have overlapping lines. If no line is displayed, this indicates that no providers in the selected country list fees 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
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.
import {prettyName} from "/assets/js/utilities.js"
import {primaryPalette as palette} from "/assets/js/colors.js"
data = transpose(prepped_data)
// Transform data into long format for plotting
data_long = data.flatMap(row => [
{
country: prettyName(row.country),
month: new Date(row.month),
category: "Cash-in via ATM",
value: row.avg_cost_cash_in_via_atm
},
{
country: prettyName(row.country),
month: new Date(row.month),
category: "On-net transfer",
value: row.avg_cost_p2p_on_network_transfer
},
{
country: prettyName(row.country),
month: new Date(row.month),
category: "Wallet-to-bank transfer",
value: row.avg_cost_wallet_to_bank
},
{
country: prettyName(row.country),
month: new Date(row.month),
category: "Merchant payment",
value: row.avg_cost_payment_at_merchant
},
{
country: prettyName(row.country),
month: new Date(row.month),
category: "Cash-out via agent",
value: row.avg_cost_cash_out_via_agent
},
{
country: prettyName(row.country),
month: new Date(row.month),
category: "Bank-to-wallet transfer",
value: row.avg_cost_bank_to_wallet
},
{
country: prettyName(row.country),
month: new Date(row.month),
category: "Cash-in via agent",
value: row.avg_cost_cash_in_via_agent
},
{
country: prettyName(row.country),
month: new Date(row.month),
category: "Cash-out via ATM",
value: row.avg_cost_cash_out_via_atm
},
{
country: prettyName(row.country),
month: new Date(row.month),
category: "Transfer to unregistered user",
value: row.avg_cost_p2p_to_unregistered_user
},
{
country: prettyName(row.country),
month: new Date(row.month),
category: "Off-net transfer",
value: row.avg_cost_p2p_off_network_transfer
},
{
country: prettyName(row.country),
month: row.month ? new Date(row.month): null,
category: "Utility payment",
value: row.avg_cost_utility_payment
}
])
// Extract category values from the data and sort in reverse chronological order
uniqueCategory = [...new Set(data_long.map(prices => prices.category))].sort()
//| panel: input
viewof category = Inputs.select(
uniqueCategory,
{ value: "Cash-out via agent",
label: "Cost Category:",
multiple: false,
sort: false,
unique: true
}
)
// Extract category values from the data and sort in reverse chronological order
uniqueCountry = [...new Set(data_long.map(prices => prices.country))].sort()
//| panel: input
viewof country = Inputs.checkbox(
uniqueCountry,
{
value: uniqueCountry,
label: "Countries:",
multiple: true,
sort: true,
unique: true
}
)
filtered = data_long.filter(function(prices) {
return (category.includes(prices.category) &&
country.includes(prices.country));
})
dataCountries = [...new Set(data_long.map(d => d.country))]
// Get countries with data for the selected category
countriesWithData = [...new Set(data_long.filter(d =>
d.category === category &&
!isNaN(d.value)
).map(d => d.country))]
// Get countries with technical issues (-55 values) in the raw data
countriesWithTechnicalIssues = ["Pakistan"] // Directly include Pakistan which is known to have technical issues
// Get countries with no data for this category
countriesWithNoData = dataCountries.filter(c =>
!countriesWithData.includes(c) &&
!countriesWithTechnicalIssues.includes(c)
)
// 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>`
// 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 ${category} 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 = countriesWithNoData.length > 0 ?
html`<div style="margin-top: ${countriesWithTechnicalIssues.length > 0 ? "5" : "10"}px; font-size: 14px;">
Countries with unlisted fees for ${category}:
<span style="font-style: italic;">${countriesWithNoData.join(", ")}</span>
</div>` :
html``;
html`<div>
${Plot.plot({
marks: [
Plot.ruleY([0]),
Plot.line(filtered, {
x: "month",
y: "value",
stroke: "country",
strokeWidth: 2,
marker: null, // Remove default markers from the line
}),
// Add separate point marks with alphabetically organized jitter on x-dimension
Plot.dot(filtered, {
x: d => {
// First, find if this point overlaps with others
const overlappingPoints = filtered.filter(point =>
point.month.getTime() === d.month.getTime() &&
Math.abs(point.value - d.value) < 0.01 && // Threshold for considering points overlapping
point.country !== d.country
);
// Only apply jitter if there are overlapping points
if (overlappingPoints.length > 0) {
const date = new Date(d.month);
const jitterAmount = 1 * 24 * 60 * 60 * 1000; // 1 day in milliseconds
// Get all unique countries for this time period and value range
const allCountriesAtPoint = filtered
.filter(point =>
point.month.getTime() === d.month.getTime() &&
Math.abs(point.value - d.value) < 0.1
)
.map(point => point.country);
// Sort countries alphabetically
const sortedCountries = [...new Set(allCountriesAtPoint)].sort();
// Find the index of the current country in the sorted list
const countryIndex = sortedCountries.indexOf(d.country);
// Calculate a deterministic offset based on the country's alphabetical position
const totalCountries = sortedCountries.length;
const jitterFactor = totalCountries > 1
? (countryIndex / (totalCountries - 1)) * 2 - 1 // Range from -1 to 1
: 0;
return new Date(date.getTime() + jitterFactor * jitterAmount);
} else {
return d.month; // No jitter needed when no overlap present
}
},
y: "value",
stroke: "country",
fill: "country",
r: 4,
channels: {
Country: "country",
"Transaction type": "category",
"Fee (%)": "value",
},
tip: {
format: {
y: false,
x: false,
stroke: false,
fill: false,
}
}
}),
],
x: {
label: "",
type: "time", // Ensure the x-axis is treated as a time scale
ticks: d3.utcMonth.every(1), // Show one tick per month
tickFormat: d3.utcFormat("%Y-%m"),
},
y: {
label: "Costs (%)",
grid: true,
},
color: {
legend: true,
domain: uniqueCountry, // List of all countries
range: palette
},
})}
${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;
}