diff --git a/packages/app/control/src/app/(app)/admin/_components/overview/AppEarnings.tsx b/packages/app/control/src/app/(app)/admin/_components/overview/AppEarnings.tsx
new file mode 100644
index 000000000..ed4796381
--- /dev/null
+++ b/packages/app/control/src/app/(app)/admin/_components/overview/AppEarnings.tsx
@@ -0,0 +1,26 @@
+'use client';
+
+import { OverviewPanel } from '@/components/overview-panel/OverviewPanel';
+import { api } from '@/trpc/client';
+
+export function AppEarningsOverview() {
+ const { data, isLoading, error } =
+ api.admin.earnings.getAppEarningsOverviewMetrics.useQuery();
+
+ return (
+
+ );
+}
+
diff --git a/packages/app/control/src/app/(app)/admin/_components/overview/PaymentHistory.tsx b/packages/app/control/src/app/(app)/admin/_components/overview/PaymentHistory.tsx
new file mode 100644
index 000000000..0b8d5f540
--- /dev/null
+++ b/packages/app/control/src/app/(app)/admin/_components/overview/PaymentHistory.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import React from 'react';
+import { OverviewPanel } from '@/components/overview-panel/OverviewPanel';
+import { api } from '@/trpc/client';
+
+export function PaymentsOverview() {
+ const { data, isLoading, error } =
+ api.admin.payments.getPaymentsOverviewMetrics.useQuery();
+
+ return (
+
+ );
+}
+
diff --git a/packages/app/control/src/app/(app)/admin/_components/overview/UserEarnings.tsx b/packages/app/control/src/app/(app)/admin/_components/overview/UserEarnings.tsx
new file mode 100644
index 000000000..e995e63e8
--- /dev/null
+++ b/packages/app/control/src/app/(app)/admin/_components/overview/UserEarnings.tsx
@@ -0,0 +1,25 @@
+'use client';
+
+import { OverviewPanel } from '@/components/overview-panel/OverviewPanel';
+import { api } from '@/trpc/client';
+
+export function UserEarningsOverview() {
+ const { data, isLoading, error } =
+ api.admin.earnings.getUserEarningsOverviewMetrics.useQuery();
+
+ return (
+
+ );
+}
diff --git a/packages/app/control/src/app/(app)/admin/_components/overview/UserSpending.tsx b/packages/app/control/src/app/(app)/admin/_components/overview/UserSpending.tsx
new file mode 100644
index 000000000..3d4789255
--- /dev/null
+++ b/packages/app/control/src/app/(app)/admin/_components/overview/UserSpending.tsx
@@ -0,0 +1,27 @@
+'use client';
+
+import { OverviewPanel } from '@/components/overview-panel/OverviewPanel';
+import { api } from '@/trpc/client';
+
+export function UserSpendingOverview() {
+ const { data, isLoading, error } =
+ api.admin.spending.getUserSpendingOverviewMetrics.useQuery();
+
+ return (
+
+ );
+}
+
+
diff --git a/packages/app/control/src/app/(app)/admin/dashboard/app-earnings/page.tsx b/packages/app/control/src/app/(app)/admin/dashboard/app-earnings/page.tsx
index 607ea6660..c6bc303e1 100644
--- a/packages/app/control/src/app/(app)/admin/dashboard/app-earnings/page.tsx
+++ b/packages/app/control/src/app/(app)/admin/dashboard/app-earnings/page.tsx
@@ -1,5 +1,6 @@
import AppEarningsTable from '@/app/(app)/admin/_components/table/AppEarnings';
import { TableLayout } from '@/app/(app)/admin/_components';
+import { AppEarningsOverview } from '@/app/(app)/admin/_components/overview/AppEarnings';
export default function AppEarningsPage() {
return (
@@ -7,6 +8,7 @@ export default function AppEarningsPage() {
title="App Earnings"
description="Monitor app performance and revenue generation metrics"
>
+
);
diff --git a/packages/app/control/src/app/(app)/admin/dashboard/payments/page.tsx b/packages/app/control/src/app/(app)/admin/dashboard/payments/page.tsx
index f6809f697..6e0468569 100644
--- a/packages/app/control/src/app/(app)/admin/dashboard/payments/page.tsx
+++ b/packages/app/control/src/app/(app)/admin/dashboard/payments/page.tsx
@@ -1,4 +1,5 @@
import PaymentHistoryTable from '@/app/(app)/admin/_components/table/PaymentHistory';
+import { PaymentsOverview } from '@/app/(app)/admin/_components/overview/PaymentHistory';
import { TableLayout } from '@/app/(app)/admin/_components';
export default function PaymentHistoryPage() {
@@ -7,6 +8,7 @@ export default function PaymentHistoryPage() {
title="Payment History"
description="Review payment transactions and payout history"
>
+
);
diff --git a/packages/app/control/src/app/(app)/admin/dashboard/user-earnings/page.tsx b/packages/app/control/src/app/(app)/admin/dashboard/user-earnings/page.tsx
index 7b92d8fdd..67f4fcd1a 100644
--- a/packages/app/control/src/app/(app)/admin/dashboard/user-earnings/page.tsx
+++ b/packages/app/control/src/app/(app)/admin/dashboard/user-earnings/page.tsx
@@ -1,5 +1,6 @@
import UserEarningsTable from '@/app/(app)/admin/_components/table/UserEarnings';
import { TableLayout } from '@/app/(app)/admin/_components';
+import { UserEarningsOverview } from '@/app/(app)/admin/_components/overview/UserEarnings';
export default function UserEarningsPage() {
return (
@@ -7,6 +8,7 @@ export default function UserEarningsPage() {
title="User Earnings"
description="Comprehensive view of user earnings and transaction aggregates across all apps"
>
+
);
diff --git a/packages/app/control/src/app/(app)/admin/dashboard/user-spending/page.tsx b/packages/app/control/src/app/(app)/admin/dashboard/user-spending/page.tsx
index 5165bc130..ad717de64 100644
--- a/packages/app/control/src/app/(app)/admin/dashboard/user-spending/page.tsx
+++ b/packages/app/control/src/app/(app)/admin/dashboard/user-spending/page.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import UserSpendingTable from '@/app/(app)/admin/_components/table/UserSpending';
import { TableLayout } from '@/app/(app)/admin/_components';
+import { UserSpendingOverview } from '@/app/(app)/admin/_components/overview/UserSpending';
export default function UserSpendingPage() {
return (
@@ -8,6 +9,7 @@ export default function UserSpendingPage() {
title="User Spending"
description="Analyze user spending patterns and credit usage across the platform"
>
+
);
diff --git a/packages/app/control/src/services/admin/app-earnings-summary.ts b/packages/app/control/src/services/admin/app-earnings-summary.ts
new file mode 100644
index 000000000..13a1579a3
--- /dev/null
+++ b/packages/app/control/src/services/admin/app-earnings-summary.ts
@@ -0,0 +1,187 @@
+import { db } from '@/lib/db';
+import { OverviewMetricConfig } from './type/overview-metric';
+
+function percentChange(currentValue: number | bigint, previousValue: number | bigint): number {
+ const currentNumber = Number(currentValue) || 0;
+ const previousNumber = Number(previousValue) || 0;
+ if (previousNumber === 0) return currentNumber ? 100 : 0;
+ const delta = currentNumber - previousNumber;
+ return (delta / previousNumber) * 100;
+}
+
+type AppEarningsOverviewRow = {
+ totalRevenue: number;
+ totalTransactions: number;
+ activeApps: number;
+ avgRevenuePerActiveApp: number;
+ avgTransactionValue: number;
+ freeTierUsage: number;
+};
+
+type AppEarningsTrendRow = {
+ spend_current: number;
+ spend_prev: number;
+ tx_count_current: number;
+ tx_count_prev: number;
+ active_apps_current: number;
+ active_apps_prev: number;
+ free_tier_current: number;
+ free_tier_prev: number;
+};
+
+export async function getAppEarningsOverviewMetrics(): Promise {
+ const summaryQuery = `
+ WITH t AS (
+ SELECT
+ COALESCE(SUM(t."totalCost"), 0)::double precision AS total_revenue,
+ COALESCE(COUNT(t.id), 0)::double precision AS total_transactions,
+ COALESCE(SUM(CASE WHEN t."spendPoolId" IS NOT NULL THEN t."totalCost" END), 0)::double precision AS free_tier_usage
+ FROM "transactions" t
+ ), a AS (
+ SELECT COALESCE(COUNT(DISTINCT t2."echoAppId"), 0)::double precision AS active_apps
+ FROM "transactions" t2
+ ), avg_tx AS (
+ SELECT COALESCE(AVG(t3."totalCost"), 0)::double precision AS avg_txn_value
+ FROM "transactions" t3
+ )
+ SELECT
+ t.total_revenue AS "totalRevenue",
+ t.total_transactions AS "totalTransactions",
+ a.active_apps AS "activeApps",
+ CASE WHEN a.active_apps > 0 THEN t.total_revenue / a.active_apps ELSE 0 END AS "avgRevenuePerActiveApp",
+ avg_tx.avg_txn_value AS "avgTransactionValue",
+ t.free_tier_usage AS "freeTierUsage"
+ FROM t, a, avg_tx;
+ `;
+
+ const summary = (await db.$queryRawUnsafe(summaryQuery)) as Array;
+ const s = summary[0] || {
+ totalRevenue: 0,
+ totalTransactions: 0,
+ activeApps: 0,
+ avgRevenuePerActiveApp: 0,
+ avgTransactionValue: 0,
+ freeTierUsage: 0,
+ };
+
+ const trendQuery = `
+ WITH ranges AS (
+ SELECT
+ (NOW()::date - INTERVAL '7 days') AS start_current,
+ NOW()::date AS end_current,
+ (NOW()::date - INTERVAL '14 days') AS start_prev,
+ (NOW()::date - INTERVAL '7 days') AS end_prev
+ ),
+ txn AS (
+ SELECT
+ COALESCE(SUM(CASE WHEN t."createdAt" >= r.start_current AND t."createdAt" < r.end_current THEN t."totalCost" END), 0)::double precision AS spend_current,
+ COALESCE(SUM(CASE WHEN t."createdAt" >= r.start_prev AND t."createdAt" < r.end_prev THEN t."totalCost" END), 0)::double precision AS spend_prev,
+ COALESCE(COUNT(CASE WHEN t."createdAt" >= r.start_current AND t."createdAt" < r.end_current THEN 1 END), 0)::double precision AS tx_count_current,
+ COALESCE(COUNT(CASE WHEN t."createdAt" >= r.start_prev AND t."createdAt" < r.end_prev THEN 1 END), 0)::double precision AS tx_count_prev,
+ COALESCE(SUM(CASE WHEN t."spendPoolId" IS NOT NULL AND t."createdAt" >= r.start_current AND t."createdAt" < r.end_current THEN t."totalCost" END), 0)::double precision AS free_tier_current,
+ COALESCE(SUM(CASE WHEN t."spendPoolId" IS NOT NULL AND t."createdAt" >= r.start_prev AND t."createdAt" < r.end_prev THEN t."totalCost" END), 0)::double precision AS free_tier_prev
+ FROM "transactions" t, ranges r
+ ),
+ apps AS (
+ SELECT
+ COALESCE(COUNT(DISTINCT CASE WHEN t."createdAt" >= r.start_current AND t."createdAt" < r.end_current THEN t."echoAppId" END), 0)::double precision AS active_apps_current,
+ COALESCE(COUNT(DISTINCT CASE WHEN t."createdAt" >= r.start_prev AND t."createdAt" < r.end_prev THEN t."echoAppId" END), 0)::double precision AS active_apps_prev
+ FROM "transactions" t, ranges r
+ )
+ SELECT
+ txn.spend_current,
+ txn.spend_prev,
+ txn.tx_count_current,
+ txn.tx_count_prev,
+ apps.active_apps_current,
+ apps.active_apps_prev,
+ txn.free_tier_current,
+ txn.free_tier_prev
+ FROM txn, apps;
+ `;
+
+ const trendRows = (await db.$queryRawUnsafe(trendQuery)) as Array;
+ const t = trendRows[0] || {
+ spend_current: 0,
+ spend_prev: 0,
+ tx_count_current: 0,
+ tx_count_prev: 0,
+ active_apps_current: 0,
+ active_apps_prev: 0,
+ free_tier_current: 0,
+ free_tier_prev: 0,
+ };
+
+ const trendLabel = 'vs previous 7d';
+
+ const avgRevCurrent = t.active_apps_current ? t.spend_current / t.active_apps_current : 0;
+ const avgRevPrev = t.active_apps_prev ? t.spend_prev / t.active_apps_prev : 0;
+ const avgTxnCurrent = t.tx_count_current ? t.spend_current / t.tx_count_current : 0;
+ const avgTxnPrev = t.tx_count_prev ? t.spend_prev / t.tx_count_prev : 0;
+
+ const metrics: OverviewMetricConfig[] = [
+ {
+ id: 'totalRevenue',
+ title: 'Total Revenue',
+ description: 'Total revenue generated across all apps',
+ displayType: 'currency',
+ value: s.totalRevenue,
+ trendValue: percentChange(t.spend_current, t.spend_prev),
+ size: 'md',
+ format: { showTrend: true, decimals: 2, trendLabel },
+ },
+ {
+ id: 'activeApps',
+ title: 'Active Apps',
+ description: 'Apps with at least one transaction',
+ displayType: 'number',
+ value: s.activeApps,
+ trendValue: percentChange(t.active_apps_current, t.active_apps_prev),
+ size: 'sm',
+ format: { showTrend: true, trendLabel },
+ },
+ {
+ id: 'avgRevenuePerActiveApp',
+ title: 'Avg Revenue / Active App',
+ description: 'Average revenue per active app',
+ displayType: 'currency',
+ value: s.avgRevenuePerActiveApp,
+ trendValue: percentChange(avgRevCurrent, avgRevPrev),
+ size: 'sm',
+ format: { showTrend: true, decimals: 2, trendLabel },
+ },
+ {
+ id: 'totalTransactions',
+ title: 'Total Transactions',
+ description: 'Number of transactions across all apps',
+ displayType: 'number',
+ value: s.totalTransactions,
+ trendValue: percentChange(t.tx_count_current, t.tx_count_prev),
+ size: 'sm',
+ format: { showTrend: true, trendLabel },
+ },
+ {
+ id: 'avgTransactionValue',
+ title: 'Avg Transaction Value',
+ description: 'Average spend per transaction',
+ displayType: 'currency',
+ value: s.avgTransactionValue,
+ trendValue: percentChange(avgTxnCurrent, avgTxnPrev),
+ size: 'sm',
+ format: { showTrend: true, decimals: 2, trendLabel },
+ },
+ {
+ id: 'freeTierUsage',
+ title: 'Free Tier Usage',
+ description: 'Total consumption covered by spend pools',
+ displayType: 'currency',
+ value: s.freeTierUsage,
+ trendValue: percentChange(t.free_tier_current, t.free_tier_prev),
+ size: 'md',
+ format: { showTrend: true, decimals: 2, trendLabel },
+ },
+ ];
+
+ return metrics;
+}
+
diff --git a/packages/app/control/src/services/admin/app-earnings.ts b/packages/app/control/src/services/admin/app-earnings.ts
index 907b16fd5..bd683c1ae 100644
--- a/packages/app/control/src/services/admin/app-earnings.ts
+++ b/packages/app/control/src/services/admin/app-earnings.ts
@@ -42,13 +42,14 @@ const COLUMN_MAPPINGS: Record = {
creatorUserId: 'creator.id',
creatorUserName: 'creator.name',
creatorUserEmail: 'creator.email',
- totalTransactions: 'COUNT(t.id)',
- totalRevenue: 'COALESCE(SUM(t."totalCost"), 0)',
- totalAppProfit: 'COALESCE(SUM(t."appProfit"), 0)',
- totalMarkupProfit: 'COALESCE(SUM(t."markUpProfit"), 0)',
- totalReferralProfit: 'COALESCE(SUM(t."referralProfit"), 0)',
- totalReferralCodes: 'COUNT(DISTINCT am_users."referrerId")',
- totalUsers: 'COUNT(DISTINCT am_users.id)',
+ // Use aggregated CTE outputs to avoid fan-out multiplication
+ totalTransactions: 'COALESCE(txn.total_transactions, 0)',
+ totalRevenue: 'COALESCE(txn.total_revenue, 0)',
+ totalAppProfit: 'COALESCE(txn.total_app_profit, 0)',
+ totalMarkupProfit: 'COALESCE(txn.total_markup_profit, 0)',
+ totalReferralProfit: 'COALESCE(txn.total_referral_profit, 0)',
+ totalReferralCodes: 'COALESCE(membership.total_referral_codes, 0)',
+ totalUsers: 'COALESCE(membership.total_users, 0)',
};
// Main TRPC procedure function for app earnings
@@ -89,6 +90,30 @@ export const getAppEarningsWithPagination = async (
// Build the main query with dynamic clauses
const baseQuery = `
+ WITH txn AS (
+ SELECT
+ t."echoAppId" AS app_id,
+ COUNT(t.id)::double precision AS total_transactions,
+ COALESCE(SUM(t."totalCost"), 0)::double precision AS total_revenue,
+ COALESCE(SUM(t."appProfit"), 0)::double precision AS total_app_profit,
+ COALESCE(SUM(t."markUpProfit"), 0)::double precision AS total_markup_profit,
+ COALESCE(SUM(t."referralProfit"), 0)::double precision AS total_referral_profit
+ FROM "transactions" t
+ GROUP BY t."echoAppId"
+ ), membership AS (
+ SELECT
+ am."echoAppId" AS app_id,
+ COALESCE(COUNT(DISTINCT am."userId"), 0)::double precision AS total_users,
+ COALESCE(COUNT(DISTINCT am."referrerId") FILTER (WHERE am."referrerId" IS NOT NULL), 0)::double precision AS total_referral_codes
+ FROM "app_memberships" am
+ GROUP BY am."echoAppId"
+ ), app_emails AS (
+ SELECT
+ oes."echoAppId" AS app_id,
+ COALESCE(array_agg(DISTINCT oes."emailCampaignId") FILTER (WHERE oes."emailCampaignId" IS NOT NULL), '{}'::text[]) AS app_email_campaigns
+ FROM "outbound_emails_sent" oes
+ GROUP BY oes."echoAppId"
+ )
SELECT
a.id,
a.name,
@@ -98,31 +123,27 @@ export const getAppEarningsWithPagination = async (
creator.id as "creatorUserId",
creator.name as "creatorUserName",
creator.email as "creatorUserEmail",
- COALESCE(
- array_agg(DISTINCT app_oes."emailCampaignId") FILTER (WHERE app_oes."emailCampaignId" IS NOT NULL),
- '{}'::text[]
- ) as "appEmailCampaigns",
- COALESCE(
- array_agg(DISTINCT owner_oes."emailCampaignId") FILTER (WHERE owner_oes."emailCampaignId" IS NOT NULL),
- '{}'::text[]
- ) as "ownerEmailCampaigns",
- COUNT(t.id) as "totalTransactions",
- COALESCE(SUM(t."totalCost"), 0) as "totalRevenue",
- COALESCE(SUM(t."appProfit"), 0) as "totalAppProfit",
- COALESCE(SUM(t."markUpProfit"), 0) as "totalMarkupProfit",
- COALESCE(SUM(t."referralProfit"), 0) as "totalReferralProfit",
- COUNT(DISTINCT am_users."referrerId") as "totalReferralCodes",
- COUNT(DISTINCT am_users.id) as "totalUsers"
+ COALESCE(app_emails.app_email_campaigns, '{}'::text[]) as "appEmailCampaigns",
+ COALESCE((
+ SELECT COALESCE(array_agg(DISTINCT owner_oes."emailCampaignId") FILTER (WHERE owner_oes."emailCampaignId" IS NOT NULL), '{}'::text[])
+ FROM "outbound_emails_sent" owner_oes
+ WHERE owner_oes."userId" = creator.id
+ ), '{}'::text[]) as "ownerEmailCampaigns",
+ ${COLUMN_MAPPINGS.totalTransactions} as "totalTransactions",
+ ${COLUMN_MAPPINGS.totalRevenue} as "totalRevenue",
+ ${COLUMN_MAPPINGS.totalAppProfit} as "totalAppProfit",
+ ${COLUMN_MAPPINGS.totalMarkupProfit} as "totalMarkupProfit",
+ ${COLUMN_MAPPINGS.totalReferralProfit} as "totalReferralProfit",
+ ${COLUMN_MAPPINGS.totalReferralCodes} as "totalReferralCodes",
+ ${COLUMN_MAPPINGS.totalUsers} as "totalUsers"
FROM "echo_apps" a
INNER JOIN "app_memberships" owner_am ON a.id = owner_am."echoAppId"
INNER JOIN "users" creator ON owner_am."userId" = creator.id
- LEFT JOIN "app_memberships" am_users ON a.id = am_users."echoAppId"
- LEFT JOIN "transactions" t ON a.id = t."echoAppId"
- LEFT JOIN "outbound_emails_sent" app_oes ON a.id = app_oes."echoAppId"
- LEFT JOIN "outbound_emails_sent" owner_oes ON creator.id = owner_oes."userId"
- -- Referral codes are linked via app_memberships.referrerId in the new schema
+ LEFT JOIN txn ON txn.app_id = a.id
+ LEFT JOIN membership ON membership.app_id = a.id
+ LEFT JOIN app_emails ON app_emails.app_id = a.id
${whereClause}
- GROUP BY a.id, a.name, a.description, a."createdAt", a."updatedAt", creator.id, creator.name, creator.email
+ GROUP BY a.id, a.name, a.description, a."createdAt", a."updatedAt", creator.id, creator.name, creator.email, app_emails.app_email_campaigns, txn.total_transactions, txn.total_revenue, txn.total_app_profit, txn.total_markup_profit, txn.total_referral_profit, membership.total_referral_codes, membership.total_users
${havingClause}
${orderByClause}
LIMIT $${parameters.length + 1}
@@ -162,16 +183,35 @@ export const getAppEarningsWithPagination = async (
// Build count query with same filters
const countQuery = `
- SELECT COUNT(DISTINCT a.id) as count
- FROM "echo_apps" a
- INNER JOIN "app_memberships" owner_am ON a.id = owner_am."echoAppId"
- INNER JOIN "users" creator ON owner_am."userId" = creator.id
- LEFT JOIN "app_memberships" am_users ON a.id = am_users."echoAppId"
- LEFT JOIN "transactions" t ON a.id = t."echoAppId"
- LEFT JOIN "outbound_emails_sent" app_oes ON a.id = app_oes."echoAppId"
- LEFT JOIN "outbound_emails_sent" owner_oes ON creator.id = owner_oes."userId"
- ${whereClause}
- ${havingClause ? `GROUP BY a.id ${havingClause}` : ''}
+ WITH txn AS (
+ SELECT
+ t."echoAppId" AS app_id,
+ COUNT(t.id)::double precision AS total_transactions,
+ COALESCE(SUM(t."totalCost"), 0)::double precision AS total_revenue,
+ COALESCE(SUM(t."appProfit"), 0)::double precision AS total_app_profit,
+ COALESCE(SUM(t."markUpProfit"), 0)::double precision AS total_markup_profit,
+ COALESCE(SUM(t."referralProfit"), 0)::double precision AS total_referral_profit
+ FROM "transactions" t
+ GROUP BY t."echoAppId"
+ ), membership AS (
+ SELECT
+ am."echoAppId" AS app_id,
+ COALESCE(COUNT(DISTINCT am."userId"), 0)::double precision AS total_users,
+ COALESCE(COUNT(DISTINCT am."referrerId") FILTER (WHERE am."referrerId" IS NOT NULL), 0)::double precision AS total_referral_codes
+ FROM "app_memberships" am
+ GROUP BY am."echoAppId"
+ )
+ SELECT COUNT(*) as count FROM (
+ SELECT a.id
+ FROM "echo_apps" a
+ INNER JOIN "app_memberships" owner_am ON a.id = owner_am."echoAppId"
+ INNER JOIN "users" creator ON owner_am."userId" = creator.id
+ LEFT JOIN txn ON txn.app_id = a.id
+ LEFT JOIN membership ON membership.app_id = a.id
+ ${whereClause}
+ GROUP BY a.id, a.name, a.description, a."createdAt", a."updatedAt", creator.id, creator.name, creator.email, txn.total_transactions, txn.total_revenue, txn.total_app_profit, txn.total_markup_profit, txn.total_referral_profit, membership.total_referral_codes, membership.total_users
+ ${havingClause}
+ ) grouped
`;
// If we have HAVING clauses, we need to count the grouped results
diff --git a/packages/app/control/src/services/admin/payments-summary.ts b/packages/app/control/src/services/admin/payments-summary.ts
new file mode 100644
index 000000000..43d8bd788
--- /dev/null
+++ b/packages/app/control/src/services/admin/payments-summary.ts
@@ -0,0 +1,189 @@
+import { db } from '@/lib/db';
+import { OverviewMetricConfig } from './type/overview-metric';
+
+function percentChange(currentValue: number | bigint, previousValue: number | bigint): number {
+ const currentNumber = Number(currentValue) || 0;
+ const previousNumber = Number(previousValue) || 0;
+ if (previousNumber === 0) return currentNumber ? 100 : 0;
+ const delta = currentNumber - previousNumber;
+ return (delta / previousNumber) * 100;
+}
+
+type PaymentsOverviewRow = {
+ totalAmount: number;
+ stripeAmount: number;
+ adminAmount: number;
+ signUpGiftAmount: number;
+ uniquePayers: number;
+};
+
+type PaymentsTrendRow = {
+ total_current: number;
+ total_prev: number;
+ stripe_current: number;
+ stripe_prev: number;
+ admin_current: number;
+ admin_prev: number;
+ signup_current: number;
+ signup_prev: number;
+ unique_payers_current: number;
+ unique_payers_prev: number;
+};
+
+export async function getPaymentsOverviewMetrics(): Promise {
+ const summaryQuery = `
+ WITH p AS (
+ SELECT
+ COALESCE(SUM(p.amount), 0)::double precision AS total_amount,
+ COALESCE(SUM(CASE WHEN p.source = 'stripe' and p.status = 'completed' THEN p.amount END), 0)::double precision AS stripe_amount,
+ COALESCE(SUM(CASE WHEN p.source = 'admin' and p.status = 'completed' THEN p.amount END), 0)::double precision AS admin_amount,
+ COALESCE(SUM(CASE WHEN p.source = 'signUpGift' and p.status = 'completed' THEN p.amount END), 0)::double precision AS signup_amount,
+ COALESCE(COUNT(DISTINCT CASE WHEN p.status = 'completed' AND p.source = 'stripe' THEN p."userId" END), 0)::double precision AS unique_payers
+ FROM "payments" p
+ WHERE p."isArchived" = false
+ )
+ SELECT
+ p.total_amount AS "totalAmount",
+ p.stripe_amount AS "stripeAmount",
+ p.admin_amount AS "adminAmount",
+ p.signup_amount AS "signUpGiftAmount",
+ p.unique_payers AS "uniquePayers"
+ FROM p;
+ `;
+
+ const summary = (await db.$queryRawUnsafe(summaryQuery)) as Array;
+ const s = summary[0] || {
+ totalAmount: 0,
+ stripeAmount: 0,
+ adminAmount: 0,
+ signUpGiftAmount: 0,
+ uniquePayers: 0,
+ };
+
+ const trendQuery = `
+ WITH ranges AS (
+ SELECT
+ (NOW()::date - INTERVAL '7 days') AS start_current,
+ NOW()::date AS end_current,
+ (NOW()::date - INTERVAL '14 days') AS start_prev,
+ (NOW()::date - INTERVAL '7 days') AS end_prev
+ ), t AS (
+ SELECT
+ COALESCE(SUM(CASE WHEN p."createdAt" >= r.start_current AND p."createdAt" < r.end_current THEN p.amount END), 0)::double precision AS total_current,
+ COALESCE(SUM(CASE WHEN p."createdAt" >= r.start_prev AND p."createdAt" < r.end_prev THEN p.amount END), 0)::double precision AS total_prev,
+ COALESCE(SUM(CASE WHEN p.source = 'stripe' AND p."createdAt" >= r.start_current AND p."createdAt" < r.end_current THEN p.amount END), 0)::double precision AS stripe_current,
+ COALESCE(SUM(CASE WHEN p.source = 'stripe' AND p."createdAt" >= r.start_prev AND p."createdAt" < r.end_prev THEN p.amount END), 0)::double precision AS stripe_prev,
+ COALESCE(SUM(CASE WHEN p.source = 'admin' AND p."createdAt" >= r.start_current AND p."createdAt" < r.end_current THEN p.amount END), 0)::double precision AS admin_current,
+ COALESCE(SUM(CASE WHEN p.source = 'admin' AND p."createdAt" >= r.start_prev AND p."createdAt" < r.end_prev THEN p.amount END), 0)::double precision AS admin_prev,
+ COALESCE(SUM(CASE WHEN p.source = 'signUpGift' AND p."createdAt" >= r.start_current AND p."createdAt" < r.end_current THEN p.amount END), 0)::double precision AS signup_current,
+ COALESCE(SUM(CASE WHEN p.source = 'signUpGift' AND p."createdAt" >= r.start_prev AND p."createdAt" < r.end_prev THEN p.amount END), 0)::double precision AS signup_prev
+ FROM "payments" p, ranges r
+ WHERE p."isArchived" = false
+ ), u AS (
+ SELECT
+ COALESCE(COUNT(DISTINCT CASE WHEN p.source = 'stripe' AND p.status = 'completed' AND p."createdAt" >= r.start_current AND p."createdAt" < r.end_current THEN p."userId" END), 0)::double precision AS unique_payers_current,
+ COALESCE(COUNT(DISTINCT CASE WHEN p.source = 'stripe' AND p.status = 'completed' AND p."createdAt" >= r.start_prev AND p."createdAt" < r.end_prev THEN p."userId" END), 0)::double precision AS unique_payers_prev
+ FROM "payments" p, ranges r
+ WHERE p."isArchived" = false
+ )
+ SELECT
+ t.total_current,
+ t.total_prev,
+ t.stripe_current,
+ t.stripe_prev,
+ t.admin_current,
+ t.admin_prev,
+ t.signup_current,
+ t.signup_prev,
+ u.unique_payers_current,
+ u.unique_payers_prev
+ FROM t, u;
+ `;
+
+ const trendRows = (await db.$queryRawUnsafe(trendQuery)) as Array;
+ const t = trendRows[0] || {
+ total_current: 0,
+ total_prev: 0,
+ stripe_current: 0,
+ stripe_prev: 0,
+ admin_current: 0,
+ admin_prev: 0,
+ signup_current: 0,
+ signup_prev: 0,
+ unique_payers_current: 0,
+ unique_payers_prev: 0,
+ };
+
+ const trendLabel = 'vs previous 7d';
+
+ const stripeFees = s.stripeAmount * 0.029;
+ const stripeFeesCurrent = t.stripe_current * 0.029;
+ const stripeFeesPrev = t.stripe_prev * 0.029;
+
+ const metrics: OverviewMetricConfig[] = [
+ {
+ id: 'totalPaymentVolume',
+ title: 'Total Payment Volume',
+ description: 'Total amount paid into the platform (all sources)',
+ displayType: 'currency',
+ value: s.totalAmount,
+ trendValue: percentChange(t.total_current, t.total_prev),
+ size: 'md',
+ format: { showTrend: true, decimals: 2, trendLabel },
+ },
+ {
+ id: 'stripePayments',
+ title: 'Stripe Payments',
+ description: 'Total amount of payments from Stripe',
+ displayType: 'currency',
+ value: s.stripeAmount,
+ trendValue: percentChange(t.stripe_current, t.stripe_prev),
+ size: 'md',
+ format: { showTrend: true, decimals: 2, trendLabel },
+ },
+ {
+ id: 'adminCredits',
+ title: 'Admin Credits',
+ description: 'Total amount of payments from Admin',
+ displayType: 'currency',
+ value: s.adminAmount,
+ trendValue: percentChange(t.admin_current, t.admin_prev),
+ size: 'sm',
+ format: { showTrend: true, decimals: 2, trendLabel },
+ },
+ {
+ id: 'signupGifts',
+ title: 'Sign-up Gifts',
+ description: 'Total amount of payments from Sign-up Gift',
+ displayType: 'currency',
+ value: s.signUpGiftAmount,
+ trendValue: percentChange(t.signup_current, t.signup_prev),
+ size: 'sm',
+ format: { showTrend: true, decimals: 2, trendLabel },
+ },
+ {
+ id: 'stripeFees',
+ title: 'Stripe Fees (2.9%)',
+ description: 'Estimated Stripe processing fees on Stripe payments',
+ displayType: 'currency',
+ value: stripeFees,
+ trendValue: percentChange(stripeFeesCurrent, stripeFeesPrev),
+ size: 'sm',
+ format: { showTrend: true, decimals: 2, trendLabel },
+ },
+ {
+ id: 'uniquePayers',
+ title: 'Unique Payers',
+ description: 'Distinct users who made at least one payment',
+ displayType: 'number',
+ value: s.uniquePayers,
+ trendValue: percentChange(t.unique_payers_current, t.unique_payers_prev),
+ size: 'sm',
+ format: { showTrend: true, trendLabel },
+ },
+ ];
+
+ return metrics;
+}
+
+
diff --git a/packages/app/control/src/services/admin/user-earning-summary.ts b/packages/app/control/src/services/admin/user-earning-summary.ts
new file mode 100644
index 000000000..d036a3a01
--- /dev/null
+++ b/packages/app/control/src/services/admin/user-earning-summary.ts
@@ -0,0 +1,209 @@
+import { db } from '@/lib/db';
+import { OverviewMetricConfig } from './type/overview-metric';
+
+function percentChange(currentValue: number | bigint, previousValue: number | bigint): number {
+ const currentNumber = Number(currentValue) || 0;
+ const previousNumber = Number(previousValue) || 0;
+ if (previousNumber === 0) return currentNumber ? 100 : 0;
+ const delta = currentNumber - previousNumber;
+ return (delta / previousNumber) * 100;
+}
+
+type EarningsOverviewRow = {
+ totalRevenue: number;
+ totalAppProfit: number;
+ totalMarkupProfit: number;
+ totalReferralProfit: number;
+ totalCompletedPayouts: number;
+ earningUsers: number;
+};
+
+type EarningsTrendRow = {
+ revenue_current: number;
+ revenue_prev: number;
+ app_profit_current: number;
+ app_profit_prev: number;
+ markup_profit_current: number;
+ markup_profit_prev: number;
+ referral_profit_current: number;
+ referral_profit_prev: number;
+ payouts_current: number;
+ payouts_prev: number;
+ earning_users_current: number;
+ earning_users_prev: number;
+};
+
+export async function getUserEarningsOverviewMetrics(): Promise {
+ const summaryQuery = `
+ WITH t AS (
+ SELECT
+ COALESCE(SUM(t."totalCost"), 0)::double precision AS total_revenue,
+ COALESCE(SUM(t."appProfit"), 0)::double precision AS total_app_profit,
+ COALESCE(SUM(t."markUpProfit"), 0)::double precision AS total_markup_profit,
+ COALESCE(SUM(t."referralProfit"), 0)::double precision AS total_referral_profit,
+ 0 AS earning_users -- placeholder; real value in eu CTE
+ FROM "transactions" t
+ ), p AS (
+ SELECT COALESCE(SUM(p."amount") FILTER (WHERE p."status" = 'completed'), 0)::double precision AS total_completed_payouts
+ FROM "payouts" p
+ ), eu AS (
+ SELECT COUNT(DISTINCT am."userId") AS earning_users
+ FROM "app_memberships" am
+ JOIN "transactions" t2 ON t2."echoAppId" = am."echoAppId"
+ WHERE am.role = 'owner'
+ )
+ SELECT
+ t.total_revenue AS "totalRevenue",
+ t.total_app_profit AS "totalAppProfit",
+ t.total_markup_profit AS "totalMarkupProfit",
+ t.total_referral_profit AS "totalReferralProfit",
+ p.total_completed_payouts AS "totalCompletedPayouts",
+ eu.earning_users AS "earningUsers"
+ FROM t, p, eu;
+ `;
+
+ const summary = (await db.$queryRawUnsafe(summaryQuery)) as Array;
+ const s = summary[0] || {
+ totalRevenue: 0,
+ totalAppProfit: 0,
+ totalMarkupProfit: 0,
+ totalReferralProfit: 0,
+ totalCompletedPayouts: 0,
+ earningUsers: 0,
+ };
+
+ const trendQuery = `
+ WITH ranges AS (
+ SELECT
+ (NOW()::date - INTERVAL '7 days') AS start_current,
+ NOW()::date AS end_current,
+ (NOW()::date - INTERVAL '14 days') AS start_prev,
+ (NOW()::date - INTERVAL '7 days') AS end_prev
+ ),
+ txn AS (
+ SELECT
+ COALESCE(SUM(CASE WHEN t."createdAt" >= r.start_current AND t."createdAt" < r.end_current THEN t."totalCost" END), 0)::double precision AS revenue_current,
+ COALESCE(SUM(CASE WHEN t."createdAt" >= r.start_prev AND t."createdAt" < r.end_prev THEN t."totalCost" END), 0)::double precision AS revenue_prev,
+ COALESCE(SUM(CASE WHEN t."createdAt" >= r.start_current AND t."createdAt" < r.end_current THEN t."appProfit" END), 0)::double precision AS app_profit_current,
+ COALESCE(SUM(CASE WHEN t."createdAt" >= r.start_prev AND t."createdAt" < r.end_prev THEN t."appProfit" END), 0)::double precision AS app_profit_prev,
+ COALESCE(SUM(CASE WHEN t."createdAt" >= r.start_current AND t."createdAt" < r.end_current THEN t."markUpProfit" END), 0)::double precision AS markup_profit_current,
+ COALESCE(SUM(CASE WHEN t."createdAt" >= r.start_prev AND t."createdAt" < r.end_prev THEN t."markUpProfit" END), 0)::double precision AS markup_profit_prev,
+ COALESCE(SUM(CASE WHEN t."createdAt" >= r.start_current AND t."createdAt" < r.end_current THEN t."referralProfit" END), 0)::double precision AS referral_profit_current,
+ COALESCE(SUM(CASE WHEN t."createdAt" >= r.start_prev AND t."createdAt" < r.end_prev THEN t."referralProfit" END), 0)::double precision AS referral_profit_prev
+ FROM "transactions" t, ranges r
+ ),
+ payouts AS (
+ SELECT
+ COALESCE(SUM(CASE WHEN p."status" = 'completed' AND p."createdAt" >= r.start_current AND p."createdAt" < r.end_current THEN p."amount" END), 0)::double precision AS payouts_current,
+ COALESCE(SUM(CASE WHEN p."status" = 'completed' AND p."createdAt" >= r.start_prev AND p."createdAt" < r.end_prev THEN p."amount" END), 0)::double precision AS payouts_prev
+ FROM "payouts" p, ranges r
+ ),
+ earning_users AS (
+ SELECT
+ COALESCE(COUNT(DISTINCT CASE WHEN t."createdAt" >= r.start_current AND t."createdAt" < r.end_current THEN am."userId" END), 0)::double precision AS earning_users_current,
+ COALESCE(COUNT(DISTINCT CASE WHEN t."createdAt" >= r.start_prev AND t."createdAt" < r.end_prev THEN am."userId" END), 0)::double precision AS earning_users_prev
+ FROM "transactions" t
+ JOIN "app_memberships" am ON am."echoAppId" = t."echoAppId" AND am.role = 'owner',
+ ranges r
+ )
+ SELECT
+ txn.revenue_current,
+ txn.revenue_prev,
+ txn.app_profit_current,
+ txn.app_profit_prev,
+ txn.markup_profit_current,
+ txn.markup_profit_prev,
+ txn.referral_profit_current,
+ txn.referral_profit_prev,
+ payouts.payouts_current,
+ payouts.payouts_prev,
+ earning_users.earning_users_current,
+ earning_users.earning_users_prev
+ FROM txn, payouts, earning_users;
+ `;
+
+ const trendRows = (await db.$queryRawUnsafe(trendQuery)) as Array;
+ const t = trendRows[0] || {
+ revenue_current: 0,
+ revenue_prev: 0,
+ app_profit_current: 0,
+ app_profit_prev: 0,
+ markup_profit_current: 0,
+ markup_profit_prev: 0,
+ referral_profit_current: 0,
+ referral_profit_prev: 0,
+ payouts_current: 0,
+ payouts_prev: 0,
+ earning_users_current: 0,
+ earning_users_prev: 0,
+ };
+
+ const trendLabel = 'vs previous 7d';
+
+ const metrics: OverviewMetricConfig[] = [
+ {
+ id: 'totalRevenue',
+ title: 'Total Revenue',
+ description: 'Total revenue generated by users',
+ displayType: 'currency',
+ value: s.totalRevenue,
+ trendValue: percentChange(t.revenue_current, t.revenue_prev),
+ size: 'md',
+ format: { showTrend: true, decimals: 2, trendLabel },
+ },
+ {
+ id: 'totalAppProfit',
+ title: 'App Profit',
+ description: 'Total profit from app usage',
+ displayType: 'currency',
+ value: s.totalAppProfit,
+ trendValue: percentChange(t.app_profit_current, t.app_profit_prev),
+ size: 'md',
+ format: { showTrend: true, decimals: 2, trendLabel },
+ },
+ {
+ id: 'totalMarkupProfit',
+ title: 'Markup Profit',
+ description: 'Profit from markup fees',
+ displayType: 'currency',
+ value: s.totalMarkupProfit,
+ trendValue: percentChange(t.markup_profit_current, t.markup_profit_prev),
+ size: 'md',
+ format: { showTrend: true, decimals: 2, trendLabel },
+ },
+ {
+ id: 'totalReferralProfit',
+ title: 'Referral Profit',
+ description: 'Profit from referrals',
+ displayType: 'currency',
+ value: s.totalReferralProfit,
+ trendValue: percentChange(t.referral_profit_current, t.referral_profit_prev),
+ size: 'md',
+ format: { showTrend: true, decimals: 2, trendLabel },
+ },
+ {
+ id: 'totalCompletedPayouts',
+ title: 'Completed Payouts',
+ description: 'Total amount paid out to users',
+ displayType: 'currency',
+ value: s.totalCompletedPayouts,
+ trendValue: percentChange(t.payouts_current, t.payouts_prev),
+ size: 'md',
+ format: { showTrend: true, decimals: 2, trendLabel },
+ },
+ {
+ id: 'earningUsers',
+ title: 'Earning Users',
+ description: 'Distinct owners with at least one app that has a transaction',
+ displayType: 'number',
+ value: s.earningUsers,
+ trendValue: percentChange(t.earning_users_current, t.earning_users_prev),
+ size: 'sm',
+ format: { showTrend: true, trendLabel },
+ },
+ ];
+
+ return metrics;
+}
+
+
diff --git a/packages/app/control/src/services/admin/user-earnings.ts b/packages/app/control/src/services/admin/user-earnings.ts
index a56eae715..69a31ffcd 100644
--- a/packages/app/control/src/services/admin/user-earnings.ts
+++ b/packages/app/control/src/services/admin/user-earnings.ts
@@ -187,4 +187,4 @@ export const getUserEarningsWithPagination = async (
page_size: params.page_size,
total_count: Number(totalCount[0].count),
});
-};
+};
\ No newline at end of file
diff --git a/packages/app/control/src/services/admin/user-spending-summary.ts b/packages/app/control/src/services/admin/user-spending-summary.ts
new file mode 100644
index 000000000..78aa58288
--- /dev/null
+++ b/packages/app/control/src/services/admin/user-spending-summary.ts
@@ -0,0 +1,197 @@
+import { db } from '@/lib/db';
+import { OverviewMetricConfig } from './type/overview-metric';
+
+function percentChange(currentValue: number | bigint, previousValue: number | bigint): number {
+ const currentNumber = Number(currentValue) || 0;
+ const previousNumber = Number(previousValue) || 0;
+ if (previousNumber === 0) return currentNumber ? 100 : 0;
+ const delta = currentNumber - previousNumber;
+ return (delta / previousNumber) * 100;
+}
+
+type SpendingOverviewRow = {
+ totalSpend: number;
+ totalPayments: number;
+ totalFreeTierUsage: number;
+ totalOutstandingBalance: number;
+ spendingUsers: number;
+};
+
+type SpendingTrendRow = {
+ spend_current: number;
+ spend_prev: number;
+ payments_current: number;
+ payments_prev: number;
+ free_tier_current: number;
+ free_tier_prev: number;
+ spending_users_current: number;
+ spending_users_prev: number;
+};
+
+export async function getUserSpendingOverviewMetrics(): Promise {
+ const summaryQuery = `
+ WITH t AS (
+ SELECT
+ COALESCE(SUM(t."totalCost"), 0)::double precision AS total_spend,
+ COALESCE(SUM(CASE WHEN t."spendPoolId" IS NOT NULL THEN t."totalCost" END), 0)::double precision AS total_free_tier_usage
+ FROM "transactions" t
+ ), p AS (
+ SELECT COALESCE(SUM(p."amount"), 0)::double precision AS total_payments
+ FROM "payments" p
+ WHERE p."isArchived" = false
+ AND p."status" = 'completed'
+ AND p."source" = 'stripe'
+ ), b AS (
+ SELECT COALESCE(SUM(u."totalPaid" - u."totalSpent"), 0)::double precision AS total_outstanding_balance
+ FROM "users" u
+ ), su AS (
+ SELECT COUNT(DISTINCT t2."userId")::double precision AS spending_users
+ FROM "transactions" t2
+ )
+ SELECT
+ t.total_spend AS "totalSpend",
+ p.total_payments AS "totalPayments",
+ t.total_free_tier_usage AS "totalFreeTierUsage",
+ b.total_outstanding_balance AS "totalOutstandingBalance",
+ su.spending_users AS "spendingUsers"
+ FROM t, p, b, su;
+ `;
+
+ const summary = (await db.$queryRawUnsafe(summaryQuery)) as Array;
+ const s = summary[0] || {
+ totalSpend: 0,
+ totalPayments: 0,
+ totalFreeTierUsage: 0,
+ totalOutstandingBalance: 0,
+ spendingUsers: 0,
+ };
+
+ const trendQuery = `
+ WITH ranges AS (
+ SELECT
+ (NOW()::date - INTERVAL '7 days') AS start_current,
+ NOW()::date AS end_current,
+ (NOW()::date - INTERVAL '14 days') AS start_prev,
+ (NOW()::date - INTERVAL '7 days') AS end_prev
+ ),
+ txn AS (
+ SELECT
+ COALESCE(SUM(CASE WHEN t."createdAt" >= r.start_current AND t."createdAt" < r.end_current THEN t."totalCost" END), 0)::double precision AS spend_current,
+ COALESCE(SUM(CASE WHEN t."createdAt" >= r.start_prev AND t."createdAt" < r.end_prev THEN t."totalCost" END), 0)::double precision AS spend_prev,
+ COALESCE(SUM(CASE WHEN t."spendPoolId" IS NOT NULL AND t."createdAt" >= r.start_current AND t."createdAt" < r.end_current THEN t."totalCost" END), 0)::double precision AS free_tier_current,
+ COALESCE(SUM(CASE WHEN t."spendPoolId" IS NOT NULL AND t."createdAt" >= r.start_prev AND t."createdAt" < r.end_prev THEN t."totalCost" END), 0)::double precision AS free_tier_prev
+ FROM "transactions" t, ranges r
+ ),
+ pay AS (
+ SELECT
+ COALESCE(SUM(CASE WHEN p."createdAt" >= r.start_current AND p."createdAt" < r.end_current THEN p."amount" END), 0)::double precision AS payments_current,
+ COALESCE(SUM(CASE WHEN p."createdAt" >= r.start_prev AND p."createdAt" < r.end_prev THEN p."amount" END), 0)::double precision AS payments_prev
+ FROM "payments" p, ranges r
+ WHERE p."isArchived" = false
+ AND p."status" = 'completed'
+ AND p."source" = 'stripe'
+ ),
+ su AS (
+ SELECT
+ COALESCE(COUNT(DISTINCT CASE WHEN t."createdAt" >= r.start_current AND t."createdAt" < r.end_current THEN t."userId" END), 0)::double precision AS spending_users_current,
+ COALESCE(COUNT(DISTINCT CASE WHEN t."createdAt" >= r.start_prev AND t."createdAt" < r.end_prev THEN t."userId" END), 0)::double precision AS spending_users_prev
+ FROM "transactions" t, ranges r
+ )
+ SELECT
+ txn.spend_current,
+ txn.spend_prev,
+ pay.payments_current,
+ pay.payments_prev,
+ txn.free_tier_current,
+ txn.free_tier_prev,
+ su.spending_users_current,
+ su.spending_users_prev
+ FROM txn, pay, su;
+ `;
+
+ const trendRows = (await db.$queryRawUnsafe(trendQuery)) as Array;
+ const t = trendRows[0] || {
+ spend_current: 0,
+ spend_prev: 0,
+ payments_current: 0,
+ payments_prev: 0,
+ free_tier_current: 0,
+ free_tier_prev: 0,
+ spending_users_current: 0,
+ spending_users_prev: 0,
+ };
+
+ const trendLabel = 'vs previous 7d';
+
+ const avgCurrent = t.spending_users_current ? t.spend_current / t.spending_users_current : 0;
+ const avgPrev = t.spending_users_prev ? t.spend_prev / t.spending_users_prev : 0;
+ const netBalanceDeltaCurrent = t.payments_current - t.spend_current;
+ const netBalanceDeltaPrev = t.payments_prev - t.spend_prev;
+
+ const metrics: OverviewMetricConfig[] = [
+ {
+ id: 'totalSpend',
+ title: 'Total User Spend',
+ description: 'Total amount users have spent via transactions',
+ displayType: 'currency',
+ value: s.totalSpend,
+ trendValue: percentChange(t.spend_current, t.spend_prev),
+ size: 'md',
+ format: { showTrend: true, decimals: 2, trendLabel },
+ },
+ {
+ id: 'totalPayments',
+ title: 'Total Payments',
+ description: 'Total payments received from users',
+ displayType: 'currency',
+ value: s.totalPayments,
+ trendValue: percentChange(t.payments_current, t.payments_prev),
+ size: 'md',
+ format: { showTrend: true, decimals: 2, trendLabel },
+ },
+ {
+ id: 'totalFreeTierUsage',
+ title: 'Free Tier Usage',
+ description: 'Total consumption covered by spend pools',
+ displayType: 'currency',
+ value: s.totalFreeTierUsage,
+ trendValue: percentChange(t.free_tier_current, t.free_tier_prev),
+ size: 'md',
+ format: { showTrend: true, decimals: 2, trendLabel },
+ },
+ {
+ id: 'spendingUsers',
+ title: 'Spending Users',
+ description: 'Distinct users with at least one transaction',
+ displayType: 'number',
+ value: s.spendingUsers,
+ trendValue: percentChange(t.spending_users_current, t.spending_users_prev),
+ size: 'sm',
+ format: { showTrend: true, trendLabel },
+ },
+ {
+ id: 'avgSpendPerUser',
+ title: 'Avg Spend per User',
+ description: 'Average spend per spending user',
+ displayType: 'currency',
+ value: s.spendingUsers ? s.totalSpend / s.spendingUsers : 0,
+ trendValue: percentChange(avgCurrent, avgPrev),
+ size: 'sm',
+ format: { showTrend: true, decimals: 2, trendLabel },
+ },
+ {
+ id: 'totalOutstandingBalance',
+ title: 'Outstanding Balance',
+ description: 'Sum of user balances (paid minus spent)',
+ displayType: 'currency',
+ value: s.totalOutstandingBalance,
+ trendValue: percentChange(netBalanceDeltaCurrent, netBalanceDeltaPrev),
+ size: 'md',
+ format: { showTrend: true, decimals: 2, trendLabel },
+ },
+ ];
+
+ return metrics;
+}
+
+
diff --git a/packages/app/control/src/services/email/emailer.ts b/packages/app/control/src/services/email/emailer.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/app/control/src/trpc/routers/admin/earnings.ts b/packages/app/control/src/trpc/routers/admin/earnings.ts
index 061f2bb4e..b63d25d64 100644
--- a/packages/app/control/src/trpc/routers/admin/earnings.ts
+++ b/packages/app/control/src/trpc/routers/admin/earnings.ts
@@ -2,6 +2,8 @@ import { adminProcedure, createTRPCRouter } from '../../trpc';
import { getUserEarningsWithPagination } from '@/services/admin/user-earnings';
import { getAppEarningsWithPagination } from '@/services/admin/app-earnings';
+import { getUserEarningsOverviewMetrics } from '@/services/admin/user-earning-summary';
+import { getAppEarningsOverviewMetrics } from '@/services/admin/app-earnings-summary';
import { paginationParamsSchema } from '@/services/lib/pagination';
import { multiSortParamsSchema } from '@/services/lib/sorting';
import { filterParamsSchema } from '@/services/lib/filtering';
@@ -26,4 +28,12 @@ export const adminEarningsRouter = createTRPCRouter({
.query(async ({ input }) => {
return await getAppEarningsWithPagination(input);
}),
+
+ getUserEarningsOverviewMetrics: adminProcedure.query(async () => {
+ return await getUserEarningsOverviewMetrics();
+ }),
+
+ getAppEarningsOverviewMetrics: adminProcedure.query(async () => {
+ return await getAppEarningsOverviewMetrics();
+ }),
});
diff --git a/packages/app/control/src/trpc/routers/admin/payments.ts b/packages/app/control/src/trpc/routers/admin/payments.ts
index 2e3c46c4b..d682a5625 100644
--- a/packages/app/control/src/trpc/routers/admin/payments.ts
+++ b/packages/app/control/src/trpc/routers/admin/payments.ts
@@ -1,6 +1,7 @@
import { adminProcedure, createTRPCRouter } from '../../trpc';
import { getPaymentsWithPagination } from '@/services/admin/payments';
+import { getPaymentsOverviewMetrics } from '@/services/admin/payments-summary';
import { paginationParamsSchema } from '@/services/lib/pagination';
import { multiSortParamsSchema } from '@/services/lib/sorting';
import { filterParamsSchema } from '@/services/lib/filtering';
@@ -15,4 +16,8 @@ export const adminPaymentsRouter = createTRPCRouter({
.query(async ({ input }) => {
return await getPaymentsWithPagination(input);
}),
+
+ getPaymentsOverviewMetrics: adminProcedure.query(async () => {
+ return await getPaymentsOverviewMetrics();
+ }),
});
diff --git a/packages/app/control/src/trpc/routers/admin/spending.ts b/packages/app/control/src/trpc/routers/admin/spending.ts
index 72464433b..e959299b8 100644
--- a/packages/app/control/src/trpc/routers/admin/spending.ts
+++ b/packages/app/control/src/trpc/routers/admin/spending.ts
@@ -1,6 +1,7 @@
import { adminProcedure, createTRPCRouter } from '../../trpc';
import { getUserSpendingWithPagination } from '@/services/admin/user-spending';
+import { getUserSpendingOverviewMetrics } from '@/services/admin/user-spending-summary';
import { paginationParamsSchema } from '@/services/lib/pagination';
import { multiSortParamsSchema } from '@/services/lib/sorting';
import { filterParamsSchema } from '@/services/lib/filtering';
@@ -15,4 +16,8 @@ export const adminSpendingRouter = createTRPCRouter({
.query(async ({ input }) => {
return await getUserSpendingWithPagination(input);
}),
+
+ getUserSpendingOverviewMetrics: adminProcedure.query(async () => {
+ return await getUserSpendingOverviewMetrics();
+ }),
});