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_feeandnet_earningon every/mentor/invoicesrow and detailplatform_feeandnet_earningson/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 β
"
labelis 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
succeededandpartially_refunded;pending,processing,failed,cancelledandexpireddo 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 isdailyfor 7/30-day periods andmonthlybeyond. 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=eurfor 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
statusenum. We have assumedpending | 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 β rejectedis refused. Rejection stays pre-approval so the two reason fields keep distinct meanings:rejection_reasonis "we declined to send this",failure_reasonis "we tried and it did not land". An approved request that goes wrong exits throughfailed.- 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:
- 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.
cardis 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.