Sonar AI · Analytics Brief · Account TD3016323
How dependent is the business on its largest customers, and is that dependence rising or falling? Herfindahl-Hirschman Index, Gini coefficient, Lorenz & Pareto curves, top-N shares and the customers driving the year-over-year shift — computed in a sandboxed reducer from posting invoices and cash sales, journals excluded.
Concentration fell sharply and the customer base broadened. HHI dropped from 683.9 (FY2025) to 474.0 (FY2026 YTD), a −30.7% move that raises the effective number of customers from 14.6 to 21.1. Both readings sit in the unconcentrated band (<1,500), so this is a shift from "healthy" to "healthier", not a rescue from a danger zone.
The largest single dependency halved. The #1 customer's share fell from 14.31% (Jones Manufacturing, FY2025) to 7.40% (Design Excellence Ltd., FY2026 YTD). The top-5 share fell 12.9 points to 34.8%; the top-10 fell 12.5 points to 62.5%. It now takes 8 customers (not 6) to reach half of revenue and 16 (not 12) to reach 80%.
Revenue is up, not just spread thinner. FY2026 YTD revenue of $1,329,784 already exceeds all of FY2025 ($1,249,050, +6.5%) with four months remaining; against the like-for-like Jan–Sep 4 2025 window ($728,254) it is up 82.6%. The annualised run-rate is ≈ $1.97M.
The nuance: inequality within the base barely moved. The Gini coefficient eased only from 0.774 to 0.759. The distribution is still a steep power law — the bottom 50% of customers produce 4.4% of revenue and 19 customers under $1,000 contribute 0.85% combined. Concentration fell because more mid-sized accounts arrived at the top (29 new customers contributed 40.2% of YTD revenue), not because the long tail grew in weight.
The risk pattern to watch: one-invoice whales. Three of the FY2026 top-10 (Red Rivers Consulting, Magna Tech Limited, Falcon Systems — 19.7% of revenue combined) are single-invoice customers. In FY2025 the four single-invoice customers in the top-12 (Global Information, Mercury Co., Gotter inc., Haskell Associates — 22.5% of that year) all produced zero revenue in FY2026. If the pattern repeats, ~20% of this year's revenue is non-recurring by construction.
Green deltas indicate lower concentration risk (the direction most finance teams treat as favourable). Amber indicates the opposite. Grey is context, not a risk signal.
Scale shown 0–5,000 (half the theoretical maximum of 10,000, which represents a single customer). Standard DOJ/FTC bands: <1,500 unconcentrated · 1,500–2,500 moderately concentrated · >2,500 highly concentrated. Applied to a customer book, a useful rule of thumb is that HHI above ~1,000 (effective N below 10) warrants explicit key-account risk management.
Left: the Lorenz curve (customers sorted ascending; the shaded area against the 45° equality line is the Gini). Right: the Pareto view (customers sorted descending) — the classic "what share of customers gives what share of revenue" reading. 20 points each at 5% intervals; both periods overlaid.
| % customers | Lorenz FY26 (asc.) | Lorenz FY25 (asc.) | Pareto FY26 (desc.) | Pareto FY25 (desc.) | # cust. FY26 / FY25 |
|---|---|---|---|---|---|
| 5% | 0.06% | 0.16% | 34.80% | 39.52% | 5 / 4 |
| 10% | 0.30% | 0.43% | 62.50% | 65.81% | 10 / 8 |
| 15% | 0.59% | 0.79% | 78.90% | 81.68% | 15 / 12 |
| 20% | 0.93% | 1.09% | 87.44% | 90.02% | 20 / 15 |
| 25% | 1.34% | 1.57% | 90.44% | 91.46% | 25 / 19 |
| 30% | 1.70% | 2.09% | 91.56% | 92.55% | 29 / 23 |
| 35% | 2.27% | 2.64% | 92.73% | 93.44% | 34 / 27 |
| 40% | 2.93% | 3.21% | 93.76% | 94.26% | 39 / 31 |
| 45% | 3.67% | 3.79% | 94.71% | 95.03% | 44 / 35 |
| 50% | 4.44% | 4.42% | 95.56% | 95.74% | 49 / 39 |
| 55% | 5.29% | 4.97% | 96.33% | 96.21% | 54 / 42 |
| 60% | 6.24% | 5.74% | 97.07% | 96.79% | 59 / 46 |
| 65% | 7.27% | 6.56% | 97.73% | 97.36% | 64 / 50 |
| 70% | 8.44% | 7.45% | 98.30% | 97.91% | 69 / 54 |
| 75% | 9.87% | 8.54% | 98.74% | 98.43% | 74 / 58 |
| 80% | 12.56% | 9.98% | 99.07% | 98.91% | 78 / 62 |
| 85% | 21.10% | 18.32% | 99.41% | 99.21% | 83 / 65 |
| 90% | 37.50% | 34.19% | 99.70% | 99.57% | 88 / 69 |
| 95% | 65.20% | 60.48% | 99.94% | 99.84% | 93 / 73 |
| 100% | 100.00% | 100.00% | 100.00% | 100.00% | 98 / 77 |
Point k at percentile j is the first ⌊n·j/20 + 0.5⌉ customers in sort order; with n = 98 and 77 the customer counts are not perfectly even multiples, hence the "# customers" column.
Roll-up key is the top-level parent (see Method). No customer in either period had a parent with revenue, so every row is a single entity. 1 invoice flags customers whose entire period revenue came from a single transaction — a recurrence risk.
| # | Customer (internal id) | Revenue | Share | Share % | Cumulative | Txns | FY25 rank | Note |
|---|---|---|---|---|---|---|---|---|
| 1 | Design Excellence Ltd. (257) | $98,451.57 | 7.40% | 7.40% | 15 | 2 | Retained · was 8.92% | |
| 2 | Red Rivers Consulting (402) | $94,370.00 | 7.10% | 14.50% | 1 | — | New in FY26 1 invoice | |
| 3 | Jones Manufacturing (276) | $91,439.53 | 6.88% | 21.38% | 10 | 1 | Retained · was 14.31% | |
| 4 | Magna Tech Limited (284) | $89,343.00 | 6.72% | 28.10% | 1 | — | New in FY26 1 invoice | |
| 5 | Pineapple Republic (398) | $89,154.90 | 6.70% | 34.80% | 8 | 6 | Retained · was 6.15% | |
| 6 | Panaderia Co. (396) | $85,596.53 | 6.44% | 41.24% | 8 | 4 | Retained · was 8.15% | |
| 7 | Falcon Systems (259) | $78,456.00 | 5.90% | 47.14% | 1 | — | New in FY26 1 invoice | |
| 8 | Recreational Outfitters (401) | $74,345.66 | 5.59% | 52.73% | 9 | 10 | Retained · was 4.10% · crosses 50% | |
| 9 | Marshall Industries (287) | $71,379.90 | 5.37% | 58.09% | 5 | 15 | Retained · was 2.28% · entered top-10 | |
| 10 | Davis Supplies (253) | $58,639.75 | 4.41% | 62.50% | 7 | 5 | Retained · was 8.13% | |
| 11 | Realpoint inc. (400) | $53,188.16 | 4.00% | 66.50% | 7 | 7 | Retained · was 6.08% · left top-10 | |
| 12 | Blockster Inc. (280) | $52,770.00 | 3.97% | 70.47% | 3 | — | New in FY26 | |
| 13 | Macgruber Incorporated (281) | $50,652.00 | 3.81% | 74.28% | 3 | — | New in FY26 | |
| 14 | Hugo Limited (270) | $31,446.48 | 2.36% | 76.65% | 10 | 11 | Retained · was 3.33% | |
| 15 | John G. Roche Opticians (275) | $29,939.00 | 2.25% | 78.90% | 1 | — | New in FY26 1 invoice | |
| Top-15 subtotal | $1,049,172.48 | 78.90% | 89 | HHI contribution 455.2 | ||||
| Remaining 83 customers | $280,611.79 | 21.10% | 565 | HHI contribution 18.8 · includes all 368 cash-sale tickets | ||||
| Total (98 customers) | $1,329,784.27 | 100.00% | 654 | HHI 474.0 |
| # | Customer (id) | Revenue | Share | Share % | Cumulative | Txns | FY26 status |
|---|---|---|---|---|---|---|---|
| 1 | Jones Manufacturing (276) | $178,754.46 | 14.31% | 14.31% | 12 | Rank 3 · 6.88% | |
| 2 | Design Excellence Ltd. (257) | $111,358.56 | 8.92% | 23.23% | 12 | Rank 1 · 7.40% | |
| 3 | Global Information (263) | $101,799.00 | 8.15% | 31.38% | 1 | No FY26 revenue 1 invoice | |
| 4 | Panaderia Co. (396) | $101,737.39 | 8.15% | 39.52% | 11 | Rank 6 · 6.44% | |
| 5 | Davis Supplies (253) | $101,590.06 | 8.13% | 47.66% | 12 | Rank 10 · 4.41% | |
| 6 | Pineapple Republic (398) | $76,837.46 | 6.15% | 53.81% | 9 | Rank 5 · 6.70% | |
| 7 | Realpoint inc. (400) | $75,943.79 | 6.08% | 59.89% | 11 | Rank 11 · 4.00% | |
| 8 | Mercury Co. (292) | $73,976.00 | 5.92% | 65.81% | 1 | No FY26 revenue 1 invoice | |
| 9 | Gotter inc. (265) | $64,112.00 | 5.13% | 70.94% | 1 | No FY26 revenue 1 invoice | |
| 10 | Recreational Outfitters (401) | $51,216.69 | 4.10% | 75.04% | 11 | Rank 8 · 5.59% | |
| 11 | Hugo Limited (270) | $41,542.26 | 3.33% | 78.37% | 12 | Rank 14 · 2.36% | |
| 12 | Haskell Associates (268) | $41,356.00 | 3.31% | 81.68% | 1 | No FY26 revenue 1 invoice | |
| 13 | Karmabit (278) | $39,753.34 | 3.18% | 84.86% | 12 | Rank >15 · 1.21% | |
| 14 | Entenmanns LLC (258) | $35,875.36 | 2.87% | 87.73% | 8 | Rank >15 | |
| 15 | Marshall Industries (287) | $28,520.65 | 2.28% | 90.02% | 7 | Rank 9 · 5.37% | |
| Top-15 subtotal | $1,124,373.02 | 90.02% | 121 | HHI contribution 681.9 | |||
| Remaining 62 customers | $124,676.57 | 9.98% | 776 | HHI contribution 2.0 | |||
| Total (77 customers) | $1,249,049.59 | 100.00% | 897 | HHI 683.9 |
Two comparisons are shown. FY2025 full year is what you asked for; the like-for-like column (Jan 1 – Sep 4, 2025) removes the seasonal/partial-year bias — and it turns out the direction and magnitude of every concentration metric are the same on both bases, so the conclusion is robust.
| Metric | FY2026 YTD | FY2025 (full) | Δ vs FY25 | FY2025 like-for-like | Δ vs LFL | Reading |
|---|---|---|---|---|---|---|
| Total revenue | $1,329,784 | $1,249,050 | +$80,735 · +6.5% | $728,254 | +82.6% | YTD already exceeds all of FY25 |
| Revenue-generating customers | 98 | 77 | +21 · +27.3% | 72 | +26 | Base broadened |
| HHI (0–10,000) | 474.0 | 683.9 | −209.9 · −30.7% | 827.4 | −353.4 · −42.7% | Less concentrated; both unconcentrated band |
| Effective # customers (1/HHI) | 21.1 | 14.6 | +6.5 · +44.5% | 12.1 | +9.0 | Diversification up |
| Gini coefficient | 0.759 | 0.774 | −0.015 · −2.0% | 0.792 | −0.033 | Marginally more equal; still power-law |
| Top-1 share | 7.40% | 14.31% | −6.91 pts | 16.04% | −8.64 pts | Single-customer dependency halved |
| Top-5 share | 34.80% | 47.66% | −12.86 pts | 55.38% | −20.58 pts | Largest absolute improvement |
| Top-10 share | 62.50% | 75.04% | −12.54 pts | 81.84% | −19.34 pts | |
| Top-20 share | 87.44% | 91.74% | −4.30 pts | 91.69% | −4.25 pts | Tail still thin below rank 20 |
| Customers to reach 50% of revenue | 8 | 6 | +2 | 5 | +3 | |
| Customers to reach 80% of revenue | 16 | 12 | +4 | 10 | +6 | |
| Customers under $1,000 | 19 (0.85%) | 8 (0.43%) | +11 · +0.42 pts | 21 (1.52%) | −2 | Store retail tail; immaterial to revenue |
| Median / mean customer revenue | $2,132 / $13,569 | $2,075 / $16,221 | +2.8% / −16.3% | — | Mean fell as whales shrank; median stable |
Share deltas are FY2026 YTD share minus FY2025 full-year share, in percentage points. The HHI decomposition attributes the −209.9 move to individual customers via Δ(share²) × 10,000.
| Customer | FY25 rank / share | FY26 rank / share | Status |
|---|---|---|---|
| Red Rivers Consulting | — / 0% | #2 / 7.10% | New 1 invoice |
| Magna Tech Limited | — / 0% | #4 / 6.72% | New 1 invoice |
| Falcon Systems | — / 0% | #7 / 5.90% | New 1 invoice |
| Marshall Industries | #15 / 2.28% | #9 / 5.37% | Grew +150% |
| Customer | FY25 rank / share | FY26 rank / share | Status |
|---|---|---|---|
| Global Information | #3 / 8.15% | — / 0% | No FY26 revenue |
| Mercury Co. | #8 / 5.92% | — / 0% | No FY26 revenue |
| Gotter inc. | #9 / 5.13% | — / 0% | No FY26 revenue |
| Realpoint inc. | #7 / 6.08% | #11 / 4.00% | Slipped one place out |
| Customer | FY25 → FY26 share | Δ pts | FY26 revenue |
|---|---|---|---|
| Red Rivers Consulting | 0.00% → 7.10% | +7.10 | $94,370 |
| Magna Tech Limited | 0.00% → 6.72% | +6.72 | $89,343 |
| Falcon Systems | 0.00% → 5.90% | +5.90 | $78,456 |
| Blockster Inc. | 0.00% → 3.97% | +3.97 | $52,770 |
| Macgruber Incorporated | 0.00% → 3.81% | +3.81 | $50,652 |
| Marshall Industries | 2.28% → 5.37% | +3.08 | $71,380 |
| John G. Roche Opticians | 0.00% → 2.25% | +2.25 | $29,939 |
| Informics International | 0.00% → 2.01% | +2.01 | $26,672 |
| Customer | FY25 → FY26 share | Δ pts | FY25 → FY26 revenue |
|---|---|---|---|
| Global Information | 8.15% → 0.00% | −8.15 | $101,799 → $0 |
| Jones Manufacturing | 14.31% → 6.88% | −7.43 | $178,754 → $91,440 |
| Mercury Co. | 5.92% → 0.00% | −5.92 | $73,976 → $0 |
| Gotter inc. | 5.13% → 0.00% | −5.13 | $64,112 → $0 |
| Davis Supplies | 8.13% → 4.41% | −3.72 | $101,590 → $58,640 |
| Haskell Associates | 3.31% → 0.00% | −3.31 | $41,356 → $0 |
| Realpoint inc. | 6.08% → 4.00% | −2.08 | $75,944 → $53,188 |
| Karmabit | 3.18% → 1.21% | −1.97 | $39,753 → $16,076 |
| Customer | Δ HHI points | Why |
|---|---|---|
| Jones Manufacturing | −157.5 | Share 14.31% → 6.88%; the single biggest de-concentration effect (75% of the total move) |
| Global Information | −66.4 | 8.15% → 0; one-invoice customer did not return |
| Red Rivers Consulting | +50.4 | New at 7.10%; partially offsets |
| Davis Supplies | −46.7 | 8.13% → 4.41% |
| Magna Tech Limited | +45.1 | New at 6.72% |
| Mercury Co. | −35.1 | 5.92% → 0; one-invoice customer did not return |
| Cohort | Customers | FY25 revenue | FY26 YTD revenue | Share of FY26 |
|---|---|---|---|---|
| Retained (revenue in both years) | 69 | $963,034 | $795,785 | 59.8% |
| New in FY26 (no FY25 revenue) | 29 | — | $533,999 | 40.2% |
| Lost (FY25 revenue, none in FY26) | 8 | $286,016 | — | — |
| Total | 98 / 77 | $1,249,050 | $1,329,784 | 100% |
Retained customers are running at 82.6% of their FY25 full-year total with 4 months left — on pace to roughly match. Growth to date is almost entirely new-logo revenue. The 8 lost customers took 22.9% of FY25 revenue with them; 4 of them were single-invoice accounts.
CustInvc and CashSale with transaction.posting = 'T'.transactionline.mainline = 'F' and taxline = 'F'; measure SUM(ABS(tl.netamount)).transactionline.subsidiary IN (1, 2, 3) — subsidiary 4 (xElim, elimination) excluded. transaction.subsidiary is NOT_EXPOSED in this account, hence the line-level filter.trandate (not posting period): FY2026 YTD = 2026-01-01..2026-09-04; FY2025 = 2025-01-01..2025-12-31; like-for-like = 2025-01-01..2025-09-04.Key = COALESCE(customer.parent, transaction.entity) — one level. Verified before running: of 274 customers, exactly 1 has a parent and the hierarchy depth is 1 (no grandparents), so one level is exhaustive. In both periods the roll-up affected 0 rows — the sole child customer had no revenue — so every reported "customer" is a single entity. The reducer still applies the rule and reports member counts, so it will work correctly if hierarchies are added later.
CustCred would reduce gross revenue for some customers. Add it (with sign) if you want net revenue.Manual spot-check of HHI from the published top-15 table (FY26): 7.40² + 7.10² + 6.88² + 6.72² + 6.70² + 6.44² + 5.90² + 5.59² + 5.37² + 4.41² + 4.00² + 3.97² + 3.81² + 2.36² + 2.25² = 455.4 (rounding of displayed shares; unrounded reducer value 455.2) + residual 18.8 ≈ 474. The residual's small size (83 customers, 21.1% of revenue, only 18.8 HHI points) confirms that concentration is entirely a top-15 phenomenon.
Everything below was executed exactly as shown via sqlReduce (rows never entered the model context — only the reducer's summary did). Queries follow the house SuiteQL style guide (uppercase keywords, standard aliases, tl.subsidiary filtering, ROUND(…,2) on money, deterministic ORDER BY).
SELECT t.entity AS customer_id, c.entityid AS customer, c.companyname AS company, c.parent AS parent_id, p.entityid AS parent_name, t.type AS tran_type, COUNT(DISTINCT t.id) AS txn_count, ROUND(SUM(ABS(tl.netamount)), 2) AS revenue FROM transaction t JOIN transactionline tl ON tl.transaction = t.id LEFT JOIN customer c ON c.id = t.entity LEFT JOIN customer p ON p.id = c.parent WHERE t.type IN ('CustInvc', 'CashSale') AND t.posting = 'T' AND tl.mainline = 'F' AND tl.taxline = 'F' AND tl.subsidiary IN (1, 2, 3) AND t.trandate >= TO_DATE('2026-01-01', 'YYYY-MM-DD') AND t.trandate <= TO_DATE('2026-09-04', 'YYYY-MM-DD') GROUP BY t.entity, c.entityid, c.companyname, c.parent, p.entityid, t.type ORDER BY t.entity, t.type -- FY2025 variant: trandate BETWEEN TO_DATE('2025-01-01') AND TO_DATE('2025-12-31') → 79 rows -- Like-for-like variant: trandate BETWEEN TO_DATE('2025-01-01') AND TO_DATE('2025-09-04') → 72 rows -- FY2026 YTD result: 100 rows (98 customers × up to 2 transaction types)
SELECT COUNT(*) AS total_customers, SUM(CASE WHEN c.parent IS NOT NULL THEN 1 ELSE 0 END) AS with_parent, SUM(CASE WHEN c.parent IS NOT NULL AND p.parent IS NOT NULL THEN 1 ELSE 0 END) AS depth2, SUM(CASE WHEN c.parent IS NOT NULL AND p.parent IS NOT NULL AND gp.parent IS NOT NULL THEN 1 ELSE 0 END) AS depth3 FROM customer c LEFT JOIN customer p ON p.id = c.parent LEFT JOIN customer gp ON gp.id = p.parent -- Result: total_customers 274, with_parent 1, depth2 0, depth3 0
SELECT CASE WHEN t.trandate >= TO_DATE('2026-01-01','YYYY-MM-DD') THEN 'FY26 YTD' ELSE 'FY25' END AS period, SUM(CASE WHEN tl.netamount < 0 THEN 1 ELSE 0 END) AS negative_lines, ROUND(SUM(CASE WHEN tl.netamount < 0 THEN tl.netamount ELSE 0 END), 2) AS negative_amount, SUM(CASE WHEN tl.netamount > 0 THEN 1 ELSE 0 END) AS positive_lines, ROUND(SUM(tl.netamount), 2) AS signed_net, ROUND(SUM(ABS(tl.netamount)), 2) AS abs_net FROM transaction t JOIN transactionline tl ON tl.transaction = t.id WHERE t.type IN ('CustInvc', 'CashSale') AND t.posting = 'T' AND tl.mainline = 'F' AND tl.taxline = 'F' AND tl.subsidiary IN (1, 2, 3) AND t.trandate >= TO_DATE('2025-01-01', 'YYYY-MM-DD') AND t.trandate <= TO_DATE('2026-09-04', 'YYYY-MM-DD') GROUP BY CASE WHEN t.trandate >= TO_DATE('2026-01-01','YYYY-MM-DD') THEN 'FY26 YTD' ELSE 'FY25' END -- FY26 YTD: 1,800 negative lines, 0 positive, signed −1,329,784.27, abs 1,329,784.27 -- FY25: 2,493 negative lines, 0 positive, signed −1,249,049.59, abs 1,249,049.59
function build(raw){
// Roll up to top-level parent: key = parent_id || customer_id (hierarchy depth verified = 1)
const map = {};
let rolled = 0, unknownEntity = 0;
const byType = {CustInvc:{rev:0,txns:0}, CashSale:{rev:0,txns:0}};
for (const r of raw){
const rev = H.num(r.revenue), txns = H.num(r.txn_count);
const key = r.parent_id ? String(r.parent_id) : String(r.customer_id);
if (r.parent_id) rolled++;
if (!r.customer) unknownEntity++;
const name = r.parent_id ? (r.parent_name || ('#'+r.parent_id)) : (r.customer || r.company || ('entity #'+r.customer_id));
if (!map[key]) map[key] = {key, name, rev:0, txns:0, inv:0, cash:0, children:new Set()};
map[key].rev += rev; map[key].txns += txns;
if (r.tran_type==='CustInvc') map[key].inv += rev; else map[key].cash += rev;
map[key].children.add(String(r.customer_id));
byType[r.tran_type].rev += rev; byType[r.tran_type].txns += txns;
}
const custs = Object.values(map).map(c=>({key:c.key,name:c.name,rev:+c.rev.toFixed(2),txns:c.txns,inv:+c.inv.toFixed(2),cash:+c.cash.toFixed(2),members:c.children.size}));
custs.sort((a,b)=>b.rev-a.rev);
const n = custs.length, total = custs.reduce((s,c)=>s+c.rev,0);
let cum = 0, hhi = 0, shareSum = 0, c50=null, c80=null;
custs.forEach((c,i)=>{ c.share = c.rev/total; shareSum += c.share; hhi += c.share*c.share; cum += c.share; c.cumShare = cum; c.rank=i+1;
if(c50===null && cum>=0.5) c50=i+1; if(c80===null && cum>=0.8) c80=i+1; });
const topShare = k => custs.slice(0,k).reduce((s,c)=>s+c.share,0);
// Gini on ascending sort: G = 2Σ(i·x_i)/(n·Σx) − (n+1)/n
const asc = custs.slice().sort((a,b)=>a.rev-b.rev);
let num=0; asc.forEach((c,i)=>{ num += (i+1)*c.rev; });
const gini = (2*num)/(n*total) - (n+1)/n;
// Lorenz (ascending) & Pareto (descending), 20 points at 5% steps
const lorenz=[], pareto=[];
for (let j=1;j<=20;j++){
const k = Math.round(n*j/20);
lorenz.push({pctCust:j*5, pctRev:+(asc.slice(0,k).reduce((s,c)=>s+c.rev,0)/total*100).toFixed(2), custs:k});
pareto.push({pctCust:j*5, pctRev:+(custs.slice(0,k).reduce((s,c)=>s+c.rev,0)/total*100).toFixed(2), custs:k});
}
const small = custs.filter(c=>c.rev<1000);
const hhi10k = hhi*10000;
const band = hhi10k<1500?'Unconcentrated (<1,500)':hhi10k<2500?'Moderately concentrated (1,500-2,500)':'Highly concentrated (>2,500)';
// Hand-check: HHI from top-15 + residual
const top15 = custs.slice(0,15);
const hhiTop15 = top15.reduce((s,c)=>s+c.share*c.share,0)*10000;
const hhiResidual = custs.slice(15).reduce((s,c)=>s+c.share*c.share,0)*10000;
const stats = H.stats(custs, c=>c.rev);
return {
n, total:+total.toFixed(2), rolledRows:rolled, unknownEntity,
byType:{CustInvc:{rev:+byType.CustInvc.rev.toFixed(2),txns:byType.CustInvc.txns}, CashSale:{rev:+byType.CashSale.rev.toFixed(2),txns:byType.CashSale.txns}},
hhi:+hhi10k.toFixed(1), band, effectiveN:+(1/hhi).toFixed(1), gini:+gini.toFixed(4),
top1:+(topShare(1)*100).toFixed(2), top5:+(topShare(5)*100).toFixed(2), top10:+(topShare(10)*100).toFixed(2), top20:+(topShare(20)*100).toFixed(2),
custTo50:c50, custTo80:c80, lorenz, pareto,
top15: top15.map(c=>({rank:c.rank,key:c.key,name:c.name,rev:c.rev,txns:c.txns,inv:c.inv,cash:c.cash,members:c.members,share:+(c.share*100).toFixed(2),cumShare:+(c.cumShare*100).toFixed(2)})),
under1k:{count:small.length, rev:+small.reduce((s,c)=>s+c.rev,0).toFixed(2), share:+(small.reduce((s,c)=>s+c.share,0)*100).toFixed(2)},
checks:{shareSumPct:+(shareSum*100).toFixed(6), hhiTop15:+hhiTop15.toFixed(1), hhiResidual:+hhiResidual.toFixed(1), hhiRecomputed:+(hhiTop15+hhiResidual).toFixed(1),
sumTop15Rev:+top15.reduce((s,c)=>s+c.rev,0).toFixed(2), sumRestRev:+custs.slice(15).reduce((s,c)=>s+c.rev,0).toFixed(2)},
dist:{min:+stats.min.toFixed(2),median:+stats.median.toFixed(2),mean:+stats.mean.toFixed(2),p90:+stats.p90.toFixed(2),max:+stats.max.toFixed(2)},
_all: custs.map(c=>({key:c.key,name:c.name,rev:c.rev,share:c.share,rank:c.rank}))
};
}
const A = build(rows.fy26), B = build(rows.fy25);
// ---- YoY drivers ----
const idx26 = H.indexBy(A._all,'key'), idx25 = H.indexBy(B._all,'key');
const top10_26 = A._all.slice(0,10).map(c=>c.key), top10_25 = B._all.slice(0,10).map(c=>c.key);
const entered = top10_26.filter(k=>!top10_25.includes(k)).map(k=>({name:idx26[k].name, rank26:idx26[k].rank, share26:+(idx26[k].share*100).toFixed(2), rank25: idx25[k]?idx25[k].rank:null, share25: idx25[k]?+(idx25[k].share*100).toFixed(2):0}));
const left = top10_25.filter(k=>!top10_26.includes(k)).map(k=>({name:idx25[k].name, rank25:idx25[k].rank, share25:+(idx25[k].share*100).toFixed(2), rank26: idx26[k]?idx26[k].rank:null, share26: idx26[k]?+(idx26[k].share*100).toFixed(2):0}));
const keys = H.uniq([...Object.keys(idx26),...Object.keys(idx25)]);
const deltas = keys.map(k=>{const a=idx26[k],b=idx25[k];return {key:k,name:(a||b).name, share25:b?b.share*100:0, share26:a?a.share*100:0, rev25:b?b.rev:0, rev26:a?a.rev:0, d:(a?a.share:0)-(b?b.share:0)};});
const fmt = d=>({name:d.name, share25:+d.share25.toFixed(2), share26:+d.share26.toFixed(2), deltaPts:+(d.d*100).toFixed(2), rev25:d.rev25, rev26:d.rev26,
status: !idx25[d.key]?'NEW in FY26':!idx26[d.key]?'No FY26 revenue':'Both'});
const gains = H.sortBy(deltas, d=>-d.d).slice(0,8).map(fmt);
const losses = H.sortBy(deltas, d=>d.d).slice(0,8).map(fmt);
const newCust = deltas.filter(d=>!idx25[d.key]), lostCust = deltas.filter(d=>!idx26[d.key]), both = deltas.filter(d=>idx25[d.key]&&idx26[d.key]);
const retention = {newCount:newCust.length, newRev26:+newCust.reduce((s,d)=>s+d.rev26,0).toFixed(2), lostCount:lostCust.length, lostRev25:+lostCust.reduce((s,d)=>s+d.rev25,0).toFixed(2),
retainedCount:both.length, retainedRev25:+both.reduce((s,d)=>s+d.rev25,0).toFixed(2), retainedRev26:+both.reduce((s,d)=>s+d.rev26,0).toFixed(2)};
// HHI decomposition: Δ(share²) × 10,000 per customer
const hhiDrivers = H.sortBy(deltas.map(d=>({name:d.name, dHHI: (Math.pow(d.share26/100,2)-Math.pow(d.share25/100,2))*10000})), d=>-Math.abs(d.dHHI)).slice(0,6).map(d=>({name:d.name,dHHI:+d.dHHI.toFixed(1)}));
delete A._all; delete B._all;
return {meta:meta.rowCounts, fy26:A, fy25:B, yoy:{entered, left, gains, losses, retention, hhiDrivers}};
Execution: 100 + 79 rows fetched (28 KB), worker time 17 ms, total 1.3 s. The like-for-like FY2025 window was computed with a trimmed variant of build() against the 72-row Jan 1 – Sep 4, 2025 result set. SVG path coordinates were generated in evalJs from the reducer's 20-point arrays (x = 60 + pct × 4.6, y = 400 − pct × 3.8) so no coordinate was hand-derived.