MollieVendor.php 17.1 KB
Newer Older
1
2
3
4
<?php

namespace Kudos\Service\Vendor;

5
use Kudos\Entity\DonorEntity;
6
7
8
9
use Kudos\Entity\SubscriptionEntity;
use Kudos\Entity\TransactionEntity;
use Kudos\Helpers\Settings;
use Kudos\Helpers\Utils;
10
use Kudos\Service\LoggerService;
11
use Kudos\Service\MapperService;
12
use Kudos\Service\RestRouteService;
13
14
use Mollie\Api\Exceptions\ApiException;
use Mollie\Api\MollieApiClient;
15
use Mollie\Api\Resources\BaseCollection;
16
use Mollie\Api\Resources\Customer;
17
use Mollie\Api\Resources\MethodCollection;
18
19
20
21
22
23
24
use Mollie\Api\Resources\Payment;
use Mollie\Api\Resources\Subscription;
use Mollie\Api\Resources\SubscriptionCollection;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;

25
class MollieVendor implements VendorInterface {
26

Michael Iseard's avatar
Michael Iseard committed
27
	/**
28
	 * This is the name of the vendor as displayed to the user.
Michael Iseard's avatar
Michael Iseard committed
29
	 */
30
	const VENDOR_NAME = 'Mollie';
Michael Iseard's avatar
Michael Iseard committed
31

32
33
34
35
36
	/**
	 * Instance of MollieApiClient
	 *
	 * @var MollieApiClient
	 */
37
	private $api_client;
38
39
40
	/**
	 * The API mode (test or live)
	 *
41
	 * @var string
42
43
	 */
	private $api_mode;
44
45
46
47
48
49
50
51
	/**
	 * @var \Kudos\Service\LoggerService
	 */
	private $logger;
	/**
	 * @var \Kudos\Service\MapperService
	 */
	private $mapper;
52
53
54
55
	/**
	 * @var array
	 */
	private $api_keys;
56
57
58
59

	/**
	 * Mollie constructor.
	 */
60
	public function __construct( MapperService $mapper_service, LoggerService $logger_service ) {
61
62
63

		$this->logger = $logger_service;
		$this->mapper = $mapper_service;
64

65
		$settings = Settings::get_setting( 'vendor_mollie' );
66

67
		$this->api_client = new MollieApiClient();
68
69
70
71
		$this->api_keys   = [
			'test' => $settings['test_key'] ?? '',
			'live' => $settings['live_key'] ?? '',
		];
72

Michael Iseard's avatar
Michael Iseard committed
73
		$this->set_api_mode( $settings['mode'] );
74
75
76
77
78
	}

