@withpotter/pages-admin 0.13.0-next.101 → 0.13.0-next.103
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/_pos/tenant-operators.js +73 -0
- package/dist/admin/_pos/tenant-operators.js.map +1 -0
- package/dist/admin/_pos/tenant-pos-figures.js +82 -0
- package/dist/admin/_pos/tenant-pos-figures.js.map +1 -0
- package/dist/admin/_pos/use-pos.js +12 -0
- package/dist/admin/_pos/use-pos.js.map +1 -1
- package/dist/admin/hooks.js +19 -0
- package/dist/admin/hooks.js.map +1 -1
- package/dist/admin/pos/shifts-tab.js +15 -8
- package/dist/admin/pos/shifts-tab.js.map +1 -1
- package/dist/admin/revenue/cohort-panel.js +5 -5
- package/dist/admin/revenue/cohort-panel.js.map +1 -1
- package/dist/admin/revenue/health-panel.js +17 -17
- package/dist/admin/revenue/health-panel.js.map +1 -1
- package/dist/admin/revenue/ledger-model.js +7 -0
- package/dist/admin/revenue/ledger-model.js.map +1 -1
- package/dist/admin/revenue/ledger.module.css +43 -0
- package/dist/admin/revenue/merchant-headline.js +37 -0
- package/dist/admin/revenue/merchant-headline.js.map +1 -0
- package/dist/admin/revenue/merchant-payments-view.js +46 -22
- package/dist/admin/revenue/merchant-payments-view.js.map +1 -1
- package/dist/admin/revenue/merchants-view.js +99 -0
- package/dist/admin/revenue/merchants-view.js.map +1 -0
- package/dist/admin/revenue/payment-drawer.js +53 -0
- package/dist/admin/revenue/payment-drawer.js.map +1 -0
- package/dist/admin/revenue/plan-mix-panel.js +1 -1
- package/dist/admin/revenue/plan-mix-panel.js.map +1 -1
- package/dist/admin/revenue/plan-payment-drawer.js +42 -0
- package/dist/admin/revenue/plan-payment-drawer.js.map +1 -0
- package/dist/admin/revenue/print-summary-button.js +34 -0
- package/dist/admin/revenue/print-summary-button.js.map +1 -0
- package/dist/admin/revenue/quality-panel.js +19 -19
- package/dist/admin/revenue/quality-panel.js.map +1 -1
- package/dist/admin/revenue/revenue-kpis.js +64 -50
- package/dist/admin/revenue/revenue-kpis.js.map +1 -1
- package/dist/admin/revenue/revenue-overview.js +68 -50
- package/dist/admin/revenue/revenue-overview.js.map +1 -1
- package/dist/admin/revenue/revenue-retention.js +17 -13
- package/dist/admin/revenue/revenue-retention.js.map +1 -1
- package/dist/admin/revenue/revenue-workspace.js +16 -0
- package/dist/admin/revenue/revenue-workspace.js.map +1 -1
- package/dist/admin/revenue/subscriptions-panel.js +14 -14
- package/dist/admin/revenue/subscriptions-panel.js.map +1 -1
- package/dist/admin/revenue/subscriptions-view.js +20 -20
- package/dist/admin/revenue/subscriptions-view.js.map +1 -1
- package/dist/admin/revenue/summary-download.js +34 -0
- package/dist/admin/revenue/summary-download.js.map +1 -0
- package/dist/admin/revenue/summary-print.js +82 -0
- package/dist/admin/revenue/summary-print.js.map +1 -0
- package/dist/admin/revenue/summary-rows.js +44 -0
- package/dist/admin/revenue/summary-rows.js.map +1 -0
- package/dist/admin/revenue/top-tenants-panel.js +1 -1
- package/dist/admin/revenue/top-tenants-panel.js.map +1 -1
- package/dist/admin/revenue/url-filters.js +36 -0
- package/dist/admin/revenue/url-filters.js.map +1 -0
- package/dist/admin/revenue/use-url-filters.js +24 -0
- package/dist/admin/revenue/use-url-filters.js.map +1 -0
- package/dist/admin/tenants/[id]/tenant-pos.js +53 -16
- package/dist/admin/tenants/[id]/tenant-pos.js.map +1 -1
- package/package.json +4 -4
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { EmptyBlock } from "../audit/empty-block";
|
|
4
|
+
import ledgerStyles from "./ledger.module.css";
|
|
4
5
|
import { CohortPanel } from "./cohort-panel";
|
|
5
6
|
import { QualityPanel } from "./quality-panel";
|
|
6
7
|
function RevenueRetention({
|
|
@@ -10,19 +11,22 @@ function RevenueRetention({
|
|
|
10
11
|
caption
|
|
11
12
|
}) {
|
|
12
13
|
if (!quality) {
|
|
13
|
-
return /* @__PURE__ */ jsx(EmptyBlock, { message: "Retention
|
|
14
|
+
return /* @__PURE__ */ jsx(EmptyBlock, { message: "Retention shows here once there is enough history to compare." });
|
|
14
15
|
}
|
|
15
|
-
return /* @__PURE__ */ jsxs(
|
|
16
|
-
/* @__PURE__ */ jsx("
|
|
17
|
-
|
|
18
|
-
{
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
16
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
17
|
+
/* @__PURE__ */ jsx("p", { className: ledgerStyles.lede, children: "This shows how well Olive keeps the income it earns, and whether the merchants it signs up carry on selling." }),
|
|
18
|
+
/* @__PURE__ */ jsxs("div", { className: "adm-grid", children: [
|
|
19
|
+
/* @__PURE__ */ jsx("div", { className: "adm-span-5", children: /* @__PURE__ */ jsx(
|
|
20
|
+
QualityPanel,
|
|
21
|
+
{
|
|
22
|
+
quality,
|
|
23
|
+
money,
|
|
24
|
+
currency,
|
|
25
|
+
caption
|
|
26
|
+
}
|
|
27
|
+
) }),
|
|
28
|
+
/* @__PURE__ */ jsx("div", { className: "adm-span-7", children: /* @__PURE__ */ jsx(CohortPanel, { cohorts: quality.cohorts }) })
|
|
29
|
+
] })
|
|
26
30
|
] });
|
|
27
31
|
}
|
|
28
32
|
export {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/admin/revenue/revenue-retention.tsx"],"sourcesContent":["'use client';\n\nimport type { PlatformRevenue } from '@withpotter/lib/admin-api';\nimport { EmptyBlock } from '../audit/empty-block';\nimport { CohortPanel } from './cohort-panel';\nimport { QualityPanel } from './quality-panel';\n\n/** Retention tab: revenue quality beside logo cohort retention. */\nexport function RevenueRetention({\n quality,\n money,\n currency,\n caption,\n}: {\n quality: PlatformRevenue['quality'];\n money: (value: number) => string;\n /** The currency filter's choice; empty shows a line per currency. */\n currency: string;\n caption: string;\n}) {\n if (!quality) {\n return (\n <EmptyBlock message=\"Retention
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/revenue/revenue-retention.tsx"],"sourcesContent":["'use client';\n\nimport type { PlatformRevenue } from '@withpotter/lib/admin-api';\nimport { EmptyBlock } from '../audit/empty-block';\nimport ledgerStyles from './ledger.module.css';\nimport { CohortPanel } from './cohort-panel';\nimport { QualityPanel } from './quality-panel';\n\n/** Retention tab: revenue quality beside logo cohort retention. */\nexport function RevenueRetention({\n quality,\n money,\n currency,\n caption,\n}: {\n quality: PlatformRevenue['quality'];\n money: (value: number) => string;\n /** The currency filter's choice; empty shows a line per currency. */\n currency: string;\n caption: string;\n}) {\n if (!quality) {\n return (\n <EmptyBlock message=\"Retention shows here once there is enough history to compare.\" />\n );\n }\n\n return (\n <>\n <p className={ledgerStyles.lede}>\n This shows how well Olive keeps the income it earns, and whether the merchants it signs up carry on selling.\n </p>\n <div className=\"adm-grid\">\n <div className=\"adm-span-5\">\n <QualityPanel\n quality={quality}\n money={money}\n currency={currency}\n caption={caption}\n />\n </div>\n <div className=\"adm-span-7\">\n <CohortPanel cohorts={quality.cohorts} />\n </div>\n </div>\n </>\n );\n}\n"],"mappings":";AAuBM,SAKF,UALE,KASA,YATA;AApBN,SAAS,kBAAkB;AAC3B,OAAO,kBAAkB;AACzB,SAAS,mBAAmB;AAC5B,SAAS,oBAAoB;AAGtB,SAAS,iBAAiB;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAMG;AACD,MAAI,CAAC,SAAS;AACZ,WACE,oBAAC,cAAW,SAAQ,iEAAgE;AAAA,EAExF;AAEA,SACE,iCACE;AAAA,wBAAC,OAAE,WAAW,aAAa,MAAM,0HAEjC;AAAA,IACA,qBAAC,SAAI,WAAU,YACf;AAAA,0BAAC,SAAI,WAAU,cACb;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UACA;AAAA,UACA;AAAA,UACA;AAAA;AAAA,MACF,GACF;AAAA,MACA,oBAAC,SAAI,WAAU,cACb,8BAAC,eAAY,SAAS,QAAQ,SAAS,GACzC;AAAA,OACA;AAAA,KACF;AAEJ;","names":[]}
|
|
@@ -25,12 +25,15 @@ import {
|
|
|
25
25
|
useTabParam
|
|
26
26
|
} from "../_ui";
|
|
27
27
|
import { MerchantPaymentsView } from "./merchant-payments-view";
|
|
28
|
+
import { MerchantsView } from "./merchants-view";
|
|
28
29
|
import { RevenueOverview } from "./revenue-overview";
|
|
29
30
|
import { RevenueRetention } from "./revenue-retention";
|
|
30
31
|
import { SubscriptionsView } from "./subscriptions-view";
|
|
32
|
+
import { SummaryDownload } from "./summary-download";
|
|
31
33
|
const TABS = [
|
|
32
34
|
{ id: "overview", label: "Overview" },
|
|
33
35
|
{ id: "merchants", label: "Merchant payments" },
|
|
36
|
+
{ id: "by-merchant", label: "Merchants" },
|
|
34
37
|
{ id: "subscriptions", label: "Subscriptions" },
|
|
35
38
|
{ id: "retention", label: "Retention" }
|
|
36
39
|
];
|
|
@@ -96,6 +99,8 @@ function RevenueWorkspace() {
|
|
|
96
99
|
currency
|
|
97
100
|
}
|
|
98
101
|
);
|
|
102
|
+
} else if (tab === "by-merchant") {
|
|
103
|
+
body = /* @__PURE__ */ jsx(MerchantsView, { startDate: range.startDate, endDate: range.endDate });
|
|
99
104
|
} else if (tab === "subscriptions") {
|
|
100
105
|
body = /* @__PURE__ */ jsx(
|
|
101
106
|
SubscriptionsView,
|
|
@@ -140,6 +145,17 @@ function RevenueWorkspace() {
|
|
|
140
145
|
title: "Revenue",
|
|
141
146
|
description: `All the money moving through ${ADMIN_NAME}: what customers pay merchants, and what merchants pay ${ADMIN_NAME} for their plan.`,
|
|
142
147
|
actions: /* @__PURE__ */ jsxs("div", { className: "adm-toolbar", children: [
|
|
148
|
+
tab === "overview" ? /* @__PURE__ */ jsx(
|
|
149
|
+
SummaryDownload,
|
|
150
|
+
{
|
|
151
|
+
ledger: ledger.data,
|
|
152
|
+
revenue: data,
|
|
153
|
+
currency: focus ?? "NGN",
|
|
154
|
+
startDate: range.startDate,
|
|
155
|
+
endDate: range.endDate,
|
|
156
|
+
period
|
|
157
|
+
}
|
|
158
|
+
) : null,
|
|
143
159
|
/* @__PURE__ */ jsx(
|
|
144
160
|
CurrencyFilter,
|
|
145
161
|
{
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/admin/revenue/revenue-workspace.tsx"],"sourcesContent":["'use client';\n\nimport { useMemo } from 'react';\nimport { ADMIN_NAME } from '../_shell/admin-brand';\nimport {\n useGatewayChannels,\n useMerchantPayments,\n usePlatformCockpit,\n usePlatformRevenue,\n} from '../hooks';\nimport { LoadFailed } from '../audit/load-failed';\nimport {\n AdmLoading,\n CurrencyFilter,\n PageHeader,\n PageTabs,\n PeriodSelector,\n customRange,\n focusCurrency,\n moneyIn,\n periodPhrase,\n rangeFromKey,\n useCurrencyParam,\n usePersistedDateRange,\n useTabParam,\n} from '../_ui';\nimport { MerchantPaymentsView } from './merchant-payments-view';\nimport { RevenueOverview } from './revenue-overview';\nimport { RevenueRetention } from './revenue-retention';\nimport { SubscriptionsView } from './subscriptions-view';\n\nconst TABS = [\n { id: 'overview', label: 'Overview' },\n { id: 'merchants', label: 'Merchant payments' },\n { id: 'subscriptions', label: 'Subscriptions' },\n { id: 'retention', label: 'Retention' },\n] as const;\n\nconst KEYS = TABS.map((t) => t.id);\n\n/**\n * Revenue: the whole picture on Overview, what customers paid merchants on\n * Merchant payments, what merchants pay Olive on Subscriptions, and revenue\n * quality and cohorts on Retention.\n */\nexport function RevenueWorkspace() {\n const [tab, setTab] = useTabParam(KEYS);\n const {\n rangeKey,\n setRangeKey,\n customStart,\n customEnd,\n onCustomChange,\n } = usePersistedDateRange();\n // Merchant GMV comes back once per currency; `?currency=` narrows it.\n const [currency, setCurrency] = useCurrencyParam();\n\n const range = useMemo(\n () =>\n rangeKey === 'custom'\n ? customRange(customStart, customEnd)\n : rangeFromKey(rangeKey),\n [rangeKey, customStart, customEnd],\n );\n\n const { data, isLoading, isError, refetch } = usePlatformRevenue({\n startDate: range.startDate,\n endDate: range.endDate,\n });\n // Revenue health (MRR movement, logo churn, concentration) is computed as\n // part of the cockpit, not the revenue endpoint — fetched here too rather\n // than duplicating that aggregation.\n const cockpit = usePlatformCockpit({\n startDate: range.startDate,\n endDate: range.endDate,\n });\n\n const channels = useGatewayChannels({\n startDate: range.startDate,\n endDate: range.endDate,\n });\n\n // The same totals the Merchant payments tab reads, one row only, for the method cards.\n const ledger = useMerchantPayments({\n startDate: range.startDate,\n endDate: range.endDate,\n limit: 1,\n });\n\n const currencies = useMemo(() => data?.currencies ?? [], [data]);\n const focus = focusCurrency(currencies, currency);\n // Recurring revenue is the platform's own billing, in the currency the API\n // names for it (`currency`), shown as it comes, never converted.\n const recurringCurrency = data?.currency || 'NGN';\n const money = useMemo(() => moneyIn(recurringCurrency), [recurringCurrency]);\n const money2 = useMemo(\n () => moneyIn(recurringCurrency, 2),\n [recurringCurrency],\n );\n\n const span = periodPhrase(range);\n const period = `${span[0].toUpperCase()}${span.slice(1)}`;\n\n let body;\n if (isError) {\n body = (\n <LoadFailed\n message=\"Revenue for this period could not be loaded.\"\n onRetry={() => void refetch()}\n />\n );\n } else if (isLoading || !data) {\n body = <AdmLoading label=\"Loading revenue metrics…\" />;\n } else if (tab === 'merchants') {\n body = (\n <MerchantPaymentsView\n startDate={range.startDate}\n endDate={range.endDate}\n currency={currency}\n />\n );\n } else if (tab === 'subscriptions') {\n body = (\n <SubscriptionsView\n startDate={range.startDate}\n endDate={range.endDate}\n revenue={data}\n />\n );\n } else if (tab === 'retention') {\n body = (\n <RevenueRetention\n quality={data.quality}\n money={money}\n currency={currency}\n caption={currency ? `${period} · ${currency}` : period}\n />\n );\n } else {\n body = (\n <RevenueOverview\n data={data}\n health={cockpit.data?.health}\n money={money}\n money2={money2}\n currency={currency}\n focus={focus}\n period={period}\n span={span}\n recurringCaption={`${period} · ${recurringCurrency}`}\n channels={channels.data?.byChannel}\n ledger={ledger.data}\n />\n );\n }\n\n return (\n <div>\n <PageHeader\n title=\"Revenue\"\n description={`All the money moving through ${ADMIN_NAME}: what customers pay merchants, and what merchants pay ${ADMIN_NAME} for their plan.`}\n actions={\n <div className=\"adm-toolbar\">\n <CurrencyFilter\n value={currency}\n onChange={setCurrency}\n currencies={currencies}\n />\n <PeriodSelector\n value={rangeKey}\n onChange={setRangeKey}\n customStart={customStart}\n customEnd={customEnd}\n onCustomChange={onCustomChange}\n />\n </div>\n }\n />\n <PageTabs tabs={TABS} active={tab} onChange={setTab} />\n {body}\n </div>\n );\n}\n"],"mappings":";
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/revenue/revenue-workspace.tsx"],"sourcesContent":["'use client';\n\nimport { useMemo } from 'react';\nimport { ADMIN_NAME } from '../_shell/admin-brand';\nimport {\n useGatewayChannels,\n useMerchantPayments,\n usePlatformCockpit,\n usePlatformRevenue,\n} from '../hooks';\nimport { LoadFailed } from '../audit/load-failed';\nimport {\n AdmLoading,\n CurrencyFilter,\n PageHeader,\n PageTabs,\n PeriodSelector,\n customRange,\n focusCurrency,\n moneyIn,\n periodPhrase,\n rangeFromKey,\n useCurrencyParam,\n usePersistedDateRange,\n useTabParam,\n} from '../_ui';\nimport { MerchantPaymentsView } from './merchant-payments-view';\nimport { MerchantsView } from './merchants-view';\nimport { RevenueOverview } from './revenue-overview';\nimport { RevenueRetention } from './revenue-retention';\nimport { SubscriptionsView } from './subscriptions-view';\nimport { SummaryDownload } from './summary-download';\n\nconst TABS = [\n { id: 'overview', label: 'Overview' },\n { id: 'merchants', label: 'Merchant payments' },\n { id: 'by-merchant', label: 'Merchants' },\n { id: 'subscriptions', label: 'Subscriptions' },\n { id: 'retention', label: 'Retention' },\n] as const;\n\nconst KEYS = TABS.map((t) => t.id);\n\n/**\n * Revenue: the whole picture on Overview, what customers paid merchants on\n * Merchant payments, what merchants pay Olive on Subscriptions, and revenue\n * quality and cohorts on Retention.\n */\nexport function RevenueWorkspace() {\n const [tab, setTab] = useTabParam(KEYS);\n const {\n rangeKey,\n setRangeKey,\n customStart,\n customEnd,\n onCustomChange,\n } = usePersistedDateRange();\n // Merchant GMV comes back once per currency; `?currency=` narrows it.\n const [currency, setCurrency] = useCurrencyParam();\n\n const range = useMemo(\n () =>\n rangeKey === 'custom'\n ? customRange(customStart, customEnd)\n : rangeFromKey(rangeKey),\n [rangeKey, customStart, customEnd],\n );\n\n const { data, isLoading, isError, refetch } = usePlatformRevenue({\n startDate: range.startDate,\n endDate: range.endDate,\n });\n // Revenue health (MRR movement, logo churn, concentration) is computed as\n // part of the cockpit, not the revenue endpoint — fetched here too rather\n // than duplicating that aggregation.\n const cockpit = usePlatformCockpit({\n startDate: range.startDate,\n endDate: range.endDate,\n });\n\n const channels = useGatewayChannels({\n startDate: range.startDate,\n endDate: range.endDate,\n });\n\n // The same totals the Merchant payments tab reads, one row only, for the method cards.\n const ledger = useMerchantPayments({\n startDate: range.startDate,\n endDate: range.endDate,\n limit: 1,\n });\n\n const currencies = useMemo(() => data?.currencies ?? [], [data]);\n const focus = focusCurrency(currencies, currency);\n // Recurring revenue is the platform's own billing, in the currency the API\n // names for it (`currency`), shown as it comes, never converted.\n const recurringCurrency = data?.currency || 'NGN';\n const money = useMemo(() => moneyIn(recurringCurrency), [recurringCurrency]);\n const money2 = useMemo(\n () => moneyIn(recurringCurrency, 2),\n [recurringCurrency],\n );\n\n const span = periodPhrase(range);\n const period = `${span[0].toUpperCase()}${span.slice(1)}`;\n\n let body;\n if (isError) {\n body = (\n <LoadFailed\n message=\"Revenue for this period could not be loaded.\"\n onRetry={() => void refetch()}\n />\n );\n } else if (isLoading || !data) {\n body = <AdmLoading label=\"Loading revenue metrics…\" />;\n } else if (tab === 'merchants') {\n body = (\n <MerchantPaymentsView\n startDate={range.startDate}\n endDate={range.endDate}\n currency={currency}\n />\n );\n } else if (tab === 'by-merchant') {\n body = <MerchantsView startDate={range.startDate} endDate={range.endDate} />;\n } else if (tab === 'subscriptions') {\n body = (\n <SubscriptionsView\n startDate={range.startDate}\n endDate={range.endDate}\n revenue={data}\n />\n );\n } else if (tab === 'retention') {\n body = (\n <RevenueRetention\n quality={data.quality}\n money={money}\n currency={currency}\n caption={currency ? `${period} · ${currency}` : period}\n />\n );\n } else {\n body = (\n <RevenueOverview\n data={data}\n health={cockpit.data?.health}\n money={money}\n money2={money2}\n currency={currency}\n focus={focus}\n period={period}\n span={span}\n recurringCaption={`${period} · ${recurringCurrency}`}\n channels={channels.data?.byChannel}\n ledger={ledger.data}\n />\n );\n }\n\n return (\n <div>\n <PageHeader\n title=\"Revenue\"\n description={`All the money moving through ${ADMIN_NAME}: what customers pay merchants, and what merchants pay ${ADMIN_NAME} for their plan.`}\n actions={\n <div className=\"adm-toolbar\">\n {tab === 'overview' ? (\n <SummaryDownload\n ledger={ledger.data}\n revenue={data}\n currency={focus ?? 'NGN'}\n startDate={range.startDate}\n endDate={range.endDate}\n period={period}\n />\n ) : null}\n <CurrencyFilter\n value={currency}\n onChange={setCurrency}\n currencies={currencies}\n />\n <PeriodSelector\n value={rangeKey}\n onChange={setRangeKey}\n customStart={customStart}\n customEnd={customEnd}\n onCustomChange={onCustomChange}\n />\n </div>\n }\n />\n <PageTabs tabs={TABS} active={tab} onChange={setTab} />\n {body}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -8,49 +8,49 @@ function SubscriptionsPanel({
|
|
|
8
8
|
}) {
|
|
9
9
|
const rows = [
|
|
10
10
|
{
|
|
11
|
-
label: "
|
|
11
|
+
label: "Payment missed",
|
|
12
12
|
status: "past_due",
|
|
13
|
-
note: "
|
|
13
|
+
note: "Their last payment failed, so they may leave",
|
|
14
14
|
value: integer.format(recurring.pastDueSubscriptions),
|
|
15
15
|
tone: recurring.pastDueSubscriptions > 0 ? "danger" : "default"
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
|
-
label: "
|
|
18
|
+
label: "Set to end",
|
|
19
19
|
status: "non_renewing",
|
|
20
|
-
note: "
|
|
20
|
+
note: "Will not renew when the paid period ends",
|
|
21
21
|
value: integer.format(recurring.nonRenewingSubscriptions),
|
|
22
22
|
tone: recurring.nonRenewingSubscriptions > 0 ? "danger" : "default"
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
label: "Active",
|
|
26
26
|
status: "active",
|
|
27
|
-
note: "Paying
|
|
27
|
+
note: "Paying for their plan",
|
|
28
28
|
value: integer.format(recurring.activeSubscriptions)
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
|
-
label: "
|
|
31
|
+
label: "On a trial",
|
|
32
32
|
status: "trialing",
|
|
33
|
-
note: "
|
|
33
|
+
note: "Trying a plan, not yet paying",
|
|
34
34
|
value: integer.format(recurring.trialingSubscriptions)
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
|
-
label: "
|
|
38
|
-
note: `Started in ${span}`,
|
|
37
|
+
label: "Joined this period",
|
|
38
|
+
note: `Started paying in ${span}`,
|
|
39
39
|
value: integer.format(recurring.newInRange)
|
|
40
40
|
},
|
|
41
41
|
{
|
|
42
|
-
label: "
|
|
43
|
-
note: `
|
|
42
|
+
label: "Left this period",
|
|
43
|
+
note: `Stopped paying in ${span}`,
|
|
44
44
|
value: integer.format(recurring.churnedInRange),
|
|
45
45
|
tone: recurring.churnedInRange > 0 ? "danger" : "default"
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
|
-
label: "Cancelled",
|
|
49
|
-
note: "
|
|
48
|
+
label: "Cancelled in total",
|
|
49
|
+
note: "Since the start",
|
|
50
50
|
value: integer.format(recurring.cancelledSubscriptions)
|
|
51
51
|
}
|
|
52
52
|
];
|
|
53
|
-
return /* @__PURE__ */ jsx(Panel, { title: "
|
|
53
|
+
return /* @__PURE__ */ jsx(Panel, { title: "Plan subscriptions today", caption: "Right now", height: "var(--adm-panel-h)", children: /* @__PURE__ */ jsx(FigureList, { rows }) });
|
|
54
54
|
}
|
|
55
55
|
export {
|
|
56
56
|
SubscriptionsPanel
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/admin/revenue/subscriptions-panel.tsx"],"sourcesContent":["import type { PlatformRevenue } from '@withpotter/lib/admin-api';\nimport { Panel } from '../_ui';\nimport { FigureList, type Figure } from './figure-list';\n\nconst integer = new Intl.NumberFormat('en-US');\n\n/** Where the subscription base stands, and what started in the window. */\nexport function SubscriptionsPanel({\n recurring,\n span,\n}: {\n recurring: PlatformRevenue['recurring'];\n span: string;\n}) {\n const rows: Figure[] = [\n {\n label: '
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/revenue/subscriptions-panel.tsx"],"sourcesContent":["import type { PlatformRevenue } from '@withpotter/lib/admin-api';\nimport { Panel } from '../_ui';\nimport { FigureList, type Figure } from './figure-list';\n\nconst integer = new Intl.NumberFormat('en-US');\n\n/** Where the subscription base stands, and what started in the window. */\nexport function SubscriptionsPanel({\n recurring,\n span,\n}: {\n recurring: PlatformRevenue['recurring'];\n span: string;\n}) {\n const rows: Figure[] = [\n {\n label: 'Payment missed',\n status: 'past_due',\n note: 'Their last payment failed, so they may leave',\n value: integer.format(recurring.pastDueSubscriptions),\n tone: recurring.pastDueSubscriptions > 0 ? 'danger' : 'default',\n },\n {\n label: 'Set to end',\n status: 'non_renewing',\n note: 'Will not renew when the paid period ends',\n value: integer.format(recurring.nonRenewingSubscriptions),\n tone: recurring.nonRenewingSubscriptions > 0 ? 'danger' : 'default',\n },\n {\n label: 'Active',\n status: 'active',\n note: 'Paying for their plan',\n value: integer.format(recurring.activeSubscriptions),\n },\n {\n label: 'On a trial',\n status: 'trialing',\n note: 'Trying a plan, not yet paying',\n value: integer.format(recurring.trialingSubscriptions),\n },\n {\n label: 'Joined this period',\n note: `Started paying in ${span}`,\n value: integer.format(recurring.newInRange),\n },\n {\n label: 'Left this period',\n note: `Stopped paying in ${span}`,\n value: integer.format(recurring.churnedInRange),\n tone: recurring.churnedInRange > 0 ? 'danger' : 'default',\n },\n {\n label: 'Cancelled in total',\n note: 'Since the start',\n value: integer.format(recurring.cancelledSubscriptions),\n },\n ];\n\n return (\n <Panel title=\"Plan subscriptions today\" caption=\"Right now\" height=\"var(--adm-panel-h)\">\n <FigureList rows={rows} />\n </Panel>\n );\n}\n"],"mappings":"AA6DM;AA5DN,SAAS,aAAa;AACtB,SAAS,kBAA+B;AAExC,MAAM,UAAU,IAAI,KAAK,aAAa,OAAO;AAGtC,SAAS,mBAAmB;AAAA,EACjC;AAAA,EACA;AACF,GAGG;AACD,QAAM,OAAiB;AAAA,IACrB;AAAA,MACE,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,MAAM;AAAA,MACN,OAAO,QAAQ,OAAO,UAAU,oBAAoB;AAAA,MACpD,MAAM,UAAU,uBAAuB,IAAI,WAAW;AAAA,IACxD;AAAA,IACA;AAAA,MACE,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,MAAM;AAAA,MACN,OAAO,QAAQ,OAAO,UAAU,wBAAwB;AAAA,MACxD,MAAM,UAAU,2BAA2B,IAAI,WAAW;AAAA,IAC5D;AAAA,IACA;AAAA,MACE,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,MAAM;AAAA,MACN,OAAO,QAAQ,OAAO,UAAU,mBAAmB;AAAA,IACrD;AAAA,IACA;AAAA,MACE,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,MAAM;AAAA,MACN,OAAO,QAAQ,OAAO,UAAU,qBAAqB;AAAA,IACvD;AAAA,IACA;AAAA,MACE,OAAO;AAAA,MACP,MAAM,qBAAqB,IAAI;AAAA,MAC/B,OAAO,QAAQ,OAAO,UAAU,UAAU;AAAA,IAC5C;AAAA,IACA;AAAA,MACE,OAAO;AAAA,MACP,MAAM,qBAAqB,IAAI;AAAA,MAC/B,OAAO,QAAQ,OAAO,UAAU,cAAc;AAAA,MAC9C,MAAM,UAAU,iBAAiB,IAAI,WAAW;AAAA,IAClD;AAAA,IACA;AAAA,MACE,OAAO;AAAA,MACP,MAAM;AAAA,MACN,OAAO,QAAQ,OAAO,UAAU,sBAAsB;AAAA,IACxD;AAAA,EACF;AAEA,SACE,oBAAC,SAAM,OAAM,4BAA2B,SAAQ,aAAY,QAAO,sBACjE,8BAAC,cAAW,MAAY,GAC1B;AAEJ;","names":[]}
|
|
@@ -18,9 +18,12 @@ import {
|
|
|
18
18
|
word
|
|
19
19
|
} from "./ledger-model";
|
|
20
20
|
import { PlanMixPanel } from "./plan-mix-panel";
|
|
21
|
+
import { PlanPaymentDrawer } from "./plan-payment-drawer";
|
|
22
|
+
import { useUrlFilters } from "./use-url-filters";
|
|
21
23
|
import styles from "./ledger.module.css";
|
|
22
24
|
import table from "./revenue.module.css";
|
|
23
25
|
const PAGE_SIZE = 25;
|
|
26
|
+
const FILTERS = { q: "", status: "", method: "" };
|
|
24
27
|
const integer = new Intl.NumberFormat("en-US");
|
|
25
28
|
const STATUSES = [
|
|
26
29
|
{ value: "", label: "Every result" },
|
|
@@ -40,23 +43,19 @@ function SubscriptionsView({
|
|
|
40
43
|
endDate,
|
|
41
44
|
revenue
|
|
42
45
|
}) {
|
|
43
|
-
const
|
|
44
|
-
const [
|
|
45
|
-
const [
|
|
46
|
-
const [method, setMethod] = useState("");
|
|
47
|
-
const [page, setPage] = useState(1);
|
|
46
|
+
const { values, page, set } = useUrlFilters("sp", FILTERS);
|
|
47
|
+
const [search, setSearch] = useState(values.q);
|
|
48
|
+
const [open, setOpen] = useState(null);
|
|
48
49
|
useEffect(() => {
|
|
49
|
-
const timer = setTimeout(() =>
|
|
50
|
+
const timer = setTimeout(() => {
|
|
51
|
+
if (search.trim() !== values.q) set({ q: search.trim() });
|
|
52
|
+
}, 300);
|
|
50
53
|
return () => clearTimeout(timer);
|
|
51
54
|
}, [search]);
|
|
52
55
|
const filters = useMemo(
|
|
53
|
-
() => ({ startDate, endDate, status, method, q, page, limit: PAGE_SIZE }),
|
|
54
|
-
[startDate, endDate,
|
|
56
|
+
() => ({ startDate, endDate, status: values.status, method: values.method, q: values.q, page, limit: PAGE_SIZE }),
|
|
57
|
+
[startDate, endDate, values, page]
|
|
55
58
|
);
|
|
56
|
-
const change = (set) => (value) => {
|
|
57
|
-
set(value);
|
|
58
|
-
setPage(1);
|
|
59
|
-
};
|
|
60
59
|
const { recurring } = revenue;
|
|
61
60
|
const tracked = recurring.tracked !== false;
|
|
62
61
|
const currency = recurring.currency ?? revenue.currency ?? "NGN";
|
|
@@ -118,13 +117,13 @@ function SubscriptionsView({
|
|
|
118
117
|
FilterSearch,
|
|
119
118
|
{
|
|
120
119
|
value: search,
|
|
121
|
-
onChange:
|
|
120
|
+
onChange: setSearch,
|
|
122
121
|
placeholder: "Search a merchant's name or email, or a reference",
|
|
123
122
|
label: "Search plan payments"
|
|
124
123
|
}
|
|
125
124
|
),
|
|
126
|
-
/* @__PURE__ */ jsx(FilterSelect, { value: status, onChange:
|
|
127
|
-
/* @__PURE__ */ jsx(FilterSelect, { value: method, onChange:
|
|
125
|
+
/* @__PURE__ */ jsx(FilterSelect, { value: values.status, onChange: (status) => set({ status }), options: STATUSES, label: "Payment result" }),
|
|
126
|
+
/* @__PURE__ */ jsx(FilterSelect, { value: values.method, onChange: (method) => set({ method }), options: METHODS, label: "Way of paying" })
|
|
128
127
|
] }),
|
|
129
128
|
/* @__PURE__ */ jsx(
|
|
130
129
|
DownloadButton,
|
|
@@ -148,7 +147,7 @@ function SubscriptionsView({
|
|
|
148
147
|
/* @__PURE__ */ jsx(Th, { children: "Result" }),
|
|
149
148
|
/* @__PURE__ */ jsx(Th, { align: "right", children: "Amount" })
|
|
150
149
|
] }),
|
|
151
|
-
children: payments.data.items.map((row) => /* @__PURE__ */ jsx(PlanPaymentRow, { row }, row.id))
|
|
150
|
+
children: payments.data.items.map((row) => /* @__PURE__ */ jsx(PlanPaymentRow, { row, onOpen: setOpen }, row.id))
|
|
152
151
|
}
|
|
153
152
|
),
|
|
154
153
|
/* @__PURE__ */ jsx(
|
|
@@ -158,15 +157,16 @@ function SubscriptionsView({
|
|
|
158
157
|
totalPages: Math.max(Math.ceil(payments.data.total / PAGE_SIZE), 1),
|
|
159
158
|
total: payments.data.total,
|
|
160
159
|
noun: "plan payments",
|
|
161
|
-
onPage:
|
|
160
|
+
onPage: (next) => set({ page: next })
|
|
162
161
|
}
|
|
163
|
-
)
|
|
162
|
+
),
|
|
163
|
+
/* @__PURE__ */ jsx(PlanPaymentDrawer, { row: open, onClose: () => setOpen(null) })
|
|
164
164
|
] })
|
|
165
165
|
] });
|
|
166
166
|
}
|
|
167
|
-
function PlanPaymentRow({ row }) {
|
|
167
|
+
function PlanPaymentRow({ row, onOpen }) {
|
|
168
168
|
const when = row.paidAt ?? row.createdAt;
|
|
169
|
-
return /* @__PURE__ */ jsxs(TableRow, { children: [
|
|
169
|
+
return /* @__PURE__ */ jsxs(TableRow, { onClick: () => onOpen(row), children: [
|
|
170
170
|
/* @__PURE__ */ jsx("td", { className: table.num, children: new Date(when).toLocaleString(void 0, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) }),
|
|
171
171
|
/* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsxs("span", { className: styles.who, children: [
|
|
172
172
|
/* @__PURE__ */ jsx("span", { className: styles.whoName, children: row.merchantName || "Unknown merchant" }),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/admin/revenue/subscriptions-view.tsx"],"sourcesContent":["'use client';\n\nimport { useEffect, useMemo, useState } from 'react';\nimport { EmptyNote, TableRow, TableShell, Th } from '@withpotter/ui-kit';\nimport {\n platformAdminApi,\n type PlatformRevenue,\n type SubscriptionPaymentRow,\n} from '@withpotter/lib/admin-api';\nimport { useSubscriptionPayments } from '../hooks';\nimport { LoadFailed } from '../audit/load-failed';\nimport { CountUp, Reveal } from '../_fx';\nimport { AdmLoading, FilterSearch, FilterSelect, Filters, Pagination, StatCard, moneyIn } from '../_ui';\nimport { DownloadButton } from './download-button';\nimport {\n PURPOSE_WORDS,\n STATUS_WORDS,\n SUBSCRIPTION_METHOD_WORDS,\n downloadName,\n minorToMoney,\n word,\n} from './ledger-model';\nimport { PlanMixPanel } from './plan-mix-panel';\nimport styles from './ledger.module.css';\nimport table from './revenue.module.css';\n\nconst PAGE_SIZE = 25;\nconst integer = new Intl.NumberFormat('en-US');\n\nconst STATUSES = [\n { value: '', label: 'Every result' },\n { value: 'succeeded', label: 'Paid' },\n { value: 'failed', label: 'Failed' },\n { value: 'pending', label: 'Waiting' },\n];\nconst METHODS = [\n { value: '', label: 'Every way of paying' },\n { value: 'gateway', label: 'Card or bank' },\n { value: 'wallet', label: 'Wallet' },\n { value: 'manual', label: 'Recorded by hand' },\n];\n\nconst tone = (status: string) =>\n status === 'succeeded' ? styles.statusGood : status === 'failed' ? styles.statusBad : styles.statusWait;\n\n/** What merchants pay Olive for their plan: the income figures, who pays what, and every plan payment. */\nexport function SubscriptionsView({\n startDate,\n endDate,\n revenue,\n}: {\n startDate?: string;\n endDate?: string;\n revenue: PlatformRevenue;\n}) {\n const [search, setSearch] = useState('');\n const [q, setQ] = useState('');\n const [status, setStatus] = useState('');\n const [method, setMethod] = useState('');\n const [page, setPage] = useState(1);\n\n useEffect(() => {\n const timer = setTimeout(() => setQ(search.trim()), 300);\n\n return () => clearTimeout(timer);\n }, [search]);\n\n const filters = useMemo(\n () => ({ startDate, endDate, status, method, q, page, limit: PAGE_SIZE }),\n [startDate, endDate, status, method, q, page],\n );\n const change = <T,>(set: (v: T) => void) => (value: T) => {\n set(value);\n setPage(1);\n };\n\n const { recurring } = revenue;\n const tracked = recurring.tracked !== false;\n const currency = recurring.currency ?? revenue.currency ?? 'NGN';\n const money = moneyIn(currency);\n const money2 = moneyIn(currency, 2);\n const notSet = 'Not set up yet';\n const why = 'The plan payments have not been connected to this page yet.';\n const payments = useSubscriptionPayments(filters);\n\n return (\n <div>\n <p className={styles.lede}>\n This is money that merchants pay Olive for their plan. It is kept apart from the money customers pay merchants.\n </p>\n\n <section aria-label=\"Plan income\" className=\"adm-grid\">\n <Reveal index={0} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income each month\"\n value={tracked ? <CountUp value={recurring.mrr} format={money} /> : notSet}\n sub={tracked ? `${integer.format(recurring.payingMerchants ?? recurring.activeSubscriptions)} merchants paying` : why}\n hint=\"What merchants on a paid plan are expected to pay Olive every month. Merchants on a free plan, a trial or a pause are not counted. (MRR)\"\n />\n </Reveal>\n <Reveal index={1} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income over a year\"\n value={tracked ? <CountUp value={recurring.arr} format={money} /> : notSet}\n sub={tracked ? 'Each month times 12' : why}\n hint=\"What plan income adds up to over a year if nothing changes: the monthly figure times 12. (ARR)\"\n />\n </Reveal>\n <Reveal index={2} className=\"adm-span-3\">\n <StatCard\n label=\"Average per paying merchant\"\n value={tracked ? <CountUp value={recurring.arpa} format={money2} /> : notSet}\n sub={tracked ? 'Each month' : why}\n hint=\"What one paying merchant pays Olive in a month, on average. (ARPA)\"\n />\n </Reveal>\n <Reveal index={3} className=\"adm-span-3\">\n <StatCard\n label=\"Joined and left\"\n value={tracked ? `${integer.format(recurring.newInRange)} joined` : notSet}\n sub={tracked ? `${integer.format(recurring.churnedInRange)} left or cancelled in this period` : why}\n hint=\"Merchants who started a plan in this period, and merchants whose plan was cancelled, ended or switched off.\"\n />\n </Reveal>\n </section>\n\n {tracked && recurring.planBreakdown.length > 0 ? (\n <>\n <h2 className={styles.sectionTitle}>Who pays what</h2>\n <p className={styles.sectionNote}>How the monthly plan income splits across plans.</p>\n <PlanMixPanel planBreakdown={recurring.planBreakdown} money={money} caption={`Each month · ${currency}`} />\n </>\n ) : null}\n\n <h2 className={styles.sectionTitle}>Every plan payment</h2>\n {payments.isError ? (\n <div className={styles.notice}>\n Plan payments are not available on this box yet. Once the subscription module is connected, every payment will\n be listed here.\n </div>\n ) : payments.isLoading || !payments.data ? (\n <AdmLoading label=\"Loading plan payments…\" />\n ) : (\n <>\n <div className={styles.toolbar}>\n <Filters label=\"Filter plan payments\">\n <FilterSearch\n value={search}\n onChange={change(setSearch)}\n placeholder=\"Search a merchant's name or email, or a reference\"\n label=\"Search plan payments\"\n />\n <FilterSelect value={status} onChange={change(setStatus)} options={STATUSES} label=\"Payment result\" />\n <FilterSelect value={method} onChange={change(setMethod)} options={METHODS} label=\"Way of paying\" />\n </Filters>\n <DownloadButton\n filename={downloadName('plan-payments', startDate, endDate)}\n load={() => platformAdminApi.subscriptionPaymentsCsv({ ...filters, page: undefined, limit: undefined })}\n />\n </div>\n\n {payments.data.items.length === 0 ? (\n <EmptyNote className={table.empty}>No plan payments match. Try a wider date range or clear a filter.</EmptyNote>\n ) : (\n <TableShell\n minWidth={860}\n density=\"compact\"\n head={\n <tr>\n <Th>When</Th>\n <Th>Merchant</Th>\n <Th>Plan</Th>\n <Th>For</Th>\n <Th>How</Th>\n <Th>Result</Th>\n <Th align=\"right\">Amount</Th>\n </tr>\n }\n >\n {payments.data.items.map((row) => (\n <PlanPaymentRow key={row.id} row={row} />\n ))}\n </TableShell>\n )}\n <Pagination\n page={page}\n totalPages={Math.max(Math.ceil(payments.data.total / PAGE_SIZE), 1)}\n total={payments.data.total}\n noun=\"plan payments\"\n onPage={setPage}\n />\n </>\n )}\n </div>\n );\n}\n\nfunction PlanPaymentRow({ row }: { row: SubscriptionPaymentRow }) {\n const when = row.paidAt ?? row.createdAt;\n\n return (\n <TableRow>\n <td className={table.num}>\n {new Date(when).toLocaleString(undefined, { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}\n </td>\n <td>\n <span className={styles.who}>\n <span className={styles.whoName}>{row.merchantName || 'Unknown merchant'}</span>\n {row.merchantEmail ? <span className={styles.whoSub}>{row.merchantEmail}</span> : null}\n </span>\n </td>\n <td className={table.name}>{row.plan.charAt(0).toUpperCase() + row.plan.slice(1)}</td>\n <td>{word(PURPOSE_WORDS, row.purpose)}</td>\n <td>{word(SUBSCRIPTION_METHOD_WORDS, row.method)}</td>\n <td>\n <span className={styles.who}>\n <span className={`${styles.status} ${tone(row.status)}`}>{word(STATUS_WORDS, row.status)}</span>\n {row.status === 'failed' && row.failureReason ? <span className={styles.whoSub}>{row.failureReason}</span> : null}\n </span>\n </td>\n <td className={table.numStrong}>{minorToMoney(row.amountKobo, 'NGN')}</td>\n </TableRow>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/revenue/subscriptions-view.tsx"],"sourcesContent":["'use client';\n\nimport { useEffect, useMemo, useState } from 'react';\nimport { EmptyNote, TableRow, TableShell, Th } from '@withpotter/ui-kit';\nimport {\n platformAdminApi,\n type PlatformRevenue,\n type SubscriptionPaymentRow,\n} from '@withpotter/lib/admin-api';\nimport { useSubscriptionPayments } from '../hooks';\nimport { LoadFailed } from '../audit/load-failed';\nimport { CountUp, Reveal } from '../_fx';\nimport { AdmLoading, FilterSearch, FilterSelect, Filters, Pagination, StatCard, moneyIn } from '../_ui';\nimport { DownloadButton } from './download-button';\nimport {\n PURPOSE_WORDS,\n STATUS_WORDS,\n SUBSCRIPTION_METHOD_WORDS,\n downloadName,\n minorToMoney,\n word,\n} from './ledger-model';\nimport { PlanMixPanel } from './plan-mix-panel';\nimport { PlanPaymentDrawer } from './plan-payment-drawer';\nimport { useUrlFilters } from './use-url-filters';\nimport styles from './ledger.module.css';\nimport table from './revenue.module.css';\n\nconst PAGE_SIZE = 25;\nconst FILTERS = { q: '', status: '', method: '' };\nconst integer = new Intl.NumberFormat('en-US');\n\nconst STATUSES = [\n { value: '', label: 'Every result' },\n { value: 'succeeded', label: 'Paid' },\n { value: 'failed', label: 'Failed' },\n { value: 'pending', label: 'Waiting' },\n];\nconst METHODS = [\n { value: '', label: 'Every way of paying' },\n { value: 'gateway', label: 'Card or bank' },\n { value: 'wallet', label: 'Wallet' },\n { value: 'manual', label: 'Recorded by hand' },\n];\n\nconst tone = (status: string) =>\n status === 'succeeded' ? styles.statusGood : status === 'failed' ? styles.statusBad : styles.statusWait;\n\n/** What merchants pay Olive for their plan: the income figures, who pays what, and every plan payment. */\nexport function SubscriptionsView({\n startDate,\n endDate,\n revenue,\n}: {\n startDate?: string;\n endDate?: string;\n revenue: PlatformRevenue;\n}) {\n const { values, page, set } = useUrlFilters('sp', FILTERS);\n const [search, setSearch] = useState(values.q);\n const [open, setOpen] = useState<SubscriptionPaymentRow | null>(null);\n\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, status: values.status, method: values.method, q: values.q, page, limit: PAGE_SIZE }),\n [startDate, endDate, values, page],\n );\n\n const { recurring } = revenue;\n const tracked = recurring.tracked !== false;\n const currency = recurring.currency ?? revenue.currency ?? 'NGN';\n const money = moneyIn(currency);\n const money2 = moneyIn(currency, 2);\n const notSet = 'Not set up yet';\n const why = 'The plan payments have not been connected to this page yet.';\n const payments = useSubscriptionPayments(filters);\n\n return (\n <div>\n <p className={styles.lede}>\n This is money that merchants pay Olive for their plan. It is kept apart from the money customers pay merchants.\n </p>\n\n <section aria-label=\"Plan income\" className=\"adm-grid\">\n <Reveal index={0} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income each month\"\n value={tracked ? <CountUp value={recurring.mrr} format={money} /> : notSet}\n sub={tracked ? `${integer.format(recurring.payingMerchants ?? recurring.activeSubscriptions)} merchants paying` : why}\n hint=\"What merchants on a paid plan are expected to pay Olive every month. Merchants on a free plan, a trial or a pause are not counted. (MRR)\"\n />\n </Reveal>\n <Reveal index={1} className=\"adm-span-3\">\n <StatCard\n label=\"Plan income over a year\"\n value={tracked ? <CountUp value={recurring.arr} format={money} /> : notSet}\n sub={tracked ? 'Each month times 12' : why}\n hint=\"What plan income adds up to over a year if nothing changes: the monthly figure times 12. (ARR)\"\n />\n </Reveal>\n <Reveal index={2} className=\"adm-span-3\">\n <StatCard\n label=\"Average per paying merchant\"\n value={tracked ? <CountUp value={recurring.arpa} format={money2} /> : notSet}\n sub={tracked ? 'Each month' : why}\n hint=\"What one paying merchant pays Olive in a month, on average. (ARPA)\"\n />\n </Reveal>\n <Reveal index={3} className=\"adm-span-3\">\n <StatCard\n label=\"Joined and left\"\n value={tracked ? `${integer.format(recurring.newInRange)} joined` : notSet}\n sub={tracked ? `${integer.format(recurring.churnedInRange)} left or cancelled in this period` : why}\n hint=\"Merchants who started a plan in this period, and merchants whose plan was cancelled, ended or switched off.\"\n />\n </Reveal>\n </section>\n\n {tracked && recurring.planBreakdown.length > 0 ? (\n <>\n <h2 className={styles.sectionTitle}>Who pays what</h2>\n <p className={styles.sectionNote}>How the monthly plan income splits across plans.</p>\n <PlanMixPanel planBreakdown={recurring.planBreakdown} money={money} caption={`Each month · ${currency}`} />\n </>\n ) : null}\n\n <h2 className={styles.sectionTitle}>Every plan payment</h2>\n {payments.isError ? (\n <div className={styles.notice}>\n Plan payments are not available on this box yet. Once the subscription module is connected, every payment will\n be listed here.\n </div>\n ) : payments.isLoading || !payments.data ? (\n <AdmLoading label=\"Loading plan payments…\" />\n ) : (\n <>\n <div className={styles.toolbar}>\n <Filters label=\"Filter plan payments\">\n <FilterSearch\n value={search}\n onChange={setSearch}\n placeholder=\"Search a merchant's name or email, or a reference\"\n label=\"Search plan payments\"\n />\n <FilterSelect value={values.status} onChange={(status) => set({ status })} options={STATUSES} label=\"Payment result\" />\n <FilterSelect value={values.method} onChange={(method) => set({ method })} options={METHODS} label=\"Way of paying\" />\n </Filters>\n <DownloadButton\n filename={downloadName('plan-payments', startDate, endDate)}\n load={() => platformAdminApi.subscriptionPaymentsCsv({ ...filters, page: undefined, limit: undefined })}\n />\n </div>\n\n {payments.data.items.length === 0 ? (\n <EmptyNote className={table.empty}>No plan payments match. Try a wider date range or clear a filter.</EmptyNote>\n ) : (\n <TableShell\n minWidth={860}\n density=\"compact\"\n head={\n <tr>\n <Th>When</Th>\n <Th>Merchant</Th>\n <Th>Plan</Th>\n <Th>For</Th>\n <Th>How</Th>\n <Th>Result</Th>\n <Th align=\"right\">Amount</Th>\n </tr>\n }\n >\n {payments.data.items.map((row) => (\n <PlanPaymentRow key={row.id} row={row} onOpen={setOpen} />\n ))}\n </TableShell>\n )}\n <Pagination\n page={page}\n totalPages={Math.max(Math.ceil(payments.data.total / PAGE_SIZE), 1)}\n total={payments.data.total}\n noun=\"plan payments\"\n onPage={(next) => set({ page: next })}\n />\n <PlanPaymentDrawer row={open} onClose={() => setOpen(null)} />\n </>\n )}\n </div>\n );\n}\n\nfunction PlanPaymentRow({ row, onOpen }: { row: SubscriptionPaymentRow; onOpen: (row: SubscriptionPaymentRow) => void }) {\n const when = row.paidAt ?? row.createdAt;\n\n return (\n <TableRow onClick={() => onOpen(row)}>\n <td className={table.num}>\n {new Date(when).toLocaleString(undefined, { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}\n </td>\n <td>\n <span className={styles.who}>\n <span className={styles.whoName}>{row.merchantName || 'Unknown merchant'}</span>\n {row.merchantEmail ? <span className={styles.whoSub}>{row.merchantEmail}</span> : null}\n </span>\n </td>\n <td className={table.name}>{row.plan.charAt(0).toUpperCase() + row.plan.slice(1)}</td>\n <td>{word(PURPOSE_WORDS, row.purpose)}</td>\n <td>{word(SUBSCRIPTION_METHOD_WORDS, row.method)}</td>\n <td>\n <span className={styles.who}>\n <span className={`${styles.status} ${tone(row.status)}`}>{word(STATUS_WORDS, row.status)}</span>\n {row.status === 'failed' && row.failureReason ? <span className={styles.whoSub}>{row.failureReason}</span> : null}\n </span>\n </td>\n <td className={table.numStrong}>{minorToMoney(row.amountKobo, 'NGN')}</td>\n </TableRow>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { toCsv } from "@withpotter/lib/csv";
|
|
4
|
+
import { DownloadButton } from "./download-button";
|
|
5
|
+
import { downloadName } from "./ledger-model";
|
|
6
|
+
import { PrintSummaryButton } from "./print-summary-button";
|
|
7
|
+
import { SUMMARY_HEAD, summaryRows } from "./summary-rows";
|
|
8
|
+
import { summaryModel } from "./summary-print";
|
|
9
|
+
function SummaryDownload({
|
|
10
|
+
ledger,
|
|
11
|
+
revenue,
|
|
12
|
+
currency,
|
|
13
|
+
startDate,
|
|
14
|
+
endDate,
|
|
15
|
+
period
|
|
16
|
+
}) {
|
|
17
|
+
const rows = summaryRows(ledger, revenue, currency);
|
|
18
|
+
if (rows.length === 0) return null;
|
|
19
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
20
|
+
/* @__PURE__ */ jsx(PrintSummaryButton, { model: summaryModel(ledger, revenue, currency, period) }),
|
|
21
|
+
/* @__PURE__ */ jsx(
|
|
22
|
+
DownloadButton,
|
|
23
|
+
{
|
|
24
|
+
label: "Download summary",
|
|
25
|
+
filename: downloadName("revenue-summary", startDate, endDate),
|
|
26
|
+
load: async () => toCsv([SUMMARY_HEAD, ...rows])
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
] });
|
|
30
|
+
}
|
|
31
|
+
export {
|
|
32
|
+
SummaryDownload
|
|
33
|
+
};
|
|
34
|
+
//# sourceMappingURL=summary-download.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/revenue/summary-download.tsx"],"sourcesContent":["'use client';\n\nimport { toCsv } from '@withpotter/lib/csv';\nimport type { MerchantPaymentsPage, PlatformRevenue } from '@withpotter/lib/admin-api';\nimport { DownloadButton } from './download-button';\nimport { downloadName } from './ledger-model';\nimport { PrintSummaryButton } from './print-summary-button';\nimport { SUMMARY_HEAD, summaryRows } from './summary-rows';\nimport { summaryModel } from './summary-print';\n\n/** The figures on the Overview as a one-page PDF (through the print window) or a small spreadsheet. */\nexport function SummaryDownload({\n ledger,\n revenue,\n currency,\n startDate,\n endDate,\n period,\n}: {\n ledger?: MerchantPaymentsPage;\n revenue?: PlatformRevenue;\n currency: string;\n startDate?: string;\n endDate?: string;\n /** The period in words, such as \"last 30 days\". */\n period: string;\n}) {\n const rows = summaryRows(ledger, revenue, currency);\n\n if (rows.length === 0) return null;\n\n return (\n <>\n <PrintSummaryButton model={summaryModel(ledger, revenue, currency, period)} />\n <DownloadButton\n label=\"Download summary\"\n filename={downloadName('revenue-summary', startDate, endDate)}\n load={async () => toCsv([SUMMARY_HEAD, ...rows])}\n />\n </>\n );\n}\n"],"mappings":";AAgCI,mBACE,KADF;AA9BJ,SAAS,aAAa;AAEtB,SAAS,sBAAsB;AAC/B,SAAS,oBAAoB;AAC7B,SAAS,0BAA0B;AACnC,SAAS,cAAc,mBAAmB;AAC1C,SAAS,oBAAoB;AAGtB,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAQG;AACD,QAAM,OAAO,YAAY,QAAQ,SAAS,QAAQ;AAElD,MAAI,KAAK,WAAW,EAAG,QAAO;AAE9B,SACE,iCACE;AAAA,wBAAC,sBAAmB,OAAO,aAAa,QAAQ,SAAS,UAAU,MAAM,GAAG;AAAA,IAC5E;AAAA,MAAC;AAAA;AAAA,QACC,OAAM;AAAA,QACN,UAAU,aAAa,mBAAmB,WAAW,OAAO;AAAA,QAC5D,MAAM,YAAY,MAAM,CAAC,cAAc,GAAG,IAAI,CAAC;AAAA;AAAA,IACjD;AAAA,KACF;AAEJ;","names":[]}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { methodCards, minorToMoney, pctChange } from "./ledger-model";
|
|
2
|
+
const integer = new Intl.NumberFormat("en-US");
|
|
3
|
+
const change = (now, before) => {
|
|
4
|
+
const pct = pctChange(now, before);
|
|
5
|
+
return pct === null ? void 0 : `${pct > 0 ? "+" : ""}${pct}% since the period before`;
|
|
6
|
+
};
|
|
7
|
+
function summaryModel(ledger, revenue, currency, period) {
|
|
8
|
+
const groups = [];
|
|
9
|
+
const now = ledger?.cards.current.find((c) => c.currency === currency);
|
|
10
|
+
const before = ledger?.cards.previous?.find((c) => c.currency === currency);
|
|
11
|
+
const money = (minor) => minorToMoney(minor, currency);
|
|
12
|
+
if (ledger && now) {
|
|
13
|
+
groups.push({
|
|
14
|
+
heading: "Money merchants earned",
|
|
15
|
+
note: "What customers paid to merchants. This is the merchants' money, not Olive's own income.",
|
|
16
|
+
rows: [
|
|
17
|
+
{ label: "Money received", value: money(now.grossMinor), detail: change(now.grossMinor, before?.grossMinor) },
|
|
18
|
+
{ label: "Fees taken", value: money(now.feeMinor), detail: change(now.feeMinor, before?.feeMinor) },
|
|
19
|
+
{ label: "Merchants keep", value: money(now.netMinor), detail: change(now.netMinor, before?.netMinor) },
|
|
20
|
+
{ label: "Refunded", value: money(now.refundsMinor) },
|
|
21
|
+
{ label: "Payments that went through", value: integer.format(now.payments) },
|
|
22
|
+
{ label: "Payments that worked", value: now.successRate === null ? "\u2013" : `${now.successRate}%` }
|
|
23
|
+
]
|
|
24
|
+
});
|
|
25
|
+
groups.push({
|
|
26
|
+
heading: "How customers paid",
|
|
27
|
+
note: "Money received, by way of paying.",
|
|
28
|
+
rows: methodCards(ledger.byChannel.current, ledger.byChannel.previous, currency).map((card) => ({
|
|
29
|
+
label: card.label,
|
|
30
|
+
value: money(card.amountMinor),
|
|
31
|
+
detail: card.payments === 0 ? "No payments this way" : [
|
|
32
|
+
`${integer.format(card.payments)} payments`,
|
|
33
|
+
`${card.share}% of the money received`,
|
|
34
|
+
card.change === null ? "" : `${card.change > 0 ? "+" : ""}${card.change}% since the period before`
|
|
35
|
+
].filter(Boolean).join(" \xB7 ")
|
|
36
|
+
}))
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
const plan = revenue?.recurring;
|
|
40
|
+
const naira = (value) => minorToMoney(Math.round(value * 100), plan?.currency ?? "NGN");
|
|
41
|
+
groups.push({
|
|
42
|
+
heading: "Money Olive earns",
|
|
43
|
+
note: "What merchants pay Olive for their plan.",
|
|
44
|
+
rows: plan && plan.tracked !== false ? [
|
|
45
|
+
{ label: "Plan income each month", value: naira(plan.mrr) },
|
|
46
|
+
{ label: "Plan income over a year", value: naira(plan.arr) },
|
|
47
|
+
{ label: "Average per paying merchant", value: naira(plan.arpa) },
|
|
48
|
+
{ label: "Merchants paying", value: integer.format(plan.payingMerchants ?? plan.activeSubscriptions) },
|
|
49
|
+
{ label: "Joined this period", value: integer.format(plan.newInRange) },
|
|
50
|
+
{ label: "Left this period", value: integer.format(plan.churnedInRange) }
|
|
51
|
+
] : [{ label: "Plan income", value: "Not set up yet", detail: "The plan payments are not connected on this box." }]
|
|
52
|
+
});
|
|
53
|
+
return { title: "Revenue summary", period, groups };
|
|
54
|
+
}
|
|
55
|
+
const esc = (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
56
|
+
function summaryHtml(model, brand, printedOn) {
|
|
57
|
+
const groups = model.groups.map(
|
|
58
|
+
(group) => `<section><h2>${esc(group.heading)}</h2><p class="note">${esc(group.note)}</p><table>${group.rows.map(
|
|
59
|
+
(row) => `<tr><td class="label">${esc(row.label)}${row.detail ? `<div class="detail">${esc(row.detail)}</div>` : ""}</td><td class="value">${esc(row.value)}</td></tr>`
|
|
60
|
+
).join("")}</table></section>`
|
|
61
|
+
).join("");
|
|
62
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${esc(model.title)}</title><style>
|
|
63
|
+
@page { size: A4; margin: 14mm; }
|
|
64
|
+
* { box-sizing: border-box; }
|
|
65
|
+
body { margin: 0; font: 12px/1.45 -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; color: #1f2a14; }
|
|
66
|
+
h1 { margin: 0 0 2px; font-size: 22px; color: #3d5a1e; }
|
|
67
|
+
.period { margin: 0 0 14px; color: #5b6356; font-size: 13px; }
|
|
68
|
+
section { margin: 0 0 14px; break-inside: avoid; }
|
|
69
|
+
h2 { margin: 0 0 2px; font-size: 14px; color: #3d5a1e; }
|
|
70
|
+
.note { margin: 0 0 6px; color: #5b6356; }
|
|
71
|
+
table { width: 100%; border-collapse: collapse; }
|
|
72
|
+
td { padding: 5px 0; border-bottom: 1px solid #d9ddd3; vertical-align: top; }
|
|
73
|
+
.value { text-align: right; font-weight: 600; white-space: nowrap; padding-left: 12px; }
|
|
74
|
+
.detail { color: #5b6356; font-size: 11px; }
|
|
75
|
+
footer { margin-top: 10px; color: #5b6356; font-size: 10px; }
|
|
76
|
+
</style></head><body><h1>${esc(model.title)}</h1><p class="period">${esc(brand)} \xB7 ${esc(model.period)}</p>${groups}<footer>Printed ${esc(printedOn)}. Amounts are in the currency shown; different currencies are never added together.</footer></body></html>`;
|
|
77
|
+
}
|
|
78
|
+
export {
|
|
79
|
+
summaryHtml,
|
|
80
|
+
summaryModel
|
|
81
|
+
};
|
|
82
|
+
//# sourceMappingURL=summary-print.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/revenue/summary-print.ts"],"sourcesContent":["import type { MerchantPaymentsPage, PlatformRevenue } from '@withpotter/lib/admin-api';\nimport { methodCards, minorToMoney, pctChange } from './ledger-model';\n\nexport interface SummaryRow {\n label: string;\n value: string;\n /** A short second line: the change since the period before, or a count. */\n detail?: string;\n}\n\nexport interface SummaryGroup {\n heading: string;\n note: string;\n rows: SummaryRow[];\n}\n\nexport interface SummaryModel {\n title: string;\n period: string;\n groups: SummaryGroup[];\n}\n\nconst integer = new Intl.NumberFormat('en-US');\n\nconst change = (now: string | number, before?: string | number): string | undefined => {\n const pct = pctChange(now, before);\n\n return pct === null ? undefined : `${pct > 0 ? '+' : ''}${pct}% since the period before`;\n};\n\n/** The Overview's figures as words and amounts a person can read on one printed page. */\nexport function summaryModel(\n ledger: MerchantPaymentsPage | undefined,\n revenue: PlatformRevenue | undefined,\n currency: string,\n period: string,\n): SummaryModel {\n const groups: SummaryGroup[] = [];\n const now = ledger?.cards.current.find((c) => c.currency === currency);\n const before = ledger?.cards.previous?.find((c) => c.currency === currency);\n const money = (minor: string) => minorToMoney(minor, currency);\n\n if (ledger && now) {\n groups.push({\n heading: 'Money merchants earned',\n note: \"What customers paid to merchants. This is the merchants' money, not Olive's own income.\",\n rows: [\n { label: 'Money received', value: money(now.grossMinor), detail: change(now.grossMinor, before?.grossMinor) },\n { label: 'Fees taken', value: money(now.feeMinor), detail: change(now.feeMinor, before?.feeMinor) },\n { label: 'Merchants keep', value: money(now.netMinor), detail: change(now.netMinor, before?.netMinor) },\n { label: 'Refunded', value: money(now.refundsMinor) },\n { label: 'Payments that went through', value: integer.format(now.payments) },\n { label: 'Payments that worked', value: now.successRate === null ? '–' : `${now.successRate}%` },\n ],\n });\n\n groups.push({\n heading: 'How customers paid',\n note: 'Money received, by way of paying.',\n rows: methodCards(ledger.byChannel.current, ledger.byChannel.previous, currency).map((card) => ({\n label: card.label,\n value: money(card.amountMinor),\n detail:\n card.payments === 0\n ? 'No payments this way'\n : [\n `${integer.format(card.payments)} payments`,\n `${card.share}% of the money received`,\n card.change === null ? '' : `${card.change > 0 ? '+' : ''}${card.change}% since the period before`,\n ]\n .filter(Boolean)\n .join(' · '),\n })),\n });\n }\n\n const plan = revenue?.recurring;\n const naira = (value: number) => minorToMoney(Math.round(value * 100), plan?.currency ?? 'NGN');\n\n groups.push({\n heading: 'Money Olive earns',\n note: 'What merchants pay Olive for their plan.',\n rows:\n plan && plan.tracked !== false\n ? [\n { label: 'Plan income each month', value: naira(plan.mrr) },\n { label: 'Plan income over a year', value: naira(plan.arr) },\n { label: 'Average per paying merchant', value: naira(plan.arpa) },\n { label: 'Merchants paying', value: integer.format(plan.payingMerchants ?? plan.activeSubscriptions) },\n { label: 'Joined this period', value: integer.format(plan.newInRange) },\n { label: 'Left this period', value: integer.format(plan.churnedInRange) },\n ]\n : [{ label: 'Plan income', value: 'Not set up yet', detail: 'The plan payments are not connected on this box.' }],\n });\n\n return { title: 'Revenue summary', period, groups };\n}\n\nconst esc = (text: string): string =>\n text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"');\n\n/** A self-contained page that prints on one A4 sheet. Every value is escaped. */\nexport function summaryHtml(model: SummaryModel, brand: string, printedOn: string): string {\n const groups = model.groups\n .map(\n (group) => `<section><h2>${esc(group.heading)}</h2><p class=\"note\">${esc(group.note)}</p><table>${group.rows\n .map(\n (row) =>\n `<tr><td class=\"label\">${esc(row.label)}${row.detail ? `<div class=\"detail\">${esc(row.detail)}</div>` : ''}</td><td class=\"value\">${esc(row.value)}</td></tr>`,\n )\n .join('')}</table></section>`,\n )\n .join('');\n\n return `<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>${esc(model.title)}</title><style>\n@page { size: A4; margin: 14mm; }\n* { box-sizing: border-box; }\nbody { margin: 0; font: 12px/1.45 -apple-system, \"Segoe UI\", Helvetica, Arial, sans-serif; color: #1f2a14; }\nh1 { margin: 0 0 2px; font-size: 22px; color: #3d5a1e; }\n.period { margin: 0 0 14px; color: #5b6356; font-size: 13px; }\nsection { margin: 0 0 14px; break-inside: avoid; }\nh2 { margin: 0 0 2px; font-size: 14px; color: #3d5a1e; }\n.note { margin: 0 0 6px; color: #5b6356; }\ntable { width: 100%; border-collapse: collapse; }\ntd { padding: 5px 0; border-bottom: 1px solid #d9ddd3; vertical-align: top; }\n.value { text-align: right; font-weight: 600; white-space: nowrap; padding-left: 12px; }\n.detail { color: #5b6356; font-size: 11px; }\nfooter { margin-top: 10px; color: #5b6356; font-size: 10px; }\n</style></head><body><h1>${esc(model.title)}</h1><p class=\"period\">${esc(brand)} · ${esc(model.period)}</p>${groups}<footer>Printed ${esc(printedOn)}. Amounts are in the currency shown; different currencies are never added together.</footer></body></html>`;\n}\n"],"mappings":"AACA,SAAS,aAAa,cAAc,iBAAiB;AAqBrD,MAAM,UAAU,IAAI,KAAK,aAAa,OAAO;AAE7C,MAAM,SAAS,CAAC,KAAsB,WAAiD;AACrF,QAAM,MAAM,UAAU,KAAK,MAAM;AAEjC,SAAO,QAAQ,OAAO,SAAY,GAAG,MAAM,IAAI,MAAM,EAAE,GAAG,GAAG;AAC/D;AAGO,SAAS,aACd,QACA,SACA,UACA,QACc;AACd,QAAM,SAAyB,CAAC;AAChC,QAAM,MAAM,QAAQ,MAAM,QAAQ,KAAK,CAAC,MAAM,EAAE,aAAa,QAAQ;AACrE,QAAM,SAAS,QAAQ,MAAM,UAAU,KAAK,CAAC,MAAM,EAAE,aAAa,QAAQ;AAC1E,QAAM,QAAQ,CAAC,UAAkB,aAAa,OAAO,QAAQ;AAE7D,MAAI,UAAU,KAAK;AACjB,WAAO,KAAK;AAAA,MACV,SAAS;AAAA,MACT,MAAM;AAAA,MACN,MAAM;AAAA,QACJ,EAAE,OAAO,kBAAkB,OAAO,MAAM,IAAI,UAAU,GAAG,QAAQ,OAAO,IAAI,YAAY,QAAQ,UAAU,EAAE;AAAA,QAC5G,EAAE,OAAO,cAAc,OAAO,MAAM,IAAI,QAAQ,GAAG,QAAQ,OAAO,IAAI,UAAU,QAAQ,QAAQ,EAAE;AAAA,QAClG,EAAE,OAAO,kBAAkB,OAAO,MAAM,IAAI,QAAQ,GAAG,QAAQ,OAAO,IAAI,UAAU,QAAQ,QAAQ,EAAE;AAAA,QACtG,EAAE,OAAO,YAAY,OAAO,MAAM,IAAI,YAAY,EAAE;AAAA,QACpD,EAAE,OAAO,8BAA8B,OAAO,QAAQ,OAAO,IAAI,QAAQ,EAAE;AAAA,QAC3E,EAAE,OAAO,wBAAwB,OAAO,IAAI,gBAAgB,OAAO,WAAM,GAAG,IAAI,WAAW,IAAI;AAAA,MACjG;AAAA,IACF,CAAC;AAED,WAAO,KAAK;AAAA,MACV,SAAS;AAAA,MACT,MAAM;AAAA,MACN,MAAM,YAAY,OAAO,UAAU,SAAS,OAAO,UAAU,UAAU,QAAQ,EAAE,IAAI,CAAC,UAAU;AAAA,QAC9F,OAAO,KAAK;AAAA,QACZ,OAAO,MAAM,KAAK,WAAW;AAAA,QAC7B,QACE,KAAK,aAAa,IACd,yBACA;AAAA,UACE,GAAG,QAAQ,OAAO,KAAK,QAAQ,CAAC;AAAA,UAChC,GAAG,KAAK,KAAK;AAAA,UACb,KAAK,WAAW,OAAO,KAAK,GAAG,KAAK,SAAS,IAAI,MAAM,EAAE,GAAG,KAAK,MAAM;AAAA,QACzE,EACG,OAAO,OAAO,EACd,KAAK,QAAK;AAAA,MACrB,EAAE;AAAA,IACJ,CAAC;AAAA,EACH;AAEA,QAAM,OAAO,SAAS;AACtB,QAAM,QAAQ,CAAC,UAAkB,aAAa,KAAK,MAAM,QAAQ,GAAG,GAAG,MAAM,YAAY,KAAK;AAE9F,SAAO,KAAK;AAAA,IACV,SAAS;AAAA,IACT,MAAM;AAAA,IACN,MACE,QAAQ,KAAK,YAAY,QACrB;AAAA,MACE,EAAE,OAAO,0BAA0B,OAAO,MAAM,KAAK,GAAG,EAAE;AAAA,MAC1D,EAAE,OAAO,2BAA2B,OAAO,MAAM,KAAK,GAAG,EAAE;AAAA,MAC3D,EAAE,OAAO,+BAA+B,OAAO,MAAM,KAAK,IAAI,EAAE;AAAA,MAChE,EAAE,OAAO,oBAAoB,OAAO,QAAQ,OAAO,KAAK,mBAAmB,KAAK,mBAAmB,EAAE;AAAA,MACrG,EAAE,OAAO,sBAAsB,OAAO,QAAQ,OAAO,KAAK,UAAU,EAAE;AAAA,MACtE,EAAE,OAAO,oBAAoB,OAAO,QAAQ,OAAO,KAAK,cAAc,EAAE;AAAA,IAC1E,IACA,CAAC,EAAE,OAAO,eAAe,OAAO,kBAAkB,QAAQ,mDAAmD,CAAC;AAAA,EACtH,CAAC;AAED,SAAO,EAAE,OAAO,mBAAmB,QAAQ,OAAO;AACpD;AAEA,MAAM,MAAM,CAAC,SACX,KAAK,QAAQ,MAAM,OAAO,EAAE,QAAQ,MAAM,MAAM,EAAE,QAAQ,MAAM,MAAM,EAAE,QAAQ,MAAM,QAAQ;AAGzF,SAAS,YAAY,OAAqB,OAAe,WAA2B;AACzF,QAAM,SAAS,MAAM,OAClB;AAAA,IACC,CAAC,UAAU,gBAAgB,IAAI,MAAM,OAAO,CAAC,wBAAwB,IAAI,MAAM,IAAI,CAAC,cAAc,MAAM,KACrG;AAAA,MACC,CAAC,QACC,yBAAyB,IAAI,IAAI,KAAK,CAAC,GAAG,IAAI,SAAS,uBAAuB,IAAI,IAAI,MAAM,CAAC,WAAW,EAAE,0BAA0B,IAAI,IAAI,KAAK,CAAC;AAAA,IACtJ,EACC,KAAK,EAAE,CAAC;AAAA,EACb,EACC,KAAK,EAAE;AAEV,SAAO,qEAAqE,IAAI,MAAM,KAAK,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,2BAcnE,IAAI,MAAM,KAAK,CAAC,0BAA0B,IAAI,KAAK,CAAC,SAAM,IAAI,MAAM,MAAM,CAAC,OAAO,MAAM,mBAAmB,IAAI,SAAS,CAAC;AACpJ;","names":[]}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { methodCards, pctChange, plainAmount } from "./ledger-model";
|
|
2
|
+
const SUMMARY_HEAD = ["Section", "Figure", "This period", "Period before", "Change (%)"];
|
|
3
|
+
const naira = (kobo) => plainAmount(Math.round(kobo * 100));
|
|
4
|
+
function summaryRows(ledger, revenue, currency) {
|
|
5
|
+
const rows = [];
|
|
6
|
+
const now = ledger?.cards.current.find((c) => c.currency === currency);
|
|
7
|
+
const before = ledger?.cards.previous?.find((c) => c.currency === currency);
|
|
8
|
+
if (now) {
|
|
9
|
+
const money = (label, a, b) => rows.push(["Merchant payments", label, plainAmount(a), b === void 0 ? "" : plainAmount(b), String(pctChange(a, b) ?? "")]);
|
|
10
|
+
money("Money received", now.grossMinor, before?.grossMinor);
|
|
11
|
+
money("Fees taken", now.feeMinor, before?.feeMinor);
|
|
12
|
+
money("Merchants keep", now.netMinor, before?.netMinor);
|
|
13
|
+
money("Refunded", now.refundsMinor, before?.refundsMinor);
|
|
14
|
+
rows.push(["Merchant payments", "Payments that went through", String(now.payments), before ? String(before.payments) : "", String(pctChange(now.payments, before?.payments) ?? "")]);
|
|
15
|
+
rows.push(["Merchant payments", "Payments that worked (%)", String(now.successRate ?? ""), String(before?.successRate ?? ""), ""]);
|
|
16
|
+
}
|
|
17
|
+
if (ledger) {
|
|
18
|
+
for (const card of methodCards(ledger.byChannel.current, ledger.byChannel.previous, currency)) {
|
|
19
|
+
const prior = ledger.byChannel.previous?.find((r) => r.currency === currency && r.channel.key === card.key);
|
|
20
|
+
rows.push([
|
|
21
|
+
"How customers paid",
|
|
22
|
+
card.label,
|
|
23
|
+
plainAmount(card.amountMinor),
|
|
24
|
+
ledger.byChannel.previous ? plainAmount(prior?.amountMinor ?? "0") : "",
|
|
25
|
+
String(card.change ?? "")
|
|
26
|
+
]);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const plan = revenue?.recurring;
|
|
30
|
+
if (plan && plan.tracked !== false) {
|
|
31
|
+
rows.push(["Plan income", "Income each month", naira(plan.mrr), "", ""]);
|
|
32
|
+
rows.push(["Plan income", "Income over a year", naira(plan.arr), "", ""]);
|
|
33
|
+
rows.push(["Plan income", "Average per paying merchant", naira(plan.arpa), "", ""]);
|
|
34
|
+
rows.push(["Plan income", "Merchants paying", String(plan.payingMerchants ?? plan.activeSubscriptions), "", ""]);
|
|
35
|
+
rows.push(["Plan income", "Joined in this period", String(plan.newInRange), "", ""]);
|
|
36
|
+
rows.push(["Plan income", "Left in this period", String(plan.churnedInRange), "", ""]);
|
|
37
|
+
}
|
|
38
|
+
return rows;
|
|
39
|
+
}
|
|
40
|
+
export {
|
|
41
|
+
SUMMARY_HEAD,
|
|
42
|
+
summaryRows
|
|
43
|
+
};
|
|
44
|
+
//# sourceMappingURL=summary-rows.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/revenue/summary-rows.ts"],"sourcesContent":["import type { MerchantPaymentsPage, PlatformRevenue } from '@withpotter/lib/admin-api';\nimport { methodCards, pctChange, plainAmount } from './ledger-model';\n\nexport const SUMMARY_HEAD = ['Section', 'Figure', 'This period', 'Period before', 'Change (%)'];\n\nconst naira = (kobo: number): string => plainAmount(Math.round(kobo * 100));\n\n/**\n * The Revenue figures as rows for a spreadsheet: what customers paid merchants (with the period before), the ways\n * of paying, and Olive's plan income. Amounts are plain numbers in one currency.\n */\nexport function summaryRows(\n ledger: MerchantPaymentsPage | undefined,\n revenue: PlatformRevenue | undefined,\n currency: string,\n): string[][] {\n const rows: string[][] = [];\n const now = ledger?.cards.current.find((c) => c.currency === currency);\n const before = ledger?.cards.previous?.find((c) => c.currency === currency);\n\n if (now) {\n const money = (label: string, a: string, b?: string) =>\n rows.push(['Merchant payments', label, plainAmount(a), b === undefined ? '' : plainAmount(b), String(pctChange(a, b) ?? '')]);\n\n money('Money received', now.grossMinor, before?.grossMinor);\n money('Fees taken', now.feeMinor, before?.feeMinor);\n money('Merchants keep', now.netMinor, before?.netMinor);\n money('Refunded', now.refundsMinor, before?.refundsMinor);\n rows.push(['Merchant payments', 'Payments that went through', String(now.payments), before ? String(before.payments) : '', String(pctChange(now.payments, before?.payments) ?? '')]);\n rows.push(['Merchant payments', 'Payments that worked (%)', String(now.successRate ?? ''), String(before?.successRate ?? ''), '']);\n }\n\n if (ledger) {\n for (const card of methodCards(ledger.byChannel.current, ledger.byChannel.previous, currency)) {\n const prior = ledger.byChannel.previous?.find((r) => r.currency === currency && r.channel.key === card.key);\n\n rows.push([\n 'How customers paid',\n card.label,\n plainAmount(card.amountMinor),\n ledger.byChannel.previous ? plainAmount(prior?.amountMinor ?? '0') : '',\n String(card.change ?? ''),\n ]);\n }\n }\n\n const plan = revenue?.recurring;\n\n if (plan && plan.tracked !== false) {\n rows.push(['Plan income', 'Income each month', naira(plan.mrr), '', '']);\n rows.push(['Plan income', 'Income over a year', naira(plan.arr), '', '']);\n rows.push(['Plan income', 'Average per paying merchant', naira(plan.arpa), '', '']);\n rows.push(['Plan income', 'Merchants paying', String(plan.payingMerchants ?? plan.activeSubscriptions), '', '']);\n rows.push(['Plan income', 'Joined in this period', String(plan.newInRange), '', '']);\n rows.push(['Plan income', 'Left in this period', String(plan.churnedInRange), '', '']);\n }\n\n return rows;\n}\n"],"mappings":"AACA,SAAS,aAAa,WAAW,mBAAmB;AAE7C,MAAM,eAAe,CAAC,WAAW,UAAU,eAAe,iBAAiB,YAAY;AAE9F,MAAM,QAAQ,CAAC,SAAyB,YAAY,KAAK,MAAM,OAAO,GAAG,CAAC;AAMnE,SAAS,YACd,QACA,SACA,UACY;AACZ,QAAM,OAAmB,CAAC;AAC1B,QAAM,MAAM,QAAQ,MAAM,QAAQ,KAAK,CAAC,MAAM,EAAE,aAAa,QAAQ;AACrE,QAAM,SAAS,QAAQ,MAAM,UAAU,KAAK,CAAC,MAAM,EAAE,aAAa,QAAQ;AAE1E,MAAI,KAAK;AACP,UAAM,QAAQ,CAAC,OAAe,GAAW,MACvC,KAAK,KAAK,CAAC,qBAAqB,OAAO,YAAY,CAAC,GAAG,MAAM,SAAY,KAAK,YAAY,CAAC,GAAG,OAAO,UAAU,GAAG,CAAC,KAAK,EAAE,CAAC,CAAC;AAE9H,UAAM,kBAAkB,IAAI,YAAY,QAAQ,UAAU;AAC1D,UAAM,cAAc,IAAI,UAAU,QAAQ,QAAQ;AAClD,UAAM,kBAAkB,IAAI,UAAU,QAAQ,QAAQ;AACtD,UAAM,YAAY,IAAI,cAAc,QAAQ,YAAY;AACxD,SAAK,KAAK,CAAC,qBAAqB,8BAA8B,OAAO,IAAI,QAAQ,GAAG,SAAS,OAAO,OAAO,QAAQ,IAAI,IAAI,OAAO,UAAU,IAAI,UAAU,QAAQ,QAAQ,KAAK,EAAE,CAAC,CAAC;AACnL,SAAK,KAAK,CAAC,qBAAqB,4BAA4B,OAAO,IAAI,eAAe,EAAE,GAAG,OAAO,QAAQ,eAAe,EAAE,GAAG,EAAE,CAAC;AAAA,EACnI;AAEA,MAAI,QAAQ;AACV,eAAW,QAAQ,YAAY,OAAO,UAAU,SAAS,OAAO,UAAU,UAAU,QAAQ,GAAG;AAC7F,YAAM,QAAQ,OAAO,UAAU,UAAU,KAAK,CAAC,MAAM,EAAE,aAAa,YAAY,EAAE,QAAQ,QAAQ,KAAK,GAAG;AAE1G,WAAK,KAAK;AAAA,QACR;AAAA,QACA,KAAK;AAAA,QACL,YAAY,KAAK,WAAW;AAAA,QAC5B,OAAO,UAAU,WAAW,YAAY,OAAO,eAAe,GAAG,IAAI;AAAA,QACrE,OAAO,KAAK,UAAU,EAAE;AAAA,MAC1B,CAAC;AAAA,IACH;AAAA,EACF;AAEA,QAAM,OAAO,SAAS;AAEtB,MAAI,QAAQ,KAAK,YAAY,OAAO;AAClC,SAAK,KAAK,CAAC,eAAe,qBAAqB,MAAM,KAAK,GAAG,GAAG,IAAI,EAAE,CAAC;AACvE,SAAK,KAAK,CAAC,eAAe,sBAAsB,MAAM,KAAK,GAAG,GAAG,IAAI,EAAE,CAAC;AACxE,SAAK,KAAK,CAAC,eAAe,+BAA+B,MAAM,KAAK,IAAI,GAAG,IAAI,EAAE,CAAC;AAClF,SAAK,KAAK,CAAC,eAAe,oBAAoB,OAAO,KAAK,mBAAmB,KAAK,mBAAmB,GAAG,IAAI,EAAE,CAAC;AAC/G,SAAK,KAAK,CAAC,eAAe,yBAAyB,OAAO,KAAK,UAAU,GAAG,IAAI,EAAE,CAAC;AACnF,SAAK,KAAK,CAAC,eAAe,uBAAuB,OAAO,KAAK,cAAc,GAAG,IAAI,EAAE,CAAC;AAAA,EACvF;AAEA,SAAO;AACT;","names":[]}
|
|
@@ -17,7 +17,7 @@ function TopTenantsPanel({
|
|
|
17
17
|
return /* @__PURE__ */ jsx(
|
|
18
18
|
Panel,
|
|
19
19
|
{
|
|
20
|
-
title: "
|
|
20
|
+
title: "Biggest merchants by sales",
|
|
21
21
|
count: tenants.length || void 0,
|
|
22
22
|
caption: focus ?? void 0,
|
|
23
23
|
action: /* @__PURE__ */ jsx(Link, { href: "/admin/tenants", children: "View all" }),
|