Passed
Push — master ( c1a9cf...81fe5a )
by Matthew
04:50
created

Subscription::checkValidPlan()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
cc 2
eloc 1
nc 2
nop 0
dl 0
loc 2
rs 10
c 0
b 0
f 0
ccs 0
cts 1
cp 0
crap 6
1
<?php
2
3
namespace MySociety\TheyWorkForYou;
4
5
function add_vat($pence) {
6 1
    return round($pence * 1.2 / 100, 2);
7
}
8
9
class Subscription {
10
    public $stripe;
11
    public $upcoming;
12
    public $has_payment_data = false;
13
14
    private static $plans = ['twfy-1k', 'twfy-5k', 'twfy-10k', 'twfy-0k'];
15
    private static $prices = [2000, 5000, 10000, 30000];
16 1
17
    public function __construct($arg) {
18 1
        # User ID
19
        if (is_int($arg)) {
20
            $user = new \USER();
21
            $user->init($arg);
22
            $arg = $user;
23
        }
24 1
25 1
        $this->db = new \ParlDB();
0 ignored issues
show
Bug Best Practice introduced by
The property db does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
26 1
        $this->redis = new Redis();
0 ignored issues
show
Bug Best Practice introduced by
The property redis does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
27 1
        if (defined('TESTING')) {
28
            $this->api = new TestStripe("");
0 ignored issues
show
Bug Best Practice introduced by
The property api does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
29
        } else {
30
            $this->api = new Stripe(STRIPE_SECRET_KEY);
31
        }
32 1
33
        if (is_a($arg, 'User')) {
34 1
            # User object
35 1
            $this->user = $arg;
0 ignored issues
show
Bug Best Practice introduced by
The property user does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
36 1
            $this->redis_prefix = "user:{$this->user->user_id}:quota:" . REDIS_API_NAME;
0 ignored issues
show
Bug Best Practice introduced by
The property redis_prefix does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
37 1
            $q = $this->db->query('SELECT * FROM api_subscription WHERE user_id = :user_id', [
38 1
                ':user_id' => $this->user->user_id()])->first();
39 1
            if ($q) {
40
                $id = $q['stripe_id'];
41 1
            } else {
42
                return;
43
            }
44
        } else {
45
            # Assume Stripe ID string
46
            $id = $arg;
47
            $q = $this->db->query('SELECT * FROM api_subscription WHERE stripe_id = :stripe_id', [
48
                ':stripe_id' => $id])->first();
49
            if ($q) {
50
                $user = new \USER();
51
                $user->init($q['user_id']);
52
                $this->user = $user;
53
                $this->redis_prefix = "user:{$this->user->user_id}:quota:" . REDIS_API_NAME;
54
            } else {
55
                return;
56
            }
57
        }
58
59 1
        try {
60 1
            $this->stripe = $this->api->getSubscription([
61
                'id' => $id,
62
                'expand' => [
63
                    'customer.default_source',
64
                    'customer.invoice_settings.default_payment_method',
65
                    'latest_invoice.payment_intent',
66
                    'schedule.phases.items.plan',
67
                ],
68
            ]);
69
        } catch (\Stripe\Exception\InvalidRequestException $e) {
70
            $this->db->query('DELETE FROM api_subscription WHERE stripe_id = :stripe_id', [':stripe_id' => $id]);
71
            $this->delete_from_redis();
72
            return;
73 1
        }
74 1
75
        $this->has_payment_data = $this->stripe->customer->default_source || $this->stripe->customer->invoice_settings->default_payment_method;
76
        if ($this->stripe->customer->invoice_settings->default_payment_method) {
77 1
            $this->card_info = $this->stripe->customer->invoice_settings->default_payment_method->card;
0 ignored issues
show
Bug Best Practice introduced by
The property card_info does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
78
        } else {
79
            $this->card_info = $this->stripe->customer->default_source;
80 1
        }
81 1
82 1
        $data = $this->stripe;
83 1
        if ($data->discount && $data->discount->coupon && $data->discount->coupon->percent_off) {
84 1
            $this->actual_paid = add_vat(floor(
0 ignored issues
show
Bug Best Practice introduced by
The property actual_paid does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
85
                $data->plan->amount * (100 - $data->discount->coupon->percent_off) / 100
86
            ));
87
            $data->plan->amount = add_vat($data->plan->amount);
88
        } else {
89
            $data->plan->amount = add_vat($data->plan->amount);
90
            $this->actual_paid = $data->plan->amount;
91 1
        }
92
93
        try {
94 1
            $this->upcoming = $this->api->getUpcomingInvoice(["customer" => $this->stripe->customer->id]);
95
        } catch (\Stripe\Exception\ApiErrorException $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
96
        }
97
    }
98
99
    private function update_subscription($form_data) {
100
        if ($form_data['payment_method']) {
101
            $this->update_payment_method($form_data['payment_method']);
102
        }
103
104
        foreach ($this::$plans as $i => $plan) {
105
            if ($plan == $form_data['plan']) {
106
                $new_price = $this::$prices[$i];
107
                if ($form_data['coupon'] == 'charitable100') {
108
                    $new_price = 0;
109
                } elseif ($form_data['coupon'] == 'charitable50') {
110
                    $new_price /= 2;
111
                }
112
            }
113
            if ($plan == $this->stripe->plan->id) {
114
                $old_price = $this::$prices[$i];
115
                if ($this->stripe->discount && ($coupon = $this->stripe->discount->coupon)) {
116
                    if ($coupon->percent_off == 100) {
117
                        $old_price = 0;
118
                    } elseif ($coupon->percent_off == 50) {
119
                        $old_price /= 2;
120
                    }
121
                }
122
            }
123
        }
124
125
        if ($old_price >= $new_price) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $old_price does not seem to be defined for all execution paths leading up to this point.
Loading history...
Comprehensibility Best Practice introduced by
The variable $new_price does not seem to be defined for all execution paths leading up to this point.
Loading history...
126
            if ($this->stripe->schedule) {
127
                $this->api->releaseSchedule($this->stripe->schedule->id);
128
            }
129
            $schedule = $this->api->createSchedule($this->stripe->id);
130
            $phases = [
131 1
                [
132 1
                    'items' => [['price' => $schedule->phases[0]->items[0]->price]],
133 1
                    'start_date' => $schedule->phases[0]->start_date,
134
                    'end_date' => $schedule->phases[0]->end_date,
135 1
                    'proration_behavior' => 'none',
136 1
                    'default_tax_rates' => [STRIPE_TAX_RATE],
0 ignored issues
show
Bug introduced by
The constant MySociety\TheyWorkForYou\STRIPE_TAX_RATE was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
137 1
                ],
138
                [
139
                    'items' => [['price' => $form_data['plan']]],
140
                    'iterations' => 1,
141
                    'metadata' => $form_data['metadata'],
142
                    'proration_behavior' => 'none',
143
                    'default_tax_rates' => [STRIPE_TAX_RATE],
144
                ],
145
            ];
146
            if ($schedule->phases[0]->discounts && $schedule->phases[0]->discounts[0]->coupon) {
147
                $phases[0]['discounts'] = [['coupon' => $schedule->phases[0]->discounts[0]->coupon]];
148
            }
149
            if ($form_data['coupon']) {
150
                $phases[1]['coupon'] = $form_data['coupon'];
151
            }
152
            $this->api->updateSchedule($schedule->id, $phases);
153
        }
154
155
        if ($old_price < $new_price) {
156
            $args = [
157
                'payment_behavior' => 'allow_incomplete',
158
                'plan' => $form_data['plan'],
159
                'metadata' => $form_data['metadata'],
160
                'cancel_at_period_end' => false, # Needed in Stripe 2018-02-28
161
                'proration_behavior' => 'always_invoice',
162
            ];
163
            if ($form_data['coupon']) {
164
                $args['coupon'] = $form_data['coupon'];
165
            } elseif ($this->stripe->discount) {
166
                $args['coupon'] = '';
167
            }
168
            if ($this->stripe->schedule) {
169
                $this->api->releaseSchedule($this->stripe->schedule->id);
170
            }
171
            $this->api->updateSubscription($this->stripe->id, $args);
172
        }
173
    }
