Skip to content

Commit

Permalink
Refactor Stripe payment intent checking and storage (#1892)
Browse files Browse the repository at this point in the history
This PR looks to improve on how payment intents are stored and how to
better prevent overlaps when manually placing orders via Stripe and also
via the webhook.

## PaymentIntent storage and reference to carts/orders

Currently PaymentIntent information is stored in the Cart model's meta,
which is then transferred to the order when created.

Whilst this works okay it causes for limitations and also means that if
the carts meta is ever updated elsewhere, or the intent information is
removed, then it will cause unrecoverable loss.

This PR looks to move away from the `payment_intent` key in the meta to
a `StripePaymentIntent` model, this allows us more flexibility in how
payment intents are handled and reacted on. A StripePaymentIntent will
be associated to both a cart and an order.

The information we store is now:

- `intent_id` - This is the PaymentIntent ID which is provided by Stripe
- `status` - The PaymentIntent status
- `event_id` - If the PaymentIntent was placed via the webhook, this
will be populated with the ID of that event
- `processing_at` - When a request to place the order is made, this is
populated
- `processed_at` - Once the order is placed, this will be populated with
the current timestamp

## Preventing overlap

Currently we delay sending the job to place the order to the queue by 20
seconds, this is less than ideal, now the payment type will check
whether we are already processing this order and if so, not do anything
further. This should prevent overlaps regardless of how they are
triggered.

---------

Co-authored-by: alecritson <[email protected]>
Co-authored-by: Glenn Jacobs <[email protected]>
  • Loading branch information
3 people authored Aug 22, 2024
1 parent 41d4e85 commit 1f5dd5b
Show file tree
Hide file tree
Showing 26 changed files with 431 additions and 94 deletions.
22 changes: 22 additions & 0 deletions docs/core/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,28 @@ Lunar currently provides bug fixes and security updates for only the latest mino

Shipping methods are now associated to Customer Groups. If you are using the shipping addon then you should ensure that all your shipping methods are associated to the correct customer groups.

#### Stripe Addon

The Stripe addon will now attempt to update an order's billing and shipping address based on what has been stored against the Payment Intent. This is due to Stripe not always returning this information during their express checkout flows. This can be disabled by setting the `lunar.stripe.sync_addresses` config value to `false`.

##### PaymentIntent storage and reference to carts/orders
Currently, PaymentIntent information is stored in the Cart model's meta, which is then transferred to the order when created.

Whilst this works okay it causes for limitations and also means that if the carts meta is ever updated elsewhere, or the intent information is removed, then it will cause unrecoverable loss.

We have now looked to move away from the payment_intent key in the meta to a StripePaymentIntent model, this allows us more flexibility in how payment intents are handled and reacted on. A StripePaymentIntent will be associated to both a cart and an order.

The information we store is now:

- `intent_id` - This is the PaymentIntent ID which is provided by Stripe
- `status` - The PaymentIntent status
- `event_id` - If the PaymentIntent was placed via the webhook, this will be populated with the ID of that event
- `processing_at` - When a request to place the order is made, this is populated
- `processed_at` - Once the order is placed, this will be populated with the current timestamp

##### Preventing overlap
Currently, we delay sending the job to place the order to the queue by 20 seconds, this is less than ideal, now the payment type will check whether we are already processing this order and if so, not do anything further. This should prevent overlaps regardless of how they are triggered.

## 1.0.0-alpha.34

### Medium Impact
Expand Down
8 changes: 4 additions & 4 deletions packages/stripe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,10 @@ Make sure you have the Stripe credentials set in `config/services.php`

Below is a list of the available configuration options this package uses in `config/lunar/stripe.php`

| Key | Default | Description |
| --- | --- | --- |
| `policy` | `automatic` | Determines the policy for taking payments and whether you wish to capture the payment manually later or take payment straight away. Available options `manual` or `automatic` |

| Key | Default | Description |
|------------------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `policy` | `automatic` | Determines the policy for taking payments and whether you wish to capture the payment manually later or take payment straight away. Available options `manual` or `automatic` |
| `sync_addresses` | `true` | When enabled, the Stripe addon will attempt to sync the billing and shipping addresses which have been stored against the payment intent on Stripe. |
---

## Backend Usage
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 @@ -25,6 +25,19 @@
*/
'policy' => 'automatic',

/*
|--------------------------------------------------------------------------
| Sync addresses
|--------------------------------------------------------------------------
|
| When enabled, the Stripe addon will attempt to sync the billing and shipping
| addresses which have been stored against the payment intent on Stripe.
| This is useful when you don't always get the full address during the
| checkout process, which can be the case when using express checkout.
|
*/
'sync_addresses' => true,

/*
|--------------------------------------------------------------------------
| Status mapping
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Lunar\Base\Migration;

return new class extends Migration
{
public function up(): void
{
Schema::create($this->prefix.'stripe_payment_intents', function (Blueprint $table) {
$table->id();
$table->foreignId('cart_id')->constrained($this->prefix.'carts');
$table->foreignId('order_id')->nullable()->constrained($this->prefix.'orders');
$table->string('intent_id')->index();
$table->string('status')->nullable();
$table->string('event_id')->index()->nullable();
$table->timestamp('processing_at')->nullable();
$table->timestamp('processed_at')->nullable();
$table->timestamps();
});
}

public function down(): void
{
Schema::dropIfExists($this->prefix.'stripe_payment_intents');
}
};
17 changes: 14 additions & 3 deletions packages/stripe/resources/responses/payment_intent_created.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"metadata": {},
"next_action": null,
"on_behalf_of": null,
"payment_method": null,
"payment_method": "pm_1MqLiJLkdIwHu7ixUEgbFdYF",
"payment_method_options": {},
"payment_method_types": [
"card"
Expand All @@ -38,10 +38,21 @@
"redaction": null,
"review": null,
"setup_future_usage": null,
"shipping": null,
"shipping": {
"address": {
"city": "ACME Shipping Land",
"country": "GB",
"line1": "123 ACME Shipping Lane",
"line2": null,
"postal_code": "AC2 2ME",
"state": "ACM3"
},
"phone": "123456",
"name": "Buggs Bunny"
},
"statement_descriptor": null,
"statement_descriptor_suffix": null,
"status": "requires_payment_method",
"transfer_data": null,
"transfer_group": null
}
}
17 changes: 14 additions & 3 deletions packages/stripe/resources/responses/payment_intent_fetched.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"metadata": {},
"next_action": null,
"on_behalf_of": null,
"payment_method": null,
"payment_method": "pm_1MqLiJLkdIwHu7ixUEgbFdYF",
"payment_method_options": {},
"payment_method_types": [
"card"
Expand All @@ -38,10 +38,21 @@
"redaction": null,
"review": null,
"setup_future_usage": null,
"shipping": null,
"shipping": {
"address": {
"city": "ACME Shipping Land",
"country": "GB",
"line1": "123 ACME Shipping Lane",
"line2": null,
"postal_code": "AC2 2ME",
"state": "ACM3"
},
"phone": "123456",
"name": "Buggs Bunny"
},
"statement_descriptor": null,
"statement_descriptor_suffix": null,
"status": "requires_payment_method",
"transfer_data": null,
"transfer_group": null
}
}
15 changes: 13 additions & 2 deletions packages/stripe/resources/responses/payment_intent_paid.json
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@
"metadata": {},
"next_action": null,
"on_behalf_of": null,
"payment_method": null,
"payment_method": "pm_1MqLiJLkdIwHu7ixUEgbFdYF",
"payment_method_options": {},
"payment_method_types": [
"card"
Expand All @@ -227,7 +227,18 @@
"redaction": null,
"review": null,
"setup_future_usage": null,
"shipping": null,
"shipping": {
"address": {
"city": "ACME Shipping Land",
"country": "GB",
"line1": "123 ACME Shipping Lane",
"line2": null,
"postal_code": "AC2 2ME",
"state": "ACM3"
},
"phone": "123456",
"name": "Buggs Bunny"
},
"statement_descriptor": null,
"statement_descriptor_suffix": null,
"status": "{status}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"metadata": {},
"next_action": null,
"on_behalf_of": null,
"payment_method": null,
"payment_method": "pm_1MqLiJLkdIwHu7ixUEgbFdYF",
"payment_method_options": {},
"payment_method_types": [
"card"
Expand All @@ -38,10 +38,21 @@
"redaction": null,
"review": null,
"setup_future_usage": null,
"shipping": null,
"shipping": {
"address": {
"city": "ACME Shipping Land",
"country": "GB",
"line1": "123 ACME Shipping Lane",
"line2": null,
"postal_code": "AC2 2ME",
"state": "ACM3"
},
"phone": "123456",
"name": "Buggs Bunny"
},
"statement_descriptor": null,
"statement_descriptor_suffix": null,
"status": "requires_payment_method",
"transfer_data": null,
"transfer_group": null
}
}
47 changes: 47 additions & 0 deletions packages/stripe/resources/responses/payment_method.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"id": "pm_1MqLiJLkdIwHu7ixUEgbFdYF",
"object": "payment_method",
"billing_details": {
"address": {
"city": "ACME Land",
"country": "GB",
"line1": "123 ACME Lane",
"line2": null,
"postal_code": "AC1 1ME",
"state": "ACME"
},
"email": "[email protected]",
"name": "Elma Thudd",
"phone": "1234567"
},
"card": {
"brand": "visa",
"checks": {
"address_line1_check": null,
"address_postal_code_check": null,
"cvc_check": "unchecked"
},
"country": "US",
"exp_month": 8,
"exp_year": 2026,
"fingerprint": "mToisGZ01V71BCos",
"funding": "credit",
"generated_from": null,
"last4": "4242",
"networks": {
"available": [
"visa"
],
"preferred": null
},
"three_d_secure_usage": {
"supported": true
},
"wallet": null
},
"created": 1679945299,
"customer": null,
"livemode": false,
"metadata": {},
"type": "card"
}
56 changes: 56 additions & 0 deletions packages/stripe/src/Actions/StoreAddressInformation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

namespace Lunar\Stripe\Actions;

use Lunar\Models\Country;
use Lunar\Models\Order;
use Lunar\Models\OrderAddress;
use Lunar\Stripe\Facades\Stripe;
use Stripe\PaymentIntent;

class StoreAddressInformation
{
public function store(Order $order, PaymentIntent $paymentIntent)
{
$billingAddress = $order->billingAddress ?: new OrderAddress([
'order_id' => $order->id,
'type' => 'billing',
]);

$shippingAddress = $order->shippingAddress ?: new OrderAddress([
'order_id' => $order->id,
'type' => 'shipping',
]);

$paymentMethod = Stripe::getPaymentMethod($paymentIntent->payment_method);

if ($paymentIntent->shipping && $stripeShipping = $paymentIntent->shipping->address) {
$country = Country::where('iso2', $stripeShipping->country)->first();
$shippingAddress->first_name = explode(' ', $paymentIntent->shipping->name)[0];
$shippingAddress->last_name = explode(' ', $paymentIntent->shipping->name)[1] ?? '';
$shippingAddress->line_one = $stripeShipping->line1;
$shippingAddress->line_two = $stripeShipping->line2;
$shippingAddress->city = $stripeShipping->city;
$shippingAddress->state = $stripeShipping->state;
$shippingAddress->postcode = $stripeShipping->postal_code;
$shippingAddress->country_id = $country?->id;
$shippingAddress->contact_phone = $paymentIntent->shipping->phone;
$shippingAddress->save();
}

if ($paymentMethod && $stripeBilling = $paymentMethod->billing_details?->address) {
$country = Country::where('iso2', $stripeBilling->country)->first();
$billingAddress->first_name = explode(' ', $paymentMethod->billing_details->name)[0];
$billingAddress->last_name = explode(' ', $paymentMethod->billing_details->name)[1] ?? '';
$billingAddress->line_one = $stripeBilling->line1;
$billingAddress->line_two = $stripeBilling->line2;
$billingAddress->city = $stripeBilling->city;
$billingAddress->state = $stripeBilling->state;
$billingAddress->postcode = $stripeBilling->postal_code;
$billingAddress->country_id = $country?->id;
$billingAddress->contact_phone = $paymentMethod->billing_details->phone;
$billingAddress->contact_email = $paymentMethod->billing_details->email;
$billingAddress->save();
}
}
}
4 changes: 4 additions & 0 deletions packages/stripe/src/Actions/UpdateOrderFromIntent.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ final public static function execute(
return $order;
}

if (config('lunar.stripe.sync_addresses', true) && $paymentIntent->payment_method) {
(new StoreAddressInformation)->store($order, $paymentIntent);
}

$order->update([
'status' => $statuses[$paymentIntent->status] ?? $paymentIntent->status,
'placed_at' => $order->placed_at ?: $placedAt,
Expand Down
5 changes: 4 additions & 1 deletion packages/stripe/src/Facades/Stripe.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
namespace Lunar\Stripe\Facades;

use Illuminate\Support\Facades\Facade;
use Lunar\Models\Cart;
use Lunar\Stripe\Enums\CancellationReason;
use Lunar\Stripe\MockClient;
use Stripe\ApiRequestor;

/**
* @method static getClient(): \Stripe\StripeClient
* @method static createIntent(\Lunar\Models\Cart $cart, array $opts): \Stripe\PaymentIntent
* @method static getCartIntentId(Cart $cart): ?string
* @method static fetchOrCreateIntent(Cart $cart, array $createOptions): ?string
* @method static createIntent(\Lunar\Models\Cart $cart, array $createOptions): \Stripe\PaymentIntent
* @method static syncIntent(\Lunar\Models\Cart $cart): void
* @method static updateIntent(\Lunar\Models\Cart $cart, array $values): void
* @method static cancelIntent(\Lunar\Models\Cart $cart, CancellationReason $reason): void
Expand Down
13 changes: 12 additions & 1 deletion packages/stripe/src/Http/Controllers/WebhookController.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Illuminate\Support\Facades\Log;
use Lunar\Stripe\Concerns\ConstructsWebhookEvent;
use Lunar\Stripe\Jobs\ProcessStripeWebhook;
use Lunar\Stripe\Models\StripePaymentIntent;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Exception\UnexpectedValueException;

Expand Down Expand Up @@ -38,7 +39,17 @@ public function __invoke(Request $request): JsonResponse
$paymentIntent = $event->data->object->id;
$orderId = $event->data->object->metadata?->order_id;

ProcessStripeWebhook::dispatch($paymentIntent, $orderId)->delay(now()->addSeconds(20));
// Is this payment intent already being processed?
$paymentIntentModel = StripePaymentIntent::where('intent_id', $paymentIntent)->first();

if (! $paymentIntentModel?->processing_at) {
$paymentIntentModel?->update([
'event_id' => $event->id,
]);
ProcessStripeWebhook::dispatch($paymentIntent, $orderId)->delay(
now()->addSeconds(5)
);
}

return response()->json([
'webhook_successful' => true,
Expand Down
Loading

0 comments on commit 1f5dd5b

Please sign in to comment.