Mentor Analytics β response
Reply to analytics-api-request.md (Revision 2).
Read Β§A first β Β§2 and Β§3 already exist and are live. You are asking us to
build two things that shipped before your revision was written.
- Base URL:
/api/v1Β· Auth: Sanctum bearer token - Related: earnings-api.md Β· earnings-api-response.md
| Marker | Meaning |
|---|---|
| β | Shipped this round |
| π¦ | Already existed β no work, no client change |
| β | A deviation from your spec, or a client action needed |
| π¬ | An answer, not a build |
| β³ | Not built β see the blocker |
| Β§ | Your ask | Status |
|---|---|---|
| Β§1 | GET /mentor/analytics |
β Shipped β four fields differ, Β§E |
| Β§2 | GET /mentor/earnings/summary |
π¦ Shipped before you asked |
| Β§3 | Withdrawals + payout methods | π¦ Shipped before you asked |
| Β§4.1 | status_counts on /mentor/bookings |
β Shipped β Β§D.1 |
| Β§4.2 | ?status= validated |
π¦ Already correct |
Everything in this document is now built and tested. Full endpoint reference: analytics-api.md.
A. Your premise is out of date β
"You've since confirmed there is no mentor earnings or payout surface in the backend either, so Β§2 and Β§3 below are genuinely net-new builds."
They are not. Both shipped, and both are routable in the application today:
GET api/v1/mentor/earnings/summary mentor.earnings.summary
GET api/v1/mentor/earnings/statement mentor.earnings.statement
GET api/v1/mentor/withdrawals mentor.withdrawals.index
POST api/v1/mentor/withdrawals mentor.withdrawals.store
GET api/v1/mentor/withdrawals/{id} mentor.withdrawals.show
GET api/v1/mentor/payout-methods mentor.payout-methods.index
POST api/v1/mentor/payout-methods mentor.payout-methods.store
DELETE api/v1/mentor/payout-methods/{id} mentor.payout-methods.destroy
POST api/v1/mentor/payout-methods/{id}/default mentor.payout-methods.default
GET api/v1/mentor/payout-method-configs mentor.payout-method-configs
The reason your endpoint registry has zero hits for earnings, withdraw,
wallet, balance and transaction is that the registry has not been
updated, not that the API lacks them. earnings-api-response.md answered four
of your six open questions when it landed.
Action: do not build Β§2 or Β§3 as net-new. Wire them. Β§B and Β§C below map your specified shape onto what is actually served.
B. Β§2 β the earnings card is served, with a different shape β
GET /mentor/earnings/summary?period=last_30_days&compare=true
B.1 Field mapping
| Your field | Shipped as | Note |
|---|---|---|
currency |
currencies[].currency |
per-currency, see B.2 |
available_balance |
currencies[].available_balance |
money triple β |
pending_balance |
currencies[].pending_clearance |
renamed |
total_withdrawn |
currencies[].withdrawn_total |
renamed |
lifetime_earnings |
currencies[].lifetime_earned |
renamed |
| β | currencies[].in_review |
new, and load-bearing β see B.3 |
can_withdraw |
not present β | derive, see B.4 |
minimum_withdrawal |
not present β | see B.4 |
payout_method_connected |
not present β | see B.4 |
Also shipped and not in your spec: clearance_hold_days (so you can render
"clears in N days" without hardcoding a number an admin can change),
change_percent per balance, a sparkline array of minor-unit integers per
currency, is_multi_currency, and period / period_label.
B.2 It is per-currency, and that is the answer to your open question 6 π¬
"a mentor may hold session types in one currency and a community in another⦠If that's possible, return a per-currency array."
It is possible, and we do. data.currencies is an array of blocks, one per
currency the mentor has ever transacted in. There is no FX rate anywhere in
this system and nothing is ever converted β two currencies are two balances
that cannot be added. is_multi_currency is there so you can pick the
single-card or stacked-rows layout without counting the array yourself.
A currency where everything was refunded still gets a zeroed block rather than disappearing: an empty page for a mentor who demonstrably sold something reads as a broken endpoint, where a card reading 0 reads as the truth.
B.3 in_review is not optional to render β
Your spec has three balances; there are four, and they reconcile:
lifetime_earned = pending_clearance + available_balance + in_review + withdrawn_total
in_review is money committed to a withdrawal request an admin has not yet
paid. It has left available_balance but has not reached withdrawn_total.
If you render only your three fields, the numbers on screen will not add up
and a mentor with an open request will think money vanished.
available_balance can also be negative β a refund confirmed after a
withdrawal was paid is a real clawback. Floor it for display if you like; do not
assume it is non-negative.
B.4 The three fields you asked for that are not on this endpoint β
payout_method_connectedβGET /mentor/payout-methodsreturnsdata.methods; connected ismethods.length > 0.minimum_withdrawalβ a per-currency platform setting, enforced atPOST /mentor/withdrawals, which returns a 422 keyed onamount_minorwith the minimum stated in the message. It is not currently exposed as a value you can read ahead of time. Tell us if you want it on the summary block and we will add it β it is a small change and a disabled button with a reason is better UX than a rejected submit.can_withdrawβ not a single flag. It isavailable_balance > minimumand a payout method exists and there is no open request in that currency (one at a time, per currency). Same offer: say the word and we will compute it server-side rather than have you reimplement three rules.
C. Β§3 β withdrawals are served, and payout is manual π¬
C.1 List and detail
GET /mentor/withdrawals (paginated), GET /mentor/withdrawals/{id}.
Your shape was { id, amount, currency, status, method, requested_at, processed_at, note }. Shipped:
{
"id": "β¦", "reference": "WD-β¦",
"amount": { "minor": 52400, "major": "524.00", "formatted": "USD 524.00" },
"fee": { "minor": 0, "major": "0.00", "formatted": "USD 0.00" },
"net_amount": { "minor": 52400, "major": "524.00", "formatted": "USD 524.00" },
"currency": "USD",
"status": "pending", "status_label": "Pending review", "is_open": true,
"method": { "type": "bank", "label": "Bank transfer", "masked_account": "017****2103" },
"requested_at": "β¦", "processed_at": null,
"rejection_reason": null, "failure_reason": null, "failure_reason_label": null,
"payout_reference": null
}
Deviations β:
amountis a money block, not a bare number β consistent with your own money rule, which your Β§3 shape quietly dropped.statushas six cases, not five. You listedpending | approved | processing | paid | rejected; there is alsofailedβ an admin attempted the transfer and the provider rejected it, which is not the same as an admin declining the request. Handle it or it falls through your switch.notedoes not exist. A mentor does not annotate a withdrawal.rejection_reason(admin declined) andfailure_reason+failure_reason_label(transfer failed) are what carries an explanation back.is_openis precomputed so you do not hardcode which statuses count as open.
C.2 Creating one
POST /mentor/withdrawals β body is { currency, amount_minor, payout_method_id },
not { amount, method?, note? } β. Minor units on the wire, for the same
reason everything else is.
422s come back as a normal Laravel validation bag keyed by field, so they map straight onto your inputs:
| Cause | Field key |
|---|---|
| Below the per-currency minimum | amount_minor |
| Insufficient available balance | amount_minor |
| Currency not payable to that method | currency |
| An open request already exists in that currency | currency |
A payout_method_id that is not this mentor's returns 404, not 422 β
ownership, not validation, and confirming the id exists would be a disclosure.
C.3 Which payout methods, and who owns the connect flow β open question 3 π¬
Neither bank-hardcoded nor Stripe Connect. It is an admin-defined catalog, and the mentor connects through our API.
GET /mentor/payout-method-configs returns the catalog with a field schema per
method β type, validation rules, masking, and the title template. Render the
form from that response rather than hardcoding bank-vs-bKash forms; an admin can
add a method without a frontend release.
Payout itself is manual and out of band. An admin approves, sends the money,
and records the provider's reference. There is no Stripe Connect and no automated
transfer. Practically: processing β paid is a human action, so do not build a UI
that expects a request to settle in seconds.
D. Β§4 β one done, one outstanding
D.1 status_counts on /mentor/bookings β
Shipped, at data.bookings.meta.status_counts β inside the pagination meta,
where you asked for it. It ignores ?status= so switching tab does not renumber
the tabs, and it counts past per_page, which is the whole reason it exists.
Your list was missing two statuses β. BookingStatus has six cases, and all
six are returned, zero-filled:
"status_counts": {
"requested": 4, "confirmed": 7, "paid": 3,
"completed": 31, "cancelled": 2, "refunded": 1
}
paid is the pay-first queue β charged, waiting on the mentor's decision β which
is exactly the "N pending requests" number you say this drives. That line is
requested + paid. Counting requested alone under-reports it, and for a
mentor who only sells paid sessions it would read zero for ever.
On casing: it is status_counts, snake_case β β not the statusCounts we
first proposed. On building it the distinction turned out to be cleaner than we
described: the key lives in Laravel's own meta envelope, beside per_page and
current_page, not inside SessionBookingResource. So it follows the envelope's
convention, and the booking rows in data stay camelCase as they were. Your
underlying point stands β that resource is the odd one out β but migrating it is
a breaking change to an endpoint you already consume, so it stays your call.
D.2 ?status= validation β already correct π¦
"Please return a 422 for an unknown status rather than silently ignoring the filter."
It already does, and always has. IndexBookingRequest validates status.* with
Rule::enum(BookingStatus::class) and returns 422 "The selected status is
invalid."
It also accepts three input forms β ?status=paid, ?status=confirmed,paid, and
?status[]=confirmed&status[]=paid β all normalised to a list. If you saw a
silent empty list, you were sending a valid status that had no rows, not an
invalid one.
E. Β§1 β GET /mentor/analytics is shipped β
GET /api/v1/mentor/analytics?period=30d. Full reference:
analytics-api.md.
The response is a strict superset of the shape you specified β every field in your example is present under the name you gave it. Four of them could not be delivered as drawn; each is described below with what we shipped instead.
E.0 What matches your spec exactly π¦
period.from / period.to, stats.* with {value, change_percent},
sessions_by_status, sessions_over_time with server-formatted label and ISO
date, session_breakdown.by_provider / by_session_model with
{key, label, count}, and top-level currency. change_percent is null when
there is no prior window, as you asked.
period accepts both vocabularies β your 7d | 30d | 90d | 12m | all and
the canonical last_7_days | β¦ | all_time that /mentor/earnings/summary
already speaks. We had said we would standardise on one; on reflection, forcing
one screen to hold two spellings and translate between them is worse than
accepting both. period.value echoes the resolved window, so you always know
what you got. An unknown value is still a 422.
Bucketing is as you specified: day for 7d/30d, week for 90d, month for
12m/all β with one addition, an all-time window past the monthly cap falls
back to yearly rather than serving a mentor eighty points.
E.1 total_earnings is served twice, and you need both β
Your stats.total_earnings is one money triple with a top-level currency. That
cannot be the whole truth β Β§B.2 β so it is the headline, in the mentor's own
trading currency, exactly as you drew it. Alongside it:
"earnings": { "is_multi_currency": false, "currencies": [ β¦ ] }
earnings.is_multi_currency is the field to branch on. While it is false,
stats.total_earnings is the complete picture and you can ignore the array.
When it is true, the headline figure is a partial view of a mentor's income and
the rows are the answer. Same split on the chart:
sessions_over_time[].earnings is the bare minor-unit integer you asked for, and
earnings_by_currency sits beside it.
E.2 average_rating is always null β
It is present, in your shape, and it will never carry a number until somebody
builds reviews. mentor_profiles.average_rating is a column nothing in the
codebase writes β permanently 0.00 β and there is no mentor or session review
system; the only reviews that exist are on courses.
Null rather than 0.0 deliberately: null is your own signal to hide an
indicator, so the card stays hidden instead of showing a mentor a fabricated
rating. Drop the card. If reviews are built later, this field starts
returning a number with no other change to the response.
E.3 profile_views is always null β
Same treatment, same reason: no table, no write path, no tracking of any kind. Taking you at your word, we have not built it β it needs a migration, a write path on the public profile endpoint, and dedupe plus bot and self-view filtering before it is anything but a vanity number. Drop the card.
E.4 sessions_by_status carries six statuses, not four β
Same reason as Β§D.1 β paid and refunded are real states and your switch will
otherwise drop them.
E.5 sessions_over_time[].earnings is an integer, not "340.00" β
Your example showed a bare decimal string, which is the exact ambiguity the money
block exists to prevent. It is minor units β which is also what your own note
asked for ("may be minor only, since we only plot it"). Each point also carries
total, so the series sums back to stats.total_sessions; a chart that does not
add up to the card above it is a support ticket waiting to happen.
E.6 A window means the session's date, not the booking's β
Worth knowing before you reconcile anything against /mentor/bookings: a booking
counts in the window its session was scheduled in, not when it was created.
A session booked in August for September is September's work.
Money is the deliberate exception β bucketed by when it was paid, because that is the definition the earnings module already uses, and two cards on one page disagreeing about income is worse than one page using two clocks.
E.7 Two confirmations π¬
- Free sessions. Correct: they count in
total_sessionsand contribute nothing to earnings. The two figures are not meant to reconcile. by_session_modelvalues. Your example showedone_on_one/1-on-1; no such model exists. The three real values areregular_weekly,recurringandone_timeβ capacity is a separate field on the session type, not a model. Labels come from the server, so you should not need to map them.
F. Your six open questions, answered π¬
- Is
profile_viewstrackable? Not today β no tracking of any kind. Plan is to drop the card. Β§E.3. - Is there a hold period on earnings, and how long? Yes. Earnings clear only
after the session is delivered and a hold has elapsed. The length is an
admin-configurable platform setting, returned as
clearance_hold_dayson the summary so you never hardcode it. Delivery alone would let a mentor withdraw against a session that ended an hour ago and could still be disputed; a hold alone would pay out a session booked six weeks ago that never happened. - Which payout methods, and who owns the connect flow? An admin-defined catalog, connected through our API, with the form driven by a server-supplied field schema. Payout is manual β no Stripe Connect. Β§C.3.
- Should analytics count course and community revenue, or sessions only?
InvoiceScope::selleralready spans all three revenue streams through one polymorphic scope, so including them is free and excluding them is the extra work. Recommendation: count all three, so the analytics figure matches the earnings card. A session-only number that disagrees with Β§2 on the same page is the worse outcome. Still your call β we can also return a per-stream split. - Is a mentor's balance gross or net of the platform fee? Net. Decided
and shipped. Every figure on
/mentor/earnings/summaryand every point on the statement isamount β platform_fee β refunded. This was answered inearnings-api-response.mdΒ§A.1. - Can a mentor's earnings span multiple currencies? Yes, and they are never converted or summed. Β§B.2.
G. What happens next
Ours: nothing outstanding. Everything in your document is built and tested.
Two offers still open, both small, neither started because they are your call:
can_withdraw and minimum_withdrawal on the earnings summary (Β§B.4), and
migrating SessionBookingResource to snake_case (Β§D.1).
Yours:
- Update the endpoint registry. Its zero hits for
earnings,withdrawandbalanceare what produced a Revision 2 asking us to build two shipped features. - Drop the mock data.
/manage/analyticsis served by one call. - Wire Β§2 and Β§3. They have been waiting on you, not the other way round β the earnings card does not need to stay stubbed, and "Withdraw Now" has had a working endpoint behind it this whole time.
- Bin the client-side fallback. Deriving session counts from page one of
/mentor/bookingswas wrong pastper_page, and is now unnecessary twice over: Β§1 serves the totals, and Β§4.1 serves the tab counts.