	/**
	 * Change the API client to the key for the specified mode.
	 */
Michael Iseard's avatar
Michael Iseard committed
79
	private function set_api_mode( ?string $mode ) {
80

Michael Iseard's avatar
Michael Iseard committed
81
		$key = $this->api_keys[ $mode ] ?? false;
82

Michael Iseard's avatar
Michael Iseard committed
83
84
85
		if ( $key ) {
			try {
				$this->api_client->setApiKey( $key );
86
				$this->api_mode = $mode;
Michael Iseard's avatar
Michael Iseard committed
87
			} catch ( ApiException $e ) {
88
89
				$this->logger->critical( $e->getMessage() );
			}
90

91
92
93
		}
	}

94
95
96
97
	public static function get_vendor_name(): string {
		return static::VENDOR_NAME;
	}

98
	/**
99
	 * Check the Mollie api keys for both test and live keys. Sends a JSON response.
100
101
102
103
104
105
106
107
108
	 */
	public function check_api_keys() {

		Settings::update_array( 'vendor_mollie',
			[
				'connected' => false,
				'recurring' => false,
			] );

109
110
		$modes    = [ "test", "live" ];
		$api_keys = $this->api_keys;
111

112
113
114
115
116
117
118
119
120
121
122
123
124
125
		// Check that the api key corresponds to each mode.
		foreach ( $modes as $mode ) {
			$api_key = $api_keys[ $mode ];
			if ( substr( $api_key, 0, 5 ) !== $mode . "_" ) {
				wp_send_json_error(
					[
						/* translators: %s: API mode */
						'message' => sprintf( __( '%1$s API key should begin with %2$s', 'kudos-donations' ),
							ucfirst( $mode ),
							$mode . '_' ),
						'setting' => Settings::get_setting( 'vendor_mollie' ),
					]
				);
			}
126

127
128
129
130
131
132
133
134
135
136
137
138
139
140
			// Test the api key.
			if ( ! $this->refresh_api_connection( $api_key ) ) {
				wp_send_json_error(
					[
						/* translators: %s: API mode */
						'message' => sprintf( __( 'Error connecting with Mollie, please check the %s API key and try again.',
							'kudos-donations' ),
							ucfirst( $mode ) ),
						'setting' => Settings::get_setting( 'vendor_mollie' ),
					]
				);
			}
		}
		// Update vendor settings.
141
142
		Settings::update_array( 'vendor_mollie',
			[
143
144
145
146
147
148
149
150
151
152
				'recurring'       => $this->can_use_recurring(),
				'connected'       => true,
				'payment_methods' => array_map( function ( $method ) {
					return [
						'id'            => $method->id,
						'status'        => $method->status,
						'maximumAmount' => (array) $method->maximumAmount,
					];
				},
					(array) $this->get_payment_methods() ),
153
154
			] );

155
		wp_send_json_success(
156
			[
157
				'message' =>
158
				/* translators: %s: API mode */
159
					__( 'API connection was successful!', 'kudos-donations' ),
160
161
162
163
164
				'setting' => Settings::get_setting( 'vendor_mollie' ),
			]
		);
	}

165
166
167
168
169
170
171
172
173
	/**
	 * Returns all subscriptions for customer
	 *
	 * @param string $customer_id Mollie customer id.
	 *
	 * @return SubscriptionCollection|false
	 */
	public function get_subscriptions( string $customer_id ) {

174
		$mollie_api = $this->api_client;
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190

		try {
			$customer = $mollie_api->customers->get( $customer_id );

			return $customer->subscriptions();
		} catch ( ApiException $e ) {
			$this->logger->critical( $e->getMessage() );

			return false;
		}

	}

	/**
	 * Cancel the specified subscription
	 *
191
	 * @param SubscriptionEntity $subscription Instance of SubscriptionEntity.
192
193
194
	 *
	 * @return bool
	 */
195
	public function cancel_subscription( SubscriptionEntity $subscription ): bool {
196

197
		$customer_id     = $subscription->customer_id;
198
		$subscription_id = $subscription->subscription_id;
199
200

		$customer = $this->get_customer( $customer_id );
201

202
203
		// Bail if no subscription found locally or if not active.
		if ( 'active' !== $subscription->status || null === $customer ) {
Michael Iseard's avatar
Michael Iseard committed
204
205
206
			return false;
		}

207
		// Cancel the subscription via Mollie's API.
Michael Iseard's avatar
Michael Iseard committed
208
		$response = $customer->cancelSubscription( $subscription_id );
209

Michael Iseard's avatar
Michael Iseard committed
210
211
		/** @var Subscription $response */
		return ( $response->status === 'canceled' );
212
213
214
215
216
217
218
219
220
	}

	/**
	 * Checks the provided api key by attempting to get associated payments
	 *
	 * @param string $api_key API key to test.
	 *
	 * @return bool
	 */
221
	public function refresh_api_connection( string $api_key ): bool {
222
223
224
225
226
227
228

		if ( ! $api_key ) {
			return false;
		}

		try {
			// Perform test call to verify api key.
229
			$mollie_api = $this->api_client;
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
			$mollie_api->setApiKey( $api_key );
			$mollie_api->payments->page();
		} catch ( ApiException $e ) {
			$this->logger->critical( $e->getMessage() );

			return false;
		}

		return true;

	}

