mysociety /
theyworkforyou
| 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
Loading history...
|
|||||||
| 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
Loading history...
|
|||||||
| 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
Loading history...
|
|||||||
| 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
Loading history...
|
|||||||
| 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
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
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
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
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
Loading history...
|
|||||||
| 360 | ]; |
||||||
| 361 | } |
||||||
| 362 | } |
||||||
| 363 |