Skip to content

feat: partner revenue breakout (payout-accurate) + net-of-partner affiliate revenue - #26

Merged
kaladinlight merged 47 commits into
mainfrom
feat/partner-revenue-breakout
Jul 8, 2026
Merged

kaladinlight merged 47 commits into
mainfrom
feat/partner-revenue-breakout

Conversation

@kaladinlight

@kaladinlight kaladinlight commented Jul 7, 2026 •

Copy link
Copy Markdown
Member

What

Adds partner revenue reporting and nets each partner's share out of ShapeShift affiliate revenue, sourced from swap-service and aligned to what the affiliate-payouts script actually pays.

  • GET /api/v1/partner/revenue — per-partner revenue/volume/swaps, sourced from swap-service /v1/affiliate/swaps.
  • getAffiliateRevenue now reports ShapeShift-NET — each partner's realized share (USD + on-chain crypto) is deducted from gross provider fees, attributed to the affiliate-fee asset.
  • Dashboard — a "Revenue by Partner" section below "Revenue by Asset" (the separate Partners tab is gone), with sortable, scrollable tables.

Payout accuracy (the important part)

A single shared predicate, getPayablePartnerFee, gates both the net deduction and the partner view. A swap only counts when it mirrors the payout script:

  • status === 'SUCCESS', verified on-chain, with a real on-chain affiliate-fee amount and verified bps;
  • passes the anomaly guard (on-chain fee vs bps-implied, 25% tolerance);
  • crypto share uses verifiedBps (same clamped bps math as swap-service).

Non-payable / unmapped / failed-provider swaps are excluded — never silently — and logged per-swap (swapId + reason) for audit. ShapeShift-net floors at 0 (never negative) and suppresses deductions when a provider failed (no deducting against absent gross). USD figures from swap-service are treated as strings and converted with BigNumber (fixes a latent string-concat bug).

Frontend

  • Partner + asset breakdown tables: sortable columns (shared SortArrow, sticky headers), lazy-rendered infinite scroll capped at ~10 rows, thin themed scrollbar.

Tests / checks

  • revenue-api: 37 tests pass (payable predicate, reconciliation, affiliate + partner aggregation), type-check + eslint clean.
  • dashboard: type-check + eslint + vite build clean.

Known follow-up (swap-service)

The payability gate is reconstructed locally from the collapsed feeUsd/partnerFeeUsd, so it can diverge from the payout for swaps whose on-chain fee exists but isn't USD-priceable (e.g. Relay third-asset routes). The clean fix is swap-service exposing a payout-basis payablePartnerFee* field; the dashboard then drops the reconstruction and matches payouts 1:1. Spec drafted separately.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added partner revenue reporting across the API and dashboard, including a new partner revenue view and data endpoint.
    • Revenue tables now support sorting, improved scrolling, and clearer sort indicators.
    • Date filters are now validated more consistently, with better error responses for invalid ranges.
  • Bug Fixes

    • Improved revenue calculations to handle edge cases more accurately, including zero or negative outcomes.
    • Existing dashboard and API environment setup now includes the new swap-service configuration values.

kaladinlight and others added 29 commits June 26, 2026 16:04
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…partner endpoint

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…api-key auth & worktree

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+ unmatched fallback

Splits a matched provider fee event by partnerBps/affiliateBps (provider
USD is source of truth), crediting the partner and reducing ShapeShift's
net fee accordingly. For partner swaps with no matching fee event (e.g.
chainflip emits no txHash), emits a synthetic negative fee on the mapped
service and flags it unreconciled; unmapped swappers are still credited
to byPartner without a synthetic fee. Adds optional `synthetic` to `Fees`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…er swaps

Fetches the affiliate registry and all partner swaps (cursor-paginated)
from swap-service, mapping enriched rows to PartnerSwapRow for downstream
settlement building.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… with graceful fallback

Wire buildSettlement/fetchPartnerSwaps into getAffiliateRevenue so byService/byDate/totals
reflect ShapeShift-NET numbers after peeling partner-attributed swaps. Extracts fee
collection into collectFees() and settlement into settle() (graceful fallback to raw
fees on swap-service failure) so B5 can reuse both. Synthetic negative-adjustment fees
are excluded from fee counts and the per-asset breakdown but still reduce totalUsd/
byService/byServiceVolume.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds AffiliateRevenue.getPartnerRevenue, reusing the collectFees/settle
helpers from B4, with a graceful fallback to affiliates: [] if the
registry fetch fails.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds PartnerRevenue/PartnerRevenueResponse types, a fetchPartnerRevenue
api client, and a usePartnerRevenue hook (with an enabled flag so the
Partners tab only fetches when active), mirroring the existing
affiliate revenue equivalents. Also adds the unreconciled field to
AffiliateRevenueResponse to match the updated backend response.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a sortable PartnerBreakdown table (revenue/volume/swaps, partner
payouts total, unreconciled badge) and a dependency-free Revenue/Partners
tab nav in App.tsx. The Revenue view is preserved unchanged and now notes
it is net of partner payouts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…partner ratio

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…'t refetch