	/**
	 * Gets specified payment
	 *
	 * @param string $mollie_payment_id Mollie payment id.
	 *
	 * @return bool|Payment
	 */
	public function get_payment( string $mollie_payment_id ) {

251
		$mollie_api = $this->api_client;
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273

		try {
			return $mollie_api->payments->get( $mollie_payment_id );
		} catch ( ApiException $e ) {
			$this->logger->critical( $e->getMessage() );
		}

		return false;

	}

	/**
	 * Create a Mollie customer.
	 *
	 * @param string $email Donor email address.
	 * @param string $name Donor name.
	 *
	 * @return bool|Customer
	 */
	public function create_customer( string $email, string $name ) {

		$customer_array = [
Michael Iseard's avatar
Michael Iseard committed
274
			'email' => $email,
275
276
277
278
279
280
281
		];

		if ( $name ) {
			$customer_array['name'] = $name;
		}

		try {
282
			return $this->api_client->customers->create( $customer_array );
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
		} catch ( ApiException $e ) {
			$this->logger->critical( $e->getMessage() );

			return false;
		}
	}

	/**
	 * Get the customer from Mollie
	 *
	 * @param $customer_id
	 *
	 * @return Customer|null
	 */
	public function get_customer( $customer_id ): ?Customer {

		try {
300
			return $this->api_client->customers->get( $customer_id );
301
302
303
304
305
306
307
308
309
310
311
312
313
		} catch ( ApiException $e ) {
			$this->logger->critical( $e->getMessage() );

			return null;
		}

	}

	/**
	 * Creates a payment and returns it as an object
	 *
	 * @param array $payment_array Parameters to pass to mollie to create a payment.
	 *
314
	 * @return null|Payment
315
316
317
318
319
	 */
	public function create_payment( array $payment_array ): ?Payment {

		try {

320
			return $this->api_client->payments->create( $payment_array );
321
322
323
324

		} catch ( ApiException $e ) {

			$this->logger->critical( $e->getMessage(), [ 'payment' => $payment_array ] );
Michael Iseard's avatar
Michael Iseard committed
325

326
			return null;
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358

		}

	}

	/**
	 * Creates a subscription based on the provided TransactionEntity
	 *
	 * @param TransactionEntity $transaction
	 * @param string $mandate_id
	 * @param string $interval
	 * @param string $years
	 *
	 * @return false|Subscription
	 */
	public function create_subscription(
		TransactionEntity $transaction,
		string $mandate_id,
		string $interval,
		string $years
	) {

		$customer_id = $transaction->customer_id;
		$start_date  = gmdate( 'Y-m-d', strtotime( '+' . $interval ) );
		$currency    = 'EUR';
		$value       = number_format( $transaction->value, 2 );

		$subscription_array = [
			'amount'      => [
				'value'    => $value,
				'currency' => $currency,
			],
359
			'webhookUrl'  => $this->get_webhook_url(),
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
			'mandateId'   => $mandate_id,
			'interval'    => $interval,
			'startDate'   => $start_date,
			'description' => sprintf(
			/* translators: %1$s: Subscription interval. %2$s: Order id. */
				__( 'Kudos Subscription (%1$s) - %2$s', 'kudos-donations' ),
				$interval,
				$transaction->order_id
			),
			'metadata'    => [
				'campaign_id' => $transaction->campaign_id,
			],
		];

		if ( 'test' === $transaction->mode ) {
			unset( $subscription_array['startDate'] );  // Disable for test mode.
		}

		if ( $years && $years > 0 ) {
			$subscription_array['times'] = Utils::get_times_from_years( $years, $interval );
		}

Michael Iseard's avatar
Michael Iseard committed
382
383
		$customer      = $this->get_customer( $customer_id );
		$valid_mandate = $this->check_mandate( $customer, $mandate_id );
384
385
386

		// Create subscription if valid mandate found
		if ( $valid_mandate ) {
Michael Iseard's avatar
Michael Iseard committed
387
			$subscription = $customer->createSubscription( $subscription_array );
388
389
390
391
392
393
394
395
396
397
398
399
400
			if ( $subscription ) {
				$kudos_subscription = new SubscriptionEntity(
					[
						'transaction_id'  => $transaction->transaction_id,
						'customer_id'     => $customer_id,
						'frequency'       => $interval,
						'years'           => $years,
						'value'           => $value,
						'currency'        => $currency,
						'subscription_id' => $subscription->id,
						'status'          => $subscription->status,
					]
				);
401
				$this->mapper->save( $kudos_subscription );
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429

				return $subscription;
			}

			$this->logger->error( 'Failed to create subscription', [ $transaction ] );

			return false;
		}

		// No valid mandates
		$this->logger->error(
			'Cannot create subscription as customer has no valid mandates.',
			[ $customer_id ]
		);

		return false;
	}

