Overview
One screen for one question: is anybody actually using this? Every tile below names the table it comes from, and shows every row in it — fixtures and load-test accounts included, unfiltered.
397 customers, platform-wide
E2E Studio … — and
the fixtures account for 41,903 of
42,088 link visits,
99.6% of the total.
Every number on this screen is the
raw table count, so read them as a measurement of the database and
not of the business.
Onboarding funnel how far each provider got, every account
Taking a booking is not downstream of the link. 22 providers have at least one appointment while only 7 have a link — because a staff-created booking needs no link at all. The two paths are drawn separately below rather than forced into one funnel, which would have implied that the 15 link-less providers with bookings were impossible.
How this is computed
-- One row per stage. EXISTS rather than a JOIN + COUNT DISTINCT: -- a provider with 40 services must count once, not forty times. SELECT COUNT(*) AS registered, SUM(EXISTS(SELECT 1 FROM business_locations l WHERE l.provider_id = p.id)) AS has_location, SUM(EXISTS(SELECT 1 FROM services s JOIN business_locations l ON l.id = s.business_location_id WHERE l.provider_id = p.id)) AS has_services, SUM(EXISTS(SELECT 1 FROM booking_links b WHERE b.provider_id = p.id)) AS has_link, SUM(EXISTS(SELECT 1 FROM appointments a WHERE a.provider_id = p.id)) AS has_booking FROM providers p WHERE p.is_internal = 0; -- ← column does not exist yet; see the note
Required before this ships: there is no way to identify a test
account. The figures above use
business_name LIKE 'E2E%' OR id = 9140, which is a guess
dressed as a filter — it breaks the day a real salon is called
"E2E Beauty", and it silently reclassifies rows when a fixture is
renamed. Add providers.is_internal TINYINT(1) NOT NULL DEFAULT 0,
set it in the E2E setup helper and on load-test seeds, and make every
query on this screen filter on it. A name pattern is not a flag.
64% of bookings ended badly — 73 no-shows, 53 expired holds, 28 cancellations against 63 completed. The seeded demo data inflates this, but no-show and expiry are the two the platform can actually act on, so they are the pair worth a real alert once the numbers are live.
public_link is the only self-service path; every other
source is somebody typing. 119 of
228 bookings were entered by staff or
taken over the phone — which is worth knowing before concluding that the
booking page is carrying the business.
Link traffic, last 16 days two panels, one x-axis — never two y-scales on one plot
link_event_daily — the load-test provider dominates
Visits peak at 638/day and paid bookings at 83/day. Plotting both against one y-axis would flatten the paid series onto the baseline, and a second y-axis would let either line be positioned anywhere — so they get two aligned panels sharing the x-axis, each with its own scale, and one crosshair reads both.
Providers
The list exists to answer "who is stuck, and at which step" — so the onboarding state is a column, not something you work out by opening five rows.
providersproviders.email_verified_at is populated on
32 of 81 rows,
but nothing in the current stack can ever set it: provider registration
mints tokens and returns, there is no verification mail, and there is no
password-reset route — so a provider who mistypes their address is
unrecoverable without a direct database write. Those timestamps are
Laravel seed data. Either wire the verification mail or drop the tile;
a metric no code can move is worse than a blank space.
Provider list
| ID | Business | Contact | Joined | Locs | Svcs | Links | Bookings | State |
|---|
How this is computed
-- business_name lives on business_locations, NOT on providers: a provider -- with three branches has three names and the admin must show one. -- MIN() over the group is a placeholder — the right answer is the primary -- location, and there is no is_primary column to ask. SELECT p.id, MIN(l.business_name) AS biz, p.email, p.status, p.created_at, COUNT(DISTINCT l.id) AS locations FROM providers p LEFT JOIN business_locations l ON l.provider_id = p.id WHERE p.is_internal = 0 GROUP BY p.id ORDER BY bookings DESC LIMIT ? OFFSET ?;
Two schema gaps this screen exposes. (1) No
business_locations.is_primary, so "the business name" is
undefined for a multi-branch provider — the provider console dodges it
by only ever showing one location at a time. (2) The per-provider
counts (services, links, bookings) are correlated subqueries, which is
fine at 81 rows and is not fine at 10,000; they become a nightly
rollup table the moment this list needs a second page.
Bookings
Platform-wide, across every provider. The provider console shows one salon's diary; this shows the shape of demand and where it leaks.
staff_quick,
staff_full or phone_call have
deposit_required = 0.00 — the walk-in write path does not
include the column in its INSERT, so it takes the schema default and
the deposit-resolution chain is never consulted. Defensible for a
genuine walk-in paying in the chair; not defensible for a phone
booking, which is exactly the case a deposit exists to protect.
appointment_items rows, so they report no services and
zero chair time. These predate the table; the admin needs to show the
gap rather than render an empty services cell that reads as a bug.
Recent bookings
| ID | Business | Customer | When | Status | Total | Deposit | Source |
|---|
Deposit reads taken / required, and a row where taken exceeds required is drawn in red — that is a double payment, not a rendering fault, and it appears on the Integrity screen as money owed back. A dash means the booking asked for no deposit at all.
Share links
Every booking that arrives on its own arrives through one of these. The link table is the closest thing the platform has to a growth metric.
is_bot, excluded aboveLink funnel every account, load-test included
booked without
ever emitting slot_selected — a resumed booking, a
staff-created appointment attributed to a link, or a step that simply
does not fire. The chart is designed to show this rather than hide
it: a stage wider than the one above it gets a hatched overflow
cap and a warning, because silently clamping it to 100% would turn a
tracking bug into a plausible-looking conversion rate.
Which table to read, and when
link_events is a trailing window, not the archive —
migration 0004_slim_link_events.sql prunes it. Verified
today: the raw table holds 2026-06-04 → 09-06, while the
link_event_daily rollup holds 2026-05-06 → 09-02. So the
two disagree by design in both directions — the rollup has a
month of history the raw table has dropped, and the raw table has four
days the rollup job has not yet aggregated.
Rule for every chart on this screen: read
link_event_daily for anything historical and
link_events only for the un-rolled tail, then add them.
Reading one table alone under-reports — all-time visits are
54,873 + 399, and either half on its own is wrong.
Link list
| Code | Channel | Provider | Visits | Booked | Paid | Visit→paid |
|---|
The top four rows are one load-test provider, and they carry a
test chip rather than being filtered out. Nothing on this
screen is filtered — the tiles above count these rows too, which is why
visit→paid conversion reads as CI's behaviour and not a customer's. The chip
labels the row; it does not remove it from any total.
Money
Four figures, not two — captured, fees, commission and remittable are different numbers, and conflating any pair of them promises a payout that cannot be made.
transactions.amount records the grossed-up figure. Captured
then reads ₦507.65 for a ₦500 deposit — literally "what the customer
paid", but no longer the deposit, and no longer what the provider is
owed. Keep this tile on gross and label it as the customer's debit,
because the remittable arithmetic below depends on
amount − fees being the money that reached welns. A tile
that quietly switched to net would double-subtract the fee one screen
down.
fee_kobo is NULL on charges settled before the fee was
stored, and commission_kobo is NULL on everything before
2026-09-04. SQL's SUM skips NULLs, so the fee total covers
24 of 27 charges and the
commission total covers 3 — correct arithmetic
over an incomplete set. Every money tile therefore carries its
unknown count, and a total that omits rows must never be shown as if
it were complete.
Successful charges
| Reference | Business | Booking | Amount | Fee | Commission | Settled |
|---|
References carry the attempt: wlns_591_2 and
wlns_591_3 are the second and third attempt on booking 591.
Both succeeded — which is why that booking is in the refund queue on the
Integrity screen rather than only in this table.
Subscriptions plan revenue, separate from deposits held on a provider's behalf
provider_subscriptions holds 1 row. There is nothing to
chart yet, and a chart of one row would imply otherwise — so this stays a
sentence until it isn't. When it fills, plan revenue belongs on its own
axis and never added to captured deposits: a subscription is welns's money,
a deposit is the provider's money in welns's custody, and one panel that
sums both is a panel that has lost track of whose money it is.
Integrity
A worklist, not a dashboard. Each row is a query that should return nothing; the count is how many rows it returned, and every one of these was found by running it against today's database.
| Check | Rows | Severity | What it means |
|---|
Payments that need a decision the refund queue — one row per booking settlement could not resolve
| Booking | Business | Customer | Kind | Required | Paid | Charges |
|---|
appointments.payment_exception is written by settlement itself,
not re-derived here — which is why short payments, wrong-currency payments and
money that arrived after a slot was resold appear alongside double payments.
All four used to be an ERROR line in the log and nothing else. A flagged
booking is also excluded from remittable and sits in held, so the
provider is not paid money that is owed back to a customer.
Messaging
What was sent, on which channel, and whether the transport was real — which turns out to be the question that matters most.
state='sent' hides itEMAIL_FROM but no
MAILTRAP_API_TOKEN, and transport selection gates on the
token — so production silently falls back to the log transport.
Confirmations, reminders, expiry notices and the resurrection link all
write state='sent' and reach nobody.
This screen must show the transport that handled each row, not
just the state: 112 rows reading "sent" is exactly the kind of green
that stops anyone looking.
message_logs is keyed to an appointment,
and provider registration sends nothing. So there is no onboarding
message on this screen because there is no onboarding message in the
product — the row below is what it would look like, greyed, with the
dedupe caveat: the unique index is
(appointment_id, purpose, channel, event_key), and a
provider-addressed message has a NULL appointment, which never
collides. A retry would send it twice.
By purpose and channel
| Purpose | Channel | Sent | Failed | Transport | Notes |
|---|