Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 $allowPartialPayment = 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->allowPartialPayment = $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
8 changes: 4 additions & 4 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 Expand Up @@ -107,7 +107,7 @@
"client_secret": "pi_1DqH152eZvKYlo2CFHYZuxkP_secret_XNCxrfxMZshhdt1VmraRVGMKY",
"confirmation_method": "automatic",
"created": 1546940219,
"currency": "eur",
"currency": "{currency}",
"customer": null,
"description": null,
"invoice": 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 All @@ -19,7 +19,7 @@
"client_secret": "pi_1DqH152eZvKYlo2CFHYZuxkP_secret_XNCxrfxMZshhdt1VmraRVGMKY",
"confirmation_method": "automatic",
"created": 1546940219,
"currency": "usd",
"currency": "{currency}",
"customer": null,
"description": null,
"invoice": 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
80 changes: 63 additions & 17 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->allowPartialPayment = config('lunar.stripe.allow_partial_payment', false);
}

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

$this->paymentIntent = $this->stripe->paymentIntents->retrieve(
$paymentIntentId
);

if (! $this->paymentIntent) {
$failure = new PaymentAuthorize(
success: false,
message: 'Unable to locate payment intent',
orderId: $this->order?->id,
paymentType: 'stripe',
);

PaymentAttemptEvent::dispatch($failure);

return $failure;
}

if ($failure = $this->assertIntentMatchesTotal()) {
return $failure;
}

if (! $paymentIntentModel) {
$paymentIntentModel = StripePaymentIntent::create([
'intent_id' => $paymentIntentId,
Expand Down Expand Up @@ -114,23 +136,6 @@ final public function authorize(): ?PaymentAuthorize
}
}

$this->paymentIntent = $this->stripe->paymentIntents->retrieve(
$paymentIntentId
);

if (! $this->paymentIntent) {
$failure = new PaymentAuthorize(
success: false,
message: 'Unable to locate payment intent',
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 Expand Up @@ -160,6 +165,47 @@ final public function authorize(): ?PaymentAuthorize
return $response;
}

/**
* Verify the retrieved payment intent matches the expected order/cart total
* and currency. Returns a failure DTO when the check fails, or null on pass.
*
* Subclasses may override to relax or extend the policy (e.g. per-order
* deposit rules).
*/
protected function assertIntentMatchesTotal(): ?PaymentAuthorize
{
if ($this->allowPartialPayment) {
return null;
}

if ($this->order) {
$expectedAmount = $this->order->total->value;
$expectedCurrency = $this->order->currency_code;
} else {
$calculated = $this->cart->calculate();
$expectedAmount = $calculated->total->value;
$expectedCurrency = $calculated->currency->code;
}

$amountMatches = $expectedAmount === (int) $this->paymentIntent->amount;
$currencyMatches = strtolower((string) $expectedCurrency) === strtolower((string) $this->paymentIntent->currency);

if ($amountMatches && $currencyMatches) {
return null;
}

$failure = new PaymentAuthorize(
success: false,
message: 'Payment intent amount does not match order total',
orderId: $this->order?->id,
paymentType: 'stripe',
);

PaymentAttemptEvent::dispatch($failure);

return $failure;
}

/**
* Capture a payment for a transaction.
*
Expand Down
Loading