174
175
    private function update_customer($args) {
176
        $this->api->updateCustomer($this->stripe->customer->id, $args);
177
    }
178
179
    public function update_email($email) {
180
        $this->update_customer([ 'email' => $email ]);
181
    }
182
183
    public function update_payment_method($payment_method) {
184
        $payment_method = \Stripe\PaymentMethod::retrieve($payment_method);
185
        $payment_method->attach(['customer' => $this->stripe->customer->id]);
186
        $this->update_customer([
187
            'invoice_settings' => [
188
                'default_payment_method' => $payment_method,
189
            ],
190
        ]);
191
    }
192
193
    private function add_subscription($form_data) {
194
        # Create new Stripe customer and subscription
195
        $cust_params = ['email' => $this->user->email()];
196
        if ($form_data['stripeToken']) {
197
            $cust_params['source'] = $form_data['stripeToken'];
198
        }
199
200
        # At the point the customer is created, details such as postcode and
201
        # security code can be checked, and therefore fail
202
        try {
203
            $obj = $this->api->createCustomer($cust_params);
204
        } catch (\Stripe\Exception\CardException $e) {
205
            $body = $e->getJsonBody();
206
            $err  = $body['error'];
207
            $error = 'Sorry, we could not process your payment, please try again. ';
208
            $error .= 'Our payment processor returned: ' . $err['message'];
209
            unset($_POST['stripeToken']); # So card form is shown again
210
            return [ $error ];
211
        }
212
213
        $customer = $obj->id;
214
215
        if (!$form_data['stripeToken'] && !($form_data['plan'] == $this::$plans[0] && $form_data['coupon'] == 'charitable100')) {
216
            exit(1); # Should never reach here!
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
217
        }
218
219
        $obj = $this->api->createSubscription([
220
            'payment_behavior' => 'allow_incomplete',
221
            'expand' => ['latest_invoice.payment_intent'],
222
            'default_tax_rates' => [STRIPE_TAX_RATE],
0 ignored issues
show
Bug introduced by
The constant MySociety\TheyWorkForYou\STRIPE_TAX_RATE was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
223
            'customer' => $customer,
224
            'plan' => $form_data['plan'],
225
            'coupon' => $form_data['coupon'],
226
            'metadata' => $form_data['metadata'],
227
        ]);
228
        $stripe_id = $obj->id;
229
230
        $this->db->query('INSERT INTO api_subscription (user_id, stripe_id) VALUES (:user_id, :stripe_id)', [
231
            ':user_id' => $this->user->user_id(),
232
            ':stripe_id' => $stripe_id,
233
        ]);
234
    }
