MeetyyAPI
Documentation / API Reference / Mentor Analytics β€” response

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.

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-methods returns data.methods; connected is methods.length > 0.
  • minimum_withdrawal β†’ a per-currency platform setting, enforced at POST /mentor/withdrawals, which returns a 422 keyed on amount_minor with 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 is available_balance > minimum and 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 ❗:

  • amount is a money block, not a bare number β€” consistent with your own money rule, which your Β§3 shape quietly dropped.
  • status has six cases, not five. You listed pending | approved | processing | paid | rejected; there is also failed β€” 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.
  • note does not exist. A mentor does not annotate a withdrawal. rejection_reason (admin declined) and failure_reason + failure_reason_label (transfer failed) are what carries an explanation back.
  • is_open is 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_sessions and contribute nothing to earnings. The two figures are not meant to reconcile.
  • by_session_model values. Your example showed one_on_one / 1-on-1; no such model exists. The three real values are regular_weekly, recurring and one_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 πŸ’¬

  1. Is profile_views trackable? Not today β€” no tracking of any kind. Plan is to drop the card. Β§E.3.
  2. 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_days on 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.
  3. 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.
  4. Should analytics count course and community revenue, or sessions only? InvoiceScope::seller already 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.
  5. Is a mentor's balance gross or net of the platform fee? Net. Decided and shipped. Every figure on /mentor/earnings/summary and every point on the statement is amount βˆ’ platform_fee βˆ’ refunded. This was answered in earnings-api-response.md Β§A.1.
  6. 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:

  1. Update the endpoint registry. Its zero hits for earnings, withdraw and balance are what produced a Revision 2 asking us to build two shipped features.
  2. Drop the mock data. /manage/analytics is served by one call.
  3. 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.
  4. Bin the client-side fallback. Deriving session counts from page one of /mentor/bookings was wrong past per_page, and is now unnecessary twice over: Β§1 serves the totals, and Β§4.1 serves the tab counts.