	/**
	 * Check the provided customer for valid mandates
	 *
	 * @param Customer $customer
	 * @param string $mandate_id
	 *
	 * @return bool
	 */
	private function check_mandate( Customer $customer, string $mandate_id ): bool {

Michael Iseard's avatar
Michael Iseard committed
430
		$mandate = $customer->getMandate( $mandate_id );
431
432
433
434
435
436
437
438

		if ( $mandate->isValid() || $mandate->isPending() ) {
			return true;
		}

		return false;
	}

439
440
441
442
443
444
445
446
	/**
	 * Uses get_payment_methods to determine if account can receive recurring payments.
	 *
	 * @return bool
	 * @since 2.3.9
	 */
	public function can_use_recurring(): bool {

447
		$methods = $this->get_payment_methods( [
Michael Iseard's avatar
Michael Iseard committed
448
449
			'sequenceType' => 'recurring',
		] );
450

Michael Iseard's avatar
Michael Iseard committed
451
		if ( $methods ) {
452
453
454
455
			return $methods->count > 0;
		}

		return false;
456
457
458

	}

459
460
461
	/**
	 * Gets a list of payment methods for the current Mollie account
	 *
462
	 * @param array $options https://docs.mollie.com/reference/v2/methods-api/list-methods
463
464
465
	 *
	 * @return BaseCollection|MethodCollection|null
	 */
466
	public function get_payment_methods( array $options = [] ) {
467
468
469

		try {

470
			return $this->api_client->methods->allActive( $options );
471

472
		} catch ( ApiException $e ) {
473
			$this->logger->critical( $e->getMessage() );
474
475
476
477
478
479

			return null;
		}

	}

480
481
482
483
484
485
486
487
488
489
	/**
	 * Mollie webhook action
	 *
	 * @param WP_REST_Request $request Request array.
	 *
	 * @return WP_Error|WP_REST_Response
	 * @since    1.0.0
	 */
	public function rest_webhook( WP_REST_Request $request ) {

490
		// ID is case-sensitive (e.g: tr_HUW39xpdFN).
491
		$id = $request->get_param( 'id' );
492
493
494
495
496
497
498
499
500
501
502
503
504
505

		/**
		 * @link https://developer.wordpress.org/reference/functions/wp_send_json_success/
		 */
		$response = rest_ensure_response(
			[
				'success' => true,
				'id'      => $id,
			]
		);

		$response->add_link( 'self', rest_url( $request->get_route() ) );

		/**
Michael Iseard's avatar
Michael Iseard committed
506
		 * Get the payment object from Mollie.
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
		 *
		 * @var Payment $payment Mollie payment object.
		 */
		$payment = $this->get_payment( $id );
		$this->logger->info(
			"Webhook requested by $this.",
			[
				'transaction_id' => $id,
				'status'         => $payment->status,
				'sequence_type'  => $payment->sequenceType,
			]
		);

		/**
		 *
		 * To not leak any information to malicious third parties, it is recommended
		 * to return a 200 OK response even if the ID is not known to your system.
		 *
		 * @link https://docs.mollie.com/guides/webhooks#how-to-handle-unknown-ids
		 */
		if ( null === $payment ) {
			return $response;
		}

		//Create webhook action.
Michael Iseard's avatar
Michael Iseard committed
532
		do_action( 'kudos_mollie_webhook_requested', $payment );
533
534
535
536
537
538
539

		//Get required data from payment object.
		$transaction_id = $payment->id;
		$order_id       = $payment->metadata->order_id ?? Utils::generate_id( 'kdo_' );
		$amount         = $payment->amount;

		// Get transaction from database.
540
		/** @var TransactionEntity $transaction */
541
		$transaction = $this->mapper
542
			->get_repository( TransactionEntity::class )
543
			->get_one_by(
544
545
546
547
548
549
				[
					'order_id'       => $order_id,
					'transaction_id' => $transaction_id,
				],
				'OR'
			);
550
551
552

		// Create new transaction if none found.
		if ( null === $transaction ) {
Michael Iseard's avatar
Michael Iseard committed
553
			$transaction = new TransactionEntity( [
554
				'order_id' => $order_id,
Michael Iseard's avatar
Michael Iseard committed
555
			] );
556
557
558
559
560
		}

		// Add refund if present.
		if ( $payment->hasRefunds() ) {

Michael Iseard's avatar
Michael Iseard committed
561
562
			do_action( 'kudos_mollie_refund', $order_id );

563
564
565
566
567
568
569
570
571
572
573
			$transaction->set_fields(
				[
					'refunds' => json_encode(
						[
							'refunded'  => $payment->getAmountRefunded(),
							'remaining' => $payment->getAmountRemaining(),
						]
					),
				]
			);

Michael Iseard's avatar
Michael Iseard committed
574
			$this->logger->info( 'Payment refunded.', [ 'transaction' => $transaction ] );
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597

		} else {
			// Check if status is the same (in case of multiple webhook calls).
			if ( $transaction->status === $payment->status ) {
				return $response;
			}
		}

		// Update payment.
		$transaction->set_fields(
			[
				'status'          => $payment->status,
				'transaction_id'  => $transaction_id,
				'customer_id'     => $payment->customerId,
				'value'           => $amount->value,
				'currency'        => $amount->currency,
				'sequence_type'   => $payment->sequenceType,
				'method'          => $payment->method,
				'mode'            => $payment->mode,
				'subscription_id' => $payment->subscriptionId,
			]
		);

598
		// Add campaign id to recurring payments.
599
		if ( $payment->hasSequenceTypeRecurring() ) {
600

Michael Iseard's avatar
Michael Iseard committed
601
602
			$subscription_id   = $payment->subscriptionId;
			$customer_id       = $payment->customerId;
603
604
			$customer          = $this->get_customer( $customer_id );
			$subscription_meta = $customer->getSubscription( $subscription_id )->metadata;
605
			if ( isset( $subscription_meta['campaign_id'] ) ) {
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
				$campaign_id = $subscription_meta->campaign_id;
				$transaction->set_fields(
					[
						'campaign_id' => $campaign_id,
					]
				);
			} else {
				$this->logger->info(
					'No campaign label found for recurring payment',
					[
						'customer_id'     => $customer_id,
						'subscription_id' => $subscription_id,
					]
				);
			}
		}

		// Save transaction to database.
624
		$this->mapper->save( $transaction );
625
626
627

		if ( $payment->isPaid() && ! $payment->hasRefunds() && ! $payment->hasChargebacks() ) {

Michael Iseard's avatar
Michael Iseard committed
628
			// Schedule processing for later.
Michael Iseard's avatar
Michael Iseard committed
629
			do_action( 'kudos_mollie_transaction_paid', $order_id );
Michael Iseard's avatar
Michael Iseard committed
630

631
632
633
634
635
636
637
638
639
640
641
642
643
644
			// Set up recurring payment if sequence is first.
			if ( $payment->hasSequenceTypeFirst() ) {
				$this->logger->debug( 'Creating subscription', [ $transaction ] );
				$this->create_subscription(
					$transaction,
					$payment->mandateId,
					$payment->metadata->interval,
					$payment->metadata->years
				);
			}
		}

		return $response;
	}
645
646
647
648
649
650