235
236
    public function cancel_subscription() {
237
        if ($this->stripe->schedule) {
238
            $this->api->releaseSchedule($this->stripe->schedule->id);
239
        }
240
        $this->api->updateSubscription($this->stripe->id, ['cancel_at_period_end' => true]);
241
    }
242
243
    public function invoices() {
244
        $invoices = $this->api->getInvoices([
245
            'subscription' => $this->stripe->id,
246
            'limit' => 24,
247
        ]);
248
        $invoices = $invoices->data;
249
        return $invoices;
250
    }
251
252
    private function getFields() {
253
        $fields = ['plan', 'charitable_tick', 'charitable', 'charity_number', 'description', 'tandcs_tick', 'stripeToken', 'payment_method'];
254
        $this->form_data = [];
0 ignored issues
show
Bug Best Practice introduced by
The property form_data does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
255
        foreach ($fields as $field) {
256
            $this->form_data[$field] = get_http_var($field);
257
        }
258
    }
259
260
    private function checkValidPlan() {
261
        return ($this->form_data['plan'] && in_array($this->form_data['plan'], $this::$plans));
262
    }
263
264
    private function checkPaymentGivenIfNeeded() {
265
        $payment_data = $this->form_data['stripeToken'] || $this->form_data['payment_method'];
266
        return ($this->has_payment_data || $payment_data || (
267
            $this->form_data['plan'] == $this::$plans[0]
268
                && in_array($this->form_data['charitable'], ['c', 'i'])
269
        ));
270
    }
