MeetyyAPI
Documentation / API Reference / Earnings & Payouts

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" }
  }]
} }
  • currency is 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.
  • label is formatted server-side: 12 Mar (daily), Mar (monthly, or Mar 2026 when the range crosses a year), 2026 (yearly). date is 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.
  • โš ๏ธ refunded here is the mentor's share of the refund, not the buyer's gross. That is what makes net = earned โˆ’ refunded exact per bucket and makes the series sum to lifetime_earned. It will not match /mentor/billing/summary's refunded_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 name values 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: amount is the money block rather than a bare number, and rejection_reason exists โ€” 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 AdminAuditLog entry (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 discard field, whose value is never there
  • a dependent select pointing at a field that has no options, or at itself
  • an option whose parent_id belongs to a different field
  • deleting a field a template still references
  • deleting a type mentors have saved methods of โ€” deactivate it instead
  • a routing_number that 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.