JRNET / bemalearn /includes /class-bl-earnings-controller.php
Manus
Deploy Bema Learn backend only
d9f0d1f
Raw
History Blame Contribute Delete
8.63 kB
<?php
/**
* Earnings and withdrawals.
*
* GET /bemalearn/v1/me/earnings instructor only
* POST /bemalearn/v1/me/withdrawals instructor only
*
* These endpoints move money. The authority on their behaviour is
* docs/API-CONTRACT.md.
*/
if (!defined('ABSPATH')) {
exit;
}
class BL_Earnings_Controller {
const MINIMUM_WITHDRAWAL_MINOR = 50000;
public function register_routes() {
register_rest_route('bemalearn/v1', '/me/earnings', [
'methods' => 'GET',
'callback' => [$this, 'get_earnings'],
'permission_callback' => [$this, 'check_instructor'],
]);
register_rest_route('bemalearn/v1', '/me/withdrawals', [
'methods' => 'POST',
'callback' => [$this, 'create_withdrawal'],
'permission_callback' => [$this, 'check_instructor'],
'args' => [
'amountMinor' => [
'required' => true,
'validate_callback' => function ($v) {
// Money is an integer in minor units, and a withdrawal
// is strictly positive. Rejects strings, floats and
// negatives with a 400 before anything reaches the DB.
if (!is_int($v) && !(is_string($v) && ctype_digit($v))) {
return new WP_Error(
'rest_invalid_param',
'amountMinor must be a positive integer in minor units.',
['status' => 400]
);
}
return (int) $v > 0;
},
],
'payoutReference' => [
'required' => true,
'validate_callback' => function ($v) {
// Must fit the VARCHAR(64) idempotency column.
return is_string($v)
&& $v !== ''
&& strlen($v) <= 64
&& preg_match('/^[A-Za-z0-9_-]+$/', $v) === 1;
},
'sanitize_callback' => function ($v) {
return sanitize_text_field((string) $v);
},
],
],
]);
register_rest_route('bemalearn/v1', '/auth/login', [
'methods' => 'POST',
'callback' => [$this, 'login'],
'permission_callback' => '__return_true',
]);
}
/**
* The caller is signed in. Says nothing about their role.
*/
public function check_authenticated($request) {
$user = BL_Auth::user_from_request($request);
if (!$user) {
return new WP_Error('unauthenticated', 'Sign in to continue.', ['status' => 401]);
}
return true;
}
public function check_instructor($request) {
$user = BL_Auth::user_from_request($request);
if (!$user) {
return new WP_Error('unauthenticated', 'Sign in to continue.', ['status' => 401]);
}
if (!in_array('bl_instructor', (array) $user->roles, true)) {
return new WP_Error('forbidden', 'Instructors only.', ['status' => 403]);
}
return true;
}
public function login($request) {
$email = sanitize_email((string) $request->get_param('email'));
$password = (string) $request->get_param('password');
$user = get_user_by('email', $email);
if (!$user || !wp_check_password($password, $user->user_pass, $user->ID)) {
return new WP_Error(
'invalid_credentials',
'Email or password is incorrect.',
['status' => 401]
);
}
return new WP_REST_Response([
'token' => BL_Auth::issue_token($user->ID),
'user' => [
'id' => (int) $user->ID,
'name' => $user->display_name,
'role' => in_array('bl_instructor', (array) $user->roles, true)
? 'instructor' : 'learner',
],
], 200);
}
public function get_earnings($request) {
global $wpdb;
$user = BL_Auth::user_from_request($request);
$ledger = $wpdb->prefix . 'bl_earnings_ledger';
$available = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(SUM(amount_minor), 0) FROM {$ledger}
WHERE instructor_id = %d
AND available_at IS NOT NULL
AND available_at <= UTC_TIMESTAMP()",
$user->ID
));
$pending = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(SUM(amount_minor), 0) FROM {$ledger}
WHERE instructor_id = %d
AND (available_at IS NULL OR available_at > UTC_TIMESTAMP())",
$user->ID
));
$last = $wpdb->get_var($wpdb->prepare(
"SELECT created_at FROM {$wpdb->prefix}bl_withdrawals
WHERE instructor_id = %d ORDER BY id DESC LIMIT 1",
$user->ID
));
return new WP_REST_Response([
'availableMinor' => $available,
'pendingMinor' => $pending,
'currency' => 'NGN',
'minimumWithdrawalMinor' => self::MINIMUM_WITHDRAWAL_MINOR,
'lastWithdrawalAt' => $last ? gmdate('c', strtotime($last)) : null,
], 200);
}
public function create_withdrawal($request) {
global $wpdb;
$user = BL_Auth::user_from_request($request);
$amount = (int) $request->get_param('amountMinor');
$ref = (string) $request->get_param('payoutReference');
$table = $wpdb->prefix . 'bl_withdrawals';
if ($amount < self::MINIMUM_WITHDRAWAL_MINOR) {
return new WP_Error(
'below_minimum',
'The withdrawal amount is below the minimum allowed.',
['status' => 422]
);
}
// Idempotency: a repeat of the same reference returns the original.
if ($ref) {
$existing = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE instructor_id = %d AND payout_reference = %s",
$user->ID, $ref
));
if ($existing) {
return new WP_REST_Response($this->shape($existing), 200);
}
}
$available = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(SUM(amount_minor), 0) FROM {$wpdb->prefix}bl_earnings_ledger
WHERE instructor_id = %d
AND available_at IS NOT NULL
AND available_at <= UTC_TIMESTAMP()",
$user->ID
));
if ($amount > $available) {
return new WP_Error(
'insufficient_balance',
'Your available balance is lower than the requested amount.',
['status' => 422]
);
}
$pending = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE instructor_id = %d AND status = 'pending'",
$user->ID
));
if ($pending > 0) {
return new WP_Error(
'withdrawal_in_progress',
'You already have a withdrawal in progress.',
['status' => 422]
);
}
$wpdb->insert($table, [
'instructor_id' => $user->ID,
'amount_minor' => $amount,
'status' => 'pending',
'payout_reference' => $ref,
'created_at' => current_time('mysql', true),
]);
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $wpdb->insert_id));
return new WP_REST_Response($this->shape($row), 201);
}
private function shape($row) {
return [
'id' => (int) $row->id,
'amountMinor' => (int) $row->amount_minor,
'status' => $row->status,
'payoutReference' => $row->payout_reference,
'createdAt' => gmdate('c', strtotime($row->created_at)),
];
}
}