Price Transparency
import {normalizeTransactionType, fixProviderTypeCapitalization, weightedMean} from "/assets/js/utilities.js"
dfsdb = DuckDBClient.of({
all_prices: FileAttachment("data/all_listed_prices_preview.csv")
})
listed_prices_raw = dfsdb.query(`
SELECT *,
CASE WHEN fee < 0 AND fee NOT IN (-55, -99, -66, -88, -44, -77) THEN NULL ELSE fee END as fee_clean,
CASE WHEN fee_pct < 0 AND fee_pct NOT IN (-55, -99, -66, -88, -44, -77) THEN NULL ELSE fee_pct END as fee_pct_clean,
CASE WHEN tax < 0 AND tax NOT IN (-55, -99, -66, -88, -44, -77) THEN NULL ELSE tax END as tax_clean,
CASE WHEN tax_pct < 0 AND tax_pct NOT IN (-55, -99, -66, -88, -44, -77) THEN NULL ELSE tax_pct END as tax_pct_clean,
CASE WHEN value_max IN ('inf', 'infinity', 'info') OR value_max IS NULL OR (CAST(value_max AS DOUBLE) IS NULL) OR (value_max < 0 AND value_max NOT IN (-55, -99, -66, -88)) OR CAST(value_max AS DOUBLE) > 999999 THEN 999999 ELSE value_max END as value_max_clean,
CASE WHEN value_min IN ('inf', 'infinity', 'info') OR value_min IS NULL OR (CAST(value_min AS DOUBLE) IS NULL) OR (value_min < 0 AND value_min NOT IN (-55, -99, -66, -88)) OR CAST(value_min AS DOUBLE) > 999999 THEN 0 ELSE value_min END as value_min_clean,
CONCAT(SUBSTR(CAST(date_collection AS VARCHAR), 1, 4), '-', SUBSTR(CAST(date_collection AS VARCHAR), 5, 2)) as month,
CAST(value_min_clean AS DOUBLE) / CAST(regexp_replace(exchange_rate, ',', '', 'g') AS DOUBLE) as value_min_usd,
CASE WHEN value_max_clean IS NULL THEN NULL ELSE CAST(value_max_clean AS DOUBLE) / CAST(regexp_replace(exchange_rate, ',', '', 'g') AS DOUBLE) END as value_max_usd,
UPPER(SUBSTR(country, 1, 1)) || LOWER(SUBSTR(country, 2)) as country_formatted
FROM all_prices
WHERE country IS NOT NULL AND transaction_type IS NOT NULL
`)
// Convert to plain JavaScript (bypass Arquero closure issues)
listed_prices = listed_prices_raw.map(row => ({
// Keep all columns except the original ones we're replacing
...Object.fromEntries(
Object.entries(row).filter(([key]) =>
!['fee', 'fee_pct', 'tax', 'tax_pct', 'value_max', 'value_min', 'country', 'transaction_type'].includes(key)
)
),
// Rename cleaned columns
fee: row.fee_clean,
fee_pct: row.fee_pct_clean,
tax: row.tax_clean,
tax_pct: row.tax_pct_clean,
value_max: row.value_max_clean,
value_min: row.value_min_clean,
country: row.country_formatted,
transaction_type: normalizeTransactionType(row.transaction_type),
actual_date_collection: row.date_collection,
date_collection: row.date_collection,
month: (() => {
const dc = row.reporting_month;
if (dc != null) {
const dcStr = dc.toString();
if (dcStr.length >= 6) {
const year = dcStr.substring(0, 4);
const month = dcStr.substring(4, 6);
return `${year}-${month}`;
}
}
return null;
})()
}))import {Plot} from "@observablehq/plot"
import {formatMonthDisplay} from "./utils/utils.js"
// Function to format transaction type names
function formatTransactionType(type) {
switch(type) {
case "cash-in via agent": return "Cash-in via agent";
case "cash-in via atm":
case "cash-in via ATM": return "Cash-in via ATM";
case "p2p on-network transfer": return "On-net transfer";
case "p2p off-network transfer": return "Off-net transfer";
case "p2p to unregistered user": return "Transfer to unregistered user";
case "cash-out via agent": return "Cash-out via agent";
case "cash-out via atm":
case "cash-out via ATM": return "Cash-out via ATM";
case "wallet to bank": return "Wallet-to-bank transfer";
case "bank to wallet": return "Bank-to-wallet transfer";
case "payment at merchant": return "Merchant payment";
case "utility payment": return "Utility payment";
default: return type;
}
}
// Unique reporting months across the full dataset, used by both the heatmap's
// and the bar chart's date pickers.
uniqueMonth = [...new Set(listed_prices.map(d => d.month))].filter(d => d != null).sort().reverse()NA_COMBINATIONS = ({
'mobile money': ['bank to wallet'],
'mobile banking': ['wallet to bank']
});
isNACombination = (fspType, transactionType) => {
const naTypes = NA_COMBINATIONS[fspType?.trim().toLowerCase()] || [];
return naTypes.includes(transactionType?.trim().toLowerCase());
};
// Get unique provider types and initialize state with fixed capitalization
uniqueProviderTypes = [...new Set(listed_prices.map(d => fixProviderTypeCapitalization(d.fsp_type)))].filter(d => d != null).sort()
// Initialize provider types if needed
{
if (sharedProviderTypes.length === 0 && uniqueProviderTypes.length > 0) {
mutable sharedProviderTypes = uniqueProviderTypes;
}
return html``;
}The stacked bar chart below breaks down the availability of pricing for each country’s providers, for a single transaction type and reporting month. Each stack represents the proportion of providers falling into each pricing availability category, defined as follows:
- Transaction and fees available — The provider’s website includes both the transaction and its associated fees.
- Transaction available but no fees — The provider’s website mentions the specific transaction type but includes no information on associated fees.
- Ambiguous fees — The fee is variable, either because no single fixed amount is given or because multiple amounts apply depending on specific conditions or configurations (for example, a utility bill payment fee that varies depending on whether the customer’s electricity provider is affiliated with the service provider). This category also covers cases where source documentation is ambiguous or contradictory, such that a correct value cannot be determined with confidence.
- Transaction confirmed unavailable — The provider explicitly mentions that the transaction is unavailable.
- Missing — No evidence of the transaction was found on the provider’s website, and no pricing information was found either.
uniqueTransactionTypesBar = [...new Set(listed_prices.map(d => d.transaction_type))].filter(d => d != null).sort()
viewof barSelectedTransactionType = Inputs.select(uniqueTransactionTypesBar, {
label: "Select transaction type:",
value: uniqueTransactionTypesBar[0],
format: formatTransactionType
})
viewof barSelectedDate = Inputs.select(uniqueMonth, {
label: "Select date:",
value: uniqueMonth[0],
sort: false,
unique: true,
format: formatMonthDisplay,
multiple: false
})
viewof barSelectedProviderTypes = Inputs.checkbox(uniqueProviderTypes, {
value: sharedProviderTypes,
label: "Provider Types:",
multiple: true,
sort: true,
unique: true
})barAllCountries = [...new Set(listed_prices.map(d => d.country))].filter(d => d != null).sort()
// Ordered category list — also the desired bottom-to-top stacking order.
barCategoryOrder = [
"Transaction and fees available",
"Transaction available but no fees",
"Ambiguous fees",
"Transaction confirmed to be unavailable",
"Missing: No evidence on transaction availability and fees"
]
// Colors are assigned by category name (not stack position):
// Transaction and fees available = green, Transaction available but no fees (-44) = blue,
// Ambiguous fees (-66/-88) = yellow, Transaction confirmed to be unavailable (-77) = gray,
// Missing (-99) = orange.
barColorScale = ({
domain: barCategoryOrder,
range: ["#2d8f47", "#4a90d9", "#e8de6c", "#a0a0a0", "#fb9e09"],
legend: true,
label: "Category"
})
// One row per applicable provider (country/fsp_type/provider), for the selected
// transaction type and month, categorized into one of the five statuses above.
// Mirrors pricesData's grouping, but keeps -55 (folded into "Transaction and fees
// available" here, per this chart's definition) and classifies -44/-77 as their
// own categories instead of collapsing everything non-missing/non-ambiguous into
// "Transaction and fees available".
barPricesData = {
const filtered = listed_prices.filter(d =>
d.transaction_type === barSelectedTransactionType &&
d.month === barSelectedDate &&
barSelectedProviderTypes.includes(fixProviderTypeCapitalization(d.fsp_type))
);
const grouped = {};
for (const row of filtered) {
const key = [row.country, row.fsp_type, row.provider].join("|");
if (!grouped[key]) {
grouped[key] = {
country: row.country,
fsp_type: row.fsp_type,
provider: row.provider,
values: []
};
}
grouped[key].values.push(row.fee, row.fee_pct);
}
const result = [];
for (const group of Object.values(grouped)) {
// Structural N/A: exclude this provider/transaction combination entirely,
// same as the heatmap's applicable-providers logic.
if (isNACombination(group.fsp_type, barSelectedTransactionType)) continue;
const values = group.values.filter(v => v != null && v !== "" && !Number.isNaN(v));
if (values.length === 0) continue;
const hasMissing = values.some(v => v === -99);
const hasAmbiguous = !hasMissing && values.some(v => v === -66 || v === -88);
const hasConfirmedNotAvailable = !hasMissing && !hasAmbiguous && values.some(v => v === -77);
const hasConfirmedAvailable = !hasMissing && !hasAmbiguous && !hasConfirmedNotAvailable && values.some(v => v === -44);
let category;
if (hasMissing) category = "Missing: No evidence on transaction availability and fees";
else if (hasAmbiguous) category = "Ambiguous fees";
else if (hasConfirmedNotAvailable) category = "Transaction confirmed to be unavailable";
else if (hasConfirmedAvailable) category = "Transaction available but no fees";
else category = "Transaction and fees available"; // real fee value (incl. 0) or -55 technical issue
result.push({
country: group.country,
fsp_type: group.fsp_type,
provider: group.provider,
category
});
}
return result;
}
// Simple (unweighted) proportion of each category, per country — each provider
// counts equally regardless of market share. Countries with no applicable
// providers for the current filters are skipped entirely, so they don't appear
// on the x axis.
barSummaryData = {
const byCountry = {};
for (const row of barPricesData) {
if (!byCountry[row.country]) byCountry[row.country] = [];
byCountry[row.country].push(row);
}
const result = [];
for (const country of barAllCountries) {
const group = byCountry[country] || [];
if (group.length === 0) continue;
for (const category of barCategoryOrder) {
const providers = group.filter(r => r.category === category).length;
const proportion = providers / group.length;
result.push({
country,
category,
proportion,
providers,
total: group.length
});
}
}
return result;
}
// Countries with at least one applicable provider for the current filters —
// used as the x-axis domain so countries without data are omitted.
barCountriesWithData = [...new Set(barSummaryData.map(d => d.country))]
// One row per visible stack segment (proportion > 0), with the segment's vertical
// midpoint (midY) — the y position Plot.pointer uses to find the nearest segment
// under the cursor. Mirrors the heatmap's tipData: a single Plot.tip mark below
// shows country, category, percentage, and provider counts on hover.
barTipData = {
const byCountry = {};
for (const row of barSummaryData) {
if (!byCountry[row.country]) byCountry[row.country] = [];
byCountry[row.country].push(row);
}
const result = [];
for (const country of barCountriesWithData) {
const rows = byCountry[country] || [];
let cumulative = 0;
for (const category of barCategoryOrder) {
const row = rows.find(r => r.category === category);
if (!row) continue;
const y0 = cumulative;
cumulative += row.proportion;
if (row.proportion > 0) {
result.push({
country,
category,
proportion: row.proportion,
providers: row.providers,
total: row.total,
midY: (y0 + cumulative) / 2
});
}
}
}
return result;
}Plot.plot({
marks: [
Plot.barY(barSummaryData, Plot.stackY({
x: "country",
y: "proportion",
fill: "category",
order: barCategoryOrder
})),
Plot.ruleY([0]),
Plot.tip(barTipData, Plot.pointer({
x: "country",
y: "midY",
// pointerSize: 0 drops the default speech-bubble "beak", leaving a plain
// rounded rectangle; r sets the corner radius.
pointerSize: 0,
r: 6,
// Hide the raw x/y (country/midY) rows so only the named channels below show.
format: {x: false, y: false},
// Each entry renders as its own row (label bolded, value alongside) — this
// gives proper per-row spacing and bold labels, unlike a single manually
// joined title string. "Missing: No evidence..." is shortened to "Missing"
// here; the full text stays in the legend and the note above the chart.
// Trailing colons are baked into the keys themselves, since Plot renders
// the channel key verbatim as the row's bold label.
channels: {
"Country:": "country",
"Category:": d => (d.category.startsWith("Missing") ? "Missing" : d.category),
"Share of providers:": d => `${(d.proportion * 100).toFixed(1)}%`,
"Providers:": d => `${d.providers} of ${d.total}`
},
lineHeight: 1.6,
// Wrap long category names onto multiple lines instead of truncating them
// with an ellipsis: lineWidth raises the per-line character budget, and
// textOverflow: null disables ellipsis clipping in favor of wrapping.
lineWidth: 40,
textOverflow: null
}))
],
color: barColorScale,
// Plot doesn't accept `fontSize` inside the x/y scale config — tick-label size
// is set via the top-level `style` option below, which applies as CSS to the
// whole chart (axes, legend, and tick labels all inherit it).
x: {
label: null,
domain: barCountriesWithData,
tickRotate: -45
},
y: {
label: null,
domain: [0, 1],
ticks: 10,
tickFormat: d => Math.round(d * 100) + "%",
grid: true
},
style: {
fontSize: "15px"
},
width: 1500,
height: 530,
marginLeft: 120,
marginBottom: 80,
marginTop: 20,
marginRight: 100
})
WarningDisclaimer
The pricing data in this database are intended to provide our best estimate of officially disclosed Digital Financial Services (DFS) fees and charges at specific points in time. This data relies wholly on information provided on DFS providers’ websites and is collected using an AI-assisted tool, which may introduce errors or omissions despite quality checks. IPA makes no warranty, expressed or implied, regarding the accuracy, completeness, or reliability of the data.