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. For more information on reference values, see our methodology.
Oanda.com was used to convert the values from local currency to USD. This was accessed on December 30, 2024.
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: 130 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 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>
${countriesWithNoData.length > 0 ? html`<span style="font-style: italic;">Countries with unlisted fees for ${category}: ${countriesWithNoData.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 ${category} 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>`
// Empty placeholders for sections that will no longer be used
technicalIssuesSection = html``;
notListedSection = 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);
// Define base jitter amount (roughly half the point diameter in time units)
// The dot radius r is 4px, so we want ~4px of jitter in time units
// Use a smaller constant value to ensure consistent, compact spacing
const baseJitter = 0.4 * 24 * 60 * 60 * 1000; // 0.4 days 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;
// Use a scaled jitter that stays more consistent regardless of number of countries
let jitterFactor;
if (totalCountries === 1) {
jitterFactor = 0;
} else if (totalCountries === 2) {
// For just 2 countries, use fixed positions at -0.5 and 0.5
jitterFactor = countryIndex === 0 ? -0.5 : 0.5;
} else {
// For 3+ countries, distribute them evenly
jitterFactor = (countryIndex / (totalCountries - 1)) * 2 - 1; // Range from -1 to 1
}
return new Date(date.getTime() + jitterFactor * baseJitter);
} 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-%b"), // Show as 2025-Jan
},
y: {
label: "Cost (% of transaction amount)",
grid: true,
tickFormat: d => d3.format(".1f")(d) + "%" // Add percent sign to y ticks
},
color: {
legend: true,
domain: uniqueCountry, // List of all countries
range: palette
},
})}
${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;
}