Subscription::update_customer()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 1
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 2
ccs 0
cts 1
cp 0
crap 2
rs 10
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($id, [
61
                'expand' => [
62
                    'customer.default_source',
63
                    'customer.invoice_settings.default_payment_method',
64
                    'latest_invoice.payment_intent',
65
                    'schedule.phases.items.plan',
66
                ],
67
            ]);
68
        } catch (\Stripe\Exception\InvalidRequestException $e) {
69
            $this->db->query('DELETE FROM api_subscription WHERE stripe_id = :stripe_id', [':stripe_id' => $id]);
70
            $this->delete_from_redis();
71
            return;
72
        }
73 1
74 1
        $this->has_payment_data = $this->stripe->customer->default_source || $this->stripe->customer->invoice_settings->default_payment_method;
75
        if ($this->stripe->customer->invoice_settings->default_payment_method) {
76
            $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...
77 1
        } else {
78
            $this->card_info = $this->stripe->customer->default_source;
79
        }
80 1
81 1
        $data = $this->stripe;
82 1
        if ($data->discount && $data->discount->coupon && $data->discount->coupon->percent_off) {
83 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...
84 1
                $data->plan->amount * (100 - $data->discount->coupon->percent_off) / 100
85
            ));
86
            $data->plan->amount = add_vat($data->plan->amount);
87
        } else {
88
            $data->plan->amount = add_vat($data->plan->amount);
89
            $this->actual_paid = $data->plan->amount;
90
        }
91 1
92
        try {
93
            $this->upcoming = $this->api->getUpcomingInvoice(["customer" => $this->stripe->customer->id]);
94 1
        } 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...
95
        }
96
    }
97
98
    private function update_subscription($form_data) {
99
        if ($form_data['payment_method']) {
100
            $this->update_payment_method($form_data['payment_method']);
101
        }
102
103
        foreach ($this::$plans as $i => $plan) {
104
            if ($plan == $form_data['plan']) {
105
                $new_price = $this::$prices[$i];
106
                if ($form_data['coupon'] == 'charitable100') {
107
                    $new_price = 0;
108
                } elseif ($form_data['coupon'] == 'charitable50') {
109
                    $new_price /= 2;
110
                }
111
            }
112
            if ($plan == $this->stripe->plan->id) {
113
                $old_price = $this::$prices[$i];
114
                if ($this->stripe->discount && ($coupon = $this->stripe->discount->coupon)) {
115
                    if ($coupon->percent_off == 100) {
116
                        $old_price = 0;
117
                    } elseif ($coupon->percent_off == 50) {
118
                        $old_price /= 2;
119
                    }
120
                }
121
            }
122
        }
123
124
        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...
125
            if ($this->stripe->schedule) {
126
                $this->api->releaseSchedule($this->stripe->schedule->id);
127
            }
128
            $schedule = $this->api->createSchedule($this->stripe->id);
129
            $phases = [
130
                [
131 1
                    'items' => [['price' => $schedule->phases[0]->items[0]->price]],
132 1
                    'start_date' => $schedule->phases[0]->start_date,
133 1
                    'end_date' => $schedule->phases[0]->end_date,
134
                    'proration_behavior' => 'none',
135 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...
136 1
                ],
137 1
                [
138
                    'items' => [['price' => $form_data['plan']]],
139
                    'iterations' => 1,
140
                    'metadata' => $form_data['metadata'],
141
                    'proration_behavior' => 'none',
142
                    'default_tax_rates' => [STRIPE_TAX_RATE],
143
                ],
144
            ];
145
            if ($schedule->phases[0]->discounts && $schedule->phases[0]->discounts[0]->coupon) {
146
                $phases[0]['discounts'] = [['coupon' => $schedule->phases[0]->discounts[0]->coupon]];
147
            }
148
            if ($form_data['coupon']) {
149
                $phases[1]['coupon'] = $form_data['coupon'];
150
            }
151
            $this->api->updateSchedule($schedule->id, $phases);
152
        }