	/**
	 * Returns the api mode
	 *
	 * @return string
	 */
Michael Iseard's avatar
Michael Iseard committed
651
	public function get_api_mode(): string {
652
653
654
655

		return $this->api_mode;

	}
656
657
658
659
660
661
662
663
664

	/**
	 * Returns the vendor name.
	 *
	 * @return string
	 */
	public function __toString(): string {
		return self::get_vendor_name();
	}
665
666
667
668
669
670
671
672
673
674

	/**
	 * Returns the Mollie Rest URL.
	 *
	 * @return string
	 */
	public static function get_webhook_url(): string {
		$route = RestRouteService::NAMESPACE . RestRouteService::PAYMENT_WEBHOOK;

		// Use APP_URL if defined in .env file.
675
		if ( isset( $_ENV['APP_URL'] ) ) {
676
677
678
			return $_ENV['APP_URL'] . 'wp-json/' . $route;
		}

679
		// Otherwise, return normal rest URL.
680
681
		return rest_url( RestRouteService::NAMESPACE . RestRouteService::PAYMENT_WEBHOOK );
	}
682
683
684
685

	/**
	 * Syncs Mollie transactions with the local DB.
	 * Returns the number of transactions updated.
Michael Iseard's avatar
Michael Iseard committed
686
	 *
687
	 * @return int
688
689
690
	 */
	public function sync_transactions(): int {
		$updated = 0;
Michael Iseard's avatar
Michael Iseard committed
691
692
		$mapper  = $this->mapper;
		$mapper->get_repository( DonorEntity::class );
693
694
		$donors = $mapper->get_all_by();
		/** @var DonorEntity $donor */
Michael Iseard's avatar
Michael Iseard committed
695
		foreach ( $donors as $donor ) {
696
			$customer_id = $donor->customer_id;
Michael Iseard's avatar
Michael Iseard committed
697
698
			if ( $donor->mode !== $this->api_mode ) {
				$this->set_api_mode( $donor->mode );
699
			}
Michael Iseard's avatar
Michael Iseard committed
700
701
			$customer = $this->get_customer( $customer_id );
			if ( $customer ) {
702
				$payments = $customer->payments();
Michael Iseard's avatar
Michael Iseard committed
703
704
				foreach ( $payments as $payment ) {
					$amount   = $payment->amount;
705
					$order_id = $payment->metadata->order_id;
Michael Iseard's avatar
Michael Iseard committed
706
					$mapper->get_repository( TransactionEntity::class );
707
					/** @var TransactionEntity $transaction */
Michael Iseard's avatar
Michael Iseard committed
708
					$transaction = $mapper->get_one_by( [
709
						'order_id' => $order_id,
Michael Iseard's avatar
Michael Iseard committed
710
711
712
						'status'   => 'open',
					] );
					if ( $transaction ) {
713
714
715
716
717
718
719
720
721
722
723
724
						$transaction->set_fields(
							[
								'status'          => $payment->status,
								'customer_id'     => $payment->customerId,
								'value'           => $amount->value,
								'currency'        => $amount->currency,
								'sequence_type'   => $payment->sequenceType,
								'method'          => $payment->method,
								'mode'            => $payment->mode,
								'subscription_id' => $payment->subscriptionId,
							]
						);
Michael Iseard's avatar
Michael Iseard committed
725
726
						$mapper->save( $transaction );
						$updated ++;
727
728
729
730
					}
				}
			}
		}
Michael Iseard's avatar
Michael Iseard committed
731

732
733
		return $updated;
	}
734
}