Earnings & Payouts API
The mentor's side of the money: what they have earned, what has cleared, and how
they take it out. Everything here sits on the payments table โ earnings are not
a new source of truth, they are the seller's view of the rows /mentor/invoices
already returns.
- Base URL:
/api/v1ยท Auth: Sanctum bearer token ยท Casing:snake_case - Money is always the
{minor, major, formatted}triple. - Everything is per currency and nothing is ever converted. There is no FX rate anywhere in this system.
What counts as earnings
A payment where this mentor is the seller and the status is settled โ
succeeded or partially_refunded. Note the enum also contains refunded,
which is not settled: a fully refunded sale earns nothing.
The mentor's share of a payment is:
mentor_share = max(0, amount_minor - platform_fee_minor)
mentor_refund = min(refunded_amount_minor, mentor_share)
net_earning = mentor_share - mentor_refund
net_earning is a third number, and neither of the two that already exist:
| Field | Meaning |
|---|---|
amount |
what the buyer paid |
net on AdminTransactionResource |
amount โ refunded โ what the platform still holds |
net_earning |
amount โ platform_fee โ refunded โ what the mentor is owed |
Free bookings and free enrolments produce no payment row, so they never appear.
Clearance
Money becomes withdrawable when it is delivered and a hold has elapsed.
The hold is earnings_clearance_hold_days (default 7), editable in admin
settings.
| Purpose | Delivered when |
|---|---|
session_booking |
the booking is completed (clock runs from completed_at) |
course_enrollment |
payment lands โ access is immediate |
community_subscription |
payment lands โ access is immediate |
A payment flagged for refund (refund_required_at set, refunded_at null) is
never available: the platform owes that money back, and there is no clawback
mechanism.
GET /mentor/earnings/summary
Query: ?period=last_7_days|last_30_days|last_90_days|last_12_months|all_time
(default last_30_days), ?compare=0 to skip the previous-window pass.
{
"status": "success",
"message": "Earnings summary retrieved successfully.",
"data": {
"period": "last_30_days",
"period_label": "Last 30 days",
"clearance_hold_days": 7,
"sparkline_interval": "daily",
"is_multi_currency": false,
"currencies": [{
"currency": "USD",
"lifetime_earned": { "minor": 498700, "major": "4987.00", "formatted": "USD 4987.00" },
"pending_clearance": { "minor": 287500, "major": "2875.00", "formatted": "USD 2875.00" },
"available_balance": { "minor": 56700, "major": "567.00", "formatted": "USD 567.00" },
"in_review": { "minor": 0, "major": "0.00", "formatted": "USD 0.00" },
"withdrawn_total": { "minor": 340000, "major": "3400.00", "formatted": "USD 3400.00" },
"change_percent": { "lifetime_earned": 10.5, "pending_clearance": null, "available_balance": 1.5 },
"sparkline": [120, 340, 210, 480, 390, 560, 610]
}]
}
}
The balances always reconcile:
lifetime_earned = pending_clearance + available_balance + in_review + withdrawn_total
This holds by construction. Only lifetime and cleared are measured from the
ledger; pending_clearance and available_balance are derived from them, so the
identity cannot break even under concurrent writes.
period does not move the balances. They are point-in-time facts. Only
change_percent and sparkline respect the window โ a mentor switching period
and seeing lifetime_earned unchanged is correct behaviour.
change_percent is null, never 0, when there is no prior figure โ
including when the previous window was zero and the current one is not, which is
an increase with no finite percentage. Hide the indicator rather than printing
"+0%".
available_balance can be negative. A refund confirmed after a withdrawal
was paid is a real clawback. It is not clamped, because clamping would break the
reconciliation above and hide the one case that most needs looking at.
sparkline values are plain integers in minor units, one per bucket. The bucket
size varies with the period โ read sparkline_interval, do not assume.
GET /mentor/earnings/statement
Query: ?currency=USD (required) &interval=daily|monthly|yearly
(default monthly) &from=&to=
{ "data": {
"currency": "USD",
"interval": "monthly",
"from": "2026-01-01",
"to": "2026-09-07",
"points": [{
"label": "Jan",
"date": "2026-01-01",
"earned": { "minor": 85000, "major": "850.00", "formatted": "USD 850.00" },
"refunded": { "minor": 0, "major": "0.00", "formatted": "USD 0.00" },
"net": { "minor": 85000, "major": "850.00", "formatted": "USD 850.00" }
}]
} }
currencyis a filter, never a conversion. An unsupported code is a 422, not an empty series. A valid currency the mentor has never traded in returns 200 with an all-zero series โ that is a filter matching nothing.labelis formatted server-side:12 Mar(daily),Mar(monthly, orMar 2026when the range crosses a year),2026(yearly).dateis always present for clients that would rather format their own.- Buckets are zero-filled. Every period in range is emitted, including empty ones, so a line chart cannot interpolate across a gap.
- Span caps: 366 daily, 60 monthly, 20 yearly buckets. Over that is a 422.
- โ ๏ธ
refundedhere is the mentor's share of the refund, not the buyer's gross. That is what makesnet = earned โ refundedexact per bucket and makes the series sum tolifetime_earned. It will not match/mentor/billing/summary'srefunded_amount, which is the buyer-facing figure. This is deliberate. - 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.
Fee on invoice rows
/mentor/invoices rows and detail now carry platform_fee and net_earning.
/mentor/billing/summary's seller block carries platform_fee and
net_earnings. The mentee's equivalents carry neither and never will โ they are
served by a different resource class, so the split is structural rather than a
runtime check.
Payout methods
GET /mentor/payout-method-configs
Renders the add-method form entirely. Adding a payout rail is an admin action, not a release.
{ "data": { "methods": [{
"type": "bkash",
"label": "bKash",
"currencies": ["BDT"],
"fields": [{
"name": "phoneNumber",
"label": "bKash Phone Number",
"input_type": "tel",
"is_required": true,
"placeholder": "01XXXXXXXXX"
}]
}] } }
A dependent select (bank โ branch) carries depends_on: "bankName" plus
options_key: "branches", and each parent option nests its children under that
key. options_source: "selectedBank.branches" is also emitted for the current
client but is deprecated โ it is a JavaScript path expression, not data.
transaction_methods is a deprecated alias for methods. dependsOn,
optionsSource, type, required, isDefault and createdAt are deprecated
camelCase duplicates. All are removed in a future release; prefer the snake_case
keys.
Field
namevalues are not normalised. They are admin-authored keys that round-trip: config โ create payload โ errors bag.
Ships with bkash, nagad, card and bank. Note the provider is Nagad โ
the frontend mock spells it nogod.
GET/POST /mentor/payout-methods
POST body is flat: { "type": "bkash", "phoneNumber": "01712342103", "accountType": "personal" }.
Returns 201 with data.method. The first method a mentor saves is their default.
Validation failures are a standard 422 with the errors bag keyed by field name.
{ "data": { "methods": [{
"id": "...", "type": "bkash", "label": "bKash",
"title": "017****2103", "subtitle": "Mobile banking",
"is_default": true, "created_at": "2026-06-18T10:24:00+00:00"
}] } }
DELETE /mentor/payout-methods/{id} and POST /mentor/payout-methods/{id}/default
are both available. Deleting the default promotes the next method. Deleting a
method with an open withdrawal against it is a 422.
What is stored
| Storage | Written to the database | Shown to the mentor |
|---|---|---|
plain |
in full | in full |
masked |
in full | masked |
discard |
nothing | nothing |
masked is a display decision, not a storage one. Payout is manual โ an
admin reads the mentor's account number and moves the money by hand โ so the
value is stored in full and masked when it is rendered. The whole details
column is encrypted at rest.
Masking on render also means the mask settings are not baked into the data:
widening mask_visible_trailing changes what existing rows show.
discard is the mode that writes nothing. A card stores brand, last four,
expiry and holder name only: the number is validated (Luhn, 13โ19 digits) and
discarded, and the security code is validated and discarded. Neither is ever
written, in any form โ PCI-DSS forbids retaining a CVV after authorization.
title is masked at snapshot time, so it is safe to render anywhere. No mentor
response contains an unmasked value. The one endpoint that returns one is
GET /admin/withdrawals/{id}/payout-details.
Withdrawals
GET /mentor/withdrawals
Paginated (data.withdrawals.data[], .meta, .links). Filter by ?status=
and ?currency=.
{
"id": "...", "reference": "WDR-2026-000042",
"amount": { "minor": 40000, "major": "400.00", "formatted": "USD 400.00" },
"currency": "USD",
"status": "pending", "status_label": "Pending review", "is_open": true,
"method": { "type": "bkash", "label": "bKash", "masked_account": "017****2103" },
"requested_at": "...", "processed_at": null,
"rejection_reason": null,
"failure_reason": null, "failure_reason_label": null,
"payout_reference": null
}
The status enum, definitively:
pending approved processing paid rejected failed
Legal transitions: pending โ approved | rejected,
approved โ processing | failed, processing โ paid | failed. Final statuses go
nowhere โ there is no retry, a mentor raises a fresh request.
pending, approved and processing are open: the money is reserved and
shows as in_review. rejected and failed release it back to
available_balance automatically.
method is a snapshot taken when the request was raised. It still renders
after the payout method is deleted, and shows what it said then.
Two additions to the shape originally specified:
amountis the money block rather than a bare number, andrejection_reasonexists โ without it, a mentor whose request was rejected would see no explanation anywhere.
POST /mentor/withdrawals
Body: { "amount_minor": 40000, "currency": "usd", "payout_method_id": "..." }
422 on: below the per-currency minimum, above available_balance, a currency the
platform cannot pay out in, or an existing open request in that currency.
404 if the payout method is not this mentor's.
One open request per currency. A second submit while one is open returns 422. A different currency is fine.
Minimums are per currency (minimum_withdrawal_usd_minor, โฆ_bdt_minor, โฆ).
A single figure cannot work: 5000 minor is $50.00 in USD, เงณ50.00 in BDT, and
ยฅ5000 in JPY, where the minor unit is the major unit.
The amount is reserved atomically as the request is created, under a row lock and a unique index โ two rapid submits cannot both succeed.
GET /mentor/withdrawals/{id}
The same shape, for a detail view.
Admin review
All under /admin, requiring the admin role.
| Method | Path |
|---|---|
GET |
/admin/withdrawals โ filter by status, currency, mentor_profile_id, search, date_from, date_to |
GET |
/admin/withdrawals/{id} |
GET |
/admin/withdrawals/{id}/payout-details โ the unmasked destination |
POST |
/admin/withdrawals/{id}/approve |
POST |
/admin/withdrawals/{id}/reject โ reason required |
POST |
/admin/withdrawals/{id}/processing |
POST |
/admin/withdrawals/{id}/paid โ payout_reference required |
POST |
/admin/withdrawals/{id}/failed โ failure_reason required, note required when the reason is other |
An illegal transition is a 422 naming both states. The admin row carries
allowed_transitions, so the panel can render only the buttons that would work.
Payout is manual โ no Stripe Connect. An admin sends the money and records the provider's reference, which is why it is required: a transfer with no external reference cannot be reconciled.
Every transition writes an AdminAuditLog entry inside the same transaction as
the state change, and notifies the mentor after the commit. processing sends no
notification.
GET /admin/withdrawals/{id}/payout-details
The mentor's real account number, for making the transfer.
{
"status": "success",
"message": "Payout details retrieved successfully.",
"data": {
"method": {
"type": "bkash",
"label": "bKash",
"masked_account": "017****2103",
"details": { "phoneNumber": "01712342103", "accountType": "personal" }
}
}
}
Its own endpoint rather than a field on GET /admin/withdrawals/{id}, for two
reasons:
- The list and the detail view carry only
masked_account. Opening the review queue must not put every mentor's bank details into a browser, a log and a cache. - Every call writes an
AdminAuditLogentry (withdrawal.details_revealed) naming the admin, the withdrawal, the IP and the time. Payout being manual means an operator legitimately needs a mentor's account number; knowing who looked is the compensating control. The entry deliberately does not contain the values โ a record of who saw an account number must not become a second copy of it.
Gated on its own policy ability (viewPayoutDetails), separate from view, so
reading the queue and reading destinations can be split between roles later.
Read off the withdrawal's snapshot, never the live payout method: the mentor may have changed or deleted the method since, and the money is owed to where it was requested.
Rows raised before full values were retained hold a masked string here. There is no way to recover those โ the mentor has to re-add the payout method.
The payout catalog
This is what makes payout methods data rather than code. An admin adds a rail here and the mentor's add-method form changes on the next request, with no deploy.
| Method | Path |
|---|---|
GET |
/admin/payout-method-types โ every type, plus the vocabularies the panel's own selects are built from |
GET POST |
/admin/payout-method-types |
GET PUT DELETE |
/admin/payout-method-types/{typeId} |
POST |
/admin/payout-method-types/{typeId}/fields |
PUT DELETE |
/admin/payout-method-types/{typeId}/fields/{fieldId} |
POST |
/admin/payout-method-types/{typeId}/fields/{fieldId}/options |
PUT DELETE |
.../options/{optionId} |
POST DELETE |
/admin/payout-method-types/{typeId}/logo โ the rail's own mark |
POST DELETE |
.../fields/{fieldId}/options/{optionId}/logo โ a bank's mark |
Logos are multipart/form-data under the key logo: PNG, JPG or WebP, 2 MB.
No SVG โ these are served back from the app's own origin to every mentor's
add-method form, and an SVG is a document that can carry script. Each collection
is singleFile(), so uploading replaces rather than accumulates, and both the
admin resource and the mentor config expose logo_url (null when none is set).
The admin view exposes storage, rule_preset and derivations โ which the
mentor-facing config deliberately hides. An admin cannot configure what they
cannot see.
Adding a whole method in one submit
The panel edits a payout method as one form โ the method, its inputs, and each
choice input's options โ so POST and PUT on /admin/payout-method-types
accept the graph nested. Sending three separate calls still works; it just means
unwinding by hand if the third one fails.
{
"key": "rocket",
"label": "Rocket",
"currencies": ["BDT"],
"title_template": "{phoneNumber}",
"input": [
{
"name": "phoneNumber",
"label": "Rocket Number",
"input_type": "tel",
"is_required": true,
"placeholder": "01XXXXXXXXX",
"rule_preset": "bd_mobile",
"storage": "masked",
"mask_visible_leading": 3,
"mask_visible_trailing": 4
},
{
"name": "accountType",
"label": "Account Type",
"input_type": "radio",
"options": [
{ "value": "personal", "label": "Personal" },
{ "value": "agent", "label": "Agent" }
]
}
]
}
The array may be called input, inputs or fields; they are the same key.
The response is the saved method with every input and option, which is also the
shape GET /admin/payout-method-types/{typeId} returns โ post it back, edited,
to save again.
Order. Array position is the running order. sort_order is only needed to
override it.
Dependent inputs. A bank's branches: the dependent input names its parent rather than pointing at an id, because an input created in the same request has no id yet. The branches nest under the bank they belong to โ where the panel shows them, and where the mentor config renders them โ and are filed against the dependent input:
{
"key": "local_bank",
"label": "Local Bank",
"input": [
{
"name": "bankName", "label": "Bank", "input_type": "select",
"options_alias": "selectedBank",
"options": [
{
"value": "brac_bank", "label": "BRAC Bank",
"children": [
{ "value": "gulshan", "label": "Gulshan Branch",
"metadata": { "routing_number": "060270435" } }
]
}
]
},
{
"name": "branch", "label": "Branch", "input_type": "select",
"depends_on": "bankName",
"options_child_key": "branches"
}
]
}
PUT is a sync, not a merge. The array you send is the set of inputs:
rows are matched by name and upserted, and an input missing from the array is
deleted. Options are matched by value the same way. Matching on names rather
than ids is what lets one payload both create a method and later edit it.
Omitting the array entirely means leave the inputs alone โ which is what a
PUT that only renames the method does.
The whole submit is one transaction, so a 422 anywhere leaves nothing behind. It refuses, with a message the admin can act on:
| Refusal | Why |
|---|---|
Two inputs sharing a name |
The name is the key a mentor's answer comes back under |
depends_on naming an input not in the submit |
Nothing to depend on |
Options on a tel/text/password input |
Only a radio or select has choices |
children under an input nothing depends on |
There is no input to file them against |
| Dropping an input the title or subtitle template still references | The template would render empty, and an unidentifiable payout method in a list is how a mentor sends money to the wrong place |
Validation is a whitelist, not free text โ ๏ธ
A field carries rule_preset, which must be a case of PayoutFieldRule
(bd_mobile, card_number, card_expiry, account_number, โฆ). It is never
a Laravel rule string, and POSTing one is a 422.
This is the security boundary of the whole feature. A rule string is a
mini-language with reach into the database and the container: exists:users,email
would turn this form into a user-enumeration oracle, regex:/(a+)+$/ into a ReDoS
that pins a worker, and a custom rule class name resolves through the container โ
making an admin-supplied string effectively new $adminInput. Adding a new kind
of validation is a pull request, which is the right amount of friction for
something deciding what the platform accepts as a place to send money.
min_length and max_length are the only free numeric knobs.
Storage modes
storage |
Written | Displayed |
|---|---|---|
plain |
in full | in full |
masked |
in full | masked, per mask_char, mask_visible_leading, mask_visible_trailing |
discard |
nothing | nothing |
Setting a field to masked does not discard the value โ see
What is stored. Only discard keeps a value off disk, and
that is the mode a card number and a CVV use.
derivations is a {derivation => target_key} map evaluated against the raw
value before storage is applied. That ordering is why a discard card
number can still leave behind cardLast4 and cardBrand โ and why supporting a
new sensitive payout type is three admin form fields rather than a code change.
Integrity guards
Each is a 422 with a message an admin can act on:
- a title or subtitle template referencing a key the type does not store โ
including one that names a
discardfield, whose value is never there - a dependent select pointing at a field that has no options, or at itself
- an option whose
parent_idbelongs to a different field - deleting a field a template still references
- deleting a type mentors have saved methods of โ deactivate it instead
- a
routing_numberthat is not exactly 9 digits
Every catalog write busts the config cache and writes an audit entry in the same
transaction. That log is not ceremonial: flipping a field from discard to
plain starts persisting card numbers.