From 908d2a552645e5eafb8cd7edbd2ccde869e387e1 Mon Sep 17 00:00:00 2001 From: Alec Ritson Date: Tue, 27 May 2025 12:13:12 +0100 Subject: [PATCH 1/4] Add amount check to payment type --- .../responses/payment_intent_paid.json | 6 ++-- packages/stripe/src/Facades/Stripe.php | 6 +++- packages/stripe/src/MockClient.php | 11 +++++++ packages/stripe/src/StripePaymentType.php | 13 ++++++++ tests/stripe/Unit/StripePaymentTypeTest.php | 30 ++++++++++++++++++- 5 files changed, 61 insertions(+), 5 deletions(-) diff --git a/packages/stripe/resources/responses/payment_intent_paid.json b/packages/stripe/resources/responses/payment_intent_paid.json index edda8000fe..eec6656022 100644 --- a/packages/stripe/resources/responses/payment_intent_paid.json +++ b/packages/stripe/resources/responses/payment_intent_paid.json @@ -1,9 +1,9 @@ { "id": "{id}", "object": "payment_intent", - "amount": 1099, - "amount_capturable": 0, - "amount_received": 0, + "amount": "{amount}", + "amount_capturable": "{amount}", + "amount_received": "{amount}", "application": null, "application_fee_amount": null, "automatic_payment_methods": null, diff --git a/packages/stripe/src/Facades/Stripe.php b/packages/stripe/src/Facades/Stripe.php index 92a8452883..2bc3fd274d 100644 --- a/packages/stripe/src/Facades/Stripe.php +++ b/packages/stripe/src/Facades/Stripe.php @@ -32,9 +32,13 @@ protected static function getFacadeAccessor(): string return 'lunar:stripe'; } - public static function fake(): void + public static function fake(array $data = []): MockClient { $mockClient = new MockClient; + $mockClient->next($data); + ApiRequestor::setHttpClient($mockClient); + + return $mockClient; } } diff --git a/packages/stripe/src/MockClient.php b/packages/stripe/src/MockClient.php index 929b41e3bf..e8bb09a948 100644 --- a/packages/stripe/src/MockClient.php +++ b/packages/stripe/src/MockClient.php @@ -11,6 +11,8 @@ class MockClient implements ClientInterface { public string $rBody = '{}'; + public array $nextData = []; + public int $rcode = 200; public array $rheaders = []; @@ -22,6 +24,13 @@ public function __construct() $this->url = 'https://checkout.stripe.com/pay/cs_test_'.Str::random(32); } + public function next(array $data): self + { + $this->nextData = $data; + + return $this; + } + public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1') { $id = array_slice(explode('/', $absUrl), -1)[0]; @@ -82,6 +91,8 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode 'payment_error' => null, 'failure_code' => null, 'captured' => true, + 'amount' => 2000, + ...$this->nextData, ]); return [$this->rBody, $this->rcode, $this->rheaders]; diff --git a/packages/stripe/src/StripePaymentType.php b/packages/stripe/src/StripePaymentType.php index 2b145b64bc..7cc9b63fbc 100644 --- a/packages/stripe/src/StripePaymentType.php +++ b/packages/stripe/src/StripePaymentType.php @@ -98,6 +98,19 @@ final public function authorize(): ?PaymentAuthorize $paymentIntentId ); + if ($this->order->total->value != $this->paymentIntent->amount) { + $failure = new PaymentAuthorize( + success: false, + message: 'Captured amount mismatch', + orderId: $this->order->id, + paymentType: 'stripe', + ); + + PaymentAttemptEvent::dispatch($failure); + + return $failure; + } + if (! $this->paymentIntent) { $failure = new PaymentAuthorize( success: false, diff --git a/tests/stripe/Unit/StripePaymentTypeTest.php b/tests/stripe/Unit/StripePaymentTypeTest.php index 23b87f55c4..d4a06a738b 100644 --- a/tests/stripe/Unit/StripePaymentTypeTest.php +++ b/tests/stripe/Unit/StripePaymentTypeTest.php @@ -14,6 +14,10 @@ $cart = CartBuilder::build(); $payment = new StripePaymentType; + Stripe::fake()->next([ + 'amount' => $cart->calculate()->total->value, + ]); + $response = $payment->cart($cart)->withData([ 'payment_intent' => 'PI_CAPTURE', ])->authorize(); @@ -27,7 +31,31 @@ 'order_id' => $cart->refresh()->completedOrder->id, 'type' => 'capture', ]); -})->group('this'); +}); + +it('wont capture an order with lesser intent amount', function () { + $cart = CartBuilder::build(); + $payment = new StripePaymentType; + + Stripe::fake()->next([ + 'amount' => 100, + ]); + + $response = $payment->cart($cart)->withData([ + 'payment_intent' => 'PI_CAPTURE', + ])->authorize(); + + expect($response)->toBeInstanceOf(PaymentAuthorize::class) + ->and($response->success)->toBeFalse() + ->and($cart->refresh()->completedOrder)->toBeNull() + ->and($cart->refresh()->draftOrder)->not()->toBeNull() + ->and($cart->paymentIntents->first()->intent_id)->toEqual('PI_CAPTURE'); + + \Pest\Laravel\assertDatabaseMissing((new Transaction)->getTable(), [ + 'order_id' => $cart->refresh()->draftOrder->id, + 'type' => 'capture', + ]); +}); it('can handle failed payments', function () { $cart = CartBuilder::build(); From 88460856f2b1746447a71e0b1f0f712ad406a3f1 Mon Sep 17 00:00:00 2001 From: Alec Ritson Date: Wed, 28 May 2025 12:16:54 +0100 Subject: [PATCH 2/4] Fix tests --- packages/stripe/src/MockClient.php | 9 ++++++++- tests/stripe/Unit/StripePaymentTypeTest.php | 14 +++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/stripe/src/MockClient.php b/packages/stripe/src/MockClient.php index e8bb09a948..54a67105d4 100644 --- a/packages/stripe/src/MockClient.php +++ b/packages/stripe/src/MockClient.php @@ -40,6 +40,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode if ($method == 'get' && str_contains($absUrl, 'charges/CH_LINK')) { $this->rBody = $this->getResponse('charge_link', [ 'status' => 'succeeded', + ...$this->nextData, ]); return [$this->rBody, $this->rcode, $this->rheaders]; @@ -58,6 +59,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode $this->rBody = $this->getResponse('charges', [ 'status' => $status, 'failure_code' => $failureCode, + ...$this->nextData, ]); return [$this->rBody, $this->rcode, $this->rheaders]; @@ -75,6 +77,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode 'payment_error' => null, 'failure_code' => null, 'captured' => true, + ...$this->nextData, ]); return [$this->rBody, $this->rcode, $this->rheaders]; @@ -107,13 +110,16 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode 'payment_error' => 'foo', 'failure_code' => 1234, 'captured' => false, + ...$this->nextData, ]); return [$this->rBody, $this->rcode, $this->rheaders]; } if (str_contains($absUrl, 'PI_REQUIRES_PAYMENT_METHOD')) { - $this->rBody = $this->getResponse('payment_intent_requires_payment_method'); + $this->rBody = $this->getResponse('payment_intent_requires_payment_method', [ + ...$this->nextData, + ]); return [$this->rBody, $this->rcode, $this->rheaders]; } @@ -127,6 +133,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode 'payment_error' => 'foo', 'failure_code' => 1234, 'captured' => false, + ...$this->nextData, ]); return [$this->rBody, $this->rcode, $this->rheaders]; diff --git a/tests/stripe/Unit/StripePaymentTypeTest.php b/tests/stripe/Unit/StripePaymentTypeTest.php index d4a06a738b..570e650517 100644 --- a/tests/stripe/Unit/StripePaymentTypeTest.php +++ b/tests/stripe/Unit/StripePaymentTypeTest.php @@ -8,7 +8,7 @@ use function Pest\Laravel\assertDatabaseHas; -uses(\Lunar\Tests\Stripe\Unit\TestCase::class); +uses(\Lunar\Tests\Stripe\Unit\TestCase::class)->group('stripe'); it('can capture an order', function () { $cart = CartBuilder::build(); @@ -60,6 +60,10 @@ it('can handle failed payments', function () { $cart = CartBuilder::build(); + Stripe::fake()->next([ + 'amount' => $cart->calculate()->total->value, + ]); + $payment = new StripePaymentType; $response = $payment->cart($cart)->withData([ @@ -76,7 +80,7 @@ 'type' => 'capture', 'success' => false, ]); -})->group('noo'); +}); it('can retrieve existing payment intent', function () { $cart = CartBuilder::build([ @@ -85,7 +89,7 @@ ], ]); - Stripe::createIntent($cart->calculate()); + Stripe::createIntent($cart->calculate(), []); expect($cart->refresh()->meta['payment_intent'])->toBe('PI_FOOBAR'); }); @@ -98,6 +102,10 @@ 'placed_at' => now(), ]); + Stripe::fake()->next([ + 'amount' => $cart->calculate()->total->value, + ]); + $payment = new StripePaymentType; $response = $payment->cart($cart)->withData([ From 70b65a930d71928a67d38ede3ca9b15e68cf929e Mon Sep 17 00:00:00 2001 From: Alec Ritson Date: Fri, 11 Jul 2025 09:46:06 +0100 Subject: [PATCH 3/4] Allow partial payments --- .../core/src/PaymentTypes/AbstractPayment.php | 12 +++++++ packages/stripe/src/MockClient.php | 1 + packages/stripe/src/StripePaymentType.php | 2 +- tests/stripe/Unit/StripePaymentTypeTest.php | 36 +++++++++++++++++-- 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/core/src/PaymentTypes/AbstractPayment.php b/packages/core/src/PaymentTypes/AbstractPayment.php index b5d6022396..ed5b8c2cb5 100644 --- a/packages/core/src/PaymentTypes/AbstractPayment.php +++ b/packages/core/src/PaymentTypes/AbstractPayment.php @@ -12,6 +12,11 @@ abstract class AbstractPayment implements PaymentTypeInterface { + /** + * Whether we should allow partial payments + */ + protected bool $allowPartialPayments = false; + /** * The instance of the cart. */ @@ -76,6 +81,13 @@ public function setConfig(array $config): self return $this; } + public function allowPartialPayment(bool $condition = true): self + { + $this->allowPartialPayments = $condition; + + return $this; + } + public function getPaymentChecks(TransactionContract $transaction): PaymentChecks { return new PaymentChecks; diff --git a/packages/stripe/src/MockClient.php b/packages/stripe/src/MockClient.php index 6e3518da2e..c627dbc3ea 100644 --- a/packages/stripe/src/MockClient.php +++ b/packages/stripe/src/MockClient.php @@ -151,6 +151,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode 'payment_error' => $succeeded ? null : 'failed', 'failure_code' => $succeeded ? null : 1234, 'captured' => $succeeded, + ...$this->nextData ]); $this->failThenCaptureCalled = true; diff --git a/packages/stripe/src/StripePaymentType.php b/packages/stripe/src/StripePaymentType.php index 52be47d4fc..e723afa422 100644 --- a/packages/stripe/src/StripePaymentType.php +++ b/packages/stripe/src/StripePaymentType.php @@ -117,7 +117,7 @@ final public function authorize(): ?PaymentAuthorize $paymentIntentId ); - if ($this->order->total->value != $this->paymentIntent->amount) { + if (! $this->allowPartialPayments && $this->order->total->value != $this->paymentIntent->amount) { $failure = new PaymentAuthorize( success: false, message: 'Captured amount mismatch', diff --git a/tests/stripe/Unit/StripePaymentTypeTest.php b/tests/stripe/Unit/StripePaymentTypeTest.php index e26fe3f01f..08357c2480 100644 --- a/tests/stripe/Unit/StripePaymentTypeTest.php +++ b/tests/stripe/Unit/StripePaymentTypeTest.php @@ -57,6 +57,30 @@ ]); }); +it('will capture an order with lesser intent amount if allowed', function () { + $cart = CartBuilder::build(); + $payment = new StripePaymentType; + + Stripe::fake()->next([ + 'amount' => 100, + ]); + + $response = $payment->cart($cart)->withData([ + 'payment_intent' => 'PI_CAPTURE', + ])->allowPartialPayment()->authorize(); + + expect($response)->toBeInstanceOf(PaymentAuthorize::class) + ->and($response->success)->toBeTrue() + ->and($cart->refresh()->completedOrder)->not()->toBeNull() + ->and($cart->refresh()->draftOrder)->toBeNull() + ->and($cart->paymentIntents->first()->intent_id)->toEqual('PI_CAPTURE'); + + \Pest\Laravel\assertDatabaseHas((new Transaction)->getTable(), [ + 'order_id' => $cart->refresh()->completedOrder->id, + 'type' => 'capture', + ]); +}); + it('can handle failed payments', function () { $cart = CartBuilder::build(); @@ -95,9 +119,14 @@ }); it('can handle multiple payment events', function () { + $cart = CartBuilder::build(); $order = $cart->createOrder(); + Stripe::fake()->next([ + 'amount' => $order->total->value, + ]); + $payment = new StripePaymentType; $response = $payment->order($order)->withData([ @@ -110,9 +139,6 @@ ->and($cart->currentDraftOrder())->not()->toBeNull() ->and($cart->paymentIntents->first()->intent_id)->toEqual('PI_FIRST_FAIL_THEN_CAPTURE'); - // $cart->refresh(); - // $cart->paymentIntents->first()->refresh(); - $response = $payment->order($order)->withData([ 'payment_intent' => 'PI_FIRST_FAIL_THEN_CAPTURE', ])->authorize(); @@ -128,6 +154,10 @@ $cart = CartBuilder::build(); $order = $cart->createOrder(); + Stripe::fake()->next([ + 'amount' => $cart->calculate()->total->value, + ]); + $payment = new StripePaymentType; $response = $payment->order($order)->withData([ From 9d9fb835f729d8f189358290c780cebd7ba16d9d Mon Sep 17 00:00:00 2001 From: Author Date: Fri, 11 Jul 2025 08:49:26 +0000 Subject: [PATCH 4/4] chore: fix code style --- packages/stripe/src/MockClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stripe/src/MockClient.php b/packages/stripe/src/MockClient.php index c627dbc3ea..1c29719cbf 100644 --- a/packages/stripe/src/MockClient.php +++ b/packages/stripe/src/MockClient.php @@ -151,7 +151,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode 'payment_error' => $succeeded ? null : 'failed', 'failure_code' => $succeeded ? null : 1234, 'captured' => $succeeded, - ...$this->nextData + ...$this->nextData, ]); $this->failThenCaptureCalled = true;