Prices by Country and Transaction Type
Consumers face costs when moving money domestically. Primarily, this entails exchanging cash for e-money and vice versa (e.g. cash-in, cash-out), moving money between personal accounts (e.g. on-network transfer, off-network transfer) and paying for goods or services (e.g. merchant payments, utility payments).
The visualization shows the price of various transaction types across countries. Prices are expressed as percentages of the transaction amount. For each country, the values represent a simple average of all providers from whom we collected data.
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.
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 !== -55);
const allNegativeFive = values.every(v => v === -55);
let avgValue;
if (validVals.length === 0) {
avgValue = allNegativeFive ? -55 : 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.map(d => ({
...d,
country: prettyName(d.country)
}))
// Function to convert YYYY-MM to YYYY-MON format
function formatMonthDisplay(yearMonth) {
const [year, month] = yearMonth.split('-');
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return `${year}-${monthNames[parseInt(month) - 1]}`;
}
// Extract unique month values from the data and sort in reverse chronological order
uniqueMonth = [...new Set(reference_data.map(prices => prices.month))].sort()
uniqueMonthDisplay = uniqueMonth.map(month => ({
value: month,
label: formatMonthDisplay(month)
}))
//| panel: input
viewof month = Inputs.select(
uniqueMonth,
{ value: uniqueMonth[uniqueMonth.length - 1],
label: "Month:",
multiple: false,
sort: false,
unique: true,
format: formatMonthDisplay
}
)
// Extract category values from the data and sort in reverse chronological order
uniqueCountry = [...new Set(reference_data.map(prices => prices.country))].sort()
//| panel: input
viewof country = Inputs.checkbox(
uniqueCountry,
{
value: uniqueCountry,
label: "Countries:",
multiple: true,
sort: true,
unique: true
}
)all_transaction_data = reference_data.flatMap(row => [
// Money Into System
{
country: prettyName(row.country),
month: row.month,
category: "Cash-in via agent",
value: row.avg_cost_cash_in_via_agent,
group: "Depositing Money"
},
{
country: prettyName(row.country),
month: row.month,
category: "Cash-in via ATM",
value: row.avg_cost_cash_in_via_atm,
group: "Depositing Money"
},
// Moving Money Around
{
country: prettyName(row.country),
month: row.month,
category: "On-net transfer",
value: row.avg_cost_p2p_on_network_transfer,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Off-net transfer",
value: row.avg_cost_p2p_off_network_transfer,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Transfer to unregistered user",
value: row.avg_cost_p2p_to_unregistered_user,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Bank-to-wallet transfer",
value: row.avg_cost_bank_to_wallet,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Wallet-to-bank transfer",
value: row.avg_cost_wallet_to_bank,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Merchant payment",
value: row.avg_cost_payment_at_merchant,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Utility payment",
value: row.avg_cost_utility_payment,
group: "Moving Money Around"
},
// Taking Money Out
{
country: prettyName(row.country),
month: row.month,
category: "Cash-out via agent",
value: row.avg_cost_cash_out_via_agent,
group: "Taking Money Out"
},
{
country: prettyName(row.country),
month: row.month,
category: "Cash-out via ATM",
value: row.avg_cost_cash_out_via_atm,
group: "Taking Money Out"
}
])
// Process all transaction data first
all_transaction_data_processed = all_transaction_data.map(d => ({
...d,
value: d.value === null || d.value === undefined ? 'N/A' : d.value === -55 ? -55 : Number(d.value),
numericValue: d.value === null || d.value === undefined ? -1 : d.value === -55 ? -55 : Number(d.value)
}));
// Define unique transaction types for each group from the full dataset
uniqueTransactionTypesIn = [...new Set(all_transaction_data_processed.filter(d => d.group === "Depositing Money").map(d => d.category))].sort()
uniqueTransactionTypesOut = [...new Set(all_transaction_data_processed.filter(d => d.group === "Taking Money Out").map(d => d.category))].sort()
uniqueTransactionTypesMoving = [...new Set(all_transaction_data_processed.filter(d => d.group === "Moving Money Around").map(d => d.category))].sort()
// Now filter based on country and month selection
filtered_long = filtered_reference.flatMap(row => [
// Money Into System
{
country: prettyName(row.country),
month: row.month,
category: "Cash-in via agent",
value: row.avg_cost_cash_in_via_agent,
group: "Depositing Money"
},
{
country: prettyName(row.country),
month: row.month,
category: "Cash-in via ATM",
value: row.avg_cost_cash_in_via_atm,
group: "Depositing Money"
},
// Moving Money Around
{
country: prettyName(row.country),
month: row.month,
category: "On-net transfer",
value: row.avg_cost_p2p_on_network_transfer,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Off-net transfer",
value: row.avg_cost_p2p_off_network_transfer,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Transfer to unregistered user",
value: row.avg_cost_p2p_to_unregistered_user,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Bank-to-wallet transfer",
value: row.avg_cost_bank_to_wallet,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Wallet-to-bank transfer",
value: row.avg_cost_wallet_to_bank,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Merchant payment",
value: row.avg_cost_payment_at_merchant,
group: "Moving Money Around"
},
{
country: prettyName(row.country),
month: row.month,
category: "Utility payment",
value: row.avg_cost_utility_payment,
group: "Moving Money Around"
},
// Taking Money Out
{
country: prettyName(row.country),
month: row.month,
category: "Cash-out via agent",
value: row.avg_cost_cash_out_via_agent,
group: "Taking Money Out"
},
{
country: prettyName(row.country),
month: row.month,
category: "Cash-out via ATM",
value: row.avg_cost_cash_out_via_atm,
group: "Taking Money Out"
}
])
filtered_long2 = filtered_long.map(d => ({
...d,
value: d.value === null || d.value === undefined || isNaN(d.value) ? 'N/A' : d.value === -55 ? -55 : Number(d.value),
numericValue: d.value === null || d.value === undefined || isNaN(d.value) ? -1 : d.value === -55 ? -55 : Number(d.value)
}));
filtered_long_in = filtered_long2.filter(d => d.group === "Depositing Money" )
filtered_long_out = filtered_long2.filter(d => d.group === "Taking Money Out")
filtered_long_moving = filtered_long2.filter(d => d.group === "Moving Money Around")viewof transactionTypeIn = Inputs.checkbox(
uniqueTransactionTypesIn,
{
label: "Transaction:",
value: uniqueTransactionTypesIn, // Default to all transaction types
multiple: false,
sort: false,
}
)
// Generate the plot for depositing money
referenceDataDepositPlot = transactionTypeIn.length > 0 ? Plot.plot({
marks: [
// Regular bars for values > 0
Plot.barX(filtered_long_in.filter(d => d.value !== 'N/A' && d.value > 0 && transactionTypeIn.includes(d.category)), {
x: "value",
y: "category",
fill: "category",
fy: "country",
channels: {
Country: "country",
"Transaction type": "category",
"Fee (%)": "value",
},
tip: {
maxRadius: 5,
format: {
y: false,
x: false,
fill: false,
fy: false,
}
}
}),
// Bar for zero values
Plot.barX(filtered_long_in.filter(d => d.value === 0 && transactionTypeIn.includes(d.category)), {
x: -0.05, // Small fixed width for visibility
y: "category",
fy: "country",
fill: "category",
fillOpacity: 0.7,
channels: {
Country: "country",
"Transaction type": "category",
"Fee (%)": "value",
},
tip: {
maxRadius: 5,
format: {
y: false,
x: false,
stroke: false,
fy: false,
fill: false
}
}
}),
// Add text label for N/A bars
Plot.text(
filtered_long_in.filter(d => (d.value === 'N/A' || d.value == -55) && transactionTypeIn.includes(d.category)),
{
x: 0,
y: "category",
fy: "country",
text: d => d.value === -55 ? `${d.category} fees unavailable due to technical issues` : `${d.category} fees not listed`,
fontSize: 12,
fill: "category",
fillOpacity: 0.8,
textAnchor:"start",
dx:5
}
)
],
marginLeft: 80,
x: {
tickFormat: d => `${d}%`,
grid: true,
label: "Cost (% of transaction amount)",
domain: [0, 8]
},
y: {
// Only include the selected transaction types in the domain
domain: uniqueTransactionTypesIn.filter(category => transactionTypeIn.includes(category)),
axis: null,
},
fy: {
label: "",
padding: 0.3, // Increase padding between rows of countries
},
color: {
range: palette.slice(0,3),
legend: true
}
}) : html`<div></div>`
// Add explanatory note about missing data only when transaction types are selected
transactionTypeIn.length > 0 ? html`<div>
${referenceDataDepositPlot}
<div style="font-size: 0.85em; margin-top: 10px; color: #555; max-width: 800px;">
<p><strong>Notes:</strong></p>
<ol>
<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>
</div>` : html`<div></div>`viewof transactionTypeOut = Inputs.checkbox(
uniqueTransactionTypesOut,
{
label: "Transaction:",
value: uniqueTransactionTypesOut, // Default to all transaction types
multiple: false,
sort: false
}
)
// Generate the plot for withdrawing money
referenceDataWithdrawPlot = transactionTypeOut.length > 0 ? Plot.plot({
marks: [
// Regular bars for values > 0
Plot.barX(filtered_long_out.filter(d => d.value !== 'N/A' && d.value > 0 && transactionTypeOut.includes(d.category)), {
x: "value",
y: "category",
fill: "category",
fy: "country",
channels: {
Country: "country",
"Transaction type": "category",
"Fee (%)": "value",
},
tip: {
maxRadius: 5,
format: {
y: false,
x: false,
fill: false,
fy: false,
}
}
}),
// Bar for zero values
Plot.barX(filtered_long_out.filter(d => d.value === 0 && transactionTypeOut.includes(d.category)), {
x: -0.04, // Small fixed width for visibility
y: "category",
fy: "country",
fill: "category",
fillOpacity: 0.7,
channels: {
Country: "country",
"Transaction type": "category",
"Fee (%)": "value",
},
tip: {
maxRadius: 5,
format: {
y: false,
x: false,
stroke: false,
fy: false,
fill: false
}
}
}),
// Add text label for N/A bars
Plot.text(
filtered_long_out.filter(d => (d.value === 'N/A' || d.value === -55) && transactionTypeOut.includes(d.category)),
{
x: 0,
y: "category",
fy: "country",
text: d => d.value === -55 ? `${d.category} fees unavailable due to technical issues` : `${d.category} fees not listed`,
fontSize: 10,
fill: "category",
fillOpacity: 0.8,
textAnchor: "start",
dx: 5
}
)
],
marginLeft: 80,
x: {
tickFormat: d => `${d}%`,
grid: true,
label: "Cost (% of transaction amount)",
domain: [0, 8]
},
y: {
// Only include the selected transaction types in the domain
domain: uniqueTransactionTypesOut.filter(category => transactionTypeOut.includes(category)),
axis: null,
},
fy: {
label: "",
padding: 0.3, // Increase padding between rows of countries
},
color: {
range: palette.slice(3,5),
legend: true
}
}): html`<div></div>`
// Add explanatory note about missing data only when transaction types are selected
transactionTypeOut.length > 0 ? html`<div>
${referenceDataWithdrawPlot}
<div style="font-size: 0.85em; margin-top: 10px; color: #555; max-width: 800px;">
<p><strong>Notes:</strong></p>
<ol>
<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>
</div>` : html`<div></div>`orderedColumns = [
"On-net transfer",
"Off-net transfer",
"Transfer to unregistered user",
"Bank-to-wallet transfer",
"Wallet-to-bank transfer",
"Merchant payment",
"Utility payment",
]
// Create a consistent color mapping for all transaction types
movingColorMap = new Map(
uniqueTransactionTypesMoving.map((type, i) => [type, palette[i + 5]])
)
// Add a dropdown select for Transfers & Payments transaction types
viewof transactionTypeMoving = Inputs.checkbox(
uniqueTransactionTypesMoving,
{
label: "Transaction:",
value: uniqueTransactionTypesMoving, // Default to all transaction types
multiple: true,
sort: false
}
)
// Generate the plot for transfers and payments
referenceDataTransfersPlot = transactionTypeMoving.length > 0 ? Plot.plot({
marks: [
// Regular bars for values > 0
Plot.barX(filtered_long_moving.filter(d => d.value !== 'N/A' && d.value > 0 && transactionTypeMoving.includes(d.category)), {
x: "value",
y: "category",
fill: d => movingColorMap.get(d.category),
fy: "country",
channels: {
Country: "country",
"Transaction type": "category",
"Fee (%)": "value",
},
tip: {
maxRadius: 5,
format: {
y: false,
x: false,
fill: false,
fy: false,
}
}
}),
// Bar for zero values
Plot.barX(filtered_long_moving.filter(d => d.value === 0 && transactionTypeMoving.includes(d.category)), {
x: -0.04, // Small fixed width for visibility
y: "category",
fy: "country",
fill: d => movingColorMap.get(d.category),
fillOpacity: 0.7,
channels: {
Country: "country",
"Transaction type": "category",
"Fee (%)": "value",
},
tip: {
maxRadius: 5,
format: {
y: false,
x: false,
stroke: false,
fy: false,
fill: false
}
}
}),
// Add text label for N/A bars
Plot.text(
filtered_long_moving.filter(d => (d.value === 'N/A' || d.value === -55) && transactionTypeMoving.includes(d.category)),
{
x: 0,
y: "category",
fy: "country",
text: d => d.value === -55 ? `${d.category} fees unavailable due to technical issues` : `${d.category} fees not listed`,
fontSize: 10,
fill: d => movingColorMap.get(d.category),
fillOpacity: 0.8,
textAnchor: "start",
dx: 5
}
)
],
marginLeft: 80,
x: {
tickFormat: d => `${d}%`,
grid: true,
label: "Cost (% of transaction amount)",
domain: [0, 8]
},
y: {
// Only include the selected transaction types in the domain
domain: orderedColumns.filter(category => transactionTypeMoving.includes(category)),
axis: null,
},
fy: {
label: "",
padding: 0.3, // Increase padding between rows of countries
},
color: {
// Create a legend that only shows selected transaction types
domain: transactionTypeMoving,
range: transactionTypeMoving.map(type => movingColorMap.get(type)),
legend: true
}
}): html`<div></div>`
// Add explanatory note about missing data only when transaction types are selected
transactionTypeMoving.length > 0 ? html`<div>
${referenceDataTransfersPlot}
<div style="font-size: 0.85em; margin-top: 10px; color: #555; max-width: 800px;">
<p><strong>Notes:</strong></p>
<ol>
<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>
</div>` : html`<div></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_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
month: d.date_collection ?
d.date_collection.getFullYear() + "-" +
String(d.date_collection.getMonth() + 1).padStart(2, '0') :
null
}))
fees_data = transaction_datadata = exploded_data_ready ? fees_data : []
// Function to convert YYYY-MM to YYYY-MON format (duplicate for unique values section)
function formatMonthDisplayUnique(yearMonth) {
const [year, month] = yearMonth.split('-');
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return `${year}-${monthNames[parseInt(month) - 1]}`;
}
uniqueTransactionTypes = [...new Set(data.map(d => d.transaction_type))].sort()
uniqueCountries = [...new Set(data.map(d => d.country))].sort()
uniqueAmounts = [...new Set(data.map(d => d.usd_transaction_amount))].sort((a, b) => a - b)
uniqueMonths = [...new Set(data.map(d => String(d.month)))].sort()
uniqueMonthsDisplay = uniqueMonths.map(month => ({
value: month,
label: formatMonthDisplayUnique(month)
}))
//| panel: input
viewof transactionAmount = Inputs.range(
[Math.min(...uniqueAmounts), Math.max(...uniqueAmounts)],
{
value: 100,
step: 1,
label: "Transaction Amount (USD):",
format: d => `$${d}`
}
)
// 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 selectedMonth = Inputs.select(
uniqueMonths,
{
value: uniqueMonths[uniqueMonths.length - 1],
label: "Month:",
multiple: false,
sort: false,
unique: true,
format: formatMonthDisplayUnique
}
)
//| panel: input
viewof selectedCountries = Inputs.checkbox(
uniqueCountries,
{
value: uniqueCountries,
label: "Countries:",
multiple: true,
sort: true,
unique: true
}
)filtered = data.filter(function(d) {
return (d.usd_transaction_amount === transactionAmount &&
String(d.month) === selectedMonth &&
selectedCountries.includes(d.country));
})
groupedData = d3.rollup(
filtered,
v => {
const validValues = v.filter(d => d.fee_pct_of_transaction !== null && !isNaN(d.fee_pct_of_transaction) && d.fee_pct_of_transaction !== undefined);
const hasTechnicalIssue = v.some(d => d.fee_technical_issue === true || d.fee_technical_issue === 'True' || d.fee_technical_issue === 1);
if (validValues.length === 0) {
return hasTechnicalIssue ? 'TECH_ISSUE' : 'N/A';
}
return d3.mean(validValues, d => d.fee_pct_of_transaction);
},
d => d.country,
d => d.transaction_type
)
plotData = Array.from(groupedData, ([country, transactionTypes]) =>
Array.from(transactionTypes, ([transactionType, avgFeePercent]) => ({
country: country,
transaction_type: transactionType,
fee_percent: avgFeePercent,
numericValue: avgFeePercent === 'N/A' || avgFeePercent === 'TECH_ISSUE' ? -1 : avgFeePercent
}))
).flat()
// Group transaction types based on actual data values
depositingTypes = ["Cash-in via agent", "Cash-in via ATM"]
withdrawingTypes = ["Cash-out via agent", "Cash-out via ATM"]
transferTypes = ["On-net transfer", "Off-net transfer", "Transfer to unregistered user", "Bank-to-wallet transfer", "Wallet-to-bank transfer", "Merchant payment", "Utility payment"]
plotData_depositing = plotData.filter(d => depositingTypes.includes(d.transaction_type))
plotData_withdrawing = plotData.filter(d => withdrawingTypes.includes(d.transaction_type))
plotData_transfers = plotData.filter(d => transferTypes.includes(d.transaction_type))html`<div id="loading-overlay" class="loading-overlay" style="z-index: 5;">
<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>`viewof transactionTypeDepositing = Inputs.checkbox(
depositingTypes.filter(type => plotData_depositing.some(d => d.transaction_type === type)),
{
label: "Transaction:",
value: depositingTypes.filter(type => plotData_depositing.some(d => d.transaction_type === type)),
multiple: true,
sort: false
}
)
depositPlot = transactionTypeDepositing.length > 0 && plotData_depositing.filter(d => transactionTypeDepositing.includes(d.transaction_type)).length > 0 ?
Plot.plot({
marks: [
Plot.barX(plotData_depositing.filter(d => transactionTypeDepositing.includes(d.transaction_type) && d.fee_percent !== 'N/A' && d.fee_percent > 0), {
x: "fee_percent",
y: "transaction_type",
fill: "transaction_type",
fy: "country",
channels: {
Country: "country",
"Transaction type": "transaction_type",
"Fee (%)": d => d.fee_percent.toFixed(2),
},
tip: {
maxRadius: 5,
format: {
y: false,
x: false,
fill: false,
fy: false,
}
}
}),
Plot.barX(plotData_depositing.filter(d => transactionTypeDepositing.includes(d.transaction_type) && d.fee_percent === 0), {
x: 0.05,
y: "transaction_type",
fy: "country",
fill: "transaction_type",
fillOpacity: 0.7,
channels: {
Country: "country",
"Transaction type": "transaction_type",
"Fee (%)": d => d.fee_percent.toFixed(2),
},
tip: {
maxRadius: 5,
format: {
y: false,
x: false,
stroke: false,
fy: false,
fill: false
}
}
}),
// Add text label for N/A values
Plot.text(
plotData_depositing.filter(d => transactionTypeDepositing.includes(d.transaction_type) && (d.fee_percent === 'N/A' || d.fee_percent === 'TECH_ISSUE')),
{
x: 0,
y: "transaction_type",
fy: "country",
text: d => d.fee_percent === 'TECH_ISSUE' ? `${d.transaction_type} fees unavailable due to technical issues` : `${d.transaction_type} fees not listed`,
fontSize: 12,
fill: "transaction_type",
fillOpacity: 0.8,
textAnchor: "start",
dx: 5
}
)
],
marginLeft: 120,
x: {
tickFormat: d => `${d}%`,
grid: true,
label: "Fee (% of transaction amount)",
domain: [0, Math.max(8, d3.max(plotData_depositing.filter(d => transactionTypeDepositing.includes(d.transaction_type) && d.fee_percent !== 'N/A'), d => d.fee_percent) || 0)]
},
y: {
domain: transactionTypeDepositing,
axis: null,
},
fy: {
label: "",
padding: 0.3,
},
color: {
range: palette.slice(0, 2),
legend: true
}
}) :
html`<div style="padding: 20px; text-align: center; color: #666;">
No data available for the selected transaction amount and filters.
</div>`
// Add explanatory note about missing data only when transaction types are selected
transactionTypeDepositing.length > 0 ? html`<div>
${depositPlot}
<div style="font-size: 0.85em; margin-top: 10px; color: #555; max-width: 800px;">
<p><strong>Notes:</strong></p>
<ol>
<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>
</div>` : html`<div></div>`viewof transactionTypeWithdrawing = Inputs.checkbox(
withdrawingTypes.filter(type => plotData_withdrawing.some(d => d.transaction_type === type)),
{
label: "Transaction:",
value: withdrawingTypes.filter(type => plotData_withdrawing.some(d => d.transaction_type === type)),
multiple: true,
sort: false
}
)
withdrawPlot = transactionTypeWithdrawing.length > 0 && plotData_withdrawing.filter(d => transactionTypeWithdrawing.includes(d.transaction_type)).length > 0 ?
Plot.plot({
marks: [
Plot.barX(plotData_withdrawing.filter(d => transactionTypeWithdrawing.includes(d.transaction_type) && d.fee_percent !== 'N/A' && d.fee_percent > 0), {
x: "fee_percent",
y: "transaction_type",
fill: "transaction_type",
fy: "country",
channels: {
Country: "country",
"Transaction type": "transaction_type",
"Fee (%)": d => d.fee_percent.toFixed(2),
},
tip: {
maxRadius: 5,
format: {
y: false,
x: false,
fill: false,
fy: false,
}
}
}),
Plot.barX(plotData_withdrawing.filter(d => transactionTypeWithdrawing.includes(d.transaction_type) && d.fee_percent === 0), {
x: 0.05,
y: "transaction_type",
fy: "country",
fill: "transaction_type",
fillOpacity: 0.7,
channels: {
Country: "country",
"Transaction type": "transaction_type",
"Fee (%)": d => d.fee_percent.toFixed(2),
},
tip: {
maxRadius: 5,
format: {
y: false,
x: false,
stroke: false,
fy: false,
fill: false
}
}
}),
// Add text label for N/A values
Plot.text(
plotData_withdrawing.filter(d => transactionTypeWithdrawing.includes(d.transaction_type) && (d.fee_percent === 'N/A' || d.fee_percent === 'TECH_ISSUE')),
{
x: 0,
y: "transaction_type",
fy: "country",
text: d => d.fee_percent === 'TECH_ISSUE' ? `${d.transaction_type} fees unavailable due to technical issues` : `${d.transaction_type} fees not listed`,
fontSize: 12,
fill: "transaction_type",
fillOpacity: 0.8,
textAnchor: "start",
dx: 5
}
)
],
marginLeft: 120,
x: {
tickFormat: d => `${d}%`,
grid: true,
label: "Fee (% of transaction amount)",
domain: [0, Math.max(8, d3.max(plotData_withdrawing.filter(d => transactionTypeWithdrawing.includes(d.transaction_type) && d.fee_percent !== 'N/A'), d => d.fee_percent) || 0)]
},
y: {
domain: transactionTypeWithdrawing,
axis: null,
},
fy: {
label: "",
padding: 0.3,
},
color: {
range: palette.slice(2, 4),
legend: true
}
}) :
html`<div style="padding: 20px; text-align: center; color: #666;">
No data available for the selected transaction amount and filters.
</div>`
// Add explanatory note about missing data only when transaction types are selected
transactionTypeWithdrawing.length > 0 ? html`<div>
${withdrawPlot}
<div style="font-size: 0.85em; margin-top: 10px; color: #555; max-width: 800px;">
<p><strong>Notes:</strong></p>
<ol>
<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>
</div>` : html`<div></div>`availableTransferTypes = transferTypes.filter(type => plotData_transfers.some(d => d.transaction_type === type))
viewof transactionTypeTransfers = Inputs.checkbox(
availableTransferTypes,
{
label: "Transaction:",
value: availableTransferTypes,
multiple: true,
sort: false
}
)
transfersPlot = transactionTypeTransfers.length > 0 && plotData_transfers.filter(d => transactionTypeTransfers.includes(d.transaction_type)).length > 0 ?
Plot.plot({
marks: [
Plot.barX(plotData_transfers.filter(d => transactionTypeTransfers.includes(d.transaction_type) && d.fee_percent !== 'N/A' && d.fee_percent > 0), {
x: "fee_percent",
y: "transaction_type",
fill: "transaction_type",
fy: "country",
channels: {
Country: "country",
"Transaction type": "transaction_type",
"Fee (%)": d => d.fee_percent.toFixed(2),
},
tip: {
maxRadius: 5,
format: {
y: false,
x: false,
fill: false,
fy: false,
}
}
}),
Plot.barX(plotData_transfers.filter(d => transactionTypeTransfers.includes(d.transaction_type) && d.fee_percent === 0), {
x: 0.05,
y: "transaction_type",
fy: "country",
fill: "transaction_type",
fillOpacity: 0.7,
channels: {
Country: "country",
"Transaction type": "transaction_type",
"Fee (%)": d => d.fee_percent.toFixed(2),
},
tip: {
maxRadius: 5,
format: {
y: false,
x: false,
stroke: false,
fy: false,
fill: false
}
}
}),
// Add text label for N/A values
Plot.text(
plotData_transfers.filter(d => transactionTypeTransfers.includes(d.transaction_type) && (d.fee_percent === 'N/A' || d.fee_percent === 'TECH_ISSUE')),
{
x: 0,
y: "transaction_type",
fy: "country",
text: d => d.fee_percent === 'TECH_ISSUE' ? `${d.transaction_type} fees unavailable due to technical issues` : `${d.transaction_type} fees not listed`,
fontSize: 10,
fill: "transaction_type",
fillOpacity: 0.8,
textAnchor: "start",
dx: 5
}
)
],
marginLeft: 120,
x: {
tickFormat: d => `${d}%`,
grid: true,
label: "Fee (% of transaction amount)",
domain: [0, Math.max(8, d3.max(plotData_transfers.filter(d => transactionTypeTransfers.includes(d.transaction_type) && d.fee_percent !== 'N/A'), d => d.fee_percent) || 0)]
},
y: {
domain: transactionTypeTransfers,
axis: null,
},
fy: {
label: "",
padding: 0.3,
},
color: {
range: palette.slice(4, 4 + availableTransferTypes.length),
legend: true
}
}) :
html`<div style="padding: 20px; text-align: center; color: #666;">
No data available for the selected transaction amount and filters.
</div>`
// Add explanatory note about missing data only when transaction types are selected
transactionTypeTransfers.length > 0 ? html`<div>
${transfersPlot}
<div style="font-size: 0.85em; margin-top: 10px; color: #555; max-width: 800px;">
<p><strong>Notes:</strong></p>
<ol>
<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>
</div>` : html`<div></div>`Inputs.table(
plotData.map(d => ({
Country: d.country,
"Transaction Type": d.transaction_type,
"Fee (%)": d.fee_percent === 'N/A' || d.fee_percent === 'TECH_ISSUE' ? (d.fee_percent === 'TECH_ISSUE' ? 'Technical Issue' : 'N/A') : d.fee_percent.toFixed(2),
"Transaction Amount (USD)": transactionAmount,
"Month": selectedMonth
})),
{select: false}
)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;
}// 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``;
}