153
154
        if ($old_price < $new_price) {
155
            $args = [
156
                'payment_behavior' => 'allow_incomplete',
157
                'plan' => $form_data['plan'],
158
                'metadata' => $form_data['metadata'],
159
                'cancel_at_period_end' => false, # Needed in Stripe 2018-02-28
160
                'proration_behavior' => 'always_invoice',
161
            ];
162
            if ($form_data['coupon']) {
163
                $args['coupon'] = $form_data['coupon'];
164
            } elseif ($this->stripe->discount) {
165
                $args['coupon'] = '';
166
            }
167
            if ($this->stripe->schedule) {
168
                $this->api->releaseSchedule($this->stripe->schedule->id);
169
            }
170
            $this->api->updateSubscription($this->stripe->id, $args);
171
        }
172
    }
173
174
    private function update_customer($args) {
175
        $this->api->updateCustomer($this->stripe->customer->id, $args);
176
    }
177
178
    public function update_email($email) {
179
        $this->update_customer([ 'email' => $email ]);
180
    }
181
182
    public function update_payment_method($payment_method) {
183
        $payment_method = $this->api->client->paymentMethods->retrieve($payment_method);
184
        $payment_method->attach(['customer' => $this->stripe->customer->id]);
185
        $this->update_customer([
186
            'invoice_settings' => [
187
                'default_payment_method' => $payment_method,
188
            ],
189
        ]);
190
    }
191
192
    private function add_subscription($form_data) {
193
        # Create new Stripe customer and subscription
194
        $cust_params = ['email' => $this->user->email()];
195
        if ($form_data['stripeToken']) {
196
            $cust_params['source'] = $form_data['stripeToken'];
197
        }
198
199
        # At the point the customer is created, details such as postcode and
200
        # security code can be checked, and therefore fail
201
        try {
202
            $obj = $this->api->createCustomer($cust_params);
203
        } catch (\Stripe\Exception\CardException $e) {
204
            $body = $e->getJsonBody();
205
            $err  = $body['error'];
206
            $error = 'Sorry, we could not process your payment, please try again. ';
207
            $error .= 'Our payment processor returned: ' . $err['message'];
208
            unset($_POST['stripeToken']); # So card form is shown again
209
            return [ $error ];
210
        }
211
212
        $customer = $obj->id;
213
214
        if (!$form_data['stripeToken'] && !($form_data['plan'] == $this::$plans[0] && $form_data['coupon'] == 'charitable100')) {
215
            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...
216
        }
217
218
        $obj = $this->api->createSubscription([
219
            'payment_behavior' => 'allow_incomplete',
220
            'expand' => ['latest_invoice.payment_intent'],
221
            '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...
222
            'customer' => $customer,
223
            'plan' => $form_data['plan'],
224
            'coupon' => $form_data['coupon'],
225
            'metadata' => $form_data['metadata'],
226
        ]);
227
        $stripe_id = $obj->id;
228
229
        $this->db->query('INSERT INTO api_subscription (user_id, stripe_id) VALUES (:user_id, :stripe_id)', [
230
            ':user_id' => $this->user->user_id(),
231
            ':stripe_id' => $stripe_id,
232
        ]);
233
    }
234
235
    public function cancel_subscription() {
236
        if ($this->stripe->schedule) {
237
            $this->api->releaseSchedule($this->stripe->schedule->id);
238
        }
239
        $this->api->updateSubscription($this->stripe->id, ['cancel_at_period_end' => true]);
240
    }
241
242
    public function invoices() {
243
        $invoices = $this->api->getInvoices([
244
            'subscription' => $this->stripe->id,
245
            'limit' => 24,
246
        ]);
247
        $invoices = $invoices->data;
248
        return $invoices;
249
    }
250
251
    private function getFields() {
252
        $fields = ['plan', 'charitable_tick', 'charitable', 'charity_number', 'description', 'tandcs_tick', 'stripeToken', 'payment_method'];
253
        $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...
254
        foreach ($fields as $field) {
255
            $this->form_data[$field] = get_http_var($field);
256
        }
257
    }
258
259
    private function checkValidPlan() {
260
        return ($this->form_data['plan'] && in_array($this->form_data['plan'], $this::$plans));
261
    }
