mutable sharedCountries = []
mutable sharedTransactionType = "Cash-out via agent"
mutable sharedTransactionAmount = 100
mutable sharedProviderTypes = []
html`<span>Use our reference values </span>
<label class="switch">
<input type="checkbox">
<span class="slider round"></span>
</label>
<span> Use unique values from $1 to $1,000</span>`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. Starting April 2026, the values represent a weighted average of all providers from whom we collected data (previously simple average pricing was used).
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 local currency values to USD.
function dateToYYYYMMDD(dateValue) {
if (dateValue instanceof Date) {
const year = dateValue.getUTCFullYear();
const month = String(dateValue.getUTCMonth() + 1).padStart(2, '0');
const day = String(dateValue.getUTCDate()).padStart(2, '0');
return parseInt(`${year}${month}${day}`);
} else {
return dateValue;
}
}
// Query and process summary data
summary_data_raw = {
yield dfsdb.query("SELECT * FROM summary_table WHERE transaction_value = 'high'");
}
// Helper function for numeric validation
isNumeric = (str) => /^[+-]?\d+(\.\d+)?$/.test(str);
// Structural N/A: these transaction types are architecturally inapplicable for
// certain provider types. bank-to-wallet is only applicable for mobile banking;
// wallet-to-bank is only applicable for mobile money.
NA_BY_PROVIDER = ({
"Mobile money": ["Bank-to-wallet transfer"],
"Mobile banking": ["Wallet-to-bank transfer"]
});
// Convert "NA" strings to JavaScript NaN and ensure type safety
summary_data_processed = {
yield 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 = {
yield 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 = {
yield (() => {
const groups = {};
// Group by country and month, filtering by provider type
for (const row of summary_data_objects) {
// Skip rows that don't match selected provider types
if (!providerTypes.includes(fixProviderTypeCapitalization(row.fsp_type))) continue;
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 allValues = group.rows.map(r => r[col]).filter(v => v != null);
const mean = weightedMean(group.rows, r => r[col], r => +r.provider_market_share);
let avgValue;
if (mean !== null) {
avgValue = 100 * mean;
} else {
// mean is null only when no rows have a valid (non-negative) fee value.
// Preserve the -55 (technical issue) signal so it isn't conflated with "not listed".
avgValue = allValues.some(v => v === -55) ? -55 : null;
}
// 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, weightedMean, fixProviderTypeCapitalization} from "/assets/js/utilities.js"
import {primaryPalette as palette} from "/assets/js/colors.js"
reference_data = prepped_data
// Helper function to create UTC dates from YYYY-MM strings
parseMonthToUTC = (monthStr) => {
if (!monthStr) return null;
const [year, month] = monthStr.split('-').map(Number);
return new Date(Date.UTC(year, month - 1, 1)); // First day of the month in UTC
}
// Transform reference_data into long format for plotting
data_long = reference_data.flatMap(row => [
{
country: prettyName(row.country),
month: parseMonthToUTC(row.month),
category: "Cash-in via ATM",
value: row.avg_cost_cash_in_via_ATM
},
{
country: prettyName(row.country),
month: parseMonthToUTC(row.month),
category: "On-net transfer",
value: row.avg_cost_p2p_on_network_transfer
},
{
country: prettyName(row.country),
month: parseMonthToUTC(row.month),
category: "Wallet-to-bank transfer",
value: row.avg_cost_wallet_to_bank
},
{
country: prettyName(row.country),
month: parseMonthToUTC(row.month),
category: "Merchant payment",
value: row.avg_cost_payment_at_merchant
},
{
country: prettyName(row.country),
month: parseMonthToUTC(row.month),
category: "Cash-out via agent",
value: row.avg_cost_cash_out_via_agent
},
{
country: prettyName(row.country),
month: parseMonthToUTC(row.month),
category: "Bank-to-wallet transfer",
value: row.avg_cost_bank_to_wallet
},
{
country: prettyName(row.country),
month: parseMonthToUTC(row.month),
category: "Cash-in via agent",
value: row.avg_cost_cash_in_via_agent
},
{
country: prettyName(row.country),
month: parseMonthToUTC(row.month),
category: "Cash-out via ATM",
value: row.avg_cost_cash_out_via_ATM
},
{
country: prettyName(row.country),
month: parseMonthToUTC(row.month),
category: "Transfer to unregistered user",
value: row.avg_cost_p2p_to_unregistered_user
},
{
country: prettyName(row.country),
month: parseMonthToUTC(row.month),
category: "Off-net transfer",
value: row.avg_cost_p2p_off_network_transfer
},
{
country: prettyName(row.country),
month: parseMonthToUTC(row.month),
category: "Utility payment",
value: row.avg_cost_utility_payment
}
]).filter(d => d.month != null)
// Extract category values from the reference_data and sort in reverse chronological order
uniqueCategory = [...new Set(data_long.map(prices => prices.category))].sort()
// Extract country values from the reference_data and sort
uniqueCountry = [...new Set(data_long.map(prices => prices.country))].sort()
// Get unique provider types from the summary data with fixed capitalization
uniqueProviderTypes = [...new Set(summary_data_objects.map(d => fixProviderTypeCapitalization(d.fsp_type)))].filter(d => d != null).sort()
// Initialize shared state if empty
{
if (sharedCountries.length === 0) {
mutable sharedCountries = uniqueCountry;
}
if (sharedProviderTypes.length === 0 && uniqueProviderTypes.length > 0) {
mutable sharedProviderTypes = uniqueProviderTypes;
}
return html``;
}viewof category = {
const input = Inputs.select(
uniqueCategory,
{ value: sharedTransactionType,
label: "Transaction Type:",
multiple: false,
sort: false,
unique: true
}
);
input.addEventListener('input', () => {
mutable sharedTransactionType = input.value;
});
return input;
}
//| panel: input
viewof country = {
const input = Inputs.checkbox(
uniqueCountry,
{
value: sharedCountries,
label: "Countries:",
multiple: true,
sort: true,
unique: true
}
);
input.addEventListener('input', () => {
mutable sharedCountries = input.value;
});
return input;
}
//| panel: input
viewof providerTypes = {
const input = Inputs.checkbox(
uniqueProviderTypes,
{
value: sharedProviderTypes,
label: "Provider Types:",
multiple: true,
sort: true,
unique: true
}
);
input.addEventListener('input', () => {
mutable sharedProviderTypes = input.value;
});
return input;
}isCurrentSelectionNA = providerTypes.length > 0 &&
providerTypes.every(pt => (NA_BY_PROVIDER[pt] || []).includes(category))
filtered_reference_raw = data_long
.filter(function(prices) {
return (prices.category === category &&
country.includes(prices.country) &&
prices.value != null &&
!isNaN(prices.value) &&
prices.value >= 0);
})
.sort((a, b) => {
// Sort by country first, then by month to ensure consistent line drawing
if (a.country !== b.country) {
return a.country.localeCompare(b.country);
}
return a.month.getTime() - b.month.getTime();
});
// Add line breaks at methodology change points
filtered_reference = (() => {
const breakDates = [
new Date(Date.UTC(2025, 8, 2)), // Sep 2025: beta → full rollout from v1- v2
new Date(Date.UTC(2026, 3, 1)), // Apr 2026: provider list updated
new Date(Date.UTC(2026, 4, 1)), // May 2026: market share estimation prompt architecture updated
];
const working = [];
const countryGroups = d3.group(filtered_reference_raw, d => d.country);
countryGroups.forEach((countryData, country) => {
const sorted = countryData.sort((a, b) => a.month.getTime() - b.month.getTime());
let segment = sorted;
breakDates.forEach(cutoff => {
const before = segment.filter(d => d.month < cutoff);
const after = segment.filter(d => d.month >= cutoff);
working.push(...before);
if (before.length > 0 && after.length > 0) {
working.push({ country, month: cutoff, category, value: null });
}
segment = after;
});
working.push(...segment);
});
return working;
})();dataCountries = [...new Set(data_long.map(d => d.country))]
uniqueMonths = [...new Set(data_long.map(d => d.month.getTime()))].map(t => new Date(t));
// Countries with technical issues for the selected category: any month where the weighted mean
// fell back to the -55 sentinel (all providers had a technical-issue fee code).
countriesWithTechnicalIssues = dataCountries.filter(country =>
data_long.some(d =>
d.country === country &&
d.category === category &&
d.value === -55
)
)
// 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} for some or all months : ${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 for some or all months: ${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>
<li>
Provider coverage. The beta version of data collection, limited to a subset of providers and countries, ran through September 2025. In October 2025, coverage expanded to include all providers and countries. In April 2026, coverage was updated to reflect revised market shares and to incorporate market share weighted average pricing (previously simple average pricing was used). In May 2026, the methodology was updated again to incorporate an improved prompt architecture for market share estimation while continuing to use market share–weighted average pricing. Differences in reported fees across periods may therefore reflect these methodological updates rather than real-world price changes.
</li>
<li>
Exchange rates. Exchange rates are sourced from Oanda.com. Rates applied as follows: for data through March 2026, the closing rate on December 30, 2024 was used; for April 2026 and beyond, the closing rate on April 1, 2026 was used. Rates are held constant within each period to avoid introducing exchange rate variation into comparisons of fee levels.</li>
</ol>
</div>`
// Empty placeholders for sections that will no longer be used
technicalIssuesSection = html``;
notListedSection = html``;
html`<div>
${isCurrentSelectionNA
? html`<div style="padding: 20px; background: #f5f5f5; border-left: 4px solid #bbb; color: #555; max-width: 800px; margin-bottom: 12px;">
<strong>Not applicable for selected provider types</strong><br/>
${category} is not offered by ${providerTypes.join(" or ")} providers and is therefore not tracked.
${category === "Bank-to-wallet transfer"
? "Select \"Mobile banking\" to view this transaction type."
: "Select \"Mobile money\" to view this transaction type."}
</div>`
: Plot.plot({
marks: [
Plot.ruleY([0]),
Plot.line(filtered_reference, {
x: "month",
y: "value",
stroke: "country",
strokeWidth: 2,
marker: null,
z: "country",
}),
Plot.dot(filtered_reference, {
x: "month",
y: "value",
stroke: "country",
fill: "country",
z: "country",
r: 4,
channels: {
Country: "country",
"Transaction type": "category",
"Fee (%)": d => d.value != null ? d.value.toFixed(1) + "%" : "N/A",
},
tip: {
format: {
y: false,
x: false,
stroke: false,
fill: false,
}
}
}),
],
x: {
label: "",
type: "time",
ticks: d3.utcMonth.every(1),
tickFormat: d3.utcFormat("%Y-%b"),
},
y: {
label: "Cost (% of transaction amount)",
grid: true,
tickFormat: d => d + "%",
nice: true
},
color: {
legend: true,
domain: uniqueCountry,
range: palette
},
width: 800,
height: 400
})
}
${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 dataset for the current slider amount using DuckDB.
// Uses an analytical query instead of a recursive CTE: filters to rows where
// transactionAmount falls within each fee's valid range and evaluates the fee
// formula at that single value. Re-runs reactively when the slider changes.
exploded_data = {
try {
// Load the CSV into DuckDB (no-op after first run thanks to IF NOT EXISTS)
await exploded_dfsdb.query(`
CREATE TABLE IF NOT EXISTS raw_prices AS
SELECT * FROM all_prices
`);
// Load the analytical SQL template and substitute the slider value.
// Clamp transactionAmount defensively before injecting into SQL.
const sqlTemplate = await FileAttachment("src/sql/fee_at_amount.sql").text();
let amountNum = Number(transactionAmount);
if (!Number.isFinite(amountNum)) amountNum = 1;
const safeAmount = Math.min(1000, Math.max(1, Math.round(amountNum)));
const sqlQuery = sqlTemplate.replaceAll("__AMOUNT__", String(safeAmount));
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, index) => {
return {
...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
actual_date_collection: d.date_collection,
date_collection: dateToYYYYMMDD(d.date_collection),
month: (() => {
const dateNum = dateToYYYYMMDD(d.date_collection);
if (dateNum != null) {
const dcStr = dateNum.toString();
if (dcStr.length >= 6) {
const year = dcStr.substring(0, 4);
const month = dcStr.substring(4, 6);
return `${year}-${month}`;
}
}
return d.month;
})()
}})
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()
uniqueProviderTypesUnique = [...new Set(data.map(d => fixProviderTypeCapitalization(d.fsp_type)))].filter(d => d != null).sort()
//| panel: input
// Slider control for selecting transaction amount ($1-$1000)
viewof transactionAmount = {
const input = Inputs.range(
[1, 1000],
{
value: sharedTransactionAmount,
step: 1, // Allow $1 increments
label: "Transaction Amount (USD):"
}
);
const slider = input.querySelector('input[type="range"]');
const textBox = input.querySelector('input[type="number"]');
// Set initial value in text box
if (textBox) textBox.value = slider.value;
// Update text box in real-time but only update state on change
slider.addEventListener('input', () => {
if (textBox) textBox.value = slider.value;
});
// Only update shared state when user finishes dragging
slider.addEventListener('change', () => {
mutable sharedTransactionAmount = parseInt(slider.value);
});
if (textBox) {
textBox.addEventListener('input', () => {
slider.value = textBox.value;
mutable sharedTransactionAmount = parseInt(textBox.value);
});
}
return input;
}
//| panel: input
// Transaction type dropdown
viewof transactionType = {
const input = Inputs.select(
uniqueTransactionTypes,
{
value: sharedTransactionType,
label: "Transaction Type:",
multiple: false, // Single selection only
sort: false, // Keep original sort order
unique: true
}
);
input.addEventListener('input', () => {
mutable sharedTransactionType = input.value;
});
return input;
}
//| panel: input
// Checkbox group for selecting countries to display
viewof selectedCountries = {
const input = Inputs.checkbox(
uniqueCountries,
{
value: sharedCountries.filter(c => uniqueCountries.includes(c)), // Use shared countries that exist in unique data
label: "Countries:",
multiple: true, // Allow multiple selections
sort: true, // Sort alphabetically
unique: true
}
);
input.addEventListener('input', () => {
mutable sharedCountries = input.value;
});
return input;
}
//| panel: input
// Checkbox group for selecting provider types to display
viewof selectedProviderTypes = {
const input = Inputs.checkbox(
uniqueProviderTypesUnique,
{
value: sharedProviderTypes.filter(pt => uniqueProviderTypesUnique.includes(pt)), // Use shared provider types that exist in unique data
label: "Provider Types:",
multiple: true, // Allow multiple selections
sort: true, // Sort alphabetically
unique: true
}
);
input.addEventListener('input', () => {
mutable sharedProviderTypes = input.value;
});
return input;
}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
selectedProviderTypes.includes(fixProviderTypeCapitalization(d.fsp_type))); // Include only selected provider types
}); // Remove the duplicate remapping - it should already be handled in the initial data processing
// Group filtered data by country and date, then calculate weighted average fees.
// Uses market-share weighting only when all providers have market share data; falls back to simple mean otherwise.
groupedData = d3.rollup(
filtered,
v => {
const mean = weightedMean(v, d => d.fee_pct_of_transaction, d => +d.provider_market_share);
if (mean !== null) return mean;
// mean is null only when no rows have a valid (non-negative) fee value.
// Preserve the -55 (technical issue) signal so it isn't conflated with "not listed".
return v.some(d => d.fee_pct_of_transaction === -55) ? -55 : null;
},
d => d.country, // Group by country
d => d.date_collection // Then by date (now with consolidated October dates)
)
// Transform the nested grouped data into a flat array suitable for plotting
plotDataRawAll = Array.from(groupedData, ([country, dates]) =>
Array.from(dates, ([date, avgFeePercent]) => {
// Convert date number to Date object
let finalDate;
if (date instanceof Date) {
finalDate = date;
} else if (date == null) {
finalDate = null;
} else {
// Convert YYYYMMDD number to Date object using UTC to avoid timezone issues
const dateStr = date.toString();
if (dateStr.length >= 8) {
const year = parseInt(dateStr.substring(0, 4));
const month = parseInt(dateStr.substring(4, 6));
const day = parseInt(dateStr.substring(6, 8));
finalDate = new Date(Date.UTC(year, month - 1, day)); // Use UTC to avoid timezone conversion
} else {
finalDate = new Date(date);
}
}
return {
country: country,
date: finalDate,
feePercent: avgFeePercent
};
})
).flat() // Flatten the nested array structure
// Filter out invalid values (including the -55 sentinel, which is surfaced separately below)
plotDataRaw = plotDataRawAll
.filter(d => d.date != null && d.feePercent != null && !isNaN(d.feePercent) && d.feePercent >= 0)
.sort((a, b) => {
// Sort by country first, then by date to ensure consistent line drawing
if (a.country !== b.country) {
return a.country.localeCompare(b.country);
}
return a.date.getTime() - b.date.getTime();
})
// Add line breaks at methodology change points
plotData = (() => {
const breakDates = [
new Date(Date.UTC(2025, 8, 2)), // Sep 2025: beta → full rollout
new Date(Date.UTC(2026, 3, 1)), // Apr 2026: provider list updated
new Date(Date.UTC(2026, 4, 1)), // May 2026: market share estimation prompt architecture updated
];
const working = [];
const countryGroups = d3.group(plotDataRaw, d => d.country);
countryGroups.forEach((countryData, country) => {
const sorted = countryData.sort((a, b) => a.date.getTime() - b.date.getTime());
let segment = sorted;
breakDates.forEach(cutoff => {
const before = segment.filter(d => d.date < cutoff);
const after = segment.filter(d => d.date >= cutoff);
working.push(...before);
if (before.length > 0 && after.length > 0) {
working.push({ country, date: cutoff, feePercent: null });
}
segment = after;
});
working.push(...segment);
});
return working;
})();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))].filter(d => d != null).map(dateNum => {
// Convert YYYYMMDD number to Date object using UTC
const dateStr = dateNum.toString();
if (dateStr.length >= 8) {
const year = parseInt(dateStr.substring(0, 4));
const month = parseInt(dateStr.substring(4, 6));
const day = parseInt(dateStr.substring(6, 8));
return new Date(Date.UTC(year, month - 1, day)); // Use UTC to avoid timezone conversion
}
return new Date(dateNum);
});
// Countries with technical issues for the selected transaction type/amount: any date where
// the weighted mean fell back to the -55 sentinel (all providers had fee_pct_of_transaction === -55).
countriesWithTechnicalIssuesUnique = [...new Set(
plotDataRawAll.filter(d => d.feePercent === -55).map(d => d.country)
)]
// 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 === dateToYYYYMMDD(date) &&
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} for some or all months: ${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 for some or all months: ${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>
<li>
Provider coverage. The beta version of data collection, limited to a subset of providers and countries, ran through September 2025. In October 2025, coverage expanded to include all providers and countries. In April 2026, coverage was updated to reflect revised market shares and to incorporate market share weighted average pricing (previously simple average pricing was used). In May 2026, the methodology was updated again to incorporate an improved prompt architecture for market share estimation while continuing to use market share–weighted average pricing. Differences in reported fees across periods may therefore reflect these methodological updates rather than real-world price changes.
</li>
<li>
Exchange rates. Exchange rates are sourced from Oanda.com. Rates applied as follows: for data through March 2026, the closing rate on December 30, 2024 was used; for April 2026 and beyond, the closing rate on April 1, 2026 was used. Rates are held constant within each period to avoid introducing exchange rate variation into comparisons of fee levels.</li>
</ol>
</div>`
// True when ALL selected provider types consider the current transaction type N/A
isCurrentSelectionUniqueNA = selectedProviderTypes.length > 0 &&
selectedProviderTypes.every(pt => (NA_BY_PROVIDER[pt] || []).includes(transactionType))
// Create the time series visualization
html`<div>
${isCurrentSelectionUniqueNA
? html`<div style="padding: 20px; background: #f5f5f5; border-left: 4px solid #bbb; color: #555; max-width: 800px; margin-bottom: 12px;">
<strong>Not applicable for selected provider types</strong><br/>
${transactionType} is not offered by ${selectedProviderTypes.join(" or ")} providers and is therefore not tracked.
${transactionType === "Bank-to-wallet transfer"
? "Select \"Mobile banking\" to view this transaction type."
: "Select \"Mobile money\" to view this transaction type."}
</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
z: "country", // Explicitly group lines by country to suppress warning and maintain continuity
}),
// Add interactive dots at each data point with alphabetically organized jitter on x-dimension
Plot.dot(plotData, {
x: "date",
y: "feePercent",
stroke: "country",
fill: "country",
z: "country", // Explicitly specify z channel to suppress high cardinality warning
r: 4, // 4px radius dots
channels: { // Custom tooltip content
Country: "country",
Date: d => d.date.toISOString().split('T')[0],
"Fee (%)": d => d.feePercent != null ? d.feePercent.toFixed(1) + "%" : "N/A",
},
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 + "%",
nice:true
},
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.filter(d => d.feePercent != null).map(d => ({
Country: d.country,
Date: d.date.toISOString().split('T')[0], // Format date as YYYY-MM-DD
"Fee (%)": d.feePercent.toFixed(1), // Round fee percentage to 1 decimal place
"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``;
}