diff --git a/docs/core/reference/payments.md b/docs/core/reference/payments.md index c4e0479dca..ce56261e1a 100644 --- a/docs/core/reference/payments.md +++ b/docs/core/reference/payments.md @@ -330,6 +330,20 @@ $order->charges; // Get all transactions that are charges. $order->refunds; // Get all transactions that are refunds. ``` +### Payment Checks + +Some providers return information based on checks that can occur before payment is validated and completed. +This is usually related to 3DSecure but in some cases can relate to credit checks or anything the payment provider has deemed relevant to the transaction. + +You can get access to these checks via the `paymentChecks()` method on the `Transaction`. + +```php +foreach($transaction->paymentChecks() as $check) { + $check->successful; + $check->label; + $check->message; +} +``` ## Payments We will be looking to add support for the most popular payment providers, so keep an eye out here as we will list them all out. diff --git a/packages/admin/resources/views/infolists/components/transaction.blade.php b/packages/admin/resources/views/infolists/components/transaction.blade.php index 51cdc4a977..ef0ada5568 100644 --- a/packages/admin/resources/views/infolists/components/transaction.blade.php +++ b/packages/admin/resources/views/infolists/components/transaction.blade.php @@ -19,7 +19,7 @@ '!border-red-500 bg-red-50' => !$transaction->success, 'bg-gray-50' => $transaction->success, ]) -> +>
{{ $transaction->driver }} // @@ -53,7 +53,7 @@ @endif
- !$transaction->success, @@ -75,26 +75,19 @@ class="w-4" />
- {{ $transaction->created_at->format('jS F Y h:ia') }} - @if($threeD = $transaction->meta['threedSecure'] ?? null) -
- @foreach([ - 'address' => 'Address', - 'postalCode' => 'Postal Code', - 'securityCode' => 'Security Code', - ] as $metaKey => $metaLabel) - - {{ $metaLabel }} - - @endforeach -
- @endif +
+ @foreach($transaction->paymentChecks() as $check) + + {{ $check->label }}: {{ $check->message }} + + @endforeach +
@if($transaction->notes) @@ -111,7 +104,7 @@ class="w-4" @endif -
!$transaction->success, diff --git a/packages/core/src/Base/DataTransferObjects/PaymentCheck.php b/packages/core/src/Base/DataTransferObjects/PaymentCheck.php new file mode 100644 index 0000000000..a64f196841 --- /dev/null +++ b/packages/core/src/Base/DataTransferObjects/PaymentCheck.php @@ -0,0 +1,13 @@ +checks[] = $check; + } + + public function getChecks(): array + { + return $this->checks; + } + + public function current(): PaymentCheck + { + return $this->checks[$this->position]; + } + + public function next(): void + { + $this->position++; + } + + public function key(): mixed + { + return $this->position; + } + + public function valid(): bool + { + return isset($this->checks[$this->position]); + } + + public function rewind(): void + { + $this->position = 0; + } + + public function offsetExists(mixed $offset): bool + { + return isset($this->checks[$offset]); + } + + public function offsetGet(mixed $offset): mixed + { + return $this->checks[$offset]; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + if (is_null($offset)) { + $this->checks[] = $value; + } else { + $this->checks[$offset] = $value; + } + } + + public function offsetUnset(mixed $offset): void + { + unset($this->checks[$offset]); + } +} diff --git a/packages/core/src/Models/Transaction.php b/packages/core/src/Models/Transaction.php index 0386c5396e..cb672a18da 100644 --- a/packages/core/src/Models/Transaction.php +++ b/packages/core/src/Models/Transaction.php @@ -87,6 +87,11 @@ public function driver() return Payments::driver($this->driver); } + public function paymentChecks() + { + return $this->driver()->getPaymentChecks($this); + } + public function refund(int $amount, $notes = null) { return $this->driver()->refund($this, $amount, $notes); diff --git a/packages/core/src/PaymentTypes/AbstractPayment.php b/packages/core/src/PaymentTypes/AbstractPayment.php index 27d1c4f9e7..f3cfc7d59a 100644 --- a/packages/core/src/PaymentTypes/AbstractPayment.php +++ b/packages/core/src/PaymentTypes/AbstractPayment.php @@ -2,9 +2,11 @@ namespace Lunar\PaymentTypes; +use Lunar\Base\DataTransferObjects\PaymentChecks; use Lunar\Base\PaymentTypeInterface; use Lunar\Models\Cart; use Lunar\Models\Order; +use Lunar\Models\Transaction; abstract class AbstractPayment implements PaymentTypeInterface { @@ -71,4 +73,9 @@ public function setConfig(array $config): self return $this; } + + public function getPaymentChecks(Transaction $transaction): PaymentChecks + { + return new PaymentChecks; + } } diff --git a/packages/opayo/src/OpayoPaymentType.php b/packages/opayo/src/OpayoPaymentType.php index a1509f380b..ad300a7b3f 100644 --- a/packages/opayo/src/OpayoPaymentType.php +++ b/packages/opayo/src/OpayoPaymentType.php @@ -4,6 +4,8 @@ use Illuminate\Support\Str; use Lunar\Base\DataTransferObjects\PaymentCapture; +use Lunar\Base\DataTransferObjects\PaymentCheck; +use Lunar\Base\DataTransferObjects\PaymentChecks; use Lunar\Base\DataTransferObjects\PaymentRefund; use Lunar\Events\PaymentAttemptEvent; use Lunar\Models\Order; @@ -421,6 +423,82 @@ public function setPolicy(mixed $policy): void $this->policy = $policy; } + public function getPaymentChecks(Transaction $transaction): PaymentChecks + { + $meta = $transaction->meta['threedSecure'] ?? null; + + $checks = new PaymentChecks; + + if (! $meta) { + return $checks; + } + + if (isset($meta['address'])) { + $message = $meta['address']; + $successful = $meta['address'] == 'Matched'; + + if (is_bool($message) && ! $message) { + $message = 'NotMatched'; + $successful = false; + } + if (is_bool($message) && $message) { + $message = 'Matched'; + $successful = true; + } + $checks->addCheck( + new PaymentCheck( + successful: $successful, + label: 'Address', + message: $message, + ) + ); + } + + if (isset($meta['postalCode'])) { + $message = $meta['postalCode']; + $successful = $meta['postalCode'] == 'Matched'; + + if (is_bool($message) && ! $message) { + $message = 'NotMatched'; + $successful = false; + } + if (is_bool($message) && $message) { + $message = 'Matched'; + $successful = true; + } + $checks->addCheck( + new PaymentCheck( + successful: $successful, + label: 'Postal Code', + message: $message, + ) + ); + } + + if (isset($meta['securityCode'])) { + $message = $meta['securityCode']; + $successful = $meta['securityCode'] == 'Matched'; + + if (is_bool($message) && ! $message) { + $message = 'NotMatched'; + $successful = false; + } + if (is_bool($message) && $message) { + $message = 'Matched'; + $successful = true; + } + $checks->addCheck( + new PaymentCheck( + successful: $successful, + label: 'Security Code', + message: $message, + ) + ); + } + + return $checks; + } + private function saveCard(Order $order, object $details, string $authCode = null) { if (! $order->user_id) { diff --git a/packages/stripe/src/StripePaymentType.php b/packages/stripe/src/StripePaymentType.php index fa8608b342..c697b23c3b 100644 --- a/packages/stripe/src/StripePaymentType.php +++ b/packages/stripe/src/StripePaymentType.php @@ -4,6 +4,8 @@ use Lunar\Base\DataTransferObjects\PaymentAuthorize; use Lunar\Base\DataTransferObjects\PaymentCapture; +use Lunar\Base\DataTransferObjects\PaymentCheck; +use Lunar\Base\DataTransferObjects\PaymentChecks; use Lunar\Base\DataTransferObjects\PaymentRefund; use Lunar\Events\PaymentAttemptEvent; use Lunar\Exceptions\DisallowMultipleCartOrdersException; @@ -193,4 +195,43 @@ public function refund(Transaction $transaction, int $amount = 0, $notes = null) success: true ); } + + public function getPaymentChecks(Transaction $transaction): PaymentChecks + { + $meta = $transaction->meta; + + $checks = new PaymentChecks; + + if (isset($meta['address_line1_check'])) { + $checks->addCheck( + new PaymentCheck( + successful: $meta['address_line1_check'] == 'pass', + label: 'Address Line 1', + message: $meta['address_line1_check'], + ) + ); + } + + if (isset($meta['address_postal_code_check'])) { + $checks->addCheck( + new PaymentCheck( + successful: $meta['address_postal_code_check'] == 'pass', + label: 'Postal Code', + message: $meta['address_postal_code_check'], + ) + ); + } + + if (isset($meta['cvc_check'])) { + $checks->addCheck( + new PaymentCheck( + successful: $meta['cvc_check'] == 'pass', + label: 'CVC Check', + message: $meta['cvc_check'], + ) + ); + } + + return $checks; + } } diff --git a/tests/opayo/Feature/OpayoPaymentTypeTest.php b/tests/opayo/Feature/OpayoPaymentTypeTest.php index 75f280db76..c4049b3f39 100644 --- a/tests/opayo/Feature/OpayoPaymentTypeTest.php +++ b/tests/opayo/Feature/OpayoPaymentTypeTest.php @@ -134,3 +134,105 @@ 'last_four' => '1111', ]); }); + +it('can return correct payment checks', function () { + \Lunar\Models\Currency::factory()->create(); + + $cart = buildCart(); + + $order = $cart->createOrder(); + + $transactionA = \Lunar\Models\Transaction::factory()->create([ + 'order_id' => $order->id, + 'driver' => 'opayo', + 'meta' => [ + 'threedSecure' => [ + 'address' => 'Matched', + 'postalCode' => 'Matched', + 'securityCode' => 'Matched', + ], + ], + ]); + + $transactionB = \Lunar\Models\Transaction::factory()->create([ + 'order_id' => $order->id, + 'driver' => 'opayo', + 'meta' => [ + 'threedSecure' => [ + 'address' => true, + 'postalCode' => true, + 'securityCode' => true, + ], + ], + ]); + + $transactionC = \Lunar\Models\Transaction::factory()->create([ + 'order_id' => $order->id, + 'driver' => 'opayo', + 'meta' => [ + 'threedSecure' => [ + 'address' => 'NotMatched', + 'postalCode' => 'NotMatched', + 'securityCode' => 'NotMatched', + ], + ], + ]); + + $transactionD = \Lunar\Models\Transaction::factory()->create([ + 'order_id' => $order->id, + 'driver' => 'opayo', + 'meta' => [ + 'threedSecure' => [ + 'address' => false, + 'postalCode' => false, + 'securityCode' => false, + ], + ], + ]); + + $paymentAChecks = $transactionA->paymentChecks(); + + expect($paymentAChecks)->toHaveCount(3) + ->and($paymentAChecks[0]->successful) + ->toBe(true) + ->and($paymentAChecks[1]->successful) + ->toBe(true) + ->and($paymentAChecks[2]->successful) + ->toBe(true); + + $paymentBChecks = $transactionB->paymentChecks(); + + expect($paymentBChecks)->toHaveCount(3) + ->and($paymentBChecks[0]->successful) + ->toBe(true) + ->and($paymentBChecks[1]->successful) + ->toBe(true) + ->and($paymentBChecks[2]->successful) + ->toBe(true); + + $paymentCChecks = $transactionC->paymentChecks(); + + expect($paymentCChecks)->toHaveCount(3) + ->and($paymentCChecks[0]->successful) + ->not + ->toBe(true) + ->and($paymentCChecks[1]->successful) + ->not + ->toBe(true) + ->and($paymentCChecks[2]->successful) + ->not + ->toBe(true); + + $paymentDChecks = $transactionD->paymentChecks(); + + expect($paymentDChecks)->toHaveCount(3) + ->and($paymentCChecks[0]->successful) + ->not + ->toBe(true) + ->and($paymentDChecks[1]->successful) + ->not + ->toBe(true) + ->and($paymentDChecks[2]->successful) + ->not + ->toBe(true); +}); diff --git a/tests/stripe/Unit/StripePaymentTypeTest.php b/tests/stripe/Unit/StripePaymentTypeTest.php index e9ff0cbc84..0ad8f4e9f6 100644 --- a/tests/stripe/Unit/StripePaymentTypeTest.php +++ b/tests/stripe/Unit/StripePaymentTypeTest.php @@ -50,7 +50,7 @@ 'type' => 'capture', 'success' => false, ]); -})->group('foo'); +}); it('can retrieve existing payment intent', function () { $cart = CartBuilder::build([ @@ -111,3 +111,101 @@ expect($cart->refresh()->completedOrder)->toBeNull(); }); + +it('can return correct payment checks', function () { + \Lunar\Models\Currency::factory()->create(); + + $cart = buildCart(); + + $order = $cart->createOrder(); + + $transactionA = \Lunar\Models\Transaction::factory()->create([ + 'order_id' => $order->id, + 'driver' => 'stripe', + 'meta' => [ + 'address_line1_check' => 'pass', + 'address_postal_code_check' => 'pass', + 'cvc_check' => 'pass', + ], + ]); + + $transactionB = \Lunar\Models\Transaction::factory()->create([ + 'order_id' => $order->id, + 'driver' => 'stripe', + 'meta' => [ + 'address_line1_check' => 'fail', + 'address_postal_code_check' => 'fail', + 'cvc_check' => 'fail', + ], + ]); + + $transactionC = \Lunar\Models\Transaction::factory()->create([ + 'order_id' => $order->id, + 'driver' => 'stripe', + 'meta' => [ + 'address_line1_check' => 'unavailable', + 'address_postal_code_check' => 'unavailable', + 'cvc_check' => 'unavailable', + ], + ]); + + $transactionD = \Lunar\Models\Transaction::factory()->create([ + 'order_id' => $order->id, + 'driver' => 'stripe', + 'meta' => [ + 'address_line1_check' => 'unchecked', + 'address_postal_code_check' => 'unchecked', + 'cvc_check' => 'unchecked', + ], + ]); + + $paymentAChecks = $transactionA->paymentChecks(); + + expect($paymentAChecks)->toHaveCount(3) + ->and($paymentAChecks[0]->successful) + ->toBe(true) + ->and($paymentAChecks[1]->successful) + ->toBe(true) + ->and($paymentAChecks[2]->successful) + ->toBe(true); + + $paymentBChecks = $transactionB->paymentChecks(); + + expect($paymentBChecks)->toHaveCount(3) + ->and($paymentBChecks[0]->successful) + ->not + ->toBe(true) + ->and($paymentBChecks[1]->successful) + ->not + ->toBe(true) + ->and($paymentBChecks[2]->successful) + ->not + ->toBe(true); + + $paymentCChecks = $transactionC->paymentChecks(); + + expect($paymentCChecks)->toHaveCount(3) + ->and($paymentCChecks[0]->successful) + ->not + ->toBe(true) + ->and($paymentCChecks[1]->successful) + ->not + ->toBe(true) + ->and($paymentCChecks[2]->successful) + ->not + ->toBe(true); + + $paymentDChecks = $transactionD->paymentChecks(); + + expect($paymentDChecks)->toHaveCount(3) + ->and($paymentCChecks[0]->successful) + ->not + ->toBe(true) + ->and($paymentDChecks[1]->successful) + ->not + ->toBe(true) + ->and($paymentDChecks[2]->successful) + ->not + ->toBe(true); + +});