262
263
    private function checkPaymentGivenIfNeeded() {
264
        $payment_data = $this->form_data['stripeToken'] || $this->form_data['payment_method'];
265
        return ($this->has_payment_data || $payment_data || (
266
            $this->form_data['plan'] == $this::$plans[0]
267
                && in_array($this->form_data['charitable'], ['c', 'i'])
268
        ));
269
    }
270
271
    public function checkForErrors() {
272
        $this->getFields();
273
        $form_data = &$this->form_data;
274
275
        $errors = [];
276
        if ($form_data['charitable'] && !in_array($form_data['charitable'], ['c', 'i', 'o'])) {
277
            $form_data['charitable'] = '';
278
        }
279
280
        if (!$this->checkValidPlan()) {
281
            $errors[] = 'Please pick a plan';
282
        }
283
284
        if (!$this->checkPaymentGivenIfNeeded()) {
285
            $errors[] = 'You need to submit payment';
286
        }
287
288
        if (!$this->stripe && !$form_data['tandcs_tick']) {
289
            $errors[] = 'Please agree to the terms and conditions';
290
        }
291
292
        if (!$form_data['charitable_tick']) {
293
            $form_data['charitable'] = '';
294
            $form_data['charity_number'] = '';
295
            $form_data['description'] = '';
296
            return $errors;
297
        }
298
299
        if ($form_data['charitable'] == 'c' && !$form_data['charity_number']) {
300
            $errors[] = 'Please provide your charity number';
301
        }
302
        if ($form_data['charitable'] == 'i' && !$form_data['description']) {
303
            $errors[] = 'Please provide details of your project';
304
        }
305
306
        return $errors;
307
    }
308
309
    public function createOrUpdateFromForm() {
310
        $form_data = $this->form_data;
311
312
        $form_data['coupon'] = null;
313
        if (in_array($form_data['charitable'], ['c', 'i'])) {
314
            $form_data['coupon'] = 'charitable50';
315
            if ($form_data['plan'] == $this::$plans[0]) {
316
                $form_data['coupon'] = 'charitable100';
317
            }
318
        }
319
320
        $form_data['metadata'] = [
321
            'charitable' => $form_data['charitable'],
322
            'charity_number' => $form_data['charity_number'],
323
            'description' => $form_data['description'],
324
        ];
325
326
        if ($this->stripe) {
327
            $this->update_subscription($form_data);
328
        } else {
329
            return $this->add_subscription($form_data);
330
        }
331
    }
332
333
    public function redis_update_max($plan) {
334
        preg_match('#^twfy-(\d+)k#', $plan, $m);
335
        $max = $m[1] * 1000;
336
        $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

336
        $this->redis->/** @scrutinizer ignore-call */ 
337
                      set("$this->redis_prefix:max", $max);
Loading history...
337
        $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

337
        $this->redis->/** @scrutinizer ignore-call */ 
338
                      del("$this->redis_prefix:blocked");
Loading history...
338
    }
339
340
    public function redis_reset_quota() {
341
        $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

341
        /** @scrutinizer ignore-call */ 
342
        $count = $this->redis->getset("$this->redis_prefix:count", 0);
Loading history...
342
        if ($count !== null) {
343
            $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

343
            $this->redis->/** @scrutinizer ignore-call */ 
344
                          rpush("$this->redis_prefix:history", $count);
Loading history...
344
        }
345
        $this->redis->del("$this->redis_prefix:blocked");
346
    }
347
348
    public function delete_from_redis() {
349
        $this->redis->del("$this->redis_prefix:max");
350
        $this->redis->del("$this->redis_prefix:count");
351
        $this->redis->del("$this->redis_prefix:blocked");
352
    }
353
354
    public function quota_status() {
355
        return [
356
            '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

356
            'count' => floor($this->redis->/** @scrutinizer ignore-call */ get("$this->redis_prefix:count")),
Loading history...
357
            'blocked' => floor($this->redis->get("$this->redis_prefix:blocked")),
358
            'quota' => floor($this->redis->get("$this->redis_prefix:max")),
359
            '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

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