@withpotter/pages-admin 0.13.0-next.119 → 0.13.0-next.122
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/_ui/admin.css +7 -0
- package/dist/admin/_ui/stat-card.js +11 -2
- package/dist/admin/_ui/stat-card.js.map +1 -1
- package/dist/admin/admins/access-dialog.js +2 -7
- package/dist/admin/admins/access-dialog.js.map +1 -1
- package/dist/admin/admins/invite-dialog.js +0 -6
- package/dist/admin/admins/invite-dialog.js.map +1 -1
- package/dist/admin/hooks.js +0 -7
- package/dist/admin/hooks.js.map +1 -1
- package/dist/admin/revenue/earnings/earnings-colors.js +23 -0
- package/dist/admin/revenue/earnings/earnings-colors.js.map +1 -0
- package/dist/admin/revenue/earnings/earnings-legend.js +38 -0
- package/dist/admin/revenue/earnings/earnings-legend.js.map +1 -0
- package/dist/admin/revenue/earnings/earnings-summary.js +194 -0
- package/dist/admin/revenue/earnings/earnings-summary.js.map +1 -0
- package/dist/admin/revenue/earnings/earnings-table.js +141 -0
- package/dist/admin/revenue/earnings/earnings-table.js.map +1 -0
- package/dist/admin/revenue/earnings/earnings.module.css +118 -0
- package/dist/admin/revenue/earnings/use-earnings.js +27 -0
- package/dist/admin/revenue/earnings/use-earnings.js.map +1 -0
- package/dist/admin/revenue/health-panel.js +5 -4
- package/dist/admin/revenue/health-panel.js.map +1 -1
- package/dist/admin/revenue/ledger-model.js +22 -9
- package/dist/admin/revenue/ledger-model.js.map +1 -1
- package/dist/admin/revenue/method-cards.js +13 -5
- package/dist/admin/revenue/method-cards.js.map +1 -1
- package/dist/admin/revenue/revenue-kpis.js +1 -1
- package/dist/admin/revenue/revenue-kpis.js.map +1 -1
- package/dist/admin/revenue/revenue-overview.js +18 -1
- package/dist/admin/revenue/revenue-overview.js.map +1 -1
- package/dist/admin/revenue/revenue-workspace.js +2 -0
- package/dist/admin/revenue/revenue-workspace.js.map +1 -1
- package/dist/admin/revenue/subscriptions-view.js +27 -2
- package/dist/admin/revenue/subscriptions-view.js.map +1 -1
- package/dist/admin/roles/page.js +4 -14
- package/dist/admin/roles/page.js.map +1 -1
- package/dist/admin/roles/role-dialog.js +1 -2
- package/dist/admin/roles/role-dialog.js.map +1 -1
- package/package.json +4 -4
- package/dist/admin/roles/role-requests.js +0 -80
- package/dist/admin/roles/role-requests.js.map +0 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import { EmptyNote, TableRow, TableShell, Th } from "@withpotter/ui-kit";
|
|
6
|
+
import { adminEarningsApi } from "@withpotter/lib/admin-earnings-api";
|
|
7
|
+
import { formatMinor } from "@withpotter/lib/money-minor";
|
|
8
|
+
import { useMerchantOptions } from "../../hooks";
|
|
9
|
+
import { LoadFailed } from "../../audit/load-failed";
|
|
10
|
+
import { AdmLoading, FilterSearch, FilterSelect, Filters, Pagination } from "../../_ui";
|
|
11
|
+
import { DownloadButton } from "../download-button";
|
|
12
|
+
import { downloadName } from "../ledger-model";
|
|
13
|
+
import { useUrlFilters } from "../use-url-filters";
|
|
14
|
+
import { useSourceColors } from "./earnings-colors";
|
|
15
|
+
import { useEarningsTransactions } from "./use-earnings";
|
|
16
|
+
import styles from "../ledger.module.css";
|
|
17
|
+
import table from "../revenue.module.css";
|
|
18
|
+
const PAGE_SIZE = 25;
|
|
19
|
+
const FILTERS = { q: "", source: "", merchant: "" };
|
|
20
|
+
const SOURCES = [
|
|
21
|
+
{ value: "", label: "Every source" },
|
|
22
|
+
{ value: "payments", label: "Customer payments" },
|
|
23
|
+
{ value: "payouts", label: "Payouts (money sent out)" },
|
|
24
|
+
{ value: "settlements", label: "Settlements to bank" },
|
|
25
|
+
{ value: "cash", label: "Cash received" },
|
|
26
|
+
{ value: "plans", label: "Plan subscriptions (from wallet)" },
|
|
27
|
+
{ value: "transfers", label: "Wallet transfers" },
|
|
28
|
+
{ value: "fx", label: "Currency conversion" },
|
|
29
|
+
{ value: "lending", label: "Lending" },
|
|
30
|
+
{ value: "other", label: "Other fees" }
|
|
31
|
+
];
|
|
32
|
+
function EarningsTable({ startDate, endDate }) {
|
|
33
|
+
const colors = useSourceColors();
|
|
34
|
+
const { values, page, set } = useUrlFilters("er", FILTERS);
|
|
35
|
+
const [search, setSearch] = useState(values.q);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const timer = setTimeout(() => {
|
|
38
|
+
if (search.trim() !== values.q) set({ q: search.trim() });
|
|
39
|
+
}, 300);
|
|
40
|
+
return () => clearTimeout(timer);
|
|
41
|
+
}, [search]);
|
|
42
|
+
const filters = useMemo(
|
|
43
|
+
() => ({ startDate, endDate, source: values.source, merchantId: values.merchant, q: values.q, page, limit: PAGE_SIZE }),
|
|
44
|
+
[startDate, endDate, values, page]
|
|
45
|
+
);
|
|
46
|
+
const merchants = useMerchantOptions({ startDate, endDate });
|
|
47
|
+
const merchantChoices = [
|
|
48
|
+
{ value: "", label: "Every merchant" },
|
|
49
|
+
...(merchants.data?.merchants ?? []).map((one) => ({ value: one.id, label: one.name }))
|
|
50
|
+
];
|
|
51
|
+
const { data, isLoading, isError, refetch } = useEarningsTransactions(filters);
|
|
52
|
+
if (isError)
|
|
53
|
+
return /* @__PURE__ */ jsx(
|
|
54
|
+
LoadFailed,
|
|
55
|
+
{
|
|
56
|
+
message: "The earnings could not be loaded. This box may not have them yet.",
|
|
57
|
+
onRetry: () => void refetch()
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
if (isLoading || !data) return /* @__PURE__ */ jsx(AdmLoading, { label: "Loading what Olive earned\u2026" });
|
|
61
|
+
return /* @__PURE__ */ jsxs("div", { children: [
|
|
62
|
+
/* @__PURE__ */ jsx("p", { className: styles.sectionNote, children: "Every fee Olive earned in this period, and where it came from. Use the filters to narrow it, and download exactly what you see." }),
|
|
63
|
+
/* @__PURE__ */ jsxs("div", { className: styles.toolbar, children: [
|
|
64
|
+
/* @__PURE__ */ jsxs(Filters, { label: "Filter earnings", children: [
|
|
65
|
+
/* @__PURE__ */ jsx(
|
|
66
|
+
FilterSearch,
|
|
67
|
+
{
|
|
68
|
+
value: search,
|
|
69
|
+
onChange: setSearch,
|
|
70
|
+
placeholder: "Search a merchant, what it was for, or a source",
|
|
71
|
+
label: "Search earnings"
|
|
72
|
+
}
|
|
73
|
+
),
|
|
74
|
+
/* @__PURE__ */ jsx(FilterSelect, { value: values.source, onChange: (source) => set({ source }), options: SOURCES, label: "Source" }),
|
|
75
|
+
/* @__PURE__ */ jsx(
|
|
76
|
+
FilterSelect,
|
|
77
|
+
{
|
|
78
|
+
value: values.merchant,
|
|
79
|
+
onChange: (merchant) => set({ merchant }),
|
|
80
|
+
options: merchantChoices,
|
|
81
|
+
label: "Merchant",
|
|
82
|
+
searchable: true
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
] }),
|
|
86
|
+
/* @__PURE__ */ jsx(
|
|
87
|
+
DownloadButton,
|
|
88
|
+
{
|
|
89
|
+
filename: downloadName("olive-earnings", startDate, endDate),
|
|
90
|
+
load: () => adminEarningsApi.transactionsCsv({ ...filters, page: void 0, limit: void 0 })
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
] }),
|
|
94
|
+
data.rows.length === 0 ? /* @__PURE__ */ jsx(EmptyNote, { className: table.empty, children: "No earnings match. Try a wider date range or clear a filter." }) : /* @__PURE__ */ jsx(
|
|
95
|
+
TableShell,
|
|
96
|
+
{
|
|
97
|
+
minWidth: 960,
|
|
98
|
+
density: "compact",
|
|
99
|
+
head: /* @__PURE__ */ jsxs("tr", { children: [
|
|
100
|
+
/* @__PURE__ */ jsx(Th, { children: "When" }),
|
|
101
|
+
/* @__PURE__ */ jsx(Th, { children: "Source" }),
|
|
102
|
+
/* @__PURE__ */ jsx(Th, { children: "Merchant" }),
|
|
103
|
+
/* @__PURE__ */ jsx(Th, { children: "What it was for" }),
|
|
104
|
+
/* @__PURE__ */ jsx(Th, { align: "right", children: "Olive earned" }),
|
|
105
|
+
/* @__PURE__ */ jsx(Th, { align: "right", children: "Processor cost" }),
|
|
106
|
+
/* @__PURE__ */ jsx(Th, { align: "right", children: "Kept" })
|
|
107
|
+
] }),
|
|
108
|
+
children: data.rows.map((row) => /* @__PURE__ */ jsx(EarningRow, { row, color: colors[row.source] }, `${row.id}-${row.currency}`))
|
|
109
|
+
}
|
|
110
|
+
),
|
|
111
|
+
/* @__PURE__ */ jsx(
|
|
112
|
+
Pagination,
|
|
113
|
+
{
|
|
114
|
+
page,
|
|
115
|
+
totalPages: data.totalPages,
|
|
116
|
+
total: data.total,
|
|
117
|
+
noun: data.total === 1 ? "earning" : "earnings",
|
|
118
|
+
onPage: (next) => set({ page: next })
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
] });
|
|
122
|
+
}
|
|
123
|
+
function EarningRow({ row, color }) {
|
|
124
|
+
const money = (minor) => formatMinor(minor, row.currency);
|
|
125
|
+
return /* @__PURE__ */ jsxs(TableRow, { children: [
|
|
126
|
+
/* @__PURE__ */ jsx("td", { className: table.num, children: new Date(row.at).toLocaleString(void 0, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) }),
|
|
127
|
+
/* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsxs("span", { style: { display: "inline-flex", alignItems: "center", gap: 8 }, children: [
|
|
128
|
+
/* @__PURE__ */ jsx("span", { "aria-hidden": true, style: { width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 } }),
|
|
129
|
+
row.sourceLabel
|
|
130
|
+
] }) }),
|
|
131
|
+
/* @__PURE__ */ jsx("td", { className: table.name, children: row.merchant.id && row.merchant.name ? /* @__PURE__ */ jsx(Link, { href: `/admin/tenants/${row.merchant.id}`, children: row.merchant.name }) : "No merchant recorded" }),
|
|
132
|
+
/* @__PURE__ */ jsx("td", { children: row.description }),
|
|
133
|
+
/* @__PURE__ */ jsx("td", { className: table.numStrong, children: money(row.feeMinor) }),
|
|
134
|
+
/* @__PURE__ */ jsx("td", { className: table.num, children: row.providerMinor === "0" ? "\u2013" : money(row.providerMinor) }),
|
|
135
|
+
/* @__PURE__ */ jsx("td", { className: table.num, children: money(row.netMinor) })
|
|
136
|
+
] });
|
|
137
|
+
}
|
|
138
|
+
export {
|
|
139
|
+
EarningsTable
|
|
140
|
+
};
|
|
141
|
+
//# sourceMappingURL=earnings-table.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../src/admin/revenue/earnings/earnings-table.tsx"],"sourcesContent":["'use client';\n\nimport { useEffect, useMemo, useState } from 'react';\nimport Link from 'next/link';\nimport { EmptyNote, TableRow, TableShell, Th } from '@withpotter/ui-kit';\nimport { adminEarningsApi, type EarningsRow } from '@withpotter/lib/admin-earnings-api';\nimport { formatMinor } from '@withpotter/lib/money-minor';\nimport { useMerchantOptions } from '../../hooks';\nimport { LoadFailed } from '../../audit/load-failed';\nimport { AdmLoading, FilterSearch, FilterSelect, Filters, Pagination } from '../../_ui';\nimport { DownloadButton } from '../download-button';\nimport { downloadName } from '../ledger-model';\nimport { useUrlFilters } from '../use-url-filters';\nimport { useSourceColors } from './earnings-colors';\nimport { useEarningsTransactions } from './use-earnings';\nimport styles from '../ledger.module.css';\nimport table from '../revenue.module.css';\n\nconst PAGE_SIZE = 25;\nconst FILTERS = { q: '', source: '', merchant: '' };\n\nconst SOURCES = [\n { value: '', label: 'Every source' },\n { value: 'payments', label: 'Customer payments' },\n { value: 'payouts', label: 'Payouts (money sent out)' },\n { value: 'settlements', label: 'Settlements to bank' },\n { value: 'cash', label: 'Cash received' },\n { value: 'plans', label: 'Plan subscriptions (from wallet)' },\n { value: 'transfers', label: 'Wallet transfers' },\n { value: 'fx', label: 'Currency conversion' },\n { value: 'lending', label: 'Lending' },\n { value: 'other', label: 'Other fees' },\n];\n\n/**\n * Every fee Olive earned, one row each: what it came from, which merchant, what Olive kept and what the processor\n * charged. The filters live in the page address so a view can be shared. Plans paid another way than from a wallet are\n * on the Subscriptions tab.\n */\nexport function EarningsTable({ startDate, endDate }: { startDate?: string; endDate?: string }) {\n const colors = useSourceColors();\n const { values, page, set } = useUrlFilters('er', FILTERS);\n const [search, setSearch] = useState(values.q);\n\n // Typing waits a moment, so a search is one request and not one per key.\n useEffect(() => {\n const timer = setTimeout(() => {\n if (search.trim() !== values.q) set({ q: search.trim() });\n }, 300);\n\n return () => clearTimeout(timer);\n // Only a change the person typed should write to the address.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [search]);\n\n const filters = useMemo(\n () => ({ startDate, endDate, source: values.source, merchantId: values.merchant, q: values.q, page, limit: PAGE_SIZE }),\n [startDate, endDate, values, page],\n );\n const merchants = useMerchantOptions({ startDate, endDate });\n const merchantChoices = [\n { value: '', label: 'Every merchant' },\n ...(merchants.data?.merchants ?? []).map((one) => ({ value: one.id, label: one.name })),\n ];\n const { data, isLoading, isError, refetch } = useEarningsTransactions(filters);\n\n if (isError)\n return (\n <LoadFailed\n message=\"The earnings could not be loaded. This box may not have them yet.\"\n onRetry={() => void refetch()}\n />\n );\n\n if (isLoading || !data) return <AdmLoading label=\"Loading what Olive earned…\" />;\n\n return (\n <div>\n <p className={styles.sectionNote}>\n Every fee Olive earned in this period, and where it came from. Use the filters to narrow it, and download exactly\n what you see.\n </p>\n <div className={styles.toolbar}>\n <Filters label=\"Filter earnings\">\n <FilterSearch\n value={search}\n onChange={setSearch}\n placeholder=\"Search a merchant, what it was for, or a source\"\n label=\"Search earnings\"\n />\n <FilterSelect value={values.source} onChange={(source) => set({ source })} options={SOURCES} label=\"Source\" />\n <FilterSelect\n value={values.merchant}\n onChange={(merchant) => set({ merchant })}\n options={merchantChoices}\n label=\"Merchant\"\n searchable\n />\n </Filters>\n <DownloadButton\n filename={downloadName('olive-earnings', startDate, endDate)}\n load={() => adminEarningsApi.transactionsCsv({ ...filters, page: undefined, limit: undefined })}\n />\n </div>\n\n {data.rows.length === 0 ? (\n <EmptyNote className={table.empty}>No earnings match. Try a wider date range or clear a filter.</EmptyNote>\n ) : (\n <TableShell\n minWidth={960}\n density=\"compact\"\n head={\n <tr>\n <Th>When</Th>\n <Th>Source</Th>\n <Th>Merchant</Th>\n <Th>What it was for</Th>\n <Th align=\"right\">Olive earned</Th>\n <Th align=\"right\">Processor cost</Th>\n <Th align=\"right\">Kept</Th>\n </tr>\n }\n >\n {data.rows.map((row) => (\n <EarningRow key={`${row.id}-${row.currency}`} row={row} color={colors[row.source]} />\n ))}\n </TableShell>\n )}\n\n <Pagination\n page={page}\n totalPages={data.totalPages}\n total={data.total}\n noun={data.total === 1 ? 'earning' : 'earnings'}\n onPage={(next) => set({ page: next })}\n />\n </div>\n );\n}\n\nfunction EarningRow({ row, color }: { row: EarningsRow; color: string }) {\n const money = (minor: string) => formatMinor(minor, row.currency);\n\n return (\n <TableRow>\n <td className={table.num}>\n {new Date(row.at).toLocaleString(undefined, { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}\n </td>\n <td>\n <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>\n <span aria-hidden style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />\n {row.sourceLabel}\n </span>\n </td>\n <td className={table.name}>\n {row.merchant.id && row.merchant.name ? (\n <Link href={`/admin/tenants/${row.merchant.id}`}>{row.merchant.name}</Link>\n ) : (\n 'No merchant recorded'\n )}\n </td>\n <td>{row.description}</td>\n <td className={table.numStrong}>{money(row.feeMinor)}</td>\n <td className={table.num}>{row.providerMinor === '0' ? '–' : money(row.providerMinor)}</td>\n <td className={table.num}>{money(row.netMinor)}</td>\n </TableRow>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/* Earnings: a pie with its key, and the ranked list of where fees come from. */
|
|
2
|
+
|
|
3
|
+
.pie {
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-wrap: wrap;
|
|
6
|
+
align-items: center;
|
|
7
|
+
justify-content: center;
|
|
8
|
+
gap: 20px;
|
|
9
|
+
padding: 4px 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.legend {
|
|
13
|
+
display: grid;
|
|
14
|
+
gap: 10px;
|
|
15
|
+
margin: 0;
|
|
16
|
+
padding: 0;
|
|
17
|
+
list-style: none;
|
|
18
|
+
flex: 1 1 200px;
|
|
19
|
+
min-width: 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.legendRow {
|
|
23
|
+
display: grid;
|
|
24
|
+
grid-template-columns: 12px minmax(0, 1fr) auto;
|
|
25
|
+
align-items: center;
|
|
26
|
+
gap: 10px;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.swatch {
|
|
30
|
+
width: 12px;
|
|
31
|
+
height: 12px;
|
|
32
|
+
border-radius: 4px;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.legendText {
|
|
36
|
+
display: grid;
|
|
37
|
+
min-width: 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.legendLabel {
|
|
41
|
+
font-size: 13px;
|
|
42
|
+
line-height: 18px;
|
|
43
|
+
color: var(--text-primary);
|
|
44
|
+
overflow: hidden;
|
|
45
|
+
text-overflow: ellipsis;
|
|
46
|
+
white-space: nowrap;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.legendDetail {
|
|
50
|
+
font-size: 12px;
|
|
51
|
+
line-height: 16px;
|
|
52
|
+
color: var(--text-tertiary);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.legendNumbers {
|
|
56
|
+
display: grid;
|
|
57
|
+
justify-items: end;
|
|
58
|
+
font-size: 12px;
|
|
59
|
+
line-height: 16px;
|
|
60
|
+
color: var(--text-tertiary);
|
|
61
|
+
font-variant-numeric: tabular-nums;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.legendNumbers strong {
|
|
65
|
+
font-size: 13px;
|
|
66
|
+
font-weight: 500;
|
|
67
|
+
color: var(--text-primary);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.rankList {
|
|
71
|
+
display: grid;
|
|
72
|
+
gap: 14px;
|
|
73
|
+
margin: 0;
|
|
74
|
+
padding: 0;
|
|
75
|
+
list-style: none;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.rankRow {
|
|
79
|
+
display: grid;
|
|
80
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
81
|
+
gap: 4px 16px;
|
|
82
|
+
align-items: baseline;
|
|
83
|
+
font-size: 14px;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.rankName {
|
|
87
|
+
min-width: 0;
|
|
88
|
+
overflow: hidden;
|
|
89
|
+
text-overflow: ellipsis;
|
|
90
|
+
white-space: nowrap;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.rankSub {
|
|
94
|
+
grid-column: 1;
|
|
95
|
+
font-size: 12px;
|
|
96
|
+
color: var(--text-tertiary);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.rankAmount {
|
|
100
|
+
grid-row: 1;
|
|
101
|
+
grid-column: 2;
|
|
102
|
+
font-weight: 500;
|
|
103
|
+
font-variant-numeric: tabular-nums;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.rankBar {
|
|
107
|
+
grid-column: 1 / -1;
|
|
108
|
+
height: 6px;
|
|
109
|
+
border-radius: 999px;
|
|
110
|
+
background: var(--bg-subtle);
|
|
111
|
+
overflow: hidden;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.rankFill {
|
|
115
|
+
display: block;
|
|
116
|
+
height: 100%;
|
|
117
|
+
border-radius: 999px;
|
|
118
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
|
3
|
+
import {
|
|
4
|
+
adminEarningsApi
|
|
5
|
+
} from "@withpotter/lib/admin-earnings-api";
|
|
6
|
+
function useEarnings(range) {
|
|
7
|
+
return useQuery({
|
|
8
|
+
queryKey: ["platform-admin", "earnings", "summary", range],
|
|
9
|
+
queryFn: () => adminEarningsApi.summary(range),
|
|
10
|
+
retry: false,
|
|
11
|
+
staleTime: 6e4
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
function useEarningsTransactions(filters) {
|
|
15
|
+
return useQuery({
|
|
16
|
+
queryKey: ["platform-admin", "earnings", "transactions", filters],
|
|
17
|
+
queryFn: () => adminEarningsApi.transactions(filters),
|
|
18
|
+
retry: false,
|
|
19
|
+
staleTime: 3e4,
|
|
20
|
+
placeholderData: keepPreviousData
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export {
|
|
24
|
+
useEarnings,
|
|
25
|
+
useEarningsTransactions
|
|
26
|
+
};
|
|
27
|
+
//# sourceMappingURL=use-earnings.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../src/admin/revenue/earnings/use-earnings.ts"],"sourcesContent":["'use client';\n\nimport { keepPreviousData, useQuery } from '@tanstack/react-query';\nimport {\n adminEarningsApi,\n type EarningsFilters,\n} from '@withpotter/lib/admin-earnings-api';\n\n/** What Olive earns in the window, by source, day and merchant. A box without the route answers 404: not available here. */\nexport function useEarnings(range: { startDate?: string; endDate?: string }) {\n return useQuery({\n queryKey: ['platform-admin', 'earnings', 'summary', range],\n queryFn: () => adminEarningsApi.summary(range),\n retry: false,\n staleTime: 60_000,\n });\n}\n\n/** The fee transactions behind the earnings, a page at a time. */\nexport function useEarningsTransactions(filters: EarningsFilters) {\n return useQuery({\n queryKey: ['platform-admin', 'earnings', 'transactions', filters],\n queryFn: () => adminEarningsApi.transactions(filters),\n retry: false,\n staleTime: 30_000,\n placeholderData: keepPreviousData,\n });\n}\n"],"mappings":";AAEA,SAAS,kBAAkB,gBAAgB;AAC3C;AAAA,EACE;AAAA,OAEK;AAGA,SAAS,YAAY,OAAiD;AAC3E,SAAO,SAAS;AAAA,IACd,UAAU,CAAC,kBAAkB,YAAY,WAAW,KAAK;AAAA,IACzD,SAAS,MAAM,iBAAiB,QAAQ,KAAK;AAAA,IAC7C,OAAO;AAAA,IACP,WAAW;AAAA,EACb,CAAC;AACH;AAGO,SAAS,wBAAwB,SAA0B;AAChE,SAAO,SAAS;AAAA,IACd,UAAU,CAAC,kBAAkB,YAAY,gBAAgB,OAAO;AAAA,IAChE,SAAS,MAAM,iBAAiB,aAAa,OAAO;AAAA,IACpD,OAAO;AAAA,IACP,WAAW;AAAA,IACX,iBAAiB;AAAA,EACnB,CAAC;AACH;","names":[]}
|
|
@@ -2,6 +2,7 @@ import { jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { AdmLoading, Panel } from "../_ui";
|
|
3
3
|
import { FigureList } from "./figure-list";
|
|
4
4
|
const integer = new Intl.NumberFormat("en-US");
|
|
5
|
+
const pct = (value) => value == null ? "\u2013" : `${value}%`;
|
|
5
6
|
function HealthPanel({
|
|
6
7
|
health,
|
|
7
8
|
money,
|
|
@@ -21,12 +22,12 @@ function HealthPanel({
|
|
|
21
22
|
{
|
|
22
23
|
label: "Plan income kept",
|
|
23
24
|
note: "Share of the plan income at the start that is still being paid",
|
|
24
|
-
value:
|
|
25
|
+
value: pct(health.grossRevenueRetentionPct)
|
|
25
26
|
},
|
|
26
27
|
{
|
|
27
28
|
label: "Plan income lost",
|
|
28
29
|
note: "Share of plan income lost to merchants who left",
|
|
29
|
-
value:
|
|
30
|
+
value: pct(health.grossMrrChurnPct),
|
|
30
31
|
tone: health.grossMrrChurnPct > 0 ? "danger" : "default"
|
|
31
32
|
},
|
|
32
33
|
{
|
|
@@ -41,13 +42,13 @@ function HealthPanel({
|
|
|
41
42
|
{
|
|
42
43
|
label: "Paying merchants lost",
|
|
43
44
|
note: "Share of paying merchants who stopped paying",
|
|
44
|
-
value:
|
|
45
|
+
value: pct(health.logoChurnPct),
|
|
45
46
|
tone: health.logoChurnPct > 0 ? "danger" : "default"
|
|
46
47
|
},
|
|
47
48
|
{
|
|
48
49
|
label: "Reliance on the top 10",
|
|
49
50
|
note: "Share of merchant sales that comes from the 10 biggest merchants",
|
|
50
|
-
value:
|
|
51
|
+
value: pct(health.gmvConcentrationTop10Pct),
|
|
51
52
|
tone: health.gmvConcentrationTop10Pct > 50 ? "danger" : "default"
|
|
52
53
|
}
|
|
53
54
|
];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/admin/revenue/health-panel.tsx"],"sourcesContent":["import type { PlatformCockpit } from '@withpotter/lib/admin-api';\nimport { AdmLoading, Panel } from '../_ui';\nimport { FigureList, type Figure } from './figure-list';\n\nconst integer = new Intl.NumberFormat('en-US');\n\n/** MRR movement, logo churn and concentration risk — how healthy the base is. */\nexport function HealthPanel({\n health,\n money,\n caption,\n}: {\n health: PlatformCockpit['health'] | undefined;\n money: (value: number) => string;\n caption: string;\n}) {\n if (!health) {\n return (\n <Panel title=\"How plan income is changing\" caption={caption}>\n <AdmLoading label=\"Loading revenue health…\" size=\"sm\" />\n </Panel>\n );\n }\n\n const rows: Figure[] = [\n {\n label: 'Plan income change',\n note: 'Gained from new merchants, less lost from merchants who left',\n value: money(health.netNewMrr),\n hint: `+${money(health.newMrr)} gained · −${money(health.churnedMrr)} lost`,\n tone: health.netNewMrr < 0 ? 'danger' : 'default',\n },\n {\n label: 'Plan income kept',\n note: 'Share of the plan income at the start that is still being paid',\n value:
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/revenue/health-panel.tsx"],"sourcesContent":["import type { PlatformCockpit } from '@withpotter/lib/admin-api';\nimport { AdmLoading, Panel } from '../_ui';\nimport { FigureList, type Figure } from './figure-list';\n\nconst integer = new Intl.NumberFormat('en-US');\n\n/** A share, or a dash when there is no base to take it of (the api sends null then, not 0). */\nconst pct = (value: number | null | undefined) => (value == null ? '–' : `${value}%`);\n\n/** MRR movement, logo churn and concentration risk — how healthy the base is. */\nexport function HealthPanel({\n health,\n money,\n caption,\n}: {\n health: PlatformCockpit['health'] | undefined;\n money: (value: number) => string;\n caption: string;\n}) {\n if (!health) {\n return (\n <Panel title=\"How plan income is changing\" caption={caption}>\n <AdmLoading label=\"Loading revenue health…\" size=\"sm\" />\n </Panel>\n );\n }\n\n const rows: Figure[] = [\n {\n label: 'Plan income change',\n note: 'Gained from new merchants, less lost from merchants who left',\n value: money(health.netNewMrr),\n hint: `+${money(health.newMrr)} gained · −${money(health.churnedMrr)} lost`,\n tone: health.netNewMrr < 0 ? 'danger' : 'default',\n },\n {\n label: 'Plan income kept',\n note: 'Share of the plan income at the start that is still being paid',\n value: pct(health.grossRevenueRetentionPct),\n },\n {\n label: 'Plan income lost',\n note: 'Share of plan income lost to merchants who left',\n value: pct(health.grossMrrChurnPct),\n tone: health.grossMrrChurnPct > 0 ? 'danger' : 'default',\n },\n {\n label: 'Paying merchants gained',\n note: 'Merchants who started paying, less merchants who stopped',\n value: integer.format(health.netNewLogos),\n hint: `+${integer.format(health.newLogos)} joined · −${integer.format(\n health.churnedLogos,\n )} left`,\n tone: health.netNewLogos < 0 ? 'danger' : 'default',\n },\n {\n label: 'Paying merchants lost',\n note: 'Share of paying merchants who stopped paying',\n value: pct(health.logoChurnPct),\n tone: health.logoChurnPct > 0 ? 'danger' : 'default',\n },\n {\n label: 'Reliance on the top 10',\n note: 'Share of merchant sales that comes from the 10 biggest merchants',\n value: pct(health.gmvConcentrationTop10Pct),\n tone: health.gmvConcentrationTop10Pct > 50 ? 'danger' : 'default',\n },\n ];\n\n return (\n <Panel title=\"How plan income is changing\" caption={caption}>\n <FigureList rows={rows} />\n </Panel>\n );\n}\n"],"mappings":"AAsBQ;AArBR,SAAS,YAAY,aAAa;AAClC,SAAS,kBAA+B;AAExC,MAAM,UAAU,IAAI,KAAK,aAAa,OAAO;AAG7C,MAAM,MAAM,CAAC,UAAsC,SAAS,OAAO,WAAM,GAAG,KAAK;AAG1E,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AACF,GAIG;AACD,MAAI,CAAC,QAAQ;AACX,WACE,oBAAC,SAAM,OAAM,+BAA8B,SACzC,8BAAC,cAAW,OAAM,gCAA0B,MAAK,MAAK,GACxD;AAAA,EAEJ;AAEA,QAAM,OAAiB;AAAA,IACrB;AAAA,MACE,OAAO;AAAA,MACP,MAAM;AAAA,MACN,OAAO,MAAM,OAAO,SAAS;AAAA,MAC7B,MAAM,IAAI,MAAM,OAAO,MAAM,CAAC,sBAAc,MAAM,OAAO,UAAU,CAAC;AAAA,MACpE,MAAM,OAAO,YAAY,IAAI,WAAW;AAAA,IAC1C;AAAA,IACA;AAAA,MACE,OAAO;AAAA,MACP,MAAM;AAAA,MACN,OAAO,IAAI,OAAO,wBAAwB;AAAA,IAC5C;AAAA,IACA;AAAA,MACE,OAAO;AAAA,MACP,MAAM;AAAA,MACN,OAAO,IAAI,OAAO,gBAAgB;AAAA,MAClC,MAAM,OAAO,mBAAmB,IAAI,WAAW;AAAA,IACjD;AAAA,IACA;AAAA,MACE,OAAO;AAAA,MACP,MAAM;AAAA,MACN,OAAO,QAAQ,OAAO,OAAO,WAAW;AAAA,MACxC,MAAM,IAAI,QAAQ,OAAO,OAAO,QAAQ,CAAC,sBAAc,QAAQ;AAAA,QAC7D,OAAO;AAAA,MACT,CAAC;AAAA,MACD,MAAM,OAAO,cAAc,IAAI,WAAW;AAAA,IAC5C;AAAA,IACA;AAAA,MACE,OAAO;AAAA,MACP,MAAM;AAAA,MACN,OAAO,IAAI,OAAO,YAAY;AAAA,MAC9B,MAAM,OAAO,eAAe,IAAI,WAAW;AAAA,IAC7C;AAAA,IACA;AAAA,MACE,OAAO;AAAA,MACP,MAAM;AAAA,MACN,OAAO,IAAI,OAAO,wBAAwB;AAAA,MAC1C,MAAM,OAAO,2BAA2B,KAAK,WAAW;AAAA,IAC1D;AAAA,EACF;AAEA,SACE,oBAAC,SAAM,OAAM,+BAA8B,SACzC,8BAAC,cAAW,MAAY,GAC1B;AAEJ;","names":[]}
|
|
@@ -9,22 +9,26 @@ function pctChange(now, before) {
|
|
|
9
9
|
return Number(((a - b) / Math.abs(b) * 100).toFixed(1));
|
|
10
10
|
}
|
|
11
11
|
const METHOD_ORDER = ["card", "ussd", "transfer", "opay"];
|
|
12
|
-
function methodCards(now, before, currency) {
|
|
12
|
+
function methodCards(now, before, currency, options = {}) {
|
|
13
13
|
const pick = (rows) => (rows ?? []).filter((r) => r.currency === currency);
|
|
14
14
|
const current = pick(now);
|
|
15
15
|
const total = current.reduce((sum, r) => sum + BigInt(r.amountMinor), BigInt(0));
|
|
16
|
-
const labels = {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
const labels = {
|
|
17
|
+
card: "Card",
|
|
18
|
+
ussd: "USSD",
|
|
19
|
+
transfer: "Bank transfer",
|
|
20
|
+
opay: "Opay",
|
|
21
|
+
...options.labels
|
|
22
|
+
};
|
|
23
|
+
const order = options.order ?? METHOD_ORDER;
|
|
24
|
+
const keys = [...order, ...current.map((r) => r.channel.key).filter((k) => !order.includes(k))];
|
|
21
25
|
return [...new Set(keys)].map((key) => {
|
|
22
26
|
const mine = current.find((r) => r.channel.key === key);
|
|
23
27
|
const prior = pick(before).find((r) => r.channel.key === key);
|
|
24
28
|
const amount = BigInt(mine?.amountMinor ?? "0");
|
|
25
29
|
return {
|
|
26
30
|
key,
|
|
27
|
-
label: mine?.channel.label ?? labels[key] ?? key,
|
|
31
|
+
label: options.labels?.[key] ?? mine?.channel.label ?? labels[key] ?? key,
|
|
28
32
|
payments: mine?.count ?? 0,
|
|
29
33
|
amountMinor: amount.toString(),
|
|
30
34
|
share: total > BigInt(0) ? Number((Number(amount) / Number(total) * 100).toFixed(1)) : 0,
|
|
@@ -60,8 +64,8 @@ const PURPOSE_WORDS = {
|
|
|
60
64
|
mandate_setup: "Card saved"
|
|
61
65
|
};
|
|
62
66
|
const SUBSCRIPTION_METHOD_WORDS = {
|
|
63
|
-
gateway: "
|
|
64
|
-
wallet: "
|
|
67
|
+
gateway: "Olive payment gateway",
|
|
68
|
+
wallet: "Box wallet",
|
|
65
69
|
manual: "Recorded by hand"
|
|
66
70
|
};
|
|
67
71
|
const word = (words, key) => words[key] ?? key.replace(/_/g, " ");
|
|
@@ -75,6 +79,14 @@ function plainAmount(minor) {
|
|
|
75
79
|
const digits = text.replace("-", "").padStart(3, "0");
|
|
76
80
|
return `${negative ? "-" : ""}${digits.slice(0, -2)}.${digits.slice(-2)}`;
|
|
77
81
|
}
|
|
82
|
+
function planPaymentTotals(byMethod, currency) {
|
|
83
|
+
return byMethod.map((row) => ({
|
|
84
|
+
channel: { key: row.method, label: word(SUBSCRIPTION_METHOD_WORDS, row.method) },
|
|
85
|
+
currency,
|
|
86
|
+
count: row.payments,
|
|
87
|
+
amountMinor: String(Math.round(row.amount * 100))
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
78
90
|
export {
|
|
79
91
|
METHOD_ORDER,
|
|
80
92
|
PURPOSE_WORDS,
|
|
@@ -86,6 +98,7 @@ export {
|
|
|
86
98
|
minorToMoney,
|
|
87
99
|
pctChange,
|
|
88
100
|
plainAmount,
|
|
101
|
+
planPaymentTotals,
|
|
89
102
|
word
|
|
90
103
|
};
|
|
91
104
|
//# sourceMappingURL=ledger-model.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/admin/revenue/ledger-model.ts"],"sourcesContent":["import type { GatewayChannelTotal } from '@withpotter/lib/admin-api';\nimport { formatMoney } from '../_ui/money-view';\n\n/** A kobo amount (a string, so no float is involved on the server) shown as naira. */\nexport function minorToMoney(minor: string | number, currency: string): string {\n return formatMoney(Number(minor) / 100, currency, 2);\n}\n\n/** Percent change from before to now; null when there was nothing before to compare with. */\nexport function pctChange(\n now: string | number | null | undefined,\n before: string | number | null | undefined,\n): number | null {\n const a = Number(now ?? 0);\n const b = Number(before ?? 0);\n if (b === 0) return null;\n\n return Number((((a - b) / Math.abs(b)) * 100).toFixed(1));\n}\n\n/** The four ways a customer can pay at the checkout, in the order the checkout shows them. */\nexport const METHOD_ORDER = ['card', 'ussd', 'transfer', 'opay'] as const;\n\nexport interface MethodCardRow {\n key: string;\n label: string;\n payments: number;\n amountMinor: string;\n /** Share of all money received in this currency, in percent. */\n share: number;\n /** Change in amount since the period before; null when there is no comparison. */\n change: number | null;\n}\n\n/**\n * One card per way of paying, for one currency. The four checkout methods are always there, with zero when nobody\n * used one, so a quiet method is visible rather than missing. Any other way found in the data follows.\n */\nexport function methodCards(\n now: readonly GatewayChannelTotal[],\n before: readonly GatewayChannelTotal[] | null,\n currency: string,\n): MethodCardRow[] {\n const pick = (rows: readonly GatewayChannelTotal[] | null) => (rows ?? []).filter((r) => r.currency === currency);\n const current = pick(now);\n const total = current.reduce((sum, r) => sum + BigInt(r.amountMinor), BigInt(0));\n const labels: Record<string, string> = {
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/revenue/ledger-model.ts"],"sourcesContent":["import type { GatewayChannelTotal } from '@withpotter/lib/admin-api';\nimport { formatMoney } from '../_ui/money-view';\n\n/** A kobo amount (a string, so no float is involved on the server) shown as naira. */\nexport function minorToMoney(minor: string | number, currency: string): string {\n return formatMoney(Number(minor) / 100, currency, 2);\n}\n\n/** Percent change from before to now; null when there was nothing before to compare with. */\nexport function pctChange(\n now: string | number | null | undefined,\n before: string | number | null | undefined,\n): number | null {\n const a = Number(now ?? 0);\n const b = Number(before ?? 0);\n if (b === 0) return null;\n\n return Number((((a - b) / Math.abs(b)) * 100).toFixed(1));\n}\n\n/** The four ways a customer can pay at the checkout, in the order the checkout shows them. */\nexport const METHOD_ORDER = ['card', 'ussd', 'transfer', 'opay'] as const;\n\nexport interface MethodCardRow {\n key: string;\n label: string;\n payments: number;\n amountMinor: string;\n /** Share of all money received in this currency, in percent. */\n share: number;\n /** Change in amount since the period before; null when there is no comparison. */\n change: number | null;\n}\n\n/**\n * One card per way of paying, for one currency. The four checkout methods are always there, with zero when nobody\n * used one, so a quiet method is visible rather than missing. Any other way found in the data follows.\n */\nexport function methodCards(\n now: readonly GatewayChannelTotal[],\n before: readonly GatewayChannelTotal[] | null,\n currency: string,\n /** Other ways of paying and their names, for money that does not come through checkout (plan payments). */\n options: { order?: readonly string[]; labels?: Record<string, string> } = {},\n): MethodCardRow[] {\n const pick = (rows: readonly GatewayChannelTotal[] | null) => (rows ?? []).filter((r) => r.currency === currency);\n const current = pick(now);\n const total = current.reduce((sum, r) => sum + BigInt(r.amountMinor), BigInt(0));\n const labels: Record<string, string> = {\n card: 'Card',\n ussd: 'USSD',\n transfer: 'Bank transfer',\n opay: 'Opay',\n ...options.labels,\n };\n const order: readonly string[] = options.order ?? METHOD_ORDER;\n\n const keys = [...order, ...current.map((r) => r.channel.key).filter((k) => !order.includes(k))];\n\n return [...new Set(keys)].map((key) => {\n const mine = current.find((r) => r.channel.key === key);\n const prior = pick(before).find((r) => r.channel.key === key);\n const amount = BigInt(mine?.amountMinor ?? '0');\n\n return {\n key,\n label: options.labels?.[key] ?? mine?.channel.label ?? labels[key] ?? key,\n payments: mine?.count ?? 0,\n amountMinor: amount.toString(),\n share: total > BigInt(0) ? Number(((Number(amount) / Number(total)) * 100).toFixed(1)) : 0,\n change: before ? pctChange(amount.toString(), prior?.amountMinor ?? '0') : null,\n };\n });\n}\n\n/** Words an accountant would use, not the database's. */\nexport const STATUS_WORDS: Record<string, string> = {\n success: 'Paid',\n pending: 'Waiting',\n failed: 'Failed',\n refunded: 'Refunded',\n cancelled: 'Cancelled',\n abandoned: 'Abandoned',\n succeeded: 'Paid',\n};\n\nexport const TYPE_WORDS: Record<string, string> = {\n order: 'Order',\n invoice: 'Invoice',\n booking: 'Booking',\n membership: 'Membership',\n subscription: 'Subscription',\n deposit: 'Deposit',\n tip: 'Tip',\n refund: 'Refund',\n payout: 'Payout',\n};\n\nexport const PURPOSE_WORDS: Record<string, string> = {\n select_plan: 'First payment',\n upgrade: 'Upgrade',\n renewal: 'Renewal',\n reactivation: 'Came back',\n mandate_setup: 'Card saved',\n};\n\nexport const SUBSCRIPTION_METHOD_WORDS: Record<string, string> = {\n gateway: 'Olive payment gateway',\n wallet: 'Box wallet',\n manual: 'Recorded by hand',\n};\n\nexport const word = (words: Record<string, string>, key: string): string => words[key] ?? key.replace(/_/g, ' ');\n\n/** The dates in a download's file name, so a file says what it holds. */\nexport function downloadName(prefix: string, startDate?: string, endDate?: string): string {\n const day = (iso?: string) => (iso ? iso.slice(0, 10) : 'all');\n\n return `${prefix}_${day(startDate)}_${day(endDate)}.csv`;\n}\n\n/** Kobo as plain text a spreadsheet reads (\"1234.50\"), with no currency sign or commas. */\nexport function plainAmount(minor: string | number): string {\n const text = String(minor);\n const negative = text.startsWith('-');\n const digits = text.replace('-', '').padStart(3, '0');\n\n return `${negative ? '-' : ''}${digits.slice(0, -2)}.${digits.slice(-2)}`;\n}\n\n/**\n * The plan payments by way of paying, as the channel totals the method cards read. The api sends naira with kobo\n * (whole kobo after the point); they are turned back into whole kobo here, never rounded to naira.\n */\nexport function planPaymentTotals(\n byMethod: ReadonlyArray<{ method: string; payments: number; amount: number }>,\n currency: string,\n): GatewayChannelTotal[] {\n return byMethod.map((row) => ({\n channel: { key: row.method, label: word(SUBSCRIPTION_METHOD_WORDS, row.method) },\n currency,\n count: row.payments,\n amountMinor: String(Math.round(row.amount * 100)),\n }));\n}\n"],"mappings":"AACA,SAAS,mBAAmB;AAGrB,SAAS,aAAa,OAAwB,UAA0B;AAC7E,SAAO,YAAY,OAAO,KAAK,IAAI,KAAK,UAAU,CAAC;AACrD;AAGO,SAAS,UACd,KACA,QACe;AACf,QAAM,IAAI,OAAO,OAAO,CAAC;AACzB,QAAM,IAAI,OAAO,UAAU,CAAC;AAC5B,MAAI,MAAM,EAAG,QAAO;AAEpB,SAAO,SAAU,IAAI,KAAK,KAAK,IAAI,CAAC,IAAK,KAAK,QAAQ,CAAC,CAAC;AAC1D;AAGO,MAAM,eAAe,CAAC,QAAQ,QAAQ,YAAY,MAAM;AAiBxD,SAAS,YACd,KACA,QACA,UAEA,UAA0E,CAAC,GAC1D;AACjB,QAAM,OAAO,CAAC,UAAiD,QAAQ,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,aAAa,QAAQ;AAChH,QAAM,UAAU,KAAK,GAAG;AACxB,QAAM,QAAQ,QAAQ,OAAO,CAAC,KAAK,MAAM,MAAM,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,CAAC;AAC/E,QAAM,SAAiC;AAAA,IACrC,MAAM;AAAA,IACN,MAAM;AAAA,IACN,UAAU;AAAA,IACV,MAAM;AAAA,IACN,GAAG,QAAQ;AAAA,EACb;AACA,QAAM,QAA2B,QAAQ,SAAS;AAElD,QAAM,OAAO,CAAC,GAAG,OAAO,GAAG,QAAQ,IAAI,CAAC,MAAM,EAAE,QAAQ,GAAG,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,SAAS,CAAC,CAAC,CAAC;AAE9F,SAAO,CAAC,GAAG,IAAI,IAAI,IAAI,CAAC,EAAE,IAAI,CAAC,QAAQ;AACrC,UAAM,OAAO,QAAQ,KAAK,CAAC,MAAM,EAAE,QAAQ,QAAQ,GAAG;AACtD,UAAM,QAAQ,KAAK,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,QAAQ,QAAQ,GAAG;AAC5D,UAAM,SAAS,OAAO,MAAM,eAAe,GAAG;AAE9C,WAAO;AAAA,MACL;AAAA,MACA,OAAO,QAAQ,SAAS,GAAG,KAAK,MAAM,QAAQ,SAAS,OAAO,GAAG,KAAK;AAAA,MACtE,UAAU,MAAM,SAAS;AAAA,MACzB,aAAa,OAAO,SAAS;AAAA,MAC7B,OAAO,QAAQ,OAAO,CAAC,IAAI,QAAS,OAAO,MAAM,IAAI,OAAO,KAAK,IAAK,KAAK,QAAQ,CAAC,CAAC,IAAI;AAAA,MACzF,QAAQ,SAAS,UAAU,OAAO,SAAS,GAAG,OAAO,eAAe,GAAG,IAAI;AAAA,IAC7E;AAAA,EACF,CAAC;AACH;AAGO,MAAM,eAAuC;AAAA,EAClD,SAAS;AAAA,EACT,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,WAAW;AAAA,EACX,WAAW;AAAA,EACX,WAAW;AACb;AAEO,MAAM,aAAqC;AAAA,EAChD,OAAO;AAAA,EACP,SAAS;AAAA,EACT,SAAS;AAAA,EACT,YAAY;AAAA,EACZ,cAAc;AAAA,EACd,SAAS;AAAA,EACT,KAAK;AAAA,EACL,QAAQ;AAAA,EACR,QAAQ;AACV;AAEO,MAAM,gBAAwC;AAAA,EACnD,aAAa;AAAA,EACb,SAAS;AAAA,EACT,SAAS;AAAA,EACT,cAAc;AAAA,EACd,eAAe;AACjB;AAEO,MAAM,4BAAoD;AAAA,EAC/D,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,QAAQ;AACV;AAEO,MAAM,OAAO,CAAC,OAA+B,QAAwB,MAAM,GAAG,KAAK,IAAI,QAAQ,MAAM,GAAG;AAGxG,SAAS,aAAa,QAAgB,WAAoB,SAA0B;AACzF,QAAM,MAAM,CAAC,QAAkB,MAAM,IAAI,MAAM,GAAG,EAAE,IAAI;AAExD,SAAO,GAAG,MAAM,IAAI,IAAI,SAAS,CAAC,IAAI,IAAI,OAAO,CAAC;AACpD;AAGO,SAAS,YAAY,OAAgC;AAC1D,QAAM,OAAO,OAAO,KAAK;AACzB,QAAM,WAAW,KAAK,WAAW,GAAG;AACpC,QAAM,SAAS,KAAK,QAAQ,KAAK,EAAE,EAAE,SAAS,GAAG,GAAG;AAEpD,SAAO,GAAG,WAAW,MAAM,EAAE,GAAG,OAAO,MAAM,GAAG,EAAE,CAAC,IAAI,OAAO,MAAM,EAAE,CAAC;AACzE;AAMO,SAAS,kBACd,UACA,UACuB;AACvB,SAAO,SAAS,IAAI,CAAC,SAAS;AAAA,IAC5B,SAAS,EAAE,KAAK,IAAI,QAAQ,OAAO,KAAK,2BAA2B,IAAI,MAAM,EAAE;AAAA,IAC/E;AAAA,IACA,OAAO,IAAI;AAAA,IACX,aAAa,OAAO,KAAK,MAAM,IAAI,SAAS,GAAG,CAAC;AAAA,EAClD,EAAE;AACJ;","names":[]}
|
|
@@ -6,10 +6,18 @@ const integer = new Intl.NumberFormat("en-US");
|
|
|
6
6
|
function MethodCards({
|
|
7
7
|
now,
|
|
8
8
|
before,
|
|
9
|
-
currency
|
|
9
|
+
currency,
|
|
10
|
+
order,
|
|
11
|
+
labels,
|
|
12
|
+
title = "How customers paid",
|
|
13
|
+
received = "of the money received",
|
|
14
|
+
describe = (label) => `Money customers paid by ${label.toLowerCase()}, before fees.`,
|
|
15
|
+
hide
|
|
10
16
|
}) {
|
|
11
|
-
const cards = methodCards(now, before, currency)
|
|
12
|
-
|
|
17
|
+
const cards = methodCards(now, before, currency, { order, labels }).filter(
|
|
18
|
+
(card) => !(hide?.includes(card.key) && card.payments === 0)
|
|
19
|
+
);
|
|
20
|
+
return /* @__PURE__ */ jsx("section", { "aria-label": title, className: "adm-grid", children: cards.map((card, index) => /* @__PURE__ */ jsx(Reveal, { index, className: "adm-span-3", children: /* @__PURE__ */ jsx(
|
|
13
21
|
StatCard,
|
|
14
22
|
{
|
|
15
23
|
label: card.label,
|
|
@@ -20,8 +28,8 @@ function MethodCards({
|
|
|
20
28
|
format: (v) => minorToMoney(Math.round(v * 100), currency)
|
|
21
29
|
}
|
|
22
30
|
),
|
|
23
|
-
sub: card.payments === 0 ? "No payments this way yet" : `${integer.format(card.payments)} ${card.payments === 1 ? "payment" : "payments"} \xB7 ${card.share}%
|
|
24
|
-
hint:
|
|
31
|
+
sub: card.payments === 0 ? "No payments this way yet" : `${integer.format(card.payments)} ${card.payments === 1 ? "payment" : "payments"} \xB7 ${card.share}% ${received}`,
|
|
32
|
+
hint: describe(card.label, card.key),
|
|
25
33
|
delta: card.change === null ? void 0 : { pct: card.change, caption: "vs the period before" }
|
|
26
34
|
}
|
|
27
35
|
) }, card.key)) });
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/admin/revenue/method-cards.tsx"],"sourcesContent":["import type { GatewayChannelTotal } from '@withpotter/lib/admin-api';\nimport { CountUp, Reveal } from '../_fx';\nimport { StatCard } from '../_ui';\nimport { methodCards, minorToMoney } from './ledger-model';\n\nconst integer = new Intl.NumberFormat('en-US');\n\n/**\n * One card for each way customers pay (Card, USSD, Bank transfer, Opay): how\n * much came in that way, how many payments, its share of all the money\n * received, and how it moved since the period before. A quiet method shows\n * zero, so it is seen rather than missing.\n */\nexport function MethodCards({\n now,\n before,\n currency,\n}: {\n now: readonly GatewayChannelTotal[];\n before: readonly GatewayChannelTotal[] | null;\n currency: string;\n}) {\n const cards = methodCards(now, before, currency);\n\n return (\n <section aria-label
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/revenue/method-cards.tsx"],"sourcesContent":["import type { GatewayChannelTotal } from '@withpotter/lib/admin-api';\nimport { CountUp, Reveal } from '../_fx';\nimport { StatCard } from '../_ui';\nimport { methodCards, minorToMoney } from './ledger-model';\n\nconst integer = new Intl.NumberFormat('en-US');\n\n/**\n * One card for each way customers pay (Card, USSD, Bank transfer, Opay): how\n * much came in that way, how many payments, its share of all the money\n * received, and how it moved since the period before. A quiet method shows\n * zero, so it is seen rather than missing.\n */\nexport function MethodCards({\n now,\n before,\n currency,\n order,\n labels,\n title = 'How customers paid',\n received = 'of the money received',\n describe = (label: string) => `Money customers paid by ${label.toLowerCase()}, before fees.`,\n hide,\n}: {\n now: readonly GatewayChannelTotal[];\n before: readonly GatewayChannelTotal[] | null;\n currency: string;\n /** The ways to always show, in this order. Customers' checkout methods when left out. */\n order?: readonly string[];\n /** Names for those ways. */\n labels?: Record<string, string>;\n title?: string;\n /** What the share is a share of (\"of the money received\"). */\n received?: string;\n /** The help text for one card. */\n describe?: (label: string, key: string) => string;\n /** Ways to leave out of the row when nothing came in that way. */\n hide?: readonly string[];\n}) {\n const cards = methodCards(now, before, currency, { order, labels }).filter(\n (card) => !(hide?.includes(card.key) && card.payments === 0),\n );\n\n return (\n <section aria-label={title} className=\"adm-grid\">\n {cards.map((card, index) => (\n <Reveal key={card.key} index={index} className=\"adm-span-3\">\n <StatCard\n label={card.label}\n value={\n <CountUp\n value={Number(card.amountMinor) / 100}\n format={(v) => minorToMoney(Math.round(v * 100), currency)}\n />\n }\n sub={\n card.payments === 0\n ? 'No payments this way yet'\n : `${integer.format(card.payments)} ${card.payments === 1 ? 'payment' : 'payments'} · ${card.share}% ${received}`\n }\n hint={describe(card.label, card.key)}\n delta={\n card.change === null\n ? undefined\n : { pct: card.change, caption: 'vs the period before' }\n }\n />\n </Reveal>\n ))}\n </section>\n );\n}\n"],"mappings":"AAkDc;AAjDd,SAAS,SAAS,cAAc;AAChC,SAAS,gBAAgB;AACzB,SAAS,aAAa,oBAAoB;AAE1C,MAAM,UAAU,IAAI,KAAK,aAAa,OAAO;AAQtC,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,QAAQ;AAAA,EACR,WAAW;AAAA,EACX,WAAW,CAAC,UAAkB,2BAA2B,MAAM,YAAY,CAAC;AAAA,EAC5E;AACF,GAeG;AACD,QAAM,QAAQ,YAAY,KAAK,QAAQ,UAAU,EAAE,OAAO,OAAO,CAAC,EAAE;AAAA,IAClE,CAAC,SAAS,EAAE,MAAM,SAAS,KAAK,GAAG,KAAK,KAAK,aAAa;AAAA,EAC5D;AAEA,SACE,oBAAC,aAAQ,cAAY,OAAO,WAAU,YACnC,gBAAM,IAAI,CAAC,MAAM,UAChB,oBAAC,UAAsB,OAAc,WAAU,cAC7C;AAAA,IAAC;AAAA;AAAA,MACC,OAAO,KAAK;AAAA,MACZ,OACE;AAAA,QAAC;AAAA;AAAA,UACC,OAAO,OAAO,KAAK,WAAW,IAAI;AAAA,UAClC,QAAQ,CAAC,MAAM,aAAa,KAAK,MAAM,IAAI,GAAG,GAAG,QAAQ;AAAA;AAAA,MAC3D;AAAA,MAEF,KACE,KAAK,aAAa,IACd,6BACA,GAAG,QAAQ,OAAO,KAAK,QAAQ,CAAC,IAAI,KAAK,aAAa,IAAI,YAAY,UAAU,SAAM,KAAK,KAAK,KAAK,QAAQ;AAAA,MAEnH,MAAM,SAAS,KAAK,OAAO,KAAK,GAAG;AAAA,MACnC,OACE,KAAK,WAAW,OACZ,SACA,EAAE,KAAK,KAAK,QAAQ,SAAS,uBAAuB;AAAA;AAAA,EAE5D,KApBW,KAAK,GAqBlB,CACD,GACH;AAEJ;","names":[]}
|
|
@@ -41,7 +41,7 @@ function RevenueKpis({
|
|
|
41
41
|
label: "Plan income each month",
|
|
42
42
|
value: tracked ? /* @__PURE__ */ jsx(CountUp, { value: recurring.mrr, format: money }) : notTracked,
|
|
43
43
|
hint: `What merchants on a paid plan are expected to pay ${ADMIN_NAME} every month, in ${data.currency || "NGN"}. Free plans, trials and pauses are not counted.`,
|
|
44
|
-
sub: tracked ? `${integer.format(recurring.payingMerchants ?? recurring.activeSubscriptions)} merchants paying` : noSource
|
|
44
|
+
sub: tracked ? `${integer.format(recurring.payingMerchants ?? recurring.activeSubscriptions)} ${(recurring.payingMerchants ?? recurring.activeSubscriptions) === 1 ? "merchant" : "merchants"} paying` : noSource
|
|
45
45
|
}
|
|
46
46
|
) }),
|
|
47
47
|
/* @__PURE__ */ jsx(Reveal, { index: 1, className: "adm-span-3", children: /* @__PURE__ */ jsx(
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/admin/revenue/revenue-kpis.tsx"],"sourcesContent":["import { ADMIN_NAME } from '../_shell/admin-brand';\nimport type { ReactNode } from 'react';\nimport type { PlatformRevenue } from '@withpotter/lib/admin-api';\nimport { CountUp, Reveal, TONE } from '../_fx';\nimport { MoneyLines, StatCard, rowFor } from '../_ui';\n\nconst integer = new Intl.NumberFormat('en-US');\n\n/**\n * The headline figures, in two groups that are never added together: what\n * merchants sold (`merchant`), and what Olive itself earns from plans (`olive`).\n */\nexport function RevenueKpis({\n part,\n extra,\n data,\n money,\n money2,\n currency,\n focus,\n gmvSpark,\n}: {\n part: 'merchant' | 'olive';\n /** More cards for the merchant group, in the same row. */\n extra?: ReactNode;\n data: PlatformRevenue;\n /** Formats recurring revenue, in the currency the API names for it. */\n money: (value: number) => string;\n money2: (value: number) => string;\n /** The currency filter's choice; empty shows a line per currency. */\n currency: string;\n /** The one currency the share and sparkline follow, or null. */\n focus: string | null;\n gmvSpark: number[];\n}) {\n const { recurring } = data;\n // Without the plan payments connected the API sends 0s; say so rather than show a zero.\n const tracked = recurring.tracked !== false;\n const notTracked = 'Not set up yet';\n const noSource = 'The plan payments are not connected yet';\n const gmv = rowFor(data.gmvByCurrency, focus);\n const lifetimeShare =\n gmv && gmv.lifetime > 0\n ? `${Math.round((gmv.inRange / gmv.lifetime) * 100)}% of lifetime`\n : undefined;\n\n return (\n <section\n aria-label={part === 'olive' ? 'What Olive earns' : 'What merchants sold'}\n className=\"adm-grid\"\n >\n {part === 'olive' && (\n <>\n <Reveal index={0} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income collected\"\n value={\n tracked && recurring.collected !== undefined ? (\n <CountUp value={recurring.collected} format={money2} />\n ) : (\n notTracked\n )\n }\n hint={`Plan payments that actually went through in this window, in ${data.currency || 'NGN'}. The monthly figure beside it is what is expected, this is what came in.`}\n sub={tracked ? 'In this window' : noSource}\n />\n </Reveal>\n <Reveal index={1} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income each month\"\n value={\n tracked ? (\n <CountUp value={recurring.mrr} format={money} />\n ) : (\n notTracked\n )\n }\n hint={`What merchants on a paid plan are expected to pay ${ADMIN_NAME} every month, in ${data.currency || 'NGN'}. Free plans, trials and pauses are not counted.`}\n sub={\n tracked\n ? `${integer.format(recurring.payingMerchants ?? recurring.activeSubscriptions)} merchants paying`\n : noSource\n }\n />\n </Reveal>\n <Reveal index={1} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income over a year\"\n value={\n tracked ? (\n <CountUp value={recurring.arr} format={money} />\n ) : (\n notTracked\n )\n }\n hint=\"What plan income adds up to over a year if nothing changes: the monthly figure times 12.\"\n sub={tracked ? 'Each month times 12' : noSource}\n />\n </Reveal>\n <Reveal index={2} className=\"adm-span-3\">\n <StatCard\n label=\"Average per paying merchant\"\n value={\n tracked ? (\n <CountUp value={recurring.arpa} format={money2} />\n ) : (\n notTracked\n )\n }\n hint=\"What one paying merchant pays in a month, on average.\"\n sub={tracked ? 'Each month' : noSource}\n />\n </Reveal>\n </>\n )}\n {part === 'merchant' && (\n <>\n <Reveal index={3} className=\"adm-span-3\">\n <StatCard\n label=\"Merchant sales\"\n value={\n <MoneyLines\n rows={data.gmvByCurrency}\n currency={currency}\n amount={(g) => g.inRange}\n empty=\"No sales yet\"\n />\n }\n href=\"/admin/tenants\"\n sub={lifetimeShare}\n hint={`Everything merchants sold through ${ADMIN_NAME}. This is the merchants' money, not ${ADMIN_NAME}'s own income. One line per currency; currencies are never added together. (GMV)`}\n spark={focus ? gmvSpark : undefined}\n sparkColor={TONE.money.fg}\n />\n </Reveal>\n {extra}\n {data.merchantWalletsByCurrency && (\n <Reveal index={4} className=\"adm-span-3\">\n <StatCard\n label=\"In merchants' wallets\"\n value={\n <MoneyLines\n rows={data.merchantWalletsByCurrency}\n currency={currency}\n amount={(r) => r.amount}\n decimals={2}\n empty=\"Nothing held\"\n />\n }\n hint=\"What merchants hold in their wallets right now: pending, available, held back and on its way to a bank, added together. A balance today, so it does not follow the dates above.\"\n sub=\"Right now\"\n />\n </Reveal>\n )}\n {data.merchantPayoutsByCurrency && (\n <Reveal index={5} className=\"adm-span-3\">\n <StatCard\n label=\"Merchants sent out\"\n value={\n <MoneyLines\n rows={data.merchantPayoutsByCurrency}\n currency={currency}\n amount={(r) => r.amount}\n decimals={2}\n empty=\"Nothing sent out\"\n />\n }\n hint=\"What merchants withdrew to their bank accounts in this window. Payouts the bank sent back are taken off.\"\n />\n </Reveal>\n )}\n </>\n )}\n </section>\n );\n}\n"],"mappings":"AAoDQ,mBAMM,KANN;AApDR,SAAS,kBAAkB;AAG3B,SAAS,SAAS,QAAQ,YAAY;AACtC,SAAS,YAAY,UAAU,cAAc;AAE7C,MAAM,UAAU,IAAI,KAAK,aAAa,OAAO;AAMtC,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAaG;AACD,QAAM,EAAE,UAAU,IAAI;AAEtB,QAAM,UAAU,UAAU,YAAY;AACtC,QAAM,aAAa;AACnB,QAAM,WAAW;AACjB,QAAM,MAAM,OAAO,KAAK,eAAe,KAAK;AAC5C,QAAM,gBACJ,OAAO,IAAI,WAAW,IAClB,GAAG,KAAK,MAAO,IAAI,UAAU,IAAI,WAAY,GAAG,CAAC,kBACjD;AAEN,SACE;AAAA,IAAC;AAAA;AAAA,MACC,cAAY,SAAS,UAAU,qBAAqB;AAAA,MACpD,WAAU;AAAA,MAET;AAAA,iBAAS,WACR,iCACF;AAAA,8BAAC,UAAO,OAAO,GAAG,WAAU,cAC1B;AAAA,YAAC;AAAA;AAAA,cACC,OAAM;AAAA,cACN,OACE,WAAW,UAAU,cAAc,SACjC,oBAAC,WAAQ,OAAO,UAAU,WAAW,QAAQ,QAAQ,IAErD;AAAA,cAGJ,MAAM,+DAA+D,KAAK,YAAY,KAAK;AAAA,cAC3F,KAAK,UAAU,mBAAmB;AAAA;AAAA,UACpC,GACF;AAAA,UACA,oBAAC,UAAO,OAAO,GAAG,WAAU,cAC1B;AAAA,YAAC;AAAA;AAAA,cACC,OAAM;AAAA,cACN,OACE,UACE,oBAAC,WAAQ,OAAO,UAAU,KAAK,QAAQ,OAAO,IAE9C;AAAA,cAGJ,MAAM,qDAAqD,UAAU,oBAAoB,KAAK,YAAY,KAAK;AAAA,cAC/G,KACE,UACI,GAAG,QAAQ,OAAO,UAAU,mBAAmB,UAAU,mBAAmB,CAAC,
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/revenue/revenue-kpis.tsx"],"sourcesContent":["import { ADMIN_NAME } from '../_shell/admin-brand';\nimport type { ReactNode } from 'react';\nimport type { PlatformRevenue } from '@withpotter/lib/admin-api';\nimport { CountUp, Reveal, TONE } from '../_fx';\nimport { MoneyLines, StatCard, rowFor } from '../_ui';\n\nconst integer = new Intl.NumberFormat('en-US');\n\n/**\n * The headline figures, in two groups that are never added together: what\n * merchants sold (`merchant`), and what Olive itself earns from plans (`olive`).\n */\nexport function RevenueKpis({\n part,\n extra,\n data,\n money,\n money2,\n currency,\n focus,\n gmvSpark,\n}: {\n part: 'merchant' | 'olive';\n /** More cards for the merchant group, in the same row. */\n extra?: ReactNode;\n data: PlatformRevenue;\n /** Formats recurring revenue, in the currency the API names for it. */\n money: (value: number) => string;\n money2: (value: number) => string;\n /** The currency filter's choice; empty shows a line per currency. */\n currency: string;\n /** The one currency the share and sparkline follow, or null. */\n focus: string | null;\n gmvSpark: number[];\n}) {\n const { recurring } = data;\n // Without the plan payments connected the API sends 0s; say so rather than show a zero.\n const tracked = recurring.tracked !== false;\n const notTracked = 'Not set up yet';\n const noSource = 'The plan payments are not connected yet';\n const gmv = rowFor(data.gmvByCurrency, focus);\n const lifetimeShare =\n gmv && gmv.lifetime > 0\n ? `${Math.round((gmv.inRange / gmv.lifetime) * 100)}% of lifetime`\n : undefined;\n\n return (\n <section\n aria-label={part === 'olive' ? 'What Olive earns' : 'What merchants sold'}\n className=\"adm-grid\"\n >\n {part === 'olive' && (\n <>\n <Reveal index={0} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income collected\"\n value={\n tracked && recurring.collected !== undefined ? (\n <CountUp value={recurring.collected} format={money2} />\n ) : (\n notTracked\n )\n }\n hint={`Plan payments that actually went through in this window, in ${data.currency || 'NGN'}. The monthly figure beside it is what is expected, this is what came in.`}\n sub={tracked ? 'In this window' : noSource}\n />\n </Reveal>\n <Reveal index={1} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income each month\"\n value={\n tracked ? (\n <CountUp value={recurring.mrr} format={money} />\n ) : (\n notTracked\n )\n }\n hint={`What merchants on a paid plan are expected to pay ${ADMIN_NAME} every month, in ${data.currency || 'NGN'}. Free plans, trials and pauses are not counted.`}\n sub={\n tracked\n ? `${integer.format(recurring.payingMerchants ?? recurring.activeSubscriptions)} ${(recurring.payingMerchants ?? recurring.activeSubscriptions) === 1 ? 'merchant' : 'merchants'} paying`\n : noSource\n }\n />\n </Reveal>\n <Reveal index={1} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income over a year\"\n value={\n tracked ? (\n <CountUp value={recurring.arr} format={money} />\n ) : (\n notTracked\n )\n }\n hint=\"What plan income adds up to over a year if nothing changes: the monthly figure times 12.\"\n sub={tracked ? 'Each month times 12' : noSource}\n />\n </Reveal>\n <Reveal index={2} className=\"adm-span-3\">\n <StatCard\n label=\"Average per paying merchant\"\n value={\n tracked ? (\n <CountUp value={recurring.arpa} format={money2} />\n ) : (\n notTracked\n )\n }\n hint=\"What one paying merchant pays in a month, on average.\"\n sub={tracked ? 'Each month' : noSource}\n />\n </Reveal>\n </>\n )}\n {part === 'merchant' && (\n <>\n <Reveal index={3} className=\"adm-span-3\">\n <StatCard\n label=\"Merchant sales\"\n value={\n <MoneyLines\n rows={data.gmvByCurrency}\n currency={currency}\n amount={(g) => g.inRange}\n empty=\"No sales yet\"\n />\n }\n href=\"/admin/tenants\"\n sub={lifetimeShare}\n hint={`Everything merchants sold through ${ADMIN_NAME}. This is the merchants' money, not ${ADMIN_NAME}'s own income. One line per currency; currencies are never added together. (GMV)`}\n spark={focus ? gmvSpark : undefined}\n sparkColor={TONE.money.fg}\n />\n </Reveal>\n {extra}\n {data.merchantWalletsByCurrency && (\n <Reveal index={4} className=\"adm-span-3\">\n <StatCard\n label=\"In merchants' wallets\"\n value={\n <MoneyLines\n rows={data.merchantWalletsByCurrency}\n currency={currency}\n amount={(r) => r.amount}\n decimals={2}\n empty=\"Nothing held\"\n />\n }\n hint=\"What merchants hold in their wallets right now: pending, available, held back and on its way to a bank, added together. A balance today, so it does not follow the dates above.\"\n sub=\"Right now\"\n />\n </Reveal>\n )}\n {data.merchantPayoutsByCurrency && (\n <Reveal index={5} className=\"adm-span-3\">\n <StatCard\n label=\"Merchants sent out\"\n value={\n <MoneyLines\n rows={data.merchantPayoutsByCurrency}\n currency={currency}\n amount={(r) => r.amount}\n decimals={2}\n empty=\"Nothing sent out\"\n />\n }\n hint=\"What merchants withdrew to their bank accounts in this window. Payouts the bank sent back are taken off.\"\n />\n </Reveal>\n )}\n </>\n )}\n </section>\n );\n}\n"],"mappings":"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","names":[]}
|