MeetyyAPI
Documentation / API Reference / Earnings & Payouts β€” response

Mentor Earnings & Payouts β€” response

Reply to the combined earnings-api-request.md + transaction-methods spec. All of it is built, tested and merged. Read Β§A first β€” four of your premises were wrong, three of them in your favour.

  • Base URL: /api/v1 Β· Auth: Sanctum bearer token Β· Casing: snake_case
  • Full endpoint reference: earnings-api.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

A. Read this before wiring anything ❗

A.1 The platform fee was never blocked 🟦

"You answered B.2 as 'still a product decision, nothing blocked on it.' The earnings page blocks on it."

It does not, and it never did. payments.platform_fee_minor has existed since the ledger was created and is populated on every charge by PaymentService, from an admin setting that defaults to 15% and is enforced. The number you needed was already being recorded on every row.

What was missing was only the decision to show it. That is now made, and both figures are exposed:

  • platform_fee and net_earning on every /mentor/invoices row and detail
  • platform_fee and net_earnings on /mentor/billing/summary's seller block

You were right that net_earning is a third number. To be precise about all three:

Field Meaning
amount what the buyer paid
net on the admin ledger amount βˆ’ refunded β€” what the platform still holds
net_earning amount βˆ’ platform_fee βˆ’ refunded β€” what the mentor is owed

The mentee's rows carry neither field and never will. They are served by a different resource class, so it is structurally impossible rather than a runtime check somebody could forget.

A.2 There is no /mentor/analytics to copy ❗

"label is what we render on the axis β€” please format it server-side, as with /mentor/analytics."

That endpoint does not exist. No route, no controller, no service. The label-formatting precedent you cited is imaginary, so we have set it here instead:

interval label example
daily j M 12 Mar
monthly M, or M Y when the range crosses a year Mar / Mar 2026
yearly Y 2026

Every point also carries an ISO date, so you can relabel without parsing.

A.3 Your settled-status list is missing refunded ❗

"That means succeeded and partially_refunded; pending, processing, failed, cancelled and expired do not."

The enum has eight values, not seven. You omitted refunded, which is not settled β€” a fully refunded sale earns nothing at all. Your arithmetic silently assumed it away.

pending  processing  succeeded  failed  cancelled  expired  refunded  partially_refunded

Prefer the server-side is_settled flag over comparing statuses yourself.

A.4 available_balance can be negative ❗

Your spec has no room for this, but it is real: a refund confirmed after a withdrawal was already paid out is a clawback, and the mentor genuinely owes the platform. We do not clamp it at zero, because clamping would break the very reconciliation invariant you asked to be guaranteed, and would hide the one situation that most needs looking at.

Render a negative balance as a negative balance. The withdrawal form should treat the ceiling as max(0, available_balance).


B. GET /mentor/earnings/summary βœ…

Shipped in the shape you specified, plus two keys you will want.

The invariant you asked for holds by construction, not by luck:

lifetime_earned = pending_clearance + available_balance + in_review + withdrawn_total

Only lifetime and cleared are measured. pending_clearance and available_balance are derived from them, so the identity is arithmetic and cannot break even under concurrent writes.

Two additions ❗

  • clearance_hold_days β€” so you can render "clears in 7 days" without hardcoding a number an admin can change.
  • sparkline_interval β€” because one bucket count does not fit every period. Twelve buckets over seven days would be fourteen-hour slices. It is daily for 7/30-day periods and monthly beyond. Read it; do not assume.

period does not move the balances πŸ’¬ β€” they are point-in-time facts. Only change_percent and sparkline respect the window. A mentor switching to last_7_days and seeing lifetime_earned unchanged is correct, not a bug.

change_percent is null exactly as you asked βœ… β€” including when the prior window was zero and the current one is not. That is an increase from nothing, which has no finite percentage; 0 and 100 would both be lies.

Clearance πŸ’¬ β€” you did not specify when money clears, so:

delivered and a configurable hold has elapsed (default 7 days). Sessions are delivered when the mentor marks them completed, so the clock runs from completed_at, not from payment. Courses and communities grant access on payment, so for those the payment date is the delivery date.

A pending refund never reaches available_balance βœ…, exactly as you asked.

C. GET /mentor/earnings/statement βœ…

