Marketplace Reporting & Dashboards¶
Purpose¶
Read-only, server-authoritative sales/settlement/return/rating/inventory reporting for sellers (their own store) and admins (the whole marketplace). Every figure is computed server-side from the same immutable transaction records the rest of the marketplace already relies on - never recalculated from current product price, current commission configuration, or any client-supplied number. There is no write endpoint in this slice: no export, no scheduled report, no admin override of a figure. Builds on Seller Order Fulfilment, Payments & Seller Settlements, Marketplace Returns & Refunds, and Product Reviews & Ratings; notifies nothing itself, but its numbers describe the same events Marketplace Notifications fires on.
Seller isolation¶
Every repository method on IMarketplaceReportingRepository takes a Guid? sellerUserId -
null means marketplace-wide. The seller-scoped API handlers always pass the caller's own
ICurrentUserService.UserId; the admin handlers always pass null. This means the admin dashboard
is not a separately-implemented aggregation with its own risk of drifting from the per-seller
numbers - it is literally the same query, unfiltered - so an admin total is trivially reconcilable
against the sum of every individual seller's own dashboard for the same date range, by construction,
not by a separate reconciliation step. SellerUserId is always resolved server-side from
ICurrentUserService inside the seller controller action - no request parameter on
SellerReportsController accepts a client-supplied seller id (enforced by
ReportingControllersAuthorizationTests.SellerReportsActions_NoParameterAcceptsASellerIdFromTheClient).
Financial source of truth¶
| Figure | Source | Notes |
|---|---|---|
| GMV | Sum(Order.SubTotal) for orders placed in range |
The original, server-authoritative customer order merchandise value - never redefined as net-after-refund. Admin-only figure (a single customer order can span multiple sellers). |
| Merchandise sales / tax | Sum(SellerOrder.SubTotal) / Sum(SellerOrder.TaxAmount) |
Per-seller-order snapshot, not the catalog Product.Price at report time. |
| Gross / commission / net payable / seller shipping | Sum(SellerSettlement.GrossAmount / PlatformCommissionAmount / NetPayableAmount / SellerShippingAmount) for settlements created in range |
Zero if no settlement exists yet for an order in range - never estimated from current commission config. A Settled settlement's fields are immutable (see Payments & Seller Settlements); reporting only ever reads them, never recomputes them. Anchored to CreatedAt - see "Settlement date semantics" below. |
| Settled amount | Sum(NetPayableAmount) where Status = Settled and SettledAt falls in range |
Not a cohort figure - anchored to SettledAt, not CreatedAt. See "Settlement date semantics" below. |
| Pending settlement amount | Sum(NetPayableAmount) where Status ∈ {Pending, Eligible, Processing, OnHold}, for settlements created in range |
The current outstanding amount for settlements created in the range - historical "pending as of a past date" cannot be reconstructed without settlement-status history, so this is the one honest definition available. Anchored to CreatedAt, like every other cohort figure above. |
| Settlement adjustments | Sum(SettlementAdjustment.NetSellerDebitAmount) |
Reported as its own, clearly separate figure (SettlementAdjustmentsAmount) - never added into NetPayableAmount or the refund total, since it is itself a distinct, additive-only post-settlement correction record. |
| Refund amount | Sum(RefundTransaction.TotalRefundAmount) |
The sole source of refund totals - never derived by subtracting a "refunded quantity" from merchandise sales, which would double-count against SettlementAdjustment. |
| Return count / rate | COUNT(ReturnRequest) in range / ReturnCount ÷ TotalSellerOrders |
Rate is null (not 0) when there were zero orders in range, so the frontend can distinguish "0% returns" from "no data yet". |
| Refund rate (admin only) | TotalRefunds ÷ Gmv |
null when GMV is zero in range. |
| Average rating / review count | ProductReview aggregates (Visible only) scoped to the seller's products |
Mirrors Product Reviews & Ratings - hidden reviews never counted. |
| Inventory stats | SellerInventory/SellerProduct current state, not date-ranged |
Active listings, total sellable units, low-stock, out-of-stock counts as of now - inventory has no meaningful "for this date range" framing, unlike every other metric here. |
No double counting: refunds, settlement adjustments, and settlement net-payable are always reported as three separate fields side by side - a UI or downstream consumer that needs a single "seller take-home" number must combine them explicitly; this slice never pre-combines them into one figure that would obscure which part came from where.
Settlement date semantics¶
A SellerSettlement has two independent timestamps - CreatedAt (when the settlement record was
generated) and SettledAt (when it actually reached Status = Settled, i.e. when the payout was
recorded) - and they routinely fall in different report ranges (generated this week, paid out next
week). Every settlement figure in this slice picks exactly one of the two anchors, deliberately:
- Gross/Shipping/Commission/NetPayable/PendingSettlementAmount are COHORT figures, anchored to
CreatedAt- "how much did settlements generated in this period represent".PendingSettlement Amountspecifically means the current outstanding amount for settlements created in the range - not "what was pending as of some past date", which cannot be reconstructed without a full settlement-status history table (this codebase has none). - SettledAmount is NOT a cohort figure - it is anchored to
SettledAt(Status = SettledANDSettledAtwithin the range), i.e. "how much cash actually moved during this period". A settlement created in an earlier period and paid out in this one counts here; one created in this period but not yet paid out does not. - SettlementAdjustmentsAmount is anchored to the adjustment's own
CreatedAt, entirely independent of its parent settlement'sCreatedAt/SettledAt- a post-settlement refund can (and routinely does) land in a different report range than the settlement it adjusts.
Consequences a report reader should expect:
- Querying the range a settlement was created in shows it in the cohort figures (Gross/
Commission/NetPayable/Pending) but not in
SettledAmountunless it also happened to be paid out in that same window. - Querying the range a settlement was settled in shows it in
SettledAmounteven if it was created in an earlier, unrelated range - and does not re-add it to that range's cohort figures. - A historical report for an old period never changes merely because a settlement created back then
was later settled - the old period's
SettledAmountstays honestly zero for it; only the current period (containing the realSettledAt) ever counts it as settled.
Every combination above (created-before/settled-inside, created-inside/settled-after,
created-and-settled-inside, created-inside/still-pending, adjustment-dated-independently, and the
old-range-unaffected case) is covered by a dedicated test in ReportingFlowTests.cs - see "Tests"
below.
Query performance¶
MarketplaceReportingRepository (Infrastructure/Repositories/) queries only the already-indexed,
date/seller-bounded rows it needs - no full entity graph is ever loaded to aggregate in memory, and
every list query is AsNoTracking.
Provider-aware aggregation - the SQLite fallback is NOT the production strategy¶
Aggregation is provider-aware, branching on _db.Database.ProviderName (a plain string check,
not a package dependency - MarketplaceReportingRepository.IsSqlite):
- MySQL/Pomelo (production): every decimal
SUM,GROUP BY,ORDER BY, and pagination (Skip/Take) is pushed to the database viaSumAsync/GroupBy/OrderBy/Skip+Take- no financial row list is ever materialized into API memory solely to aggregate it. A year's worth of order/order-line/settlement rows is never loaded to compute a total; only the already-aggregated result (one row per status/product/seller, or one page of already-grouped rows) crosses into application memory. - Microsoft.EntityFrameworkCore.Sqlite (this codebase's own test provider, ONLY): falls back to
projecting just the needed decimal column(s) into a small anonymous shape
(
.Select(x => new { x.Amount }).ToListAsync()) and calling.Sum()/.GroupBy()in-process. This exists purely for test-provider compatibility, not as the production aggregation strategy - SQLite's EF Core provider cannot translateSum()over adecimalcolumn at all (NotSupportedException, SQLite has no native DECIMAL type), including for a plain, non-groupedSumAsynccall. Even where this fallback runs, only bounded scalar columns are ever pulled - still filtered by the same date/seller predicate as everything else - never a full entity graph.
Every aggregation preserves exact decimal precision on both code paths - amounts are never
converted to double for summing (ReportRatingStats.AverageRating and
ReportSellerPerformanceRow.AverageRating are the sole double fields in this slice, and both are
genuine star-rating averages, never a financial amount).
Applies to GetOrderStatsAsync, GetParentOrderStatsAsync, GetSettlementStatsAsync,
GetReturnStatsAsync, GetTopProductsAsync, GetTopSellersAsync, GetProductPerformanceReportAsync,
and GetSellerPerformanceReportAsync. GetOrdersReportAsync/GetReturnsReportAsync/
GetSettlementsReportAsync never needed the branch at all - their pagination was already pushed to
the database (Skip/Take before ToListAsync) on both providers from the start.
GetProductPerformanceReportAsync's MySQL path additionally computes the per-product distinct
order count via the standard EF Core Select(x => x.Id).Distinct().Count() idiom inside the
GroupBy projection, which translates to COUNT(DISTINCT ...) - the one aggregate expression in
this file worth re-checking if a future EF Core/Pomelo version changes GroupBy translation support.
Known Pomelo gotcha (found by the manual MySQL smoke test below, now fixed): constructing a
custom record/class directly inside a database-translated GroupBy().Select(g => new
SomeRecord(...)) does not reliably translate on Pomelo - it throws
InvalidOperationException at query-compile time instead of translating, even though the
equivalent projection into a plain anonymous type (new { ... }) translates fine. Every
MySQL-branch GroupBy().Select() in this file therefore projects into a plain anonymous type
first, and only maps that already-materialized, already-bounded result into the real
record/DTO in C# afterward (GetTopProductsAsync, and all three sub-aggregates inside
GetSellerPerformanceReportAsync). GetProductPerformanceReportAsync already followed this shape
by construction (its record projection happens in a separate .Select() after Skip/Take, not
inside the GroupBy().Select() itself). SQLite's own GroupBy path is always client-side
(LINQ-to-Objects, after ToListAsync()), so it structurally cannot reproduce this failure -
ReportingFlowTests.cs's GetTopProductsAsync_MultipleProducts_... and
GetSellerPerformanceReportAsync_MultipleSellers_... tests guard the underlying grouping/
ordering/merge logic on both providers, but the Pomelo-specific translation risk itself is only
re-verified by re-running the manual smoke test below.
Manual MySQL provider verification¶
The automated test suite (ReportingFlowTests.cs) runs exclusively against
Microsoft.EntityFrameworkCore.Sqlite - it never executes the MySQL/Pomelo aggregation branch
described above. Before merging any change to a GroupBy/Sum/OrderBy query in
MarketplaceReportingRepository, re-verify it against a real MySQL 8 server:
cdto the repo root,docker compose up -d mysql redis(uses the existingdocker-compose.ymlservice, port fromdocker compose ps-3309by default,${MYSQL_HOST_PORT}if overridden in.env). Never point this atecommerce_dbif a devapicontainer is already using it -CREATE DATABASEa disposable, throwaway database instead (e.g.reporting_mysql_smoke).dotnet ef database update --project src/ECommerce.Infrastructure --startup-project src/ECommerce.API --connection "Server=localhost;Port=<port>;Database=<disposable-db>;User=root; Password=<local-only>;SslMode=None;AllowPublicKeyRetrieval=True;"to apply every migration.- Add a temporary, never-committed test file that opens
AppDbContextagainst that connection string (mirroringDependencyInjection.cs's realUseMySql(...)call exactly - sameMySqlServerVersion, no retry strategy) instead ofSqliteDbContextFactory, seed representative data through the same real command handlersReportingFlowTests.csuses, and call eachMarketplaceReportingRepositorymethod directly. DROP DATABASEthe disposable database and delete the temporary test file(s) once done - nothing from this procedure is ever committed.
This exact procedure caught the GroupBy().Select()-into-a-record gotcha above.
GetSellerPerformanceReportAsync - a documented, bounded exception¶
GetSellerPerformanceReportAsync (admin-only seller leaderboard) is the one query that does not
paginate in the database on either provider. On MySQL it still pushes each of its three
sub-aggregates (per-seller order count/merchandise, per-seller net-payable, per-seller average
rating) to the database independently via GroupBy/Sum/Average - so no order/settlement/review
row list is loaded, only one small row per seller per sub-aggregate. Only the final step - merging
those three already-small per-seller lists into one row per seller, and sorting/paginating that
merged result - happens in memory. This is a deliberate, bounded-by-seller-count tradeoff (documented
here, not an oversight): merging three independently-grouped per-seller aggregates into one row is
not naturally expressible as a single further-grouped SQL query without duplicating a three-way LEFT
JOIN by hand, and the merge is bounded by the number of sellers with activity in the range - never by
order count - which stays small even at real marketplace scale.
Additive index migrations (no data change, clean Down() on both):
AddMarketplaceReportingIndexes
SellerOrder (SellerUserId, CreatedAt)
SellerSettlement (SellerUserId, CreatedAt)
ReturnRequest (SellerUserId, RequestedAt)
RefundTransaction (SellerUserId, RecordedAt)
Order (CreatedAt)
AddSellerSettlementSettledAtIndex
SellerSettlement (SellerUserId, SettledAt) -- supports the SettledAmount query above,
-- which filters by SettledAt independently
-- of the (SellerUserId, CreatedAt) index
Date/time semantics¶
ReportDateRange.Resolve(preset, from, to) (Application/Features/Reporting/ReportingShared.cs) -
every date boundary is UTC and inclusive of both ends.
| Preset | Range |
|---|---|
Today |
Start of today (UTC) → now |
Last7Days |
7 days ago → now |
Last30Days (default when preset is omitted) |
30 days ago → now |
ThisMonth |
1st of current month → now |
PreviousMonth |
1st → last day of the previous calendar month |
Custom |
from/to as supplied - max 366 days, from must be ≤ to, otherwise ReportInvalidDateRangeException → 400 REPORT_INVALID_DATE_RANGE |
A zero-result range (e.g. before the seller's first order) is a normal, successful response with every count/sum at zero and every list empty - never an error.
Pagination¶
ReportPagination.Validate(page, pageSize) - default pageSize = 20, max 100. An out-of-range
page/pageSize (≤ 0, or pageSize > 100) is rejected, not silently clamped -
ReportInvalidPaginationException → 400 REPORT_INVALID_PAGINATION - so a caller never silently
receives a different page shape than it asked for.
Backend architecture¶
CQRS queries via MediatR, mirroring every other read feature in this codebase - no commands, no writes.
Seller (Application/Features/Reporting/SellerReportingFeatures.cs):
GetSellerDashboardQuery, GetSellerSalesReportQuery, GetSellerOrderReportQuery,
GetSellerProductPerformanceQuery, GetSellerReturnsReportQuery, GetSellerSettlementsReportQuery.
Admin (Application/Features/Reporting/AdminReportingFeatures.cs):
GetAdminMarketplaceDashboardQuery, GetMarketplaceSalesReportQuery,
GetMarketplaceSellerPerformanceQuery, GetMarketplaceProductPerformanceQuery,
GetMarketplaceReturnsReportQuery, GetAdminSettlementsReportQuery - each is the unfiltered
(sellerUserId: null) form of the matching seller query (see "Seller isolation" above); admin DTOs
additionally carry TotalCustomers/ActiveSellers/Gmv/TopSellers, which have no seller-scoped
equivalent.
Both sets sit on IMarketplaceReportingRepository (Infrastructure/Repositories/
MarketplaceReportingRepository.cs), a standalone repository (not on IUnitOfWork) - the same
pattern as ISettlementRepository/IReturnsRepository/IProductReviewRepository/
IMarketplaceNotificationRepository.
Seller APIs¶
GET /api/seller/reports/dashboard ?preset|from&to
GET /api/seller/reports/sales ?preset|from&to
GET /api/seller/reports/orders ?preset|from&to&page&pageSize&sort
GET /api/seller/reports/products ?preset|from&to&page&pageSize&sort
GET /api/seller/reports/returns ?preset|from&to&page&pageSize&sort
GET /api/seller/reports/settlements ?preset|from&to&page&pageSize&sort
[Authorize(Roles = "Seller,Admin")] - matches the existing SettlementsController convention: an
Admin caller can reach these actions, but sees only their own (typically empty) seller data, since
SellerUserId always comes from ICurrentUserService, never a route/query parameter.
Admin APIs¶
GET /api/admin/reports/dashboard ?preset|from&to
GET /api/admin/reports/sales ?preset|from&to
GET /api/admin/reports/sellers ?preset|from&to&page&pageSize&sort
GET /api/admin/reports/products ?preset|from&to&page&pageSize&sort
GET /api/admin/reports/returns ?preset|from&to&page&pageSize&sort
GET /api/admin/reports/settlements ?preset|from&to&page&pageSize&sort
[Authorize(Roles = "Admin")]. Distinct from the pre-existing GET /api/admin/dashboard (basic
Product/Order/User counts + revenue, used by the legacy AdminDashboard.jsx cards) - that endpoint
is untouched; /admin/reports/* is an additive, deeper reporting surface the legacy dashboard now
links out to.
DTOs¶
Every reporting DTO is defined once in SellerReportingFeatures.cs (seller-scoped) or
AdminReportingFeatures.cs (admin-scoped, importing the seller namespace's shared shapes -
ReportDateRangeDto, TopProductDto, PagedResultDto<T>). Paginated endpoints return
PagedResultDto<T> { items, total, page, pageSize }; the returns/settlements endpoints additionally
wrap that page inside a small summary DTO ({ range, returnCount, refundAmount, items } /
{ range, grossAmount, ..., items }) so a caller gets the range-wide totals and one page of rows in
a single response.
Frontend¶
- Seller -
/seller/reports(pages/Seller/Reports/SellerReports.jsx), linked from the seller sidebar and from a "Reports & Dashboards" button on/seller(the existing listings-focused dashboard is otherwise untouched). Six tabs - Overview, Sales, Orders, Products, Returns, Settlements - each independently fetches its endpoint via a dedicated React Query hook (useSellerDashboard,useSellerSalesReport, etc. inhooks/index.js), sharing one date-range picker across tabs. - Admin -
/admin/reports(pages/Admin/Reports/AdminReports.jsx), linked from the admin sidebar and from a "Reports & Dashboards" button on/admin. Same six-tab shape, with "Sellers" in place of "Orders" (a marketplace-wide seller leaderboard has no seller-scoped equivalent). - Shared controls (
components/reports/ReportToolbar.jsx) -DateRangeFilter(preset select + conditional custom from/to date inputs, values matchingReportDateRange.Resolveexactly),Pagination,BarList(a labelled, proportional-width bar list built from plain CSS/HTML - no charting library exists inpackage.jsonand none was added here, matching the requirement not to pull in a large new dependency for this slice), and sharedStatCard/Carddisplay widgets so the two dashboards stay visually consistent. - Every status badge pairs a color with its text label (
Badgecomponent) - status is never conveyed by color alone. Tables use<caption class="sr-only">,scope="col"headers, and accessible pagination (nav[aria-label="Pagination"]).
Authorization¶
SellerReportsController- class-level[Authorize(Roles = "Seller,Admin")], no action-level[AllowAnonymous]override, no action parameter accepts a client-supplied seller/user id.AdminReportsController- class-level[Authorize(Roles = "Admin")], same no-override guarantee.- Verified by reflection-based wiring tests
(
Api/ReportingControllersAuthorizationTests.cs) - these check attribute wiring only; the actual HTTP-level enforcement is ASP.NET Core's authorization middleware, exercised end-to-end by the rest of the test suite (login as Seller/Admin/Customer and call the endpoints) rather than re-proven here.
Deferred work (explicitly out of scope for this slice)¶
- Exports (CSV/Excel/PDF) - every endpoint returns JSON only; no export/download feature exists in this slice.
- Scheduled/emailed reports - no background job or delivery channel produces a report automatically; every figure is computed on-demand, synchronously, per request.
- Promotions/coupons metrics -
feature/promotions-couponshas not been merged into this branch and no coupon/discount field is guessed at or referenced anywhere in this slice. The DTOs and repository queries are structured so that a future discount-amount figure could be added as one more field alongsideMerchandiseSales/TaxAmountwithout reshaping any existing endpoint - but no such field exists yet. - Custom/saved report configurations - date range and pagination are the only supported filters; there is no per-user saved-report or custom-column feature.
- Real-time/live-updating dashboards - every figure is a point-in-time snapshot fetched on tab load/date-range change; no polling or push-based refresh exists here (contrast Marketplace Notifications's unread-count polling, which this slice does not replicate).
Tests¶
Application/ReportingFlowTests.cs- the financial-correctness suite: zero-order seller, one-order seller, seller-to-seller isolation (Seller A's report excludes Seller B's orders; the admin report reconciles A+B), partial/full/multiple returns without double counting, refund-before-settlement (settlement stats stay honestly zero), refund-after-settlement (creates aSettlementAdjustment, never mutates the original settlement'sNetPayableAmount), settled-immutable settlement, commission/shipping/tax always sourced from the settlement/order snapshot, date-range boundary inclusion, zero-result ranges, invalid date-range/pagination rejection, pagination correctness (including the marketplace-wide product-performance report, whose pagination is pushed to the database on MySQL), and a reflection check proving no query record exposes a spoofable "requested seller" parameter.- Settlement date semantics (see the section above) - six dedicated scenarios, each backdating
a real, command-handler-produced settlement's
CreatedAt/SettledAtvia a raw SQL update (the domain entity itself exposes no public setter for either field) rather than hand-seeding a synthetic row: created-before-range/settled-inside-range (counted inSettledAmount), created-inside-range/settled-after-range (not counted inSettledAmountfor that range), created-and-settled-inside-range (counted exactly once in both the cohort and settled figures, verified against a disjoint range and against the admin-reconciled total), created-inside-range/ still-pending (counted inPendingSettlementAmount, notSettledAmount), a post-settlement adjustment remaining dated by its ownCreatedAtindependent of its parent settlement's, and a historical range around the oldCreatedAtnever showingSettledAmountfor a settle event that happened later. - Grouping/ordering regression coverage -
GetTopProductsAsync_MultipleProducts_ GroupsAndOrdersByRevenueDescendingandGetSellerPerformanceReportAsync_MultipleSellers_ MergesPerSellerAggregatesCorrectly(the latter previously had no dedicated test at all). Added after the manual MySQL smoke test (see "Manual MySQL provider verification" above) found both methods' MySQL-only code paths threwInvalidOperationExceptionfrom an unrelatedGroupBy().Select()-into-a-record translation gap - these two tests guard the underlying grouping/ordering/merge logic on both providers; they cannot reproduce the Pomelo-specific translation failure itself (SQLite'sGroupByis always client-side), which only the manual smoke-test procedure re-verifies. Api/ReportingControllersAuthorizationTests.cs- reflection-based[Authorize]wiring for both controllers (see "Authorization" above).
Related pages¶
- Seller Order Fulfilment - the
SellerOrderrecords this slice aggregates - Payments & Seller Settlements - the settlement lifecycle and immutability rule this slice's financial figures are sourced from
- Marketplace Returns & Refunds - the return/refund workflow this slice's return/refund metrics are sourced from
- Product Reviews & Ratings - the rating aggregates this slice surfaces per seller and marketplace-wide
- Marketplace Notifications - the same underlying business events, reported here in aggregate rather than as individual notifications