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/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 783b2329d6..00a3434742 100644 --- a/packages/stripe/src/Facades/Stripe.php +++ b/packages/stripe/src/Facades/Stripe.php @@ -33,9 +33,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 1021fd70aa..1c29719cbf 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 = []; @@ -24,6 +26,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]; @@ -33,6 +42,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]; @@ -51,6 +61,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]; @@ -68,6 +79,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]; @@ -84,6 +96,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]; @@ -98,13 +112,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]; } @@ -118,6 +135,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]; @@ -133,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 67e854cf0d..e723afa422 100644 --- a/packages/stripe/src/StripePaymentType.php +++ b/packages/stripe/src/StripePaymentType.php @@ -117,6 +117,19 @@ final public function authorize(): ?PaymentAuthorize $paymentIntentId ); + if (! $this->allowPartialPayments && $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 408ef25dea..08357c2480 100644 --- a/tests/stripe/Unit/StripePaymentTypeTest.php +++ b/tests/stripe/Unit/StripePaymentTypeTest.php @@ -8,12 +8,16 @@ 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(); $payment = new StripePaymentType; + Stripe::fake()->next([ + 'amount' => $cart->calculate()->total->value, + ]); + $response = $payment->cart($cart)->withData([ 'payment_intent' => 'PI_CAPTURE', ])->authorize(); @@ -27,11 +31,63 @@ '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('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(); + Stripe::fake()->next([ + 'amount' => $cart->calculate()->total->value, + ]); + $payment = new StripePaymentType; $response = $payment->cart($cart)->withData([ @@ -48,7 +104,7 @@ 'type' => 'capture', 'success' => false, ]); -})->group('noo'); +}); it('can retrieve existing payment intent', function () { $cart = CartBuilder::build([ @@ -57,15 +113,20 @@ ], ]); - Stripe::createIntent($cart->calculate()); + Stripe::createIntent($cart->calculate(), []); expect($cart->refresh()->meta['payment_intent'])->toBe('PI_FOOBAR'); }); 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([ @@ -78,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(); @@ -96,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([ @@ -126,6 +188,10 @@ 'placed_at' => now(), ]); + Stripe::fake()->next([ + 'amount' => $cart->calculate()->total->value, + ]); + $payment = new StripePaymentType; $response = $payment->cart($cart)->withData([