Price Transparency
import {normalizeTransactionType, fixProviderTypeCapitalization, weightedMean, createDownloadButton} from "/assets/js/utilities.js"
// Apache Arrow types, needed to force explicit CSV column types below (pinned CDN;
// npm: specifiers don't resolve in Quarto's OJS runtime).
arrow = import("https://cdn.jsdelivr.net/npm/apache-arrow@17/+esm")
// PREVIEW-ONLY FIX (see CHANGELOG 2026-09-24): load every column as text with type
// detection off, then TRY_CAST in SQL. Previously the query compared value_min/value_max
// with 'inf'/'info', which throws a conversion error whenever DuckDB's sniffer happens to
// type those columns as DOUBLE — a new month of data could break the whole page.
dfsdb = {
const A = await arrow;
const fa = FileAttachment("data/all_listed_prices.csv");
const text = await fa.text();
const header = text.slice(0, text.indexOf("\n")).trim().replace(/\r$/, "").split(",");
const columns = Object.fromEntries(header.map(h => [h, new A.Utf8()]));
return DuckDBClient.of({ all_prices: { file: fa, columns, detect: false, header: true } });
}
listed_prices_raw = dfsdb.query(`
SELECT *,
CASE WHEN TRY_CAST(fee AS DOUBLE) < 0 AND TRY_CAST(fee AS DOUBLE) NOT IN (-55, -99, -66, -88, -44, -77) THEN NULL ELSE TRY_CAST(fee AS DOUBLE) END as fee_clean,
CASE WHEN TRY_CAST(fee_pct AS DOUBLE) < 0 AND TRY_CAST(fee_pct AS DOUBLE) NOT IN (-55, -99, -66, -88, -44, -77) THEN NULL ELSE TRY_CAST(fee_pct AS DOUBLE) END as fee_pct_clean,
CASE WHEN TRY_CAST(tax AS DOUBLE) < 0 AND TRY_CAST(tax AS DOUBLE) NOT IN (-55, -99, -66, -88, -44, -77) THEN NULL ELSE TRY_CAST(tax AS DOUBLE) END as tax_clean,
CASE WHEN TRY_CAST(tax_pct AS DOUBLE) < 0 AND TRY_CAST(tax_pct AS DOUBLE) NOT IN (-55, -99, -66, -88, -44, -77) THEN NULL ELSE TRY_CAST(tax_pct AS DOUBLE) END as tax_pct_clean,
CASE WHEN TRY_CAST(value_max AS DOUBLE) IS NULL OR NOT ISFINITE(TRY_CAST(value_max AS DOUBLE)) OR (TRY_CAST(value_max AS DOUBLE) < 0 AND TRY_CAST(value_max AS DOUBLE) NOT IN (-55, -99, -66, -88)) OR TRY_CAST(value_max AS DOUBLE) > 999999 THEN 999999 ELSE TRY_CAST(value_max AS DOUBLE) END as value_max_clean,
CASE WHEN TRY_CAST(value_min AS DOUBLE) IS NULL OR NOT ISFINITE(TRY_CAST(value_min AS DOUBLE)) OR (TRY_CAST(value_min AS DOUBLE) < 0 AND TRY_CAST(value_min AS DOUBLE) NOT IN (-55, -99, -66, -88)) OR TRY_CAST(value_min AS DOUBLE) > 999999 THEN 0 ELSE TRY_CAST(value_min AS DOUBLE) 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:
- Fees disclosed: The provider’s website lists fees for this transaction type.
- Fees ambiguous: The provider’s website describes fees unclearly, or lists different fees in different locations.
- Fees not disclosed, transaction available: The provider’s website mentions this transaction type but includes no information on associated fees.
- Transaction not available: The provider’s website confirms that this transaction type is not available.
- Fees not disclosed, transaction availability unknown: The provider’s website does not mention this transaction type or any associated fees. The provider may not offer this transaction type, or may offer it without disclosing fees.
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.
// Must match the exact strings assigned to `category` in barPricesData below,
// or the legend/color scale/stack silently fail to recognize them.
barCategoryOrder = [
"Fees disclosed",
"Fees ambiguous",
"Fees not disclosed, transaction available",
"Transaction not available",
"Fees not disclosed, transaction availability unknown"
]
// Colors are assigned by category name (each stays paired with its name below,
// regardless of legend order): Fees disclosed (real fee/-55) = green,
// Fees ambiguous (-66/-88) = yellow, Fees not disclosed but available (-44) = orange-red,
// Fees not disclosed / availability unknown (-77) = gray, Transaction not available (-99) = orange.
// legend: false here — a scale's inline `legend` option only accepts
// true/false/a legend-type string, not a style override. The legend itself is
// rendered separately below via Plot.legend(), which does accept a `style`
// option, so its font size can be set independently of the chart's own.
barColorScale = ({
domain: barCategoryOrder,
range: ["#49ac57", "#f5cb57", "#c8420a", "#a0a0a0", "#fb9e09"],
legend: false,
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,
marketShare: row.provider_market_share,
values: []
};
}
grouped[key].values.push(row.fee, row.fee_pct);
}
const result = [];
for (const group of Object.values(grouped)) {
// Structural N/A exclusion temporarily disabled (Option A): these providers
// still carry real fee/fee_pct data (usually -99), so they now flow through
// the normal categorization below instead of being skipped and undercounting
// the country's denominator. To revert, uncomment the line below.
// if (isNACombination(group.fsp_type, barSelectedTransactionType)) continue;
// Coerce to Number: DuckDB-WASM may return integer columns as BigInt, and
// `BigInt(-99) === -99` is false — that silently sends every -99 row to the
// "Fees disclosed" default (all-green bug). Number() normalizes both cases.
const values = group.values.map(Number).filter(v => v != null && !Number.isNaN(v));
if (values.length === 0) continue;
const hasMissing = values.some(v => v === -99);
// -88 is no longer treated as ambiguous — with no other sentinel code
// present, it now falls through to the "Fees disclosed" default below.
const hasAmbiguous = !hasMissing && values.some(v => v === -66);
const hasConfirmedNotAvailable = !hasMissing && !hasAmbiguous && values.some(v => v === -77);
const hasConfirmedAvailable = !hasMissing && !hasAmbiguous && !hasConfirmedNotAvailable && values.some(v => v === -44);
let category;
if (hasMissing) category = "Fees not disclosed, transaction availability unknown";
else if (hasAmbiguous) category = "Fees ambiguous";
else if (hasConfirmedNotAvailable) category = "Transaction not available" ;
else if (hasConfirmedAvailable) category = "Fees not disclosed, transaction available";
else category = "Fees disclosed"; // real fee value (incl. 0) or -55 technical issue
result.push({
country: group.country,
fsp_type: group.fsp_type,
provider: group.provider,
marketShare: group.marketShare,
category
});
}
return result;
}
// Market-share-weighted proportion of each category, per country, via the shared
// weightedMean utility: each provider's contribution is weighted by
// provider_market_share (0/1 indicator per category). Falls back to a simple,
// unweighted mean for a country/category group if any provider in that country
// lacks a valid positive market share (all-or-nothing, see weightedMean).
// 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 = weightedMean(group, r => r.category === category ? 1 : 0, r => r.marketShare) ?? 0;
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. Each category is "Short label: full description" —
// the tooltip shows only the part before the colon; 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.split(":")[0],
"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: 950,
height: 500,
marginLeft: 100,
marginBottom: 100,
marginTop: 20,
marginRight: 100
}){
// Filter for technical issues affecting the selected transaction type and
// reporting month — scoped to transaction type too, since (unlike the
// heatmap) this chart only ever shows one transaction type at a time.
const technicalIssuesData = listed_prices.filter(d => {
return d.transaction_type === barSelectedTransactionType &&
d.month === barSelectedDate &&
(Number(d.fee) === -55 || Number(d.fee_pct) === -55);
});
// Build technical issues footnote text (conditional)
let technicalIssueItem = null;
if (technicalIssuesData.length > 0) {
const affectedCountries = [...new Set(technicalIssuesData.map(d => d.country))].sort();
technicalIssueItem = html`<li>Some providers had fees available on their websites but could not be collected due to technical difficulties.
For ${formatTransactionType(barSelectedTransactionType)} in ${formatMonthDisplay(barSelectedDate)}, technical issues affected: ${affectedCountries.join(', ')}. These providers were excluded from the calculations above.</li>`;
}
return html`<div style="margin-top: 4px; padding: 15px; background-color: white; font-size: 0.9em;">
<strong>Notes:</strong>
<ol>
${technicalIssueItem || ''}
<li>Results restricted to the selected provider type(s): ${barSelectedProviderTypes.join(", ")}.</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.</li>
<li>Starting April 2026, proportions shown are based on the market share-weighted share of providers, using the market share estimates available from April 2026 onward. Prior to this, the proportion are based on a simple unweighted share where each provider is weighted equally.</li>
</ol>
</div>`;
}Inputs.table(
barSummaryData.map(d => ({
Country: d.country,
"Transaction Type": formatTransactionType(barSelectedTransactionType),
Category: d.category,
"Proportion (%)": (d.proportion * 100).toFixed(1),
"Number of Providers": d.providers,
"Total Providers": d.total,
Month: formatMonthDisplay(barSelectedDate)
})),
{select: false} // Disable row selection
)createDownloadButton(barSummaryData.map(d => ({
country: d.country,
transaction_type: barSelectedTransactionType,
category: d.category,
proportion_percent: (d.proportion * 100).toFixed(1),
providers: d.providers,
total_providers: d.total,
month: barSelectedDate
})), 'transparency-bar-chart-data.csv')
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.