271
272
    public function checkForErrors() {
273
        $this->getFields();
274
        $form_data = &$this->form_data;
275
276
        $errors = [];
277
        if ($form_data['charitable'] && !in_array($form_data['charitable'], ['c', 'i', 'o'])) {
278
            $form_data['charitable'] = '';
279
        }
280
281
        if (!$this->checkValidPlan()) {
282
            $errors[] = 'Please pick a plan';
283
        }
284
285
        if (!$this->checkPaymentGivenIfNeeded()) {
286
            $errors[] = 'You need to submit payment';
287
        }
288
289
        if (!$this->stripe && !$form_data['tandcs_tick']) {
290
            $errors[] = 'Please agree to the terms and conditions';
291
        }
292
293
        if (!$form_data['charitable_tick']) {
294
            $form_data['charitable'] = '';
295
            $form_data['charity_number'] = '';
296
            $form_data['description'] = '';
297
            return $errors;
298
        }
299
300
        if ($form_data['charitable'] == 'c' && !$form_data['charity_number']) {
301
            $errors[] = 'Please provide your charity number';
302
        }
303
        if ($form_data['charitable'] == 'i' && !$form_data['description']) {
304
            $errors[] = 'Please provide details of your project';
305
        }
306
307
        return $errors;
308
    }
309
310
    public function createOrUpdateFromForm() {
311
        $form_data = $this->form_data;
312
313
        $form_data['coupon'] = null;
314
        if (in_array($form_data['charitable'], ['c', 'i'])) {
315
            $form_data['coupon'] = 'charitable50';
316
            if ($form_data['plan'] == $this::$plans[0]) {
317
                $form_data['coupon'] = 'charitable100';
318
            }
319
        }
320
321
        $form_data['metadata'] = [
322
            'charitable' => $form_data['charitable'],
323
            'charity_number' => $form_data['charity_number'],
324
            'description' => $form_data['description'],
325
        ];
326
327
        if ($this->stripe) {
328
            $this->update_subscription($form_data);
329
        } else {
330
            return $this->add_subscription($form_data);
331
        }
332
    }
333
334
    public function redis_update_max($plan) {
335
        preg_match('#^twfy-(\d+)k#', $plan, $m);
336
        $max = $m[1] * 1000;
337
        $this->redis->set("$this->redis_prefix:max", $max);
0 ignored issues
show
Bug introduced by
The method set() does not exist on MySociety\TheyWorkForYou\Redis. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

337
        $this->redis->/** @scrutinizer ignore-call */ 
338
                      set("$this->redis_prefix:max", $max);
Loading history...
338
        $this->redis->del("$this->redis_prefix:blocked");
0 ignored issues
show
Bug introduced by
The method del() does not exist on MySociety\TheyWorkForYou\Redis. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

338
        $this->redis->/** @scrutinizer ignore-call */ 
339
                      del("$this->redis_prefix:blocked");
Loading history...
339
    }
340
341
    public function redis_reset_quota() {
342
        $count = $this->redis->getset("$this->redis_prefix:count", 0);
0 ignored issues
show
Bug introduced by
The method getset() does not exist on MySociety\TheyWorkForYou\Redis. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

342
        /** @scrutinizer ignore-call */ 
343
        $count = $this->redis->getset("$this->redis_prefix:count", 0);
Loading history...
343
        if ($count !== null) {
344
            $this->redis->rpush("$this->redis_prefix:history", $count);
0 ignored issues
show
Bug introduced by
The method rpush() does not exist on MySociety\TheyWorkForYou\Redis. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

344
            $this->redis->/** @scrutinizer ignore-call */ 
345
                          rpush("$this->redis_prefix:history", $count);
Loading history...
345
        }
346
        $this->redis->del("$this->redis_prefix:blocked");
347
    }
348
349
    public function delete_from_redis() {
350
        $this->redis->del("$this->redis_prefix:max");
351
        $this->redis->del("$this->redis_prefix:count");
352
        $this->redis->del("$this->redis_prefix:blocked");
353
    }
354
355
    public function quota_status() {
356
        return [
357
            'count' => floor($this->redis->get("$this->redis_prefix:count")),
0 ignored issues
show
Bug introduced by
The method get() does not exist on MySociety\TheyWorkForYou\Redis. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

357
            'count' => floor($this->redis->/** @scrutinizer ignore-call */ get("$this->redis_prefix:count")),
Loading history...
358
            'blocked' => floor($this->redis->get("$this->redis_prefix:blocked")),
359
            'quota' => floor($this->redis->get("$this->redis_prefix:max")),
360
            'history' => $this->redis->lrange("$this->redis_prefix:history", 0, -1),
0 ignored issues
show
Bug introduced by
The method lrange() does not exist on MySociety\TheyWorkForYou\Redis. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

360
            'history' => $this->redis->/** @scrutinizer ignore-call */ lrange("$this->redis_prefix:history", 0, -1),
Loading history...
361
        ];
362
    }
363
}
364