The partner query was gated on `enabled: tab === 'partners'`, so the first
Partners click triggered a fresh fetch for a period already loaded. Fetch it
in parallel with the revenue query and let react-query cache by date range;
switching tabs now reads cache, and changing the period refetches both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These were dated one-off QA/analysis artifacts (integration test runs,
token-identification scratch, a one-time discrepancy root-cause) with no
ongoing reference value and nothing linking to them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cratch

- Move the design spec to docs/design/ (out of the tool-specific superpowers/ path)
- Drop the implementation plan (ephemeral execution scaffolding)
- gitignore docs/plans/ and .superpowers/ as local-only, uncommitted scratch

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e into its own module

Relocate the partner-revenue settlement (settle/swapServiceClient/swapperServiceMap/
types) from affiliateRevenue/partnerSettlement to a sibling src/partnerRevenue/ module
to separate partner-revenue concerns from affiliate-revenue; repoint imports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…evenue routes

Both /affiliate/revenue and /partner/revenue duplicated the same YYYY-MM-DD
validation, timestamp conversion, and 400/500 error handling. Extract a
createRevenueRoute factory + parseDateRange helper; each route file is now a
thin binding to its service method.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…p-service

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… util

Reverts the createRevenueRoute factory (over-abstracted — hid the route
structure and forced boilerplate through it). Routes keep their own explicit
handlers; only the date-range validation is shared, as utils/parseDateRange.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The test only exercised an injected fake fetch (pagination plumbing), asserting
little about real behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… swaps

getPartnerRevenue no longer runs the affiliate provider collection + netting
settlement. Partner revenue is a straight roll-up of each partner swap's
partnerFeeUsd (new aggregatePartnerRevenue), independent of the ShapeShift-net
revenue view. The txHash settlement (buildSettlement) is now only used by
getAffiliateRevenue to peel cuts from gross fees, so its byPartner output is
dropped (netFees + unreconciled only), and the orphaned swapperServiceMap
module is removed. Drops the settlement-only `unreconciled` field from the
partner response (backend + frontend).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…jection)

Rename fetchAffiliateRegistry -> fetchAffiliates and drop its opts/fetchImpl
seam (only the removed swapServiceClient test used it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…waps

Remove the opts/fetchImpl seam and the now-unused FetchLike/Opts types; the
only consumer was the removed swapServiceClient test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…st at boot)

These are required to talk to swap-service; a missing value was silently
falling back to localhost / an empty key. Throw at module load instead, so a
misconfiguration fails at boot rather than degrading to gross revenue at runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+ net reconciliation)

Consolidate partner revenue into affiliateRevenue: reconcileRevenue (net
deduction from gross), aggregateRevenue (affiliate + partner responses), and a
shared payablePartnerFee gate. Only on-chain-verified, payable swaps (status
SUCCESS + verified bps + actual affiliate-fee amount + anomaly-guarded) are
counted — matching the affiliate-payouts script — so partner numbers never
overstate. Non-payable/unmapped/failed-provider swaps are excluded and logged
per-swap for audit. ShapeShift-net floors at 0 (never negative) and suppresses
deductions when a provider failed. Move Fees + date helpers into their own
modules; drop the unused `unreconciled` response field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…bles

Render the partner breakdown as a section below revenue-by-asset (drop the
separate Partners tab). Add sortable columns (shared SortArrow indicator,
sticky headers), lazy-rendered infinite scroll capped at ~10 rows, and a thin
themed scrollbar — applied to both the partner and asset breakdown tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 7, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
revenue-dashboard Ready Ready Preview, Comment Jul 8, 2026 4:27pm

Request Review

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
kaladinlight and others added 2 commits July 7, 2026 16:13
Address CodeRabbit review: add AbortSignal.timeout to both swap-service fetches
(a stalled response no longer hangs the request) and a page cap on the cursor
loop; exclude swaps with negative partnerBps as invalid rather than emitting a
negative payable share. Add cap-path and negative-bps tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address CodeRabbit review: extract a shared SortHeader with role/tabIndex/
aria-sort/Enter-Space handling (used by asset + partner tables, removing the
duplicated inline markup); surface partner-revenue fetch failures as an error
state instead of the empty 'no data' message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove MAX_PAGES (the fetch timeout is the meaningful guard; a correct cursor
won't loop). Surface partner-revenue fetch failures as a top-of-page banner
(consistent with the affiliate error) instead of an in-component message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…vice

The external per-slice labels overlapped on small slices and their uneven
widths pushed the ring off-center. The legend + table (with color dots) + hover
tooltip already map colors to services, so the donut is now a clean centered ring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The chart cell is shorter than the table, so it top-aligned in the grid row.
self-center vertically centers it against the table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Stabilize the fee-anomaly reason (drop the per-swap deviation %) so exclusions
  group by reason instead of each % becoming its own bucket.
- Log $ impact per reason (sorted by $), with a per-swapper breakdown that
  surfaces systematic gaps, and a total header. Drop the misused 'e.g. <ids>'.
- Carry partnerFeeUsd on ExcludedPartnerSwap for the $ totals.
- Remove a stray debug console.log in the anomaly branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…DB inspection

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tnerRevenue log)

