Pricing Trends Over Time
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.
Depending on the selected option, the prices shown reflect either the cost of completing a transaction equal to a reference value, or a user-specified amount ranging from $1 to $1,000. Pre-specified reference values vary by country, while user-specified values use the same USD value across all countries. 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.
summary_data_raw = dfsdb.query("SELECT * FROM summary_table WHERE transaction_value = 'high'")
// Helper function for numeric validation
isNumeric = (str) => /^[+-]?\d+(\.\d+)?$/.test(str);
// Convert "NA" strings to JavaScript NaN and ensure type safety
summary_data_processed = summary_data_raw.map(row => {
Object.keys(row).forEach(key => {
if (row[key] === "NA")
row[key] = NaN;
else if (isNumeric(row[key]))
row[key] = parseFloat(row[key]);
});
return row;
});
// Add month column using plain JavaScript
summary_data_objects = summary_data_processed.map(d => ({
...d,
month: d.date_collection ?
d.date_collection.toString().slice(0, 4) + "-" + d.date_collection.toString().slice(4, 6) :
null
}))// Group and calculate averages manually
prepped_data = (() => {
const groups = {};
// Group by country and month
for (const row of summary_data_objects) {
const key = `${row.country}|${row.month}`;
if (!groups[key]) {
groups[key] = {
country: row.country,
month: row.month,
rows: []
};
}
groups[key].rows.push(row);
}
// Calculate averages for each group
const results = [];
for (const group of Object.values(groups)) {
const result = {
country: group.country,
month: group.month
};
// Process each fee column (using actual CSV column names)
const columns = [
'pct_cost_cash-in via agent',
'pct_cost_cash-in via atm',
'pct_cost_p2p on-network transfer',
'pct_cost_p2p off-network transfer',
'pct_cost_p2p to unregistered user',
'pct_cost_bank to wallet',
'pct_cost_wallet to bank',
'pct_cost_payment at merchant',
'pct_cost_utility payment',
'pct_cost_cash-out via agent',
'pct_cost_cash-out via atm'
];
for (const col of columns) {
const values = group.rows.map(r => r[col]).filter(v => v != null);
const validVals = values.filter(v => v >= 0); // Keep current time series filtering
let avgValue;
if (validVals.length === 0) {
avgValue = null;
} else {
avgValue = 100 * (validVals.reduce((a, b) => {
if (isNaN(a) || isNaN(b)) return isNaN(a) ? b : a;
return a + b;
}) / validVals.length);
}
// Convert column name to expected format for visualization
const newColName = col.replace('pct_cost_', 'avg_cost_').replace(/[\s-]/g, '_');
result[newColName] = avgValue;
}
results.push(result);
}
return results;
})()import {prettyName} from "/assets/js/utilities.js"
import {primaryPalette as palette} from "/assets/js/colors.js"
reference_data = prepped_data
// Transform reference_data into long format for plotting
data_long = reference_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 reference_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: "Transaction Type:",
multiple: false,
sort: false,
unique: true
}
)
// Extract category values from the reference_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
}
)dataCountries = [...new Set(data_long.map(d => d.country))]
uniqueMonths = [...new Set(data_long.map(d => d.month.getTime()))].map(t => new Date(t));
// Get countries with technical issues (-55 values) in the raw reference_data
countriesWithTechnicalIssues = ["Pakistan"] // Directly include Pakistan which is known to have technical issues
// Find countries that have missing data in ANY month for the selected category
countriesWithAnyMissingData = dataCountries.filter(country =>
uniqueMonths.some(month =>
!data_long.some(d =>
d.country === country &&
d.category === category &&
d.month.getTime() === month.getTime() &&
d.value != null &&
!isNaN(d.value)
)
) && !countriesWithTechnicalIssues.includes(country)
)
// 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>
${countriesWithAnyMissingData.length > 0 ? html`<span style="font-style: italic;">Countries with missing fees for ${category}: ${countriesWithAnyMissingData.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_reference, {
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_reference, {
x: d => {
// First, find if this point overlaps with others
const overlappingPoints = filtered_reference.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_reference
.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"),
},
y: {
label: "Cost (% of transaction amount)",
grid: true,
tickFormat: d => d + "%"
},
color: {
legend: true,
domain: uniqueCountry, // List of all countries
range: palette
},
})}
${footnoteText}
</div>`// from https://observablehq.com/@jeremiak/download-data-button
reference_data_button = (reference_data, filename = 'reference_data.csv') => {
if (!reference_data) throw new Error('Array of data required as first argument');
let downloadData;
if (filename.includes('.csv')) {
downloadData = new Blob([d3.csvFormat(reference_data)], { type: "text/csv" });
} else {
downloadData = new Blob([JSON.stringify(reference_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;
}exploded_dfsdb = DuckDBClient.of({
all_prices: FileAttachment("data/all_listed_prices.csv")
})
// Create the exploded dataset using DuckDB
exploded_data = {
try {
// First, load the CSV into DuckDB
await exploded_dfsdb.query(`
CREATE TABLE IF NOT EXISTS raw_prices AS
SELECT * FROM all_prices
`);
// Load the SQL query from file
const sqlQuery = await FileAttachment("src/sql/data_explosion.sql").text()
// Run the main exploded data query
const result = await exploded_dfsdb.query(sqlQuery);
return result;
} catch (error) {
throw error;
}
}
// Track loading state - starts as false (not ready)
exploded_data_ready = {
try {
await exploded_data;
// Hide the overlay loading spinner when data is ready
const loadingOverlay = document.getElementById('loading-overlay');
if (loadingOverlay) {
loadingOverlay.style.display = 'none';
}
return true;
} catch (error) {
// Hide overlay on error too
const loadingOverlay = document.getElementById('loading-overlay');
if (loadingOverlay) {
loadingOverlay.style.display = 'none';
}
return false;
}
}
// Filter and process exploded data with NA handling
transaction_data = exploded_data
.filter(d => d.usd_fee != null && d.usd_transaction_amount != null)
.map(d => ({
...d,
// Convert NA strings to NaN and ensure numeric types
usd_fee: d.usd_fee === "NA" ? NaN : (typeof d.usd_fee === 'string' ? parseFloat(d.usd_fee) : d.usd_fee),
fee_pct_of_transaction: d.fee_pct_of_transaction === "NA" ? NaN : (typeof d.fee_pct_of_transaction === 'string' ? parseFloat(d.fee_pct_of_transaction) : d.fee_pct_of_transaction),
country: d.country.replace(/\b\w/g, l => l.toUpperCase()), // Title case
date_collection: d.date_collection // Already a Date object from DuckDB
}))
fees_data = transaction_datadata = exploded_data_ready ? fees_data : []
// Extract unique values from data for filter options
uniqueTransactionTypes = [...new Set(data.map(d => d.transaction_type))].sort()
uniqueCountries = [...new Set(data.map(d => d.country))].sort()
//| panel: input
// Slider control for selecting transaction amount ($1-$1000)
viewof transactionAmount = Inputs.range(
[1, 1000],
{
value: 100, // Default to $100
step: 1, // Allow $1 increments
label: "Transaction Amount (USD):",
format: d => `$${d}` // Display with dollar sign
}
)
// Force text box to sync with slider and show initial value
{
const slider = viewof transactionAmount.querySelector('input[type="range"]');
const textBox = viewof transactionAmount.querySelector('input[type="number"]');
// Set initial value in text box
textBox.value = slider.value;
slider.addEventListener('input', () => {
textBox.value = slider.value;
});
return html``;
}viewof transactionType = Inputs.select(
uniqueTransactionTypes,
{
value: "Cash-out via agent", // Default selection
label: "Transaction Type:",
multiple: false, // Single selection only
sort: false, // Keep original sort order
unique: true
}
)
//| panel: input
// Checkbox group for selecting countries to display
viewof selectedCountries = Inputs.checkbox(
uniqueCountries,
{
value: uniqueCountries, // Default: all countries selected
label: "Countries:",
multiple: true, // Allow multiple selections
sort: true, // Sort alphabetically
unique: true
}
)filtered = data.filter(function(d) {
return (d.usd_transaction_amount === transactionAmount && // Exact match on slider value
d.transaction_type === transactionType && // Match selected transaction type
selectedCountries.includes(d.country)); // Include only selected countries
})
// Group filtered data by country and date, then calculate average fees
// This handles cases where multiple providers exist for the same country/date
groupedData = d3.rollup(
filtered,
v => d3.mean(v, d => d.fee_pct_of_transaction), // Calculate mean fee percentage for each group
d => d.country, // Group by country
d => d.date_collection // Then by date
)
// Transform the nested grouped data into a flat array suitable for plotting
plotData = Array.from(groupedData, ([country, dates]) =>
Array.from(dates, ([date, avgFeePercent]) => ({
country: country,
date: new Date(date), // Convert date string to Date object
feePercent: avgFeePercent
}))
).flat()html`<div id="loading-overlay" class="loading-overlay">
<div class="spinner"></div>
<div class="loading-text">Processing Pricing Data</div>
<div class="loading-subtext">Generating interactive fee calculations for all transaction amounts from $1 to $1,000.<br/>This intensive process may take a moment and temporarily freeze the page.</div>
</div>`dataCountriesUnique = [...new Set(data.map(d => d.country))]
uniqueDatesUnique = [...new Set(data.map(d => d.date_collection.getTime()))].map(t => new Date(t));
// Get countries with technical issues (hardcoded based on known issues)
countriesWithTechnicalIssuesUnique = ["Pakistan"] // Directly include Pakistan which is known to have technical issues
// Find countries that have missing data in ANY date for the selected transaction type and amount
countriesWithAnyMissingDataUnique = dataCountriesUnique.filter(country =>
uniqueDatesUnique.some(date =>
!data.some(d =>
d.country === country &&
d.transaction_type === transactionType &&
d.usd_transaction_amount === transactionAmount &&
d.date_collection.getTime() === date.getTime() &&
d.usd_fee != null &&
!isNaN(d.usd_fee)
)
) && !countriesWithTechnicalIssuesUnique.includes(country)
)
// Create combined footnote text with both notes and country listings
footnoteTextUnique = 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>
${countriesWithAnyMissingDataUnique.length > 0 ? html`<span style="font-style: italic;">Countries with missing fees for ${transactionType} at $${transactionAmount}: ${countriesWithAnyMissingDataUnique.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>
${countriesWithTechnicalIssuesUnique.length > 0 ? html`<span style="font-style: italic;">Countries with unavailable fees for ${transactionType} at $${transactionAmount} due to technical issues: ${countriesWithTechnicalIssuesUnique.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>`
// Create the time series visualization
html`<div>
${plotData.length === 0 ?
// Show helpful message when no data matches the current filters
html`<div style="padding: 20px; text-align: center; color: #666;">
No data available for the selected filters. Try adjusting the transaction amount, type, or countries.
</div>` :
// Create the main plot when data is available
Plot.plot({
marks: [
// Add horizontal line at y=0 for reference
Plot.ruleY([0]),
// Draw connected lines for each country over time
Plot.line(plotData, {
x: "date",
y: "feePercent",
stroke: "country", // Color lines by country
strokeWidth: 2,
marker: null, // No markers on the line itself
}),
// Add interactive dots at each data point with alphabetically organized jitter on x-dimension
Plot.dot(plotData, {
x: d => {
// First, find if this point overlaps with others
const overlappingPoints = plotData.filter(point =>
point.date.getTime() === d.date.getTime() &&
Math.abs(point.feePercent - d.feePercent) < 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.date);
// 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 fee range
const allCountriesAtPoint = plotData
.filter(point =>
point.date.getTime() === d.date.getTime() &&
Math.abs(point.feePercent - d.feePercent) < 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.date; // No jitter needed when no overlap present
}
},
y: "feePercent",
stroke: "country", // Border color matches country
fill: "country", // Fill color matches country
r: 4, // 4px radius dots
channels: { // Custom tooltip content
Country: "country",
Date: d => d.date.toISOString().split('T')[0],
"Fee (%)": d => d.feePercent.toFixed(2),
},
tip: {
format: {
y: false, // Don't show default y value
x: false, // Don't show default x value
stroke: false, // Don't show stroke color
fill: false, // Don't show fill color
}
}
}),
],
x: {
label: "Date",
type: "time", // Treat x-axis as time scale
ticks: d3.utcMonth.every(1), // One tick per month
tickFormat: d3.utcFormat("%Y-%b"), // Format as YYYY-MON
},
y: {
label: "Cost (% of transaction amount)",
grid: true, // Show horizontal grid lines
tickFormat: d => d + "%"
},
color: {
legend: true, // Show color legend
domain: uniqueCountries, // All possible countries
range: palette // Use consistent color palette
},
width: 800,
height: 400
})
}
${footnoteTextUnique}
</div>`Inputs.table(
plotData.map(d => ({
Country: d.country,
Date: d.date.toISOString().split('T')[0], // Format date as YYYY-MM-DD
"Fee (%)": d.feePercent.toFixed(2), // Round fee percentage to 2 decimal places
"Transaction Amount (USD)": transactionAmount, // Include current filter values
"Transaction Type": transactionType
})),
{select: false} // Disable row selection
)// Reusable download button function (adapted from pricing-over-time.qmd)
button = (data, filename = 'data.csv') => {
if (!data) throw new Error('Array of data required as first argument');
let downloadData;
if (filename.includes('.csv')) {
// Format data as CSV for download
downloadData = new Blob([d3.csvFormat(data)], { type: "text/csv" });
} else {
// Format data as JSON for download
downloadData = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json"
});
}
// Calculate and display file size in KB
const size = (downloadData.size / 1024).toFixed(0);
// Create and return the download button element
const button = DOM.download(
downloadData,
filename,
`Download Data (~${size} KB)`
);
return button;
}// Wait for DOM to load
{
setTimeout(() => {
// Select the first toggle checkbox in the custom switch
const checkbox = document.querySelector('label.switch input[type="checkbox"]');
if (!checkbox) return;
function toggleDisplay() {
const referenceDataElems = document.querySelectorAll('.reference-data');
const uniqueValuesElems = document.querySelectorAll('.unique-values');
if (checkbox.checked) {
referenceDataElems.forEach(el => el.style.display = 'none');
uniqueValuesElems.forEach(el => el.style.display = '');
} else {
referenceDataElems.forEach(el => el.style.display = '');
uniqueValuesElems.forEach(el => el.style.display = 'none');
}
}
// Initial state
toggleDisplay();
// Listen for changes
checkbox.addEventListener('change', toggleDisplay);
}, 0);
return html``;
}