Appearance
Reports
Overview
Status (verified from code): The bare
/reportspage is still an empty placeholder, but a Reports navigation group now exists with one child — Kitchen Reports (/reports/kitchen), a KDS speed-of-service report. It is double-gated: theview-kitchen-reportspermission andfirstPartyPosOnly, so it is hidden from every merchant not running the first-party Upvendo POS — which in production only merchants flaggedis_testcan select self-serve (theupvendoprovider is staged'test_only' => trueinconfig/pos-providers.php:52). For ordinary merchants there is still no reports/analytics dashboard, and the reporting that exists is the summary statistics and spreadsheet export on the Transactions page. This document describes only what the code implements; previously-documented report types (sales, items, categories, channels, customers, payments, loyalty, offers, inventory, time) and features (period comparison, scheduled email delivery, CSV/PDF export) do not exist in the code and have been removed.
Key Purpose: View completed-transaction summary figures for a date range, and export the transaction list to a spreadsheet.
Purpose
The only merchant reporting surface that exists in the back office is on the Transactions page:
- Summary stat cards – three figures for the selected date range (completed transaction count, total items, total amount collected), backed by
GET /transactions/stats. - Spreadsheet export – downloads the filtered transaction list as an
.xlsxfile viaGET /transactions/export.
A separate, administrator-only analytics surface exists for global admins and partners/resellers (platform-wide dashboards), which is a different feature from merchant reporting — see Admin / Partner Analytics below.
Key Concepts
- Completed Transactions Only: The summary stats count and sum only transactions with the
Completestatus. A separate "total pending" figure sums transactions inAwaiting Invoice/Awaiting Paymentstatus. (Verified:TransactionService::stats().) - Date Range: The summary stats accept a
date.from/date.torange (Y-m-d). When no range is provided, the backend defaults to the last 7 days. (Verified:TransactionService::stats()—now()->subDays(7).) - Location Scoping: Stats can be filtered by
location_id. Atimezoneparameter may be supplied; if omitted, the app default timezone is used for the MongoDB date boundaries. (Verified:TransactionService::stats().) - Real-time "today": When the supplied end date is the current day (in the request timezone), no upper date bound is applied, so figures include transactions up to the current moment. (Verified:
TransactionService::stats().) - Currency: The stats response returns the system default currency (
Constants::$DEFAULT_CURRENCY), not a per-location currency. (Verified:TransactionService::stats().)
Actions
View Transaction Summary Stats
On the Transactions page, three summary cards show, for the selected date range:
- Completed Transactions – count of completed transactions
- Total Items – total item quantity across completed transactions
- Total Collected – total amount collected (before fee deductions)
The date range is chosen via a button group with these presets: Today, Last Week, Last Month, Last 3 Months, Last Year, Custom. (Verified: Transactions.vue btnGroupStats; default tab is "today".)
Export Transactions to Spreadsheet
Download the filtered transaction list as an .xlsx (Excel) file. This requires the export-transaction-reports permission. (Verified: route GET /transactions/export; TransactionController::export() writes an Xlsx file.)
The exported columns are: Order No, Date, Location, Customer, Payment Status, Total, Items, Dining Option, Type, Device, Receipt No, Channel. (Verified: TransactionService::exportToSpreadSheet() headers.)
Not verified from code: CSV and PDF export formats, scheduled/emailed report delivery, and "previous period / same period last year" comparison do not appear in the code and are treated as non-existent.
Location
- Backoffice Route:
/reports— placeholder page only (src/pages/reports/index.vuerenders an empty<div>). - Kitchen Reports:
/reports/kitchen—src/pages/reports/kitchen/index.vuerenderingsrc/views/reports/KitchenReports.vue(average time-to-ready and time-to-complete, plus ticket volume by daypart and order type). The nav group is live atsrc/navigation/vertical/index.ts:31-45; both the nav child and the routedefinePagemeta carrypermission: 'view-kitchen-reports'andfirstPartyPosOnly: true, and the group hides entirely when the user has no visible child. - Backend (kitchen report):
GET /back-office/kds-reports/summary→KitchenReportController::summary(), behind thefirst-party-posmiddleware plus theview-kitchen-reportspermission (routes/api/backoffice/kds-reports.php:22-25). The middleware is the real gate: the merchant-owner wildcard role grantsview-kitchen-reportsto every merchant owner, so the permission alone would not scope it. - Where reporting actually lives: the Transactions page (
/transactions),src/views/transactions/Transactions.vue. - Backend (transaction stats):
GET /transactions/stats→App\Http\Controllers\Api\BackOffice\TransactionController::stats()→App\Services\BackOffice\TransactionService::stats(). Requiresview-transactionspermission. - Backend (transaction export):
GET /transactions/export→TransactionController::export()→TransactionService::exportToSpreadSheet(). Requiresexport-transaction-reportspermission.
There is no general-purpose
ReportController.php, andsrc/views/reports/contains onlyKitchenReports.vue— there is no sales/items/categories/customers report view.
What Exists vs. What Was Documented
| Previously documented | Reality in code |
|---|---|
/reports rich analytics dashboard | /reports is an empty placeholder; nav link commented out |
ReportController.php | Does not exist; merchant stats come from TransactionController |
/reports/sales, /reports/items, /reports/categories, /reports/channels, /reports/customers, /reports/payments, /reports/loyalty, /reports/offers, /reports/inventory, /reports/time | None of these routes exist |
| Period comparison (previous period / last year) | Not in code |
| CSV / PDF export | Only .xlsx export exists |
| Scheduled email report delivery | Not in code |
| AOV / CLV / repeat-customer-rate metrics | Not computed anywhere in these repos |
Transaction Stats Endpoint
| Property | Value |
|---|---|
| Route | GET /transactions/stats |
| Permission | view-transactions |
| Description | Summary figures for completed transactions over a date range |
Request parameters (Verified: StatsRequest):
date.from(nullable,Y-m-d)date.to(nullable,Y-m-d)location_id(nullable, must exist inlocations)timezone(nullable, valid timezone)
If only one of date.from / date.to is supplied, the request fills the other with the same value (single-day range). (Verified: StatsRequest::prepareForValidation().)
Response fields (Verified: TransactionService::stats() return shape):
completed_transactions– count ofCompletetransactions in rangetotal_quantity– total item quantity across those transactionstotal_amount– total of thetotalfield across those transactions (decimal)total_pending– total of transactions inAwaiting Invoice/Awaiting Payment(location-scoped, not date-scoped)currency– system default currency
Not verified from code: Breakdowns "by day/week/month", "by channel", and "by dining option" are not produced by
/transactions/stats. The stats endpoint returns only the scalar figures above. (Channel and dining option do appear as columns in the transaction export and as filters on the transaction list, but there is no aggregated revenue-by-channel report.)
Admin / Partner Analytics (administrator-only)
This is a separate, platform-level feature, not merchant reporting. Access requires the global administrator or partner/reseller role.
- Global admin analytics:
GET /admin/analytics/{stats|hourly-orders|weekly-revenue|growth|top-products|activity|system-status}→AnalyticsController→App\Services\Dashboard\AnalyticsService. Every one of those seven methods callsauthorizeGlobalAdmin()first, which aborts 403 unless the user holds theGLOBAL_ADMINISTRATORrole. (Verified:routes/api/backoffice/dashboard.phplines 18-26;app/Http/Controllers/Api/BackOffice/AnalyticsController.phplines 21-27.) - Global admin dashboard:
GET /admin/dashboard/{stats|merchants}→GlobalAdminDashboardController. - Partner dashboard / analytics:
GET /partner/dashboard/stats,GET /partner/analytics/stats→PartnerDashboardController. - Frontend:
src/views/dashboard/Admin.vue, redirected to from/homefor users withview global-settings(admin) orview reseller-analytics(partner). (Verified:src/pages/home/index.vue.)
The "Active Locations" card: Online vs Clients
The admin dashboard's Active Locations card shows three figures — Kiosks, Online, and Clients — fed by total_kiosks, online_stores, and active_clients on GET /admin/analytics/stats. (Verified: src/views/dashboard/Admin.vue lines 141-151.)
| Figure | Counts | Definition |
|---|---|---|
Online (online_stores) | Locations | Locations with a non-null first_real_transaction_paid_at, excluding locations belonging to test merchants. (Verified: AnalyticsService.php line 126 → LocationRepository::countSelling() lines 63-69.) |
Clients (active_clients) | Merchants | All merchants with stripe_onboarding_completed = true and is_test != true. (Verified: AnalyticsService.php lines 105-108.) |
Two things trip people up when reading these numbers:
- They are not mutually exclusive. A merchant that has started selling is counted in both buckets — "Online" is a subset of real activity, not a bucket a merchant is moved into and out of. Adding Online + Clients therefore double-counts; Clients is already the total. (Verified:
AnalyticsService.phplines 102-108 — the client count applies nofirst_real_transaction_paid_atexclusion.) - The units differ. "Online" counts locations; "Clients" counts merchants. A multi-location merchant contributes one to Clients and one per selling location to Online.
Test accounts are excluded from both: Merchant.is_test = true merchants are filtered out of the client count directly, and their IDs are excluded from the location count via getTestMerchantIds(). (Verified: AnalyticsService.php line 100; MerchantRepository::getTestMerchantIds() line 167.)
Location.first_real_transaction_paid_at
| Property | Value |
|---|---|
| Field ID | first_real_transaction_paid_at |
| Stored on | Location document |
| Type | Date (BSON UTCDateTime), nullable |
| Written | Set-once, on the location's first non-test completed sale |
UpdateMerchantDailyStatsJob stamps it when the location has no value yet; the repository write filters on first_real_transaction_paid_at => null, so it is idempotent and concurrent first-day sales cannot clobber each other. The job is only dispatched for non-test transactions — both dispatch sites guard on getIsTestMode() — so any sale that reaches it counts as real. (Verified: app/Jobs/UpdateMerchantDailyStatsJob.php lines 53-58; app/Repositories/LocationRepository.php lines 43-55; dispatch guards at app/Services/Payment/PaymentCaptureService.php line 432 and app/Services/Orchestrators/OnlineOrderingOrchestrator.php line 1025.)
Historical locations were backfilled by the backfill_first_real_transaction data-repair routine, which stamps each unstamped location with its oldest Complete, non-test transaction's paid_at (falling back to created_at for legacy rows). Locations with no real sale are skipped and stay "Clients". (Verified: app/Console/Commands/DataRepair/LocationRepair.php lines 200-252, dispatched from the execute() match arm at line 260.)
Not verified from code: the remaining
AnalyticsServicemetric formulas — growth %, weekly revenue rollups, hourly distribution, top products — were not audited and should not be asserted as documented formulas.
Note on "Reporting Categories"
The back office contains a page named Reporting Categories (src/views/reporting-categories/ReportingCategories.vue, route menus-categories, under Menus). Despite the "reporting" name, this is a category management CRUD page (create/edit/delete categories, toggle Hidden/Unavailable status) — it is not an analytics report. It is documented under the Categories feature, not here. (Verified: ReportingCategories.vue; nav under menus.)
Business Rules
- Summary stats count and sum only transactions with the
Completestatus; incomplete, awaiting, or other statuses are excluded from the completed figures. (Verified:TransactionService::stats().) - When no date range is provided, the stats default to the last 7 days from the current date. (Verified.)
- Date filtering uses MongoDB date queries with the request
timezone(or app default); when the end date is the current day, no upper bound is applied so figures stay real-time. (Verified.) total_pendingis location-scoped but not date-scoped — it always reflects all currently-outstanding (Awaiting Invoice/Awaiting Payment) transactions. (Verified.)- The stats response currency is the system default currency, not a per-location currency. (Verified — corrects prior claim.)
- Transaction export is XLSX only and requires the
export-transaction-reportspermission. (Verified.)
FAQs
- "Where is the Reports page?" The bare
/reportspage is an empty placeholder. A Reports → Kitchen report exists, but only for merchants on the first-party Upvendo POS (which is not self-serve selectable today), so most merchants see no Reports menu at all. The reporting available to them is the summary stats and spreadsheet export on the Transactions page. - "Why do my summary totals not match my full transaction list?" The summary cards (Completed Transactions, Total Items, Total Collected) count only transactions in the
Completestatus; awaiting/unpaid/stuck transactions are excluded from those figures. - "Can I see today's data in real time?" Yes — when the selected end date is today, the backend applies no upper date bound, so stats include completed transactions up to the current moment.
- "What date ranges can I pick?" Today, Last Week, Last Month, Last 3 Months, Last Year, or a Custom range. (Verified:
Transactions.vue.) - "What export formats are available?" Only Excel (
.xlsx). There is no CSV or PDF export, and no scheduled email delivery, in the current code. - "Is there a sales / customer / loyalty / inventory report?" Not as a dedicated report. Those report types do not exist in the code today.
- "On the admin dashboard, why do Online and Clients not add up to my total?" Because they overlap by design. Clients is every onboarded non-test merchant — that is already the total. Online is the subset that has actually started selling, counted per location rather than per merchant. A merchant that takes its first sale is added to Online and stays in Clients; it is not moved between the two. Adding the two figures together double-counts. (Verified:
app/Services/Dashboard/AnalyticsService.phplines 102-108 and 124-126.) - "What makes a location count as Online?" Its first real (non-test) completed sale. That stamps
first_real_transaction_paid_aton the location, once, and the Online figure counts locations where that field is set. Test-merchant locations are excluded. (Verified:app/Jobs/UpdateMerchantDailyStatsJob.phplines 53-58;app/Repositories/LocationRepository.phplines 63-69.)
Troubleshooting
Problem: Export returns "No transactions found"
Cause: The current filters (date range, location, status, etc.) match zero transactions; the export aborts with HTTP 400 when the first page is empty. (Verified: exportToSpreadSheet() aborts 400 on empty first page.)
Solution: Widen the date range or clear filters, then export again.
Problem: Summary stats look wrong
Causes:
- Wrong date range / preset selected
- A location filter is applied
- Only completed transactions are counted (pending/unpaid excluded)
- Timezone differences at day boundaries
Solutions:
- Re-check the selected date preset / custom range
- Clear or change the location filter
- Remember pending and unpaid orders are excluded by design
- Confirm the location/request timezone
Relations
Depends On
- Transactions: All summary stats and the export are derived from transaction records.
- Locations: Used to scope stats and to resolve the timezone for export.