getAffiliateRevenue and getPartnerRevenue independently evaluated the same
partner swaps, so non-payable exclusions were logged twice. The reconcile path
is a superset (also covers unmapped/failed-provider), so keep only that log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ciliation]

Neutral tag (matches reconcilePartnerRevenue) — accurate for both partner-fee
exclusions and net-only exclusions (unmapped/failed provider), unlike
[PartnerRevenue] which would imply those were dropped from partner revenue.
Drop the now-single-use context param.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…iate bps

A swap verified on-chain with verifiedBps 0 (a real, settled swap that
earned $0) was excluded from the partner breakdown entirely — no swap
count, no volume — because getPayablePartnerFee conflated it with an
unverified swap (verifiedBps null).

Treat verifiedBps === 0 as a distinct, payable-with-$0 case: it counts
toward swapCount + volume but contributes $0 revenue, so the partner view
matches the affiliate side without overstating anyone. The branch returns
before the fee-asset/amount checks (legitimately absent when nothing was
collected) and before the ÷verifiedBps share (which would divide by zero);
null verifiedBps stays excluded. Hoist the partnerBps >= 0 guard above the
branch so malformed swaps are rejected regardless of fee, and skip the
$0 synthetic in reconcile (nothing to deduct).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
apps/revenue-dashboard/src/api/partnerRevenue.ts (1)

3-18: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract shared API base URL and fetch logic to reduce duplication.

partnerRevenue.ts and affiliateRevenue.ts now share identical API_BASE_URL guards and nearly identical fetch wrappers. Extracting a shared module (e.g., api/client.ts) for the base URL validation and a generic apiFetch<T>(path, params) helper would eliminate this duplication and ensure timeout/error-handling conventions stay consistent across endpoints.

♻️ Suggested shared client extraction
// api/client.ts
const API_BASE_URL: string = import.meta.env.VITE_API_BASE_URL
if (!API_BASE_URL) throw new Error('VITE_API_BASE_URL is required')

export async function apiFetch<T>(path: string, params: Record<string, string>): Promise<T> {
  const url = new URL(path, API_BASE_URL)
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
  const response = await fetch(url.toString())
  if (!response.ok) {
    throw new Error(`API request failed: ${response.status} ${response.statusText}`)
  }
  return (await response.json()) as T
}

// api/partnerRevenue.ts
import { apiFetch } from './client'
import type { DateRange } from '../types'
import type { PartnerRevenueResponse } from '../types'

