Skip to content
Open
5 changes: 5 additions & 0 deletions packages/core/src/Base/PaymentTypeInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ public function withData(array $data): self;
*/
public function setConfig(array $config): self;

/**
* Allow partial payments (e.g. deposits).
*/
public function allowPartialPayment(bool $condition = true): self;

/**
* Authorize the payment.
*/
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/PaymentTypes/AbstractPayment.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@

abstract class AbstractPayment implements PaymentTypeInterface
{
/**
* Whether we should allow partial payments
*/
protected bool $allowPartialPayments = false;

/**
* The instance of the cart.
*/
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions packages/stripe/config/stripe.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@
*/
'sync_addresses' => true,

/*
|--------------------------------------------------------------------------
| Allow partial payments
|--------------------------------------------------------------------------
|
| When enabled, the amount on the PaymentIntent does not need to match the
| order total. This is useful for stores that accept deposits or partial
| payments. When disabled (default), a mismatch will cause authorization
| to fail.
|
*/
'allow_partial_payment' => false,

/*
|--------------------------------------------------------------------------
| Status mapping
Expand Down
6 changes: 3 additions & 3 deletions packages/stripe/resources/responses/payment_intent_paid.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "pi_1DqH152eZvKYlo2CFHYZuxkP",
"object": "payment_intent",
"amount": 2000,
"amount": {amount},
"amount_capturable": 0,
"amount_received": 0,
"application": null,
Expand Down
6 changes: 5 additions & 1 deletion packages/stripe/src/Facades/Stripe.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,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;
}
}
21 changes: 20 additions & 1 deletion packages/stripe/src/MockClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class MockClient implements ClientInterface
{
public string $rBody = '{}';

public array $nextData = [];

public int $rcode = 200;

public array $rheaders = [];
Expand All @@ -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];
Expand All @@ -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];
Expand All @@ -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];
Expand All @@ -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];
Expand All @@ -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];
Expand All @@ -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];
}
Expand All @@ -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];
Expand All @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions packages/stripe/src/StripePaymentType.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public function __construct()
$this->stripe = Stripe::getClient();

$this->policy = config('lunar.stripe.policy', 'automatic');
$this->allowPartialPayments = config('lunar.stripe.allow_partial_payment', false);
}

/**
Expand Down Expand Up @@ -131,6 +132,19 @@ final public function authorize(): ?PaymentAuthorize
return $failure;
}

if (! $this->allowPartialPayments && $this->order->total->value !== (int) $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->status == PaymentIntent::STATUS_REQUIRES_CAPTURE && $this->policy == 'automatic') {
$this->paymentIntent = $this->stripe->paymentIntents->capture(
$this->data['payment_intent']
Expand Down
110 changes: 104 additions & 6 deletions tests/stripe/Unit/StripePaymentTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,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();
Expand All @@ -29,11 +33,87 @@
'order_id' => $cart->refresh()->completedOrder->id,
'type' => 'capture',
]);
})->group('this');
});

it('wont capture an order with mismatched 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('wont capture an order with greater intent amount', function () {
$cart = CartBuilder::build();
$payment = new StripePaymentType;

Stripe::fake()->next([
'amount' => $cart->calculate()->total->value + 1,
]);

$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 mismatched 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([
Expand All @@ -50,7 +130,7 @@
'type' => 'capture',
'success' => false,
]);
})->group('noo');
});

it('can retrieve existing payment intent', function () {
$cart = CartBuilder::build([
Expand All @@ -59,15 +139,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([
Expand All @@ -80,9 +165,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();
Expand All @@ -98,6 +180,10 @@
$cart = CartBuilder::build();
$order = $cart->createOrder();

Stripe::fake()->next([
'amount' => $cart->calculate()->total->value,
]);

$payment = new StripePaymentType;

$response = $payment->order($order)->withData([
Expand Down Expand Up @@ -128,6 +214,10 @@
'placed_at' => now(),
]);

Stripe::fake()->next([
'amount' => $cart->calculate()->total->value,
]);

$payment = new StripePaymentType;

$response = $payment->cart($cart)->withData([
Expand All @@ -145,6 +235,10 @@
it('will fail if payment intent status is requires_payment_method', function () {
$cart = CartBuilder::build();

Stripe::fake()->next([
'amount' => $cart->calculate()->total->value,
]);

$payment = new StripePaymentType;

$response = $payment->cart($cart)->withData([
Expand All @@ -160,6 +254,10 @@
it('create a pending transaction when status is requires_action', function () {
$cart = CartBuilder::build();

Stripe::fake()->next([
'amount' => $cart->calculate()->total->value,
]);

$payment = new StripePaymentType;

$response = $payment->cart($cart)->withData([
Expand Down
Loading