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
![]() |
|||||||
26 | 1 | $this->redis = new Redis(); |
|||||
0 ignored issues
–
show
|
|||||||
27 | 1 | if (defined('TESTING')) { |
|||||
28 | $this->api = new TestStripe(""); |
||||||
0 ignored issues
–
show
|
|||||||
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
|
|||||||
36 | 1 | $this->redis_prefix = "user:{$this->user->user_id}:quota:" . REDIS_API_NAME; |
|||||
0 ignored issues
–
show
|
|||||||
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
|
|||||||
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
|
|||||||
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
|
|||||||
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
Comprehensibility
Best Practice
introduced
by
|
|||||||
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
|
|||||||
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
|
|||||||
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
|
|||||||
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
|
|||||||
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
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
![]() |
|||||||
337 | $this->redis->del("$this->redis_prefix:blocked"); |
||||||
0 ignored issues
–
show
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
![]() |
|||||||
338 | } |
||||||
339 | |||||||
340 | public function redis_reset_quota() { |
||||||
341 | $count = $this->redis->getset("$this->redis_prefix:count", 0); |
||||||
0 ignored issues
–
show
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
![]() |
|||||||
342 | if ($count !== null) { |
||||||
343 | $this->redis->rpush("$this->redis_prefix:history", $count); |
||||||
0 ignored issues
–
show
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
![]() |
|||||||
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
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
![]() |
|||||||
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
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
![]() |
|||||||
360 | ]; |
||||||
361 | } |
||||||
362 | } |
||||||
363 |