export function fetchPartnerRevenue(dateRange: DateRange) {
  return apiFetch<PartnerRevenueResponse>('/api/v1/partner/revenue', {
    startDate: dateRange.startDate,
    endDate: dateRange.endDate,
  })
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/revenue-dashboard/src/api/partnerRevenue.ts` around lines 3 - 18, The
API base URL guard and fetch wrapper in fetchPartnerRevenue are duplicated and
should be centralized with the matching logic in affiliateRevenue. Move the
VITE_API_BASE_URL validation and the shared request/error handling into a
reusable api client module (for example, api/client.ts) and expose a generic
apiFetch<T>(path, params) helper. Update fetchPartnerRevenue to use the shared
helper instead of constructing the URL and handling the response inline, so both
revenue endpoints rely on the same API_BASE_URL and fetch conventions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/revenue-api/src/utils/date.ts`:
- Around line 41-56: The parseDateRange function in date utilities accepts
reversed ranges because it only checks format and NaN, so add a guard after
computing startTimestamp and endTimestamp to reject cases where startTimestamp
is greater than endTimestamp. Update the DateRange validation path in
parseDateRange to return an error for reversed input, and make sure downstream
callers such as getDateRange continue to rely on the corrected startDate/endDate
ordering.

In `@apps/revenue-dashboard/src/components/SortHeader.tsx`:
- Around line 22-26: The SortHeader header cell is overriding its native
columnheader semantics by setting role="button" directly on the <th>, which also
makes aria-sort invalid. Update the SortHeader component so the <th> keeps the
aria-sort and header semantics, and move the interactive behavior into a nested
<button> element inside the <th>, including the click and keyboard handlers
currently attached to the header cell.

---

Nitpick comments:
In `@apps/revenue-dashboard/src/api/partnerRevenue.ts`:
- Around line 3-18: The API base URL guard and fetch wrapper in
fetchPartnerRevenue are duplicated and should be centralized with the matching
logic in affiliateRevenue. Move the VITE_API_BASE_URL validation and the shared
request/error handling into a reusable api client module (for example,
api/client.ts) and expose a generic apiFetch<T>(path, params) helper. Update
fetchPartnerRevenue to use the shared helper instead of constructing the URL and
handling the response inline, so both revenue endpoints rely on the same
API_BASE_URL and fetch conventions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bb92d9cd-4586-40ed-8ea2-00903ff70578

📥 Commits

Reviewing files that changed from the base of the PR and between 6f104ed and 11adf3f.

📒 Files selected for processing (21)
  • apps/revenue-api/src/affiliateRevenue/aggregateRevenue.test.ts
  • apps/revenue-api/src/affiliateRevenue/aggregateRevenue.ts
  • apps/revenue-api/src/affiliateRevenue/cache.ts
  • apps/revenue-api/src/affiliateRevenue/index.ts
  • apps/revenue-api/src/affiliateRevenue/payablePartnerFee.test.ts
  • apps/revenue-api/src/affiliateRevenue/payablePartnerFee.ts
  • apps/revenue-api/src/affiliateRevenue/reconcileRevenue.test.ts
  • apps/revenue-api/src/affiliateRevenue/reconcileRevenue.ts
  • apps/revenue-api/src/affiliateRevenue/swapServiceClient.ts
  • apps/revenue-api/src/affiliateRevenue/types.ts
  • apps/revenue-api/src/routes/affiliateRevenue.ts
  • apps/revenue-api/src/routes/partnerRevenue.ts
  • apps/revenue-api/src/utils/date.ts
  • apps/revenue-dashboard/src/App.tsx
  • apps/revenue-dashboard/src/api/affiliateRevenue.ts
  • apps/revenue-dashboard/src/api/partnerRevenue.ts
  • apps/revenue-dashboard/src/components/AssetBreakdown.tsx
  • apps/revenue-dashboard/src/components/PartnerBreakdown.tsx
  • apps/revenue-dashboard/src/components/ServiceBreakdown.tsx
  • apps/revenue-dashboard/src/components/SortHeader.tsx
  • apps/revenue-dashboard/src/components/TotalVolume.tsx
🚧 Files skipped from review as they are similar to previous changes (13)
  • apps/revenue-dashboard/src/App.tsx
  • apps/revenue-api/src/routes/partnerRevenue.ts
  • apps/revenue-api/src/routes/affiliateRevenue.ts
  • apps/revenue-api/src/affiliateRevenue/reconcileRevenue.test.ts
  • apps/revenue-api/src/affiliateRevenue/aggregateRevenue.test.ts
  • apps/revenue-api/src/affiliateRevenue/swapServiceClient.ts
  • apps/revenue-api/src/affiliateRevenue/cache.ts
  • apps/revenue-api/src/affiliateRevenue/types.ts
  • apps/revenue-dashboard/src/components/AssetBreakdown.tsx
  • apps/revenue-dashboard/src/components/PartnerBreakdown.tsx
  • apps/revenue-api/src/affiliateRevenue/reconcileRevenue.ts
  • apps/revenue-api/src/affiliateRevenue/aggregateRevenue.ts
  • apps/revenue-api/src/affiliateRevenue/index.ts

Comment thread apps/revenue-api/src/utils/date.ts
Comment thread apps/revenue-dashboard/src/components/SortHeader.tsx Outdated
kaladinlight and others added 4 commits July 8, 2026 10:23
Guard against startTimestamp > endTimestamp so a reversed range fails
validation instead of silently yielding empty/odd results downstream.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The chainId prefix + mapped value already convey the chain grouping, and
the dailyAsset/globalAsset names already say what each block aggregates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
role="button" on the <th> was overriding its native columnheader role,
which also invalidated the adjacent aria-sort. Nest a native <button> for
the click/keyboard interaction instead, keeping aria-sort valid on the th.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Show the partner error banner with its message and suppress the Revenue
by Partner section on error, so a failed fetch is no longer rendered as an
empty "No partner revenue in this range" range. Also name the query-result
variables with a Query suffix rather than aliased destructures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kaladinlight
kaladinlight merged commit 6ffed98 into main Jul 8, 2026
4 checks passed
@kaladinlight
kaladinlight deleted the feat/partner-revenue-breakout branch July 8, 2026 16:30

This branch was successfully deployed

1 active deployment
Preview — 059039f6 Deployed Jul 8, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant