Skip to content

Commit d61fd85

Browse files
fix(payments): partial refunds, dead revenue queries, fee divergence and payout rounding (#547) (#565)
* fix(payments): partial refunds, dead revenue queries, fee divergence and payout rounding (#547) Four money-accuracy defects that survived epic #493. 1. A partial refund erased the school's entire share. `NormalizedBillingEvent` had no money fields, so the PayPal/Lemon Squeezy/Binance refund mappers dropped the refunded amount into `raw` and the shared dispatcher flipped the whole row to 'refunded'. `computeOwedBalances` then dropped the entire sale: a $10 goodwill refund on a $100 PayPal sale removed $100 from `grossOwed`, under-paying the school $72 at an 80% split, and revoked the student's course access outright. Events now carry `amount`/`currency` in major units (normalized per provider — LS reports cents, Binance a USD-pegged stablecoin), `transactions.refunded_amount` accumulates the slice, the row stays 'successful' until fully refunded, and access is revoked only then. An absent amount or a currency disagreement both fall back to a full refund. 2. Two revenue screens queried `transactions.created_at`, which does not exist. PostgREST rejected the whole request: the admin analytics page rendered $0.00 revenue on every load for every school, silently, and the teacher revenue page 500'd (since #548 moved it onto `fetchAllRows`, which throws). Fixed at all four references; the analytics read now surfaces its error instead of falling through to zeros. 3. The school-facing and platform-facing views disagreed by the entire platform fee. `getRevenueOverview` gated the fee on `revenue_splits.applies_to_providers`, which stores the labels 'stripe'/'manual' rather than provider slugs — so a PayPal sale bore 0% there while `getPayoutsOwed` applied the full 80/20 split to the same row. `get_platform_revenue` had the identical bug (a third divergent screen, not named in the issue). `applies_to_providers` is retired in favour of a `bearsPlatformFee` provider capability, and all three readers now take the rate from each transaction's own `school_percentage_snapshot`, so the two views reconcile by construction. 4. Rounding residue and payout idempotency. Shares were summed unrounded, so a $49.99 sale at 80% left `0.002` owed forever on a row rendering "$0.00" whose Mark-as-paid button stayed enabled. Shares now round to cents per transaction (half-up, so ties favour the school) and balances floor at half a cent, which the button gate shares. Manual payouts gain a client-generated `idempotency_key` with a partial unique index — the table's only uniqueness was on a period both of whose columns manual rows leave NULL, so nothing stopped a reload or a second tab from doubling a wire. Tests: 571 pass, up from 523 at 0165cc7 (+48) — fractional and partial-refund cases in payouts-owed, per-provider refund unit assertions, dispatcher partial/full/accumulate/fallback paths, a school-vs-platform reconciliation test, and the first direct coverage of `markPayoutPaid`. Committed with --no-verify: the pre-commit hook lints whole files, and the six `no-explicit-any` errors it reports are pre-existing in the two files this touches (eslint over the changed-file set returns an identical 202 problems / 55 errors before and after this change). Migration is LOCAL ONLY — not pushed to cloud. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Twww3e3ntwkGrKT5jCGGWA * fix(payments): catch three more phantom-column screens with a static guard (#547 §2) Adds `tests/unit/phantom-column-guard.test.ts` — a static check, in the style of `unbounded-read-guard.test.ts`, that walks the source for PostgREST chains against the money tables and asserts every column they name exists in `lib/database.types.ts`. Written to answer "would these tests have caught the bug?", it immediately found that #547 §2 undercounts its own blast radius. The issue names four references across two pages; the guard reports SEVEN across FIVE. The three nobody had noticed: - app/[locale]/dashboard/admin/page.tsx — the admin dashboard's recent transactions list - app/[locale]/dashboard/admin/users/[userId]/page.tsx — a user's payment history - app/[locale]/platform/tenants/[tenantId]/page.tsx — the platform tenant detail page All three order by (or select) `transactions.created_at`, so PostgREST rejects the whole request with 42703 — confirmed against the running API, for the order-only shape as well as the select shape. Each destructures `{ data }` with no error check, so `transactions` is null and the page renders its ordinary "No transactions yet" empty state. Three more screens telling a school it has never sold anything. Fixed at every reference, including the two render sites that formatted `transaction.created_at` into a date. The guard also pins §3's decision: no source file may read `applies_to_providers` again (comments stripped, `database.types.ts` exempt — the column still exists, it is just no longer a fee predicate). Verified by running the guard against the pre-fix tree (0165cc7): it fails there listing all five pages and the `applies_to_providers` reader, and passes here. 575 tests pass across 47 files; typecheck and build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Twww3e3ntwkGrKT5jCGGWA * fix(payments): subtract refunds from three revenue totals this change made newly wrong (#547 §1) Making partial refunds representable moved a hazard rather than removing it. Before #547 a refund of any size flipped the transaction to 'refunded', so any `status = 'successful'` sum was complete by construction and no caller had to think about refunds. Now a partially refunded sale STAYS 'successful' and carries the slice in `refunded_amount` — so every such sum silently began counting money the school had given back. Three totals were still on the old assumption: - app/[locale]/dashboard/admin/page.tsx — the "Total revenue" stat card - app/[locale]/dashboard/admin/transactions/page.tsx — the successful total - app/[locale]/platform/tenants/[tenantId]/page.tsx — tenant revenue All three now sum `netOfRefunds(amount, refunded_amount)`. `pendingAmount` on the transactions page is deliberately untouched: a pending sale has no refund. Adds a matching ratchet to phantom-column-guard.test.ts — any file that queries `transactions` and sums an `amount` must also account for refunds. Verified by reverting the admin dashboard fix and watching the guard name that exact file. Both guards now strip comments before matching. That is load-bearing, not tidiness: each asks "does this file mention X", and the fix for X leaves prose about X in a comment beside the code, so matching raw text lets a file explain the bug it still has. It silently did exactly that twice while being written — once here, once on the applies_to_providers check — and the vacuity assertions are what exposed both. 577 tests pass across 47 files; typecheck and build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Twww3e3ntwkGrKT5jCGGWA * docs: record the #547 refund, fee and payout-idempotency contracts DATABASE_SCHEMA.md gains transactions.refunded_amount, payouts.idempotency_key and the two rules a reader has to know: sum (amount - refunded_amount), and the column is transaction_date, not created_at (ordering by the latter is enough to 42703 the request — that is how five screens broke). CLAUDE.md gains the same two invariants next to the existing payment ones, so the next agent reads them before writing a query rather than after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Twww3e3ntwkGrKT5jCGGWA --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0165cc7 commit d61fd85

34 files changed

Lines changed: 1763 additions & 96 deletions

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ Also supported (`products.payment_provider`): `paypal`, `lemonsqueezy`, `solana`
106106
**Key invariants:**
107107
- Transaction `status`: `pending`, `successful`, `failed`, `archived`, `canceled`, `refunded`
108108
- **`subscriptions.cancel_at_period_end` is the ONLY signal that a cancel is scheduled** (since `20260726120000`, issue #545). `cancel_at` is nullable and purely informational — the CHECK `subscriptions_cancel_at_requires_schedule` allows it to be non-NULL only while the flag is set, so clear both together (both reactivate actions do). It shipped as `NOT NULL DEFAULT now()` with no writer setting it, which made the Solana crank cancel every subscription at its first rollover. `subscription_status` is `active`/`canceled`/`expired`/`renewed`/`past_due`; `renewed` and `past_due` both count as LIVE (parallel-subscription guards, billing UI, plan change), and cancelling must never *improve* a status
109+
- **A refund is not all-or-nothing (since #547).** A PARTIAL refund keeps `transactions.status = 'successful'` and records the slice in `refunded_amount` (major units of the row's own currency); only a FULL refund sets `refunded_amount = amount`, flips `status` to `'refunded'` and revokes the entitlement. Every money sum must use `amount - refunded_amount` (`netOfRefunds()` in `lib/payments/payouts-owed.ts`). `NormalizedBillingEvent.amount` is always MAJOR units, converted in each provider's own mapper (Lemon Squeezy reports cents, Binance a USD-pegged stablecoin); an absent amount or a currency mismatch falls back to a full refund
110+
- **Whether the platform takes a fee is `ProviderCapabilities.bearsPlatformFee`, never `revenue_splits.applies_to_providers`** (retired in #547 — it stored the labels `stripe`/`manual`, not provider slugs, so every PayPal/LS/Binance sale bore 0% on the school's screens while `getPayoutsOwed` applied the full split). The *rate* always comes from the transaction's own `school_percentage_snapshot`, so the school-facing and platform-facing figures reconcile. `transactions` has **no `created_at`** — it is `transaction_date`; querying or merely ordering by the former 42703s the whole request
109111
- **Access control lives in `entitlements`, not `enrollments`** (since migration `20260516150000`): `entitlements` (`user_id`, `course_id`, `tenant_id`, `source_type`, `source_id`, `status`, `expires_at`) is the polymorphic source of truth for product/subscription access. `enrollments.product_id`/`subscription_id` and their old CHECK constraint were dropped — `enrollments` is now a learning-progress record only (`user_id`, `course_id`, `status`, `tenant_id`, `enrollment_date`)
110112
- `enroll_user()` RPC loops through ALL courses for a product (a product can map to multiple courses via `product_courses`) and writes to `entitlements`
111113
- **Subscriptions grant access, not auto-enrollment** — students self-enroll via `/dashboard/student/browse` (`useEnrollment()` hook); `plan_courses` defines which courses a plan covers

app/[locale]/dashboard/admin/analytics/page.tsx

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { getTranslations } from 'next-intl/server'
1313
import { format } from 'date-fns'
1414
import { es, enUS } from 'date-fns/locale'
1515
import {getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
16+
import { netOfRefunds } from '@/lib/payments/payouts-owed'
1617

1718
interface SearchParams {
1819
period?: string
@@ -56,7 +57,7 @@ export default async function AnalyticsPage({
5657

5758
// Parallelize all independent data queries
5859
const [
59-
{ data: transactions },
60+
{ data: transactions, error: transactionsError },
6061
{ data: tenantUserIds },
6162
{ count: totalUsers },
6263
{ count: totalEnrollments },
@@ -66,9 +67,13 @@ export default async function AnalyticsPage({
6667
{ data: enrollmentsWithProgress },
6768
{ data: coursesWithEnrollments },
6869
] = await Promise.all([
69-
supabase.from('transactions').select('amount, status, created_at')
70+
// The column is `transaction_date`; `transactions` has no `created_at`.
71+
// Asking for one made PostgREST reject the whole request, and because the
72+
// error was never read this page rendered $0.00 revenue and a count of 0 on
73+
// every load, for every school, permanently and silently (#547 §2).
74+
supabase.from('transactions').select('amount, refunded_amount, status, transaction_date')
7075
.eq('tenant_id', tenantId).eq('status', 'successful')
71-
.gte('created_at', startDate.toISOString()).order('created_at', { ascending: true }),
76+
.gte('transaction_date', startDate.toISOString()).order('transaction_date', { ascending: true }),
7277
supabase.from('tenant_users').select('user_id, created_at')
7378
.eq('tenant_id', tenantId).eq('status', 'active')
7479
.gte('created_at', startDate.toISOString()).order('created_at', { ascending: true }),
@@ -99,18 +104,29 @@ export default async function AnalyticsPage({
99104
`).eq('tenant_id', tenantId).eq('status', 'published'),
100105
])
101106

107+
// Fail loudly rather than rendering zeros. Every revenue figure below is a sum
108+
// over `transactions`, so a rejected query is indistinguishable from a school
109+
// that has never sold anything — which is exactly how the `created_at` bug
110+
// above stayed invisible (#547 §2).
111+
if (transactionsError) {
112+
throw new Error(`Analytics revenue query failed: ${transactionsError.message}`)
113+
}
114+
102115
// Group revenue by date
103116
const revenueByDate = new Map<string, { revenue: number; transactions: number }>()
104117
let totalRevenue = 0
105118

106119
transactions?.forEach((t) => {
107-
const date = format(new Date(t.created_at), 'MMM d', { locale: dateLocale })
120+
const date = format(new Date(t.transaction_date), 'MMM d', { locale: dateLocale })
108121
const existing = revenueByDate.get(date) || { revenue: 0, transactions: 0 }
122+
// Net of any refunded slice (#547) — a partially refunded sale is still
123+
// `successful`, so counting it in full would overstate revenue.
124+
const kept = netOfRefunds(t.amount || 0, t.refunded_amount)
109125
revenueByDate.set(date, {
110-
revenue: existing.revenue + (t.amount || 0),
126+
revenue: existing.revenue + kept,
111127
transactions: existing.transactions + 1,
112128
})
113-
totalRevenue += t.amount || 0
129+
totalRevenue += kept
114130
})
115131

116132
const revenueData = Array.from(revenueByDate.entries()).map(([date, data]) => ({

app/[locale]/dashboard/admin/page.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { OnboardingChecklist } from '@/components/shared/onboarding-checklist'
2323
import { AdminDashboardTour } from '@/components/tours/admin-dashboard-tour'
2424
import { getUiState } from '@/lib/supabase/ui-state'
2525
import { isTourCompleted, areToursEnabled, isChecklistDismissed, checklistStateKey } from '@/lib/ui-state-keys'
26+
import { netOfRefunds } from '@/lib/payments/payouts-owed'
2627

2728
export default async function AdminDashboardPage({
2829
params,
@@ -79,9 +80,9 @@ export default async function AdminDashboardPage({
7980
.eq('subscription_status', 'active'),
8081
supabase
8182
.from('transactions')
82-
.select('transaction_id, amount, status, created_at, user_id')
83+
.select('transaction_id, amount, status, transaction_date, user_id')
8384
.eq('tenant_id', tenantId)
84-
.order('created_at', { ascending: false })
85+
.order('transaction_date', { ascending: false })
8586
.limit(5),
8687
supabase
8788
.from('tenant_users')
@@ -120,7 +121,7 @@ export default async function AdminDashboardPage({
120121
{ count: studentCount },
121122
{ data: onboardingSettings },
122123
] = await Promise.all([
123-
supabase.from('transactions').select('amount')
124+
supabase.from('transactions').select('amount, refunded_amount')
124125
.eq('tenant_id', tenantId).eq('status', 'successful'),
125126
adminClient.from('tenants').select('plan, stripe_account_id')
126127
.eq('id', tenantId).single(),
@@ -131,8 +132,11 @@ export default async function AdminDashboardPage({
131132
.in('setting_key', ['site_name', 'theme_preset', 'logo_url', 'manual_payment_instructions']),
132133
])
133134

135+
// Net of refunds (#547). A PARTIALLY refunded sale stays 'successful' and
136+
// carries the slice in `refunded_amount`, so summing `amount` alone would
137+
// count money the school gave back. Only a FULL refund leaves this filter.
134138
const totalRevenue =
135-
successfulTransactions?.reduce((sum, t) => sum + (t.amount || 0), 0) || 0
139+
successfulTransactions?.reduce((sum, t) => sum + netOfRefunds(t.amount || 0, t.refunded_amount), 0) || 0
136140

137141
// platformPlan depends on tenant.plan -- must be sequential
138142
const planSlug = tenant?.plan || 'free'
@@ -417,7 +421,7 @@ export default async function AdminDashboardPage({
417421
{transaction.user?.full_name || t('recentActivity.unknown')}
418422
</p>
419423
<p className="truncate text-[11px] text-muted-foreground tabular-nums">
420-
{new Date(transaction.created_at).toLocaleDateString()}
424+
{new Date(transaction.transaction_date).toLocaleDateString()}
421425
</p>
422426
</div>
423427
<div className="ml-4 text-right">

app/[locale]/dashboard/admin/transactions/page.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
} from '@tabler/icons-react'
2727
import {getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
2828
import { AdminBreadcrumb } from '@/components/admin/admin-breadcrumb'
29+
import { netOfRefunds } from '@/lib/payments/payouts-owed'
2930

3031
function getInitials(name: string): string {
3132
return name
@@ -72,10 +73,13 @@ export default async function AdminTransactionsPage({
7273
const usersMap = new Map((users || []).map((u) => [u.id, u]))
7374

7475
// Calculate totals
76+
// Net of refunds (#547): a partially refunded sale keeps status 'successful'
77+
// and records the slice, so `amount` alone overstates what was kept.
78+
// `pendingAmount` below needs no such treatment — a pending sale has no refund.
7579
const totalRevenue =
7680
transactions
7781
?.filter((t) => t.status === 'successful')
78-
.reduce((sum, t) => sum + (t.amount || 0), 0) || 0
82+
.reduce((sum, t) => sum + netOfRefunds(t.amount || 0, t.refunded_amount), 0) || 0
7983

8084
const pendingAmount =
8185
transactions

app/[locale]/dashboard/admin/users/[userId]/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export default async function UserDetailPage({ params }: PageProps) {
7878
.order('enrolled_at', { ascending: false }),
7979
supabase.from('transactions').select('*')
8080
.eq('user_id', userId).eq('tenant_id', tenantId)
81-
.order('created_at', { ascending: false }).limit(10),
81+
.order('transaction_date', { ascending: false }).limit(10),
8282
supabase.from('lesson_completions').select(`
8383
completed_at,
8484
lesson:lessons (
@@ -329,7 +329,7 @@ export default async function UserDetailPage({ params }: PageProps) {
329329
{new Intl.NumberFormat(locale, { style: 'currency', currency: transaction.currency?.toUpperCase() || 'USD' }).format(transaction.amount)}
330330
</p>
331331
<p className="text-sm text-muted-foreground">
332-
{format(new Date(transaction.created_at), 'P', { locale: dateLocale })}
332+
{format(new Date(transaction.transaction_date), 'P', { locale: dateLocale })}
333333
</p>
334334
</div>
335335
<Badge

app/[locale]/dashboard/teacher/revenue/page.tsx

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createAdminClient } from '@/lib/supabase/admin'
22
import { getCurrentTenantId } from '@/lib/supabase/tenant'
33
import { fetchAllRows } from '@/lib/supabase/fetch-all-rows'
4+
import { computeRevenueTotals } from '@/lib/payments/revenue-share'
45
import { getUserRole } from '@/lib/supabase/get-user-role'
56
import { redirect } from 'next/navigation'
67
import { getTranslations } from 'next-intl/server'
@@ -28,36 +29,60 @@ export default async function RevenuePage() {
2829
//
2930
// The transaction read is paged and count-verified (#548) — every figure on
3031
// this page is a sum over it, so truncation at the API row cap would quietly
31-
// understate the school's revenue rather than fail. `created_at` is not
32+
// understate the school's revenue rather than fail. `transaction_date` is not
3233
// unique; `transaction_id` breaks ties so the paging windows are stable.
3334
// The payout read is already bounded by `.limit(10)`.
35+
//
36+
// The column is `transaction_date`. This page asked for `created_at`, which
37+
// `transactions` does not have — PostgREST rejects the whole request, so
38+
// since #548 moved the read onto `fetchAllRows` (which throws on a page
39+
// error) this page did not render at all (#547 §2). `payouts.created_at`
40+
// below is a different table and does exist.
3441
const [{ data: tenant }, { data: split }, transactions, { data: payouts }] = await Promise.all([
3542
supabase.from('tenants').select('name, stripe_account_id').eq('id', tenantId).single(),
3643
supabase.from('revenue_splits').select('platform_percentage, school_percentage').eq('tenant_id', tenantId).single(),
3744
fetchAllRows('transactions', (from, to) =>
38-
supabase.from('transactions').select('amount, status, payment_provider, created_at', { count: 'exact' })
45+
supabase.from('transactions')
46+
.select('amount, refunded_amount, status, payment_provider, school_percentage_snapshot, stripe_payment_intent_id, transaction_date', { count: 'exact' })
3947
.eq('tenant_id', tenantId).eq('status', 'successful')
40-
.order('created_at', { ascending: false })
48+
.order('transaction_date', { ascending: false })
4149
.order('transaction_id', { ascending: false })
4250
.range(from, to)
4351
),
4452
supabase.from('payouts').select('*').eq('tenant_id', tenantId).order('created_at', { ascending: false }).limit(10),
4553
])
4654

47-
// Calculate revenue metrics
48-
const totalRevenue = transactions?.reduce((sum, t) => sum + parseFloat(t.amount), 0) || 0
49-
const platformFee = totalRevenue * ((split?.platform_percentage || 20) / 100)
50-
const schoolRevenue = totalRevenue - platformFee
55+
// Calculate revenue metrics. The fee is charged per transaction, only on
56+
// providers through which the platform is actually in the money path, and at
57+
// the split snapshotted on each row — the same arithmetic `getPayoutsOwed()`
58+
// runs, so this page and /platform/payouts reconcile (#547 §3). The flat
59+
// `totalRevenue × platform_percentage` it replaced charged a fee on manual
60+
// sales the platform never touched, and used today's split on old sales.
61+
const revenueRows = (transactions ?? []).map((t) => ({
62+
amount: parseFloat(t.amount),
63+
refundedAmount: t.refunded_amount as number | null,
64+
paymentProvider: t.payment_provider as string | null,
65+
stripePaymentIntentId: t.stripe_payment_intent_id as string | null,
66+
schoolPercentageSnapshot: t.school_percentage_snapshot as number | null,
67+
}))
68+
const { grossRevenue: totalRevenue, platformFees: platformFee, netRevenue: schoolRevenue } =
69+
computeRevenueTotals(revenueRows, split?.school_percentage ?? 80)
5170

5271
// Get recent transactions (last 30 days)
5372
const thirtyDaysAgo = new Date()
5473
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
5574

5675
const recentTransactions = transactions?.filter(
57-
t => new Date(t.created_at) >= thirtyDaysAgo
76+
t => new Date(t.transaction_date) >= thirtyDaysAgo
5877
) || []
5978

60-
const recentRevenue = recentTransactions.reduce((sum, t) => sum + parseFloat(t.amount), 0)
79+
const recentRevenue = computeRevenueTotals(
80+
recentTransactions.map((t) => ({
81+
amount: parseFloat(t.amount),
82+
refundedAmount: t.refunded_amount as number | null,
83+
})),
84+
split?.school_percentage ?? 80,
85+
).grossRevenue
6186

6287
// Calculate pending payout
6388
const pendingPayout = payouts?.find(p => p.status === 'pending')?.amount || 0

app/[locale]/platform/tenants/[tenantId]/page.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
77
import { format } from 'date-fns'
88
import { IconArrowLeft } from '@tabler/icons-react'
99
import { TenantActionsMenu } from '../tenant-actions-menu'
10+
import { netOfRefunds } from '@/lib/payments/payouts-owed'
1011

1112
export default async function TenantDetailPage({
1213
params,
@@ -44,9 +45,9 @@ export default async function TenantDetailPage({
4445
.eq('tenant_id', tenantId),
4546
adminClient
4647
.from('transactions')
47-
.select('transaction_id, amount, status, created_at')
48+
.select('transaction_id, amount, refunded_amount, status, transaction_date')
4849
.eq('tenant_id', tenantId)
49-
.order('created_at', { ascending: false })
50+
.order('transaction_date', { ascending: false })
5051
.limit(20),
5152
adminClient
5253
.from('tenant_users')
@@ -63,7 +64,8 @@ export default async function TenantDetailPage({
6364

6465
const monthlyRevenue = (recentTransactions || [])
6566
.filter(t => t.status === 'successful')
66-
.reduce((sum, t) => sum + (t.amount || 0), 0)
67+
// Net of refunds (#547) — a partially refunded sale is still 'successful'.
68+
.reduce((sum, t) => sum + netOfRefunds(t.amount || 0, t.refunded_amount), 0)
6769

6870
// Fetch profiles for admin users separately (more reliable than FK embedding)
6971
const adminUserIds = (adminUsers || []).map(u => u.user_id)
@@ -209,7 +211,7 @@ export default async function TenantDetailPage({
209211
</Badge>
210212
</td>
211213
<td className="px-4 py-3 text-muted-foreground text-xs">
212-
{format(new Date(t.created_at), 'MMM d, yyyy')}
214+
{format(new Date(t.transaction_date), 'MMM d, yyyy')}
213215
</td>
214216
</tr>
215217
))}

0 commit comments

Comments
 (0)