currency is required ❗ β€” your spec writes it as though optional. It is not, for the reason you gave yourself: defaulting silently would label a chart with a currency the mentor never chose. /mentor/earnings/summary tells you exactly which currencies they have.

It never converts, and an unknown code is a 422 βœ… β€” not an empty series, as you asked. Note the distinction:

  • ?currency=xyz β†’ 422. Not a currency the platform can pay out in.
  • ?currency=eur for a mentor who has never earned in EUR β†’ 200 with an all-zero series. That is a filter matching nothing, which is not an error.

Buckets are zero-filled βœ… β€” every period in range is emitted, so your line chart cannot interpolate across a gap and invent income.

Span caps ❗ β€” zero-filling means the bucket count is the response size: 366 daily, 60 monthly, 20 yearly. Over that is a 422 naming the cap.

⚠️ refunded is the mentor's share, not the buyer's gross refund ❗

This is the one thing here most likely to be filed as a bug, so please read it. For an 800.00 sale with a 120.00 fee and a 200.00 refund:

earned   = 680.00   (800 βˆ’ 120)
refunded = 200.00   (the mentor's share of what went back)
net      = 480.00

This makes net = earned βˆ’ refunded exact per bucket, and makes the whole series sum to lifetime_earned. It will not equal /mentor/billing/summary's refunded_amount, which is the buyer-facing figure. Both are correct; they answer different questions.

A sale is booked into the period it was paid in, not the period a refund happened in. A March sale refunded in April reduces March.


D. Withdrawals βœ…

The status enum, as requested πŸ’¬

"Please send us the exact status enum. We have assumed pending | approved | processing | paid | rejected | failed."

Your assumption was right. All six, spelled exactly that way.

Legal transitions:

pending    β†’ approved | rejected
approved   β†’ processing | failed
processing β†’ paid | failed
paid, rejected, failed β†’ (final)

Two absences worth knowing:

  • approved β†’ rejected is refused. Rejection stays pre-approval so the two reason fields keep distinct meanings: rejection_reason is "we declined to send this", failure_reason is "we tried and it did not land". An approved request that goes wrong exits through failed.
  • There is no retry. A failed request is final; the money is already back in available_balance, and the mentor raises a fresh request.

Each row carries is_open, so you do not need to hardcode which three states mean "still in flight".

One open request at a time β€” yes, per currency πŸ’¬

"an existing request still open (if you only allow one at a time β€” tell us either way, it changes the button)."

One open request per currency. A second submit in the same currency is a 422; a different currency is fine. So the button disables per currency, not globally.

rejection_reason was missing from your row shape ❗

Your shape carries failure_reason but nothing for a rejected request β€” which means a mentor whose request an admin declined would have seen no explanation anywhere in the payload. We have added rejection_reason.

amount is the money block, not a bare number ❗

Your example shows "amount": {…} in one place and implies a scalar elsewhere. It is the standard {minor, major, formatted} triple, like every other amount in this API. A flat number is ambiguous about units, which is exactly the bug the triple exists to prevent.

Server-side reservation βœ…

"Server-side reservation, please… If it is computed as sum(earnings) βˆ’ sum(paid_withdrawals), two submits in quick succession both pass validation and the mentor withdraws the same money twice."

Correct, and closed. The amount leaves available_balance atomically as the request is created, guarded by a row lock and a database unique index β€” the lock narrows the window, the index is what actually guarantees it. Two rapid submits cannot both succeed.

Releasing is automatic: rejecting or failing a request returns the money to available_balance with no compensating write.

Minimums are per currency ❗

There is no single minimum_withdrawal. 5000 minor units is $50.00 in USD, ΰ§³50.00 (about $0.42) in BDT, and Β₯5000 (about $32) in JPY β€” where the minor unit is the major unit. One figure would be wrong by two orders of magnitude across the currencies we charge in, and there is no FX rate in this system to normalise them. The 422 message names the actual minimum for the currency you sent.


E. Transaction Methods βœ…

Built as fully dynamic, as asked β€” the admin defines the types and their fields, so adding a payout rail is an admin action rather than a release.

E.1 nogod is a misspelling ❗ β€” please fix it

The provider is Nagad. We ship "type": "nagad". Since type is the value your client switches on and will end up in analytics and support macros, this is the only free moment to correct it.

E.2 Key it methods, not transaction_methods ❗

We send both β€” transaction_methods is a deprecated alias so your current mock renders unmodified β€” but please migrate. This codebase reserves "transaction" for the payments ledger, and reusing it for money going out will cost somebody an hour in a year.

E.3 Casing: snake_case is canonical ❗

Every other response in this API is snake_case. Making this one module camelCase guarantees a future bug where a client that snake_cases everything silently drops isDefault and renders every method as non-default.

Canonical: input_type, is_required, depends_on, options_source, is_default, created_at. The camelCase duplicates (type, required, dependsOn, optionsSource, isDefault, createdAt) ship alongside for one release and are then removed.

Field name values are the exception and are not normalised β€” phoneNumber and cardholderName stay exactly as the admin authored them. They are dictionary keys that round-trip: config β†’ create payload β†’ errors bag. Touching their case anywhere breaks the loop.

E.4 optionsSource is a JavaScript expression in an API contract ❗

"selectedBank.branches" is a path your client evaluates, not data. Storing it would couple our database to one client's variable naming.

We derive it, so you still receive it verbatim β€” and we send the structurally honest form beside it:

{
  "name": "branch",
  "depends_on": "bankName",
  "options_key": "branches",
  "options_source": "selectedBank.branches"
}

Each bank option nests its branches under that key, so you can read the dependency directly rather than evaluating a path. options_source is deprecated.

E.5 Card πŸ’¬ ❗ β€” three things

We keep the brand, last four, expiry and holder name. Nothing else. The card number is validated and discarded; the security code is validated and discarded. Neither is ever written, in any form.

That is stricter than your spec's "store card numbers encrypted". Storing a PAN puts this API in PCI-DSS scope, and since this module cannot charge a card there is no purpose the stored number would serve.

Two things you should know:

  1. Accepting a PAN at all is still PCI-adjacent. The number transits TLS termination, the request body, and any APM trace, even though we drop it. If you can collect brand + last four client-side and never send us the number, that is strictly better and we will take it.
  2. card is not actually a payout instrument. You cannot push money to an arbitrary card without Visa Direct or Mastercard Send, and this module is manual/out-of-band. As shipped, a card method is a display-only record an operator cannot act on. It works, it validates, it stores safely β€” but it does not yet pay anyone. Flagging so nobody is surprised.

Card number validation is 13–19 digits plus Luhn, not "16 digits" ❗ β€” 16 rejects Amex (15) and several Maestro and UnionPay ranges. Luhn is the check that actually catches a typo; length alone does not.

E.6 The masks in your spec are two different mechanisms πŸ’¬

Worth knowing, since it explains why one config drives all three:

  • 017****2103 β€” a real mask. 3 leading + 4 trailing visible, length preserved.
  • β€’β€’β€’β€’ 3456 β€” not a mask. A masked 16-digit PAN would be twelve bullets, and we do not store the PAN. It is a literal in the title template over the derived last four.
  • BRAC Bank PLC β€’ β€’β€’β€’β€’4521 β€” a literal, plus a real mask on the account number.

Masking and templating are separate stages, which is what lets all three come out of admin configuration rather than code.

E.7 A branch name is not a payout address ❗

A Bangladeshi bank transfer is routed by a 9-digit routing number, which identifies bank and branch together. An operator handed "Gulshan Branch" has to look it up and can get it wrong. The schema carries routing_number per branch; we have seeded it null rather than inventing digits, so it needs sourcing from Bangladesh Bank data before bank payouts run at volume.

E.8 Delete and set-default shipped now, not "later" βœ…

You listed both as optional follow-ups. We built them: a default flag that can never be changed is a trap, and a mentor who mistypes an account number should not need a support ticket. Deleting the default promotes the next method. Deleting one with an open withdrawal against it is a 422.


F. Scope note πŸ’¬

Payout is manual. An admin approves a request, sends the money through bKash or a bank, and records the provider's reference. There is no Stripe Connect and no automated transfer β€” destination_account_id is reserved on the payments table for the day there is.

Practically, that means processing β†’ paid is a human action, so do not build a UI that expects a request to settle in seconds.