File size: 8,628 Bytes
d9f0d1f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
<?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)),
        ];
    }
}