diff --git a/.github/workflows/split_packages.yml b/.github/workflows/split_packages.yml index 216da4deef..0007098cdc 100644 --- a/.github/workflows/split_packages.yml +++ b/.github/workflows/split_packages.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - package: ["admin", "core", "opayo", "paypal", "scout-database-engine", "table-rate-shipping"] + package: ["admin", "core", "opayo", "paypal", "scout-database-engine", "table-rate-shipping", "stripe"] steps: - uses: actions/checkout@v2 - uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/stripe_tests.yml b/.github/workflows/stripe_tests.yml new file mode 100644 index 0000000000..78f51b86d9 --- /dev/null +++ b/.github/workflows/stripe_tests.yml @@ -0,0 +1,46 @@ +name: Lunar Stripe Tests +on: + pull_request: +defaults: + run: + working-directory: ./ +jobs: + run: + runs-on: ubuntu-latest + strategy: + matrix: + php: [8.1, 8.2] + laravel: [10.*] + phpunit-versions: ['latest'] + name: PHP:${{ matrix.php }} / Laravel:${{ matrix.laravel }} + steps: + - uses: actions/checkout@v2 + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, intl, amqp, dba + tools: composer:v2, phpunit:${{ matrix.php }} + + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache composer dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} + restore-keys: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer- + + - name: Install Composer dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update --dev + composer update --prefer-stable --no-interaction --no-suggest + + - name: Execute tests (Unit and Feature tests) via PHPUnit + env: + APP_ENV: testing + DB_CONNECTION: testing + DB_DATABASE: ":memory:" + run: vendor/bin/pest --testsuite stripe diff --git a/.github/workflows/table_rate_shipping_tests.yml b/.github/workflows/table_rate_shipping_tests.yml index dba54c38f1..2d80589c67 100644 --- a/.github/workflows/table_rate_shipping_tests.yml +++ b/.github/workflows/table_rate_shipping_tests.yml @@ -43,4 +43,4 @@ jobs: APP_ENV: testing DB_CONNECTION: testing DB_DATABASE: ":memory:" - run: vendor/bin/pest --testsuite shipping --parallel + run: vendor/bin/pest --testsuite shipping diff --git a/composer.json b/composer.json index e4ae1a1887..034067d1d2 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ "ext-bcmath": "*", "ext-exif": "*", "ext-intl": "*", - "filament/filament": "^3.1.23", + "filament/filament": "^3.2.25", "filament/spatie-laravel-media-library-plugin": "^3.0-stable", "guzzlehttp/guzzle": "^7.3", "illuminate/http": "^10.0", @@ -24,6 +24,7 @@ "laravel/framework": "^10.0", "laravel/scout": "^10.0", "leandrocfe/filament-apex-charts": "^3.1.2", + "livewire/livewire": "^3.0", "lukascivil/treewalker": "0.9.1", "marvinosswald/filament-input-select-affix": "^0.1.0", "php": "^8.1", @@ -31,6 +32,7 @@ "spatie/laravel-blink": "^1.6", "spatie/laravel-medialibrary": "^10.0.0", "spatie/laravel-permission": "^5.10", + "stripe/stripe-php": "^7.114", "technikermathe/blade-lucide-icons": "^v2.24.0" }, "require-dev": { @@ -65,7 +67,8 @@ ], "Lunar\\ScoutDatabaseEngine\\": "packages/scout-database-engine/src", "Lunar\\Shipping\\": "packages/table-rate-shipping/src", - "Lunar\\Shipping\\Database\\Factories\\": "packages/table-rate-shipping/database/factories" + "Lunar\\Shipping\\Database\\Factories\\": "packages/table-rate-shipping/database/factories", + "Lunar\\Stripe\\": "packages/stripe/src/" } }, "autoload-dev": { @@ -76,7 +79,8 @@ "Lunar\\Tests\\Paypal\\": "tests/paypal", "Lunar\\Tests\\ScoutDatabaseEngine\\": "tests/scout-database-engine", "Lunar\\Tests\\Shipping\\": "tests/shipping", - "Lunar\\Shipping\\Tests\\": "packages/table-rate-shipping/tests" + "Lunar\\Shipping\\Tests\\": "packages/table-rate-shipping/tests", + "Lunar\\Tests\\Stripe\\": "tests/stripe" } }, "extra": { @@ -84,11 +88,13 @@ "name": [ "Table Rate Shipping", "Opayo Payments", - "Paypal Payments" + "Paypal Payments", + "Stripe Payments" ] }, "laravel": { "providers": [ + "Lunar\\Stripe\\StripePaymentsServiceProvider", "Lunar\\Paypal\\PaypalServiceProvider", "Lunar\\Admin\\LunarPanelProvider", "Lunar\\Opayo\\OpayoServiceProvider", @@ -104,6 +110,7 @@ "lunarphp/opayo": "self.version", "lunarphp/paypal": "self.version", "lunarphp/scout-database-engine": "self.version", + "lunarphp/stripe": "self.version", "lunarphp/table-rate-shipping": "self.version" }, "scripts": { diff --git a/packages/admin/composer.json b/packages/admin/composer.json index b08e6b463a..10316f504d 100644 --- a/packages/admin/composer.json +++ b/packages/admin/composer.json @@ -15,7 +15,7 @@ "minimum-stability": "dev", "require": { "lunarphp/core": "self.version", - "filament/filament": "^3.1.23", + "filament/filament": "^3.2.25", "filament/spatie-laravel-media-library-plugin": "^3.0-stable", "spatie/laravel-permission": "^5.10", "barryvdh/laravel-dompdf": "^2.0", diff --git a/packages/admin/resources/lang/en/widgets.php b/packages/admin/resources/lang/en/widgets.php index 71e042746f..83f2f3e09f 100644 --- a/packages/admin/resources/lang/en/widgets.php +++ b/packages/admin/resources/lang/en/widgets.php @@ -11,16 +11,16 @@ 'neutral' => 'No change compared to yesterday', ], 'stat_two' => [ - 'label' => 'Orders this week', - 'increase' => ':percentage% increase from :count last week', - 'decrease' => ':percentage% decrease from :count last week', - 'neutral' => 'No change compared to last week', + 'label' => 'Orders past 7 days', + 'increase' => ':percentage% increase from :count last period', + 'decrease' => ':percentage% decrease from :count last period', + 'neutral' => 'No change compared to last period', ], 'stat_three' => [ - 'label' => 'Orders this month', - 'increase' => ':percentage% increase from :count last month', - 'decrease' => ':percentage% decrease from :count last month', - 'neutral' => 'No change compared to last month', + 'label' => 'Orders past 30 days', + 'increase' => ':percentage% increase from :count last period', + 'decrease' => ':percentage% decrease from :count last period', + 'neutral' => 'No change compared to last period', ], 'stat_four' => [ 'label' => 'Sales today', @@ -29,16 +29,16 @@ 'neutral' => 'No change compared to yesterday', ], 'stat_five' => [ - 'label' => 'Sales this week', - 'increase' => ':percentage% increase from :total last week', - 'decrease' => ':percentage% decrease from :total last week', - 'neutral' => 'No change compared to last week', + 'label' => 'Sales past 7 days', + 'increase' => ':percentage% increase from :total last period', + 'decrease' => ':percentage% decrease from :total last period', + 'neutral' => 'No change compared to last period', ], 'stat_six' => [ - 'label' => 'Sales this month', - 'increase' => ':percentage% increase from :total last month', - 'decrease' => ':percentage% decrease from :total last month', - 'neutral' => 'No change compared to last month', + 'label' => 'Sales past 30 days', + 'increase' => ':percentage% increase from :total last period', + 'decrease' => ':percentage% decrease from :total last period', + 'neutral' => 'No change compared to last period', ], ], 'order_totals_chart' => [ diff --git a/packages/admin/src/Filament/Widgets/Dashboard/Orders/OrderStatsOverview.php b/packages/admin/src/Filament/Widgets/Dashboard/Orders/OrderStatsOverview.php index ae8be594a6..b56dbad367 100644 --- a/packages/admin/src/Filament/Widgets/Dashboard/Orders/OrderStatsOverview.php +++ b/packages/admin/src/Filament/Widgets/Dashboard/Orders/OrderStatsOverview.php @@ -25,24 +25,24 @@ protected function getStats(): array 'monthOverflow' => false, ]); - $currentMonth = $this->getOrderQuery( - from: $date->clone()->startOfMonth(), + $current30Days = $this->getOrderQuery( + from: $date->clone()->subDays(30), to: $date->clone(), ); - $previousMonth = $this->getOrderQuery( - from: $date->clone()->subMonth()->startOfMonth(), - to: $date->clone(), + $previous30Days = $this->getOrderQuery( + from: $date->clone()->subDays(60), + to: $date->clone()->subDays(30), ); - $currentWeek = $this->getOrderQuery( - from: $date->clone()->startOfWeek(), + $current7Days = $this->getOrderQuery( + from: $date->clone()->subDays(7), to: $date->clone(), ); - $previousWeek = $this->getOrderQuery( - from: $date->clone()->subWeek()->startOfWeek(), - to: $date->clone()->subWeek(), + $previous7Days = $this->getOrderQuery( + from: $date->clone()->subDays(14), + to: $date->clone()->subDays(7), ); $today = $this->getOrderQuery( @@ -57,11 +57,11 @@ protected function getStats(): array return [ $this->getStatCount($today, $yesterday, 'stat_one'), - $this->getStatCount($currentWeek, $previousWeek, 'stat_two'), - $this->getStatCount($currentMonth, $previousMonth, 'stat_three'), + $this->getStatCount($current7Days, $previous7Days, 'stat_two'), + $this->getStatCount($current30Days, $previous30Days, 'stat_three'), $this->getStatTotal($today, $yesterday, 'stat_four'), - $this->getStatTotal($currentWeek, $previousWeek, 'stat_five'), - $this->getStatTotal($currentMonth, $previousMonth, 'stat_six'), + $this->getStatTotal($current7Days, $previous7Days, 'stat_five'), + $this->getStatTotal($current30Days, $previous30Days, 'stat_six'), ]; } diff --git a/packages/admin/src/LunarPanelManager.php b/packages/admin/src/LunarPanelManager.php index 6aae5564af..4972c5e0e1 100644 --- a/packages/admin/src/LunarPanelManager.php +++ b/packages/admin/src/LunarPanelManager.php @@ -86,8 +86,6 @@ public function register(): self $panel = $fn($panel); } - $panel->id($this->panelId); - Filament::registerPanel($panel); FilamentIcon::register([ diff --git a/packages/stripe/.gitignore b/packages/stripe/.gitignore new file mode 100644 index 0000000000..49dc4d5573 --- /dev/null +++ b/packages/stripe/.gitignore @@ -0,0 +1,6 @@ +composer.lock +/vendor +.phpunit.result.cache +/.idea +/.vscode +.DS_Store \ No newline at end of file diff --git a/packages/stripe/README.md b/packages/stripe/README.md new file mode 100644 index 0000000000..3c2a312551 --- /dev/null +++ b/packages/stripe/README.md @@ -0,0 +1,280 @@ +

+ + +

This addon enables Stripe payments on your Lunar storefront.

+ +## Alpha Release + +This addon is currently in Alpha, whilst every step is taken to ensure this is working as intended, it will not be considered out of Alpha until more tests have been added and proved. + +## Tests required: + +- [ ] Successful charge response from Stripe. +- [ ] Unsuccessful charge response from Stripe. +- [ ] Test `manual` config reacts appropriately. +- [x] Test `automatic` config reacts appropriately. +- [ ] Ensure transactions are stored correctly in the database +- [x] Ensure that the payment intent is not duplicated when using the same Cart +- [ ] Ensure appropriate responses are returned based on Stripe's responses. +- [ ] Test refunds and partial refunds create the expected transactions +- [ ] Make sure we can manually release a payment or part payment and handle the different responses. + +## Minimum Requirements + +- Lunar `1.x` +- A [Stripe](http://stripe.com/) account with secret and public keys + +## Optional Requirements + +- Laravel Livewire (if using frontend components) +- Alpinejs (if using frontend components) +- Javascript framework + +## Installation + +### Require the composer package + +```sh +composer require lunarphp/stripe +``` + +### Publish the configuration + +This will publish the configuration under `config/getcandy/stripe.php`. + +```sh +php artisan vendor:publish --tag=getcandy.stripe.config +``` + +### Publish the views (optional) + +Lunar Stripe comes with some helper components for you to use on your checkout, if you intend to edit the views they provide, you can publish them. + +```sh +php artisan vendor:publish --tag=getcandy.stripe.components +``` + +### Enable the driver + +Set the driver in `config/getcandy/payments.php` + +```php + [ + 'card' => [ + // ... + 'driver' => 'stripe', + ], + ], +]; +``` + +### Add your Stripe credentials + +Make sure you have the Stripe credentials set in `config/services.php` + +```php +'stripe' => [ + 'key' => env('STRIPE_SECRET'), + 'public_key' => env('STRIPE_PK'), +], +``` + +> Keys can be found in your Stripe account https://dashboard.stripe.com/apikeys + +## Configuration + +Below is a list of the available configuration options this package uses in `config/getcandy/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` | + +--- + +## Backend Usage + +### Create a PaymentIntent + +```php +use \Lunar\Stripe\Facades\Stripe; + +Stripe::createIntent(\Lunar\Models\Cart $cart); +``` + +This method will create a Stripe PaymentIntent from a Cart and add the resulting ID to the meta for retrieval later. If a PaymentIntent already exists for a cart this will fetch it from Stripe and return that instead to avoid duplicate PaymentIntents being created. + +```php +$paymentIntentId = $cart->meta['payment_intent']; // The resulting ID from the method above. +``` +```php +$cart->meta->payment_intent; +``` + +### Fetch an existing PaymentIntent + +```php +use \Lunar\Stripe\Facades\Stripe; + +Stripe::fetchIntent($paymentIntentId); +``` + +### Syncing an existing intent + +If a payment intent has been created and there are changes to the cart, you will want to update the intent so it has the correct totals. + +```php +use \Lunar\Stripe\Facades\Stripe; + +Stripe::syncIntent(\Lunar\Models\Cart $cart); +``` + +## Webhooks + +The plugin provides a webhook you will need to add to Stripe. You can read the guide on how to do this on the Stripe website [https://stripe.com/docs/webhooks/quickstart](https://stripe.com/docs/webhooks/quickstart). + +The 3 events you should listen to are `payment_intent.payment_failed`,`payment_intent.processing`,`payment_intent.succeeded`. + +The path to the webhook will be `http:://yoursite.com/stripe/webhook`. + +You can customise the path for the webhook in `config/lunar/stripe.php`. + +You will also need to add the webhook signing secret to the `services.php` config file: + +```php + [ + // ... + 'webhooks' => [ + 'payment_intent' => '...' + ], + ], +]; +``` + +## Storefront Examples + +First we need to set up the backend API call to fetch or create the intent, this isn't Vue specific but will likely be different if you're using Livewire. + +```php +use \Lunar\Stripe\Facades\Stripe; + +Route::post('api/payment-intent', function () { + $cart = CartSession::current(); + + $cartData = CartData::from($cart); + + if ($paymentIntent = $cartData->meta['payment_intent'] ?? false) { + $intent = StripeFacade::fetchIntent($paymentIntent); + } else { + $intent = StripeFacade::createIntent($cart); + } + + if ($intent->amount != $cart->total->value) { + StripeFacade::syncIntent($cart); + } + + return $intent; +})->middleware('web'); +``` + +### Vuejs + +This is just using Stripe's payment elements, for more information [check out the Stripe guides](https://stripe.com/docs/payments/elements) + +### Payment component + +```js + +``` + +```html + +``` +--- + +## Contributing + +Contributions are welcome, if you are thinking of adding a feature, please submit an issue first so we can determine whether it should be included. + + +## Testing + +A [MockClient](https://github.com/getcandy/stripe/blob/main/tests/Stripe/MockClient.php) class is used to mock responses the Stripe API will return. diff --git a/packages/stripe/composer.json b/packages/stripe/composer.json new file mode 100644 index 0000000000..c02bf21491 --- /dev/null +++ b/packages/stripe/composer.json @@ -0,0 +1,40 @@ +{ + "name": "lunarphp/stripe", + "type": "project", + "description": "Stripe payment driver for Lunar.", + "keywords": ["lunarphp", "laravel", "ecommerce", "e-commerce", "headless", "store", "shop", "cart", "stripe"], + "license": "MIT", + "authors": [ + { + "name": "Lunar", + "homepage": "https://lunarphp.io/" + } + ], + "require": { + "php": "^8.1", + "lunarphp/core": "self.version", + "stripe/stripe-php": "^7.114" + }, + "autoload": { + "psr-4": { + "Lunar\\Stripe\\": "src/" + } + }, + "extra": { + "lunar": { + "name": "Stripe Payments" + }, + "laravel": { + "providers": [ + "Lunar\\Stripe\\StripePaymentsServiceProvider" + ] + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + } +} diff --git a/packages/stripe/config/stripe.php b/packages/stripe/config/stripe.php new file mode 100644 index 0000000000..8f0c3f2083 --- /dev/null +++ b/packages/stripe/config/stripe.php @@ -0,0 +1,63 @@ + 'stripe/webhook', + + /* + |-------------------------------------------------------------------------- + | Capture policy + |-------------------------------------------------------------------------- + | + | Here is where you can set whether you want to capture and charge payments + | straight away, or create the Payment Intent and release them at a later date. + | + | automatic - Capture the payment straight away. + | manual - Don't take payment straight away and capture later. + | + */ + 'policy' => 'automatic', + + /* + |-------------------------------------------------------------------------- + | Status mapping + |-------------------------------------------------------------------------- + | + | When a payment intent is retrieved from Stripe it will have a status which is + | unique to Stripe and potentially not what you have in Lunar. Here you can define + | what each Stripe status should be in Lunar. + | + | Reference: https://stripe.com/docs/api/charges/object + */ + 'status_mapping' => [ + \Stripe\PaymentIntent::STATUS_REQUIRES_CAPTURE => 'requires-capture', + \Stripe\PaymentIntent::STATUS_CANCELED => 'cancelled', + \Stripe\PaymentIntent::STATUS_PROCESSING => 'processing', + \Stripe\PaymentIntent::STATUS_REQUIRES_ACTION => 'awaiting-payment', + \Stripe\PaymentIntent::STATUS_REQUIRES_CONFIRMATION => 'auth-pending', + \Stripe\PaymentIntent::STATUS_REQUIRES_PAYMENT_METHOD => 'failed', + \Stripe\PaymentIntent::STATUS_SUCCEEDED => 'payment-received', + ], + + 'actions' => [ + /* + |-------------------------------------------------------------------------- + | Store charges + |-------------------------------------------------------------------------- + | + | A payment intent might have a number of charges associated to them, these + | could be either pending, captured or refunds. This action takes the charges + | which are associated to the payment intent and stores them against the order. + | + | Reference: https://stripe.com/docs/api/charges/object + */ + 'store_charges' => \Lunar\Stripe\Actions\StoreCharges::class, + ], +]; diff --git a/packages/stripe/resources/views/stripe/components/payment-form.blade.php b/packages/stripe/resources/views/stripe/components/payment-form.blade.php new file mode 100644 index 0000000000..9c94540c44 --- /dev/null +++ b/packages/stripe/resources/views/stripe/components/payment-form.blade.php @@ -0,0 +1,108 @@ +
+ +
+
+ +
+
+ +
+
+
+
\ No newline at end of file diff --git a/packages/stripe/routes/webhooks.php b/packages/stripe/routes/webhooks.php new file mode 100644 index 0000000000..6343a83679 --- /dev/null +++ b/packages/stripe/routes/webhooks.php @@ -0,0 +1,8 @@ +middleware([\Lunar\Stripe\Http\Middleware\StripeWebhookMiddleware::class, 'api']) + ->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class]) + ->name('lunar.stripe.webhook'); diff --git a/packages/stripe/src/Actions/ConstructWebhookEvent.php b/packages/stripe/src/Actions/ConstructWebhookEvent.php new file mode 100644 index 0000000000..e2a5f47dff --- /dev/null +++ b/packages/stripe/src/Actions/ConstructWebhookEvent.php @@ -0,0 +1,16 @@ +isEmpty()) { + return $order; + } + + /** + * Get the most up to date transactions. + */ + $transactions = $order->transactions()->get(); + + foreach ($charges as $charge) { + $timestamp = now()->createFromTimestamp($charge->created); + + $transaction = $transactions->first( + fn ($t) => $t->reference == $charge->id + ) ?: new Transaction; + + $type = 'capture'; + + if (! $charge->captured) { + $type = 'intent'; + } + + if ($charge->amount_refunded && $charge->amount_refunded < $charge->amount) { + $type = 'refund'; + } + + $paymentType = collect($charge->payment_method_details)->keys()->first(); + $paymentDetails = collect($charge->payment_method_details)->first(); + + $lastFour = null; + $cardType = $paymentType; + $meta = []; + + if (! empty($paymentDetails['brand'])) { + $cardType = $paymentDetails['brand']; + } + + if (! empty($paymentDetails['last4'])) { + $lastFour = $paymentDetails['last4']; + } + + if (! empty($paymentDetails['checks'])) { + $meta = array_merge($meta, (array) $paymentDetails['checks']); + } + + $transaction->fill([ + 'order_id' => $order->id, + 'success' => (bool) ! $charge->failure_code, + 'type' => $charge->refunded ? 'refund' : $type, + 'driver' => 'stripe', + 'amount' => $charge->amount, + 'reference' => $charge->id, + 'status' => $charge->status, + 'notes' => $charge->failure_message ?: $charge->description, + 'card_type' => $cardType, + 'last_four' => $lastFour, + 'captured_at' => $charge->amount_captured ? $timestamp : null, + 'meta' => $meta, + ]); + + $transaction->save(); + } + + return $order; + } +} diff --git a/packages/stripe/src/Actions/UpdateOrderFromIntent.php b/packages/stripe/src/Actions/UpdateOrderFromIntent.php new file mode 100644 index 0000000000..6e705985e1 --- /dev/null +++ b/packages/stripe/src/Actions/UpdateOrderFromIntent.php @@ -0,0 +1,45 @@ +id); + + $order = app(StoreCharges::class)->store($order, $charges); + $requiresCapture = $paymentIntent->status === PaymentIntent::STATUS_REQUIRES_CAPTURE; + + $statuses = config('lunar.stripe.status_mapping', []); + + $placedAt = null; + + if ($paymentIntent->status === PaymentIntent::STATUS_SUCCEEDED) { + $placedAt = now(); + } + + if ($charges->isEmpty() && ! $requiresCapture) { + return $order; + } + + $order->update([ + 'status' => $statuses[$paymentIntent->status] ?? $paymentIntent->status, + 'placed_at' => $order->placed_at ?: $placedAt, + ]); + + return $order; + }); + } +} diff --git a/packages/stripe/src/Components/PaymentForm.php b/packages/stripe/src/Components/PaymentForm.php new file mode 100644 index 0000000000..3dac3bd60a --- /dev/null +++ b/packages/stripe/src/Components/PaymentForm.php @@ -0,0 +1,78 @@ +policy = config('stripe.policy', 'capture'); + } + + /** + * Return the client secret for Payment Intent + * + * @return void + */ + public function getClientSecretProperty() + { + $intent = StripeFacade::createIntent($this->cart); + + return $intent->client_secret; + } + + /** + * Return the carts billing address. + * + * @return void + */ + public function getBillingProperty() + { + return $this->cart->billingAddress; + } + + /** + * {@inheritDoc} + */ + public function render() + { + return view('lunar::stripe.components.payment-form'); + } +} diff --git a/packages/stripe/src/Concerns/ConstructsWebhookEvent.php b/packages/stripe/src/Concerns/ConstructsWebhookEvent.php new file mode 100644 index 0000000000..21360f9a24 --- /dev/null +++ b/packages/stripe/src/Concerns/ConstructsWebhookEvent.php @@ -0,0 +1,8 @@ +header('Stripe-Signature'); + + try { + $event = app(ConstructsWebhookEvent::class)->constructEvent( + $request->getContent(), + $stripeSig, + $secret + ); + } catch (UnexpectedValueException|SignatureVerificationException $e) { + Log::error( + $error = $e->getMessage() + ); + + return response(status: 400)->json([ + 'webhook_successful' => false, + 'message' => $error, + ]); + } + + $paymentIntent = $event->data->object->id; + + $cart = Cart::where('meta->payment_intent', '=', $paymentIntent)->first(); + + if (! $cart) { + Log::error( + $error = "Unable to find cart with intent ${paymentIntent}" + ); + + return response(status: 400)->json([ + 'webhook_successful' => false, + 'message' => $error, + ]); + } + + $payment = Payments::driver('stripe')->cart($cart->calculate())->withData([ + 'payment_intent' => $paymentIntent, + ])->authorize(); + + PaymentAttemptEvent::dispatch($payment); + + return response()->json([ + 'webhook_successful' => true, + 'message' => 'Webook handled successfully', + ]); + } +} diff --git a/packages/stripe/src/Http/Middleware/StripeWebhookMiddleware.php b/packages/stripe/src/Http/Middleware/StripeWebhookMiddleware.php new file mode 100644 index 0000000000..0d46382dd5 --- /dev/null +++ b/packages/stripe/src/Http/Middleware/StripeWebhookMiddleware.php @@ -0,0 +1,43 @@ +header('Stripe-Signature'); + + try { + $event = app(ConstructsWebhookEvent::class)->constructEvent( + $request->getContent(), + $stripeSig, + $secret + ); + } catch (UnexpectedValueException|SignatureVerificationException $e) { + abort(400, $e->getMessage()); + } + + if (! in_array( + $event->type, + [ + 'payment_intent.canceled', + 'payment_intent.created', + 'payment_intent.payment_failed', + 'payment_intent.processing', + 'payment_intent.succeeded', + ] + )) { + return response('', 200); + } + + return $next($request); + } +} diff --git a/packages/stripe/src/Managers/StripeManager.php b/packages/stripe/src/Managers/StripeManager.php new file mode 100644 index 0000000000..3df792a7f9 --- /dev/null +++ b/packages/stripe/src/Managers/StripeManager.php @@ -0,0 +1,152 @@ +shippingAddress; + + $meta = (array) $cart->meta; + + if ($meta && ! empty($meta['payment_intent'])) { + $intent = $this->fetchIntent( + $meta['payment_intent'] + ); + + if ($intent) { + return $intent; + } + } + + $paymentIntent = $this->buildIntent( + $cart->total->value, + $cart->currency->code, + $shipping, + ); + + if (! $meta) { + $cart->update([ + 'meta' => [ + 'payment_intent' => $paymentIntent->id, + ], + ]); + } else { + $meta['payment_intent'] = $paymentIntent->id; + $cart->meta = $meta; + $cart->save(); + } + + return $paymentIntent; + } + + public function syncIntent(Cart $cart) + { + $meta = (array) $cart->meta; + + if (empty($meta['payment_intent'])) { + return; + } + + $cart = $cart->calculate(); + + $this->getClient()->paymentIntents->update( + $meta['payment_intent'], + ['amount' => $cart->total->value] + ); + } + + /** + * Fetch an intent from the Stripe API. + * + * @param string $intentId + * @return null|\Stripe\PaymentIntent + */ + public function fetchIntent($intentId) + { + try { + $intent = PaymentIntent::retrieve($intentId); + } catch (InvalidRequestException $e) { + return null; + } + + return $intent; + } + + public function getCharges(string $paymentIntentId): Collection + { + try { + return collect( + $this->getClient()->charges->all([ + 'payment_intent' => $paymentIntentId, + ])['data'] ?? null + ); + } catch (\Exception $e) { + // + } + + return collect(); + } + + public function getCharge($chargeId) + { + return $this->getClient()->charges->retrieve($chargeId); + } + + /** + * Build the intent + * + * @param int $value + * @param string $currencyCode + * @param \Lunar\Models\CartAddress $shipping + * @return \Stripe\PaymentIntent + */ + protected function buildIntent($value, $currencyCode, $shipping) + { + return PaymentIntent::create([ + 'amount' => $value, + 'currency' => $currencyCode, + 'automatic_payment_methods' => ['enabled' => true], + 'capture_method' => config('lunar.stripe.policy', 'automatic'), + 'shipping' => [ + 'name' => "{$shipping->first_name} {$shipping->last_name}", + 'address' => [ + 'city' => $shipping->city, + 'country' => $shipping->country->iso2, + 'line1' => $shipping->line_one, + 'line2' => $shipping->line_two, + 'postal_code' => $shipping->postcode, + 'state' => $shipping->state, + ], + ], + ]); + } +} diff --git a/packages/stripe/src/Pipelines/UpdateOrderFromCharges.php b/packages/stripe/src/Pipelines/UpdateOrderFromCharges.php new file mode 100644 index 0000000000..af6e2e3ddc --- /dev/null +++ b/packages/stripe/src/Pipelines/UpdateOrderFromCharges.php @@ -0,0 +1,13 @@ +stripe = StripeFacade::getClient(); + + $this->policy = config('lunar.stripe.policy', 'automatic'); + } + + /** + * Authorize the payment for processing. + */ + final public function authorize(): PaymentAuthorize + { + $this->order = $this->cart->draftOrder ?: $this->cart->completedOrder; + + if (! $this->order) { + try { + $this->order = $this->cart->createOrder(); + } catch (DisallowMultipleCartOrdersException $e) { + return new PaymentAuthorize( + success: false, + message: $e->getMessage(), + ); + } + } + + $paymentIntentId = $this->data['payment_intent']; + + $this->paymentIntent = $this->stripe->paymentIntents->retrieve( + $paymentIntentId + ); + + if (! $this->paymentIntent) { + return new PaymentAuthorize( + success: false, + message: 'Unable to locate payment intent', + orderId: $this->order->id, + ); + } + + if ($this->paymentIntent->status == PaymentIntent::STATUS_REQUIRES_CAPTURE && $this->policy == 'automatic') { + $this->paymentIntent = $this->stripe->paymentIntents->capture( + $this->data['payment_intent'] + ); + } + + if ($this->cart) { + if (! ($this->cart->meta['payment_intent'] ?? null)) { + $this->cart->update([ + 'meta' => [ + 'payment_intent' => $this->paymentIntent->id, + ], + ]); + } else { + $this->cart->meta['payment_intent'] = $this->paymentIntent->id; + $this->cart->save(); + } + } + + $order = (new UpdateOrderFromIntent)->execute( + $this->order, + $this->paymentIntent + ); + + return new PaymentAuthorize( + success: (bool) $order->placed_at, + message: $this->paymentIntent->last_payment_error, + orderId: $order->id + ); + } + + /** + * Capture a payment for a transaction. + * + * @param int $amount + */ + public function capture(Transaction $transaction, $amount = 0): PaymentCapture + { + $payload = []; + + if ($amount > 0) { + $payload['amount_to_capture'] = $amount; + } + + $charge = StripeFacade::getCharge($transaction->reference); + + $paymentIntent = StripeFacade::fetchIntent($charge->payment_intent); + + try { + $response = $this->stripe->paymentIntents->capture( + $paymentIntent->id, + $payload + ); + } catch (InvalidRequestException $e) { + return new PaymentCapture( + success: false, + message: $e->getMessage() + ); + } + + UpdateOrderFromIntent::execute($transaction->order, $paymentIntent); + + return new PaymentCapture(success: true); + } + + /** + * Refund a captured transaction + * + * @param string|null $notes + */ + public function refund(Transaction $transaction, int $amount = 0, $notes = null): PaymentRefund + { + $charge = StripeFacade::getCharge($transaction->reference); + + try { + $refund = $this->stripe->refunds->create( + ['payment_intent' => $charge->payment_intent, 'amount' => $amount] + ); + } catch (InvalidRequestException $e) { + return new PaymentRefund( + success: false, + message: $e->getMessage() + ); + } + + $transaction->order->transactions()->create([ + 'success' => $refund->status != 'failed', + 'type' => 'refund', + 'driver' => 'stripe', + 'amount' => $refund->amount, + 'reference' => $refund->payment_intent, + 'status' => $refund->status, + 'notes' => $notes, + 'card_type' => $transaction->card_type, + 'last_four' => $transaction->last_four, + ]); + + return new PaymentRefund( + success: true + ); + } +} diff --git a/packages/stripe/src/StripePaymentsServiceProvider.php b/packages/stripe/src/StripePaymentsServiceProvider.php new file mode 100644 index 0000000000..153ea6fe7c --- /dev/null +++ b/packages/stripe/src/StripePaymentsServiceProvider.php @@ -0,0 +1,59 @@ +make(StripePaymentType::class); + }); + + $this->app->bind(ConstructsWebhookEvent::class, function ($app) { + return $app->make(ConstructWebhookEvent::class); + }); + + $this->app->singleton('gc:stripe', function ($app) { + return $app->make(StripeManager::class); + }); + + Blade::directive('stripeScripts', function () { + return <<<'EOT' + + EOT; + }); + + $this->loadViewsFrom(__DIR__.'/../resources/views', 'lunar'); + $this->loadRoutesFrom(__DIR__.'/../routes/webhooks.php'); + $this->mergeConfigFrom(__DIR__.'/../config/stripe.php', 'lunar.stripe'); + + $this->publishes([ + __DIR__.'/../config/stripe.php' => config_path('lunar/stripe.php'), + ], 'lunar.stripe.config'); + + $this->publishes([ + __DIR__.'/../resources/views' => resource_path('views/vendor/lunar'), + ], 'lunar.stripe.components'); + + if (class_exists(Livewire::class)) { + // Register the stripe payment component. + Livewire::component('stripe.payment', PaymentForm::class); + } + } +} diff --git a/phpunit.xml b/phpunit.xml index 0225ed202d..3a2cc8e943 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,6 +18,9 @@ tests/opayo/Feature tests/opayo/Unit + + tests/stripe/Unit + tests/scout-database-engine/Feature tests/scout-database-engine/Unit diff --git a/tests/stripe/Stripe/MockClient.php b/tests/stripe/Stripe/MockClient.php new file mode 100644 index 0000000000..717e8c9ffd --- /dev/null +++ b/tests/stripe/Stripe/MockClient.php @@ -0,0 +1,153 @@ +url = 'https://checkout.stripe.com/pay/cs_test_'.Str::random(32); + } + + public function request($method, $absUrl, $headers, $params, $hasFile) + { + $id = array_slice(explode('/', $absUrl), -1)[0]; + + $policy = config('lunar.stripe.policy'); + + if ($method == 'get' && str_contains($absUrl, 'charges')) { + + $status = 'succeeded'; + $failureCode = null; + + if (($params['payment_intent'] ?? null) == 'PI_FAIL') { + $status = 'failed'; + $failureCode = 'FAILED'; + } + + $this->rBody = $this->getResponse('charges', [ + 'status' => $status, + 'failure_code' => $failureCode, + ]); + + return [$this->rBody, $this->rcode, $this->rheaders]; + } + + if ($method == 'get' && str_contains($absUrl, 'payment_intents')) { + if (str_contains($absUrl, 'PI_CAPTURE')) { + $this->rBody = $this->getResponse('payment_intent_paid', [ + 'id' => $id, + 'status' => 'succeeded', + 'capture_method' => 'automatic', + 'payment_status' => 'succeeded', + 'payment_error' => null, + 'failure_code' => null, + 'captured' => true, + ]); + + return [$this->rBody, $this->rcode, $this->rheaders]; + } + + if (str_contains($absUrl, 'PI_FAIL')) { + $this->rBody = $this->getResponse('payment_intent_paid', [ + 'id' => $id, + 'status' => 'requires_payment_method', + 'capture_method' => 'automatic', + 'payment_status' => 'failed', + 'payment_error' => 'foo', + 'failure_code' => 1234, + 'captured' => false, + ]); + + return [$this->rBody, $this->rcode, $this->rheaders]; + } + + if (str_contains($absUrl, 'PI_REQUIRES_PAYMENT_METHOD')) { + $this->rBody = $this->getResponse('payment_intent_requires_payment_method'); + + return [$this->rBody, $this->rcode, $this->rheaders]; + } + + if (str_contains($absUrl, 'PI_REQUIRES_ACTION')) { + $this->rBody = $this->getResponse('payment_intent_paid', [ + 'id' => $id, + 'status' => PaymentIntent::STATUS_REQUIRES_ACTION, + 'capture_method' => 'automatic', + 'payment_status' => 'failed', + 'payment_error' => 'foo', + 'failure_code' => 1234, + 'captured' => false, + ]); + + return [$this->rBody, $this->rcode, $this->rheaders]; + } + + } + + if ($method == 'post' && str_contains($absUrl, 'payment_intents')) { + $this->rBody = $this->getResponse('payment_intent_created'); + + return [$this->rBody, $this->rcode, $this->rheaders]; + } + + if ($method == 'get' && str_contains($absUrl, 'payment_intents')) { + $this->rBody = $this->getResponse('payment_intent_created', [ + 'id' => $id, + ]); + + return [$this->rBody, $this->rcode, $this->rheaders]; + } + + dd($method, $absUrl, $headers, $params, $hasFile); + + // // Handle Laravel Cashier creating/getting a customer + // if ($method == "get" && strpos($absUrl, "https://api.stripe.com/v1/customers/") === 0) { + // $this->rBody = $this->getCustomer(str_replace("https://api.stripe.com/v1/customers/", "", $absUrl)); + // return [$this->rBody, $this->rcode, $this->rheaders]; + // } + + // if ($method == "post" && $absUrl == "https://api.stripe.com/v1/customers") { + // $this->rBody = $this->getCustomer("cus_".Str::random(14)); + // return [$this->rBody, $this->rcode, $this->rheaders]; + // } + + // // Handle creating a Stripe Checkout session + // if ($method == "post" && $absUrl == "https://api.stripe.com/v1/checkout/sessions") { + // $this->rBody = $this->getSession($this->url); + // return [$this->rBody, $this->rcode, $this->rheaders]; + // } + + // return [$this->rbody, $this->rcode, $this->rheaders]; + } + + /** + * Fetches a response for the mock + * + * @param string $filename + * @param array $replace + * @return string + */ + protected function getResponse($filename, $replace = []) + { + $response = File::get(__DIR__.'/responses/'.$filename.'.json'); + + foreach ($replace as $token => $value) { + $response = str_replace('{'.$token.'}', $value, $response); + } + + return $response; + } +} diff --git a/tests/stripe/Stripe/responses/charge.json b/tests/stripe/Stripe/responses/charge.json new file mode 100644 index 0000000000..86291f26a7 --- /dev/null +++ b/tests/stripe/Stripe/responses/charge.json @@ -0,0 +1,84 @@ +{ + "id": "{id}", + "object": "charge", + "amount": 1099, + "amount_captured": 1099, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_3MmlLrLkdIwHu7ix0uke3Ezy", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "calculated_statement_descriptor": "Stripe", + "captured": true, + "created": 1679090539, + "currency": "usd", + "customer": null, + "description": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": "{failure_code}", + "failure_message": null, + "fraud_details": {}, + "invoice": null, + "livemode": false, + "metadata": {}, + "on_behalf_of": null, + "outcome": { + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 32, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": null, + "payment_method": "card_1MmlLrLkdIwHu7ixIJwEWSNR", + "payment_method_details": { + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": null + }, + "country": "US", + "exp_month": 3, + "exp_year": 2024, + "fingerprint": "mToisGZ01V71BCos", + "funding": "credit", + "installments": null, + "last4": "4242", + "mandate": null, + "network": "visa", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xTTJKVGtMa2RJd0h1N2l4KOvG06AGMgZfBXyr1aw6LBa9vaaSRWU96d8qBwz9z2J_CObiV_H2-e8RezSK_sw0KISesp4czsOUlVKY", + "refunded": false, + "review": null, + "shipping": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "{status}", + "transfer_data": null, + "transfer_group": null +} \ No newline at end of file diff --git a/tests/stripe/Stripe/responses/charges.json b/tests/stripe/Stripe/responses/charges.json new file mode 100644 index 0000000000..a317bf62ff --- /dev/null +++ b/tests/stripe/Stripe/responses/charges.json @@ -0,0 +1,91 @@ +{ + "object": "list", + "url": "/v1/charges", + "has_more": false, + "data": [ + { + "id": "ch_3MmlLrLkdIwHu7ix0snN0B15", + "object": "charge", + "amount": 1099, + "amount_captured": 1099, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_3MmlLrLkdIwHu7ix0uke3Ezy", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "calculated_statement_descriptor": "Stripe", + "captured": true, + "created": 1679090539, + "currency": "usd", + "customer": null, + "description": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": "{failure_code}", + "failure_message": null, + "fraud_details": {}, + "invoice": null, + "livemode": false, + "metadata": {}, + "on_behalf_of": null, + "outcome": { + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 32, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": null, + "payment_method": "card_1MmlLrLkdIwHu7ixIJwEWSNR", + "payment_method_details": { + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": null + }, + "country": "US", + "exp_month": 3, + "exp_year": 2024, + "fingerprint": "mToisGZ01V71BCos", + "funding": "credit", + "installments": null, + "last4": "4242", + "mandate": null, + "network": "visa", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xTTJKVGtMa2RJd0h1N2l4KOvG06AGMgZfBXyr1aw6LBa9vaaSRWU96d8qBwz9z2J_CObiV_H2-e8RezSK_sw0KISesp4czsOUlVKY", + "refunded": false, + "review": null, + "shipping": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "{status}", + "transfer_data": null, + "transfer_group": null + } + ] +} \ No newline at end of file diff --git a/tests/stripe/Stripe/responses/payment_intent_created.json b/tests/stripe/Stripe/responses/payment_intent_created.json new file mode 100644 index 0000000000..14c3edca62 --- /dev/null +++ b/tests/stripe/Stripe/responses/payment_intent_created.json @@ -0,0 +1,47 @@ +{ + "id": "pi_1DqH152eZvKYlo2CFHYZuxkP", + "object": "payment_intent", + "amount": 2000, + "amount_capturable": 0, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [], + "has_more": false, + "url": "/v1/charges?payment_intent=pi_1DqH152eZvKYlo2CFHYZuxkP" + }, + "client_secret": "pi_1DqH152eZvKYlo2CFHYZuxkP_secret_XNCxrfxMZshhdt1VmraRVGMKY", + "confirmation_method": "automatic", + "created": 1546940219, + "currency": "usd", + "customer": null, + "description": null, + "invoice": null, + "last_payment_error": null, + "livemode": false, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_options": {}, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "redaction": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null +} \ No newline at end of file diff --git a/tests/stripe/Stripe/responses/payment_intent_fetched.json b/tests/stripe/Stripe/responses/payment_intent_fetched.json new file mode 100644 index 0000000000..d274260124 --- /dev/null +++ b/tests/stripe/Stripe/responses/payment_intent_fetched.json @@ -0,0 +1,47 @@ +{ + "id": "{id}", + "object": "payment_intent", + "amount": 2000, + "amount_capturable": 0, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [], + "has_more": false, + "url": "/v1/charges?payment_intent=pi_1DqH152eZvKYlo2CFHYZuxkP" + }, + "client_secret": "pi_1DqH152eZvKYlo2CFHYZuxkP_secret_XNCxrfxMZshhdt1VmraRVGMKY", + "confirmation_method": "automatic", + "created": 1546940219, + "currency": "usd", + "customer": null, + "description": null, + "invoice": null, + "last_payment_error": null, + "livemode": false, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_options": {}, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "redaction": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null +} \ No newline at end of file diff --git a/tests/stripe/Stripe/responses/payment_intent_paid.json b/tests/stripe/Stripe/responses/payment_intent_paid.json new file mode 100644 index 0000000000..2bb285f3c4 --- /dev/null +++ b/tests/stripe/Stripe/responses/payment_intent_paid.json @@ -0,0 +1,135 @@ +{ + "id": "{id}", + "object": "payment_intent", + "amount": 1099, + "amount_capturable": 0, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "{capture_method}", + "latest_charge": "ch_3Kj1O52eZvKYlo2C1uoaNKty", + "charges": { + "object": "list", + "data": [ + { + "id": "ch_3Kj1O52eZvKYlo2C1uoaNKty", + "object": "charge", + "amount": 100, + "amount_captured": 0, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_1032HU2eZvKYlo2CEPtcnUvl", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "calculated_statement_descriptor": null, + "captured": "{captured}", + "created": 1648646197, + "currency": "usd", + "customer": null, + "description": "My First Test Charge (created for API docs)", + "disputed": false, + "failure_balance_transaction": null, + "failure_code": "{failure_code}", + "failure_message": null, + "fraud_details": {}, + "invoice": null, + "livemode": false, + "metadata": {}, + "on_behalf_of": null, + "order": null, + "outcome": null, + "paid": true, + "payment_intent": null, + "payment_method": "card_1Kj1O22eZvKYlo2C6wOeM223", + "payment_method_details": { + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 8, + "exp_year": 2023, + "fingerprint": "Xt5EWLLDS7FJjR1c", + "funding": "credit", + "installments": null, + "last4": "4242", + "mandate": null, + "moto": null, + "network": "visa", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/acct_1032D82eZvKYlo2C/ch_3Kj1O52eZvKYlo2C1uoaNKty/rcpt_LPr6IoEA84tnVv2KpRx7eHrhytjjkHn", + "redaction": null, + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "url": "/v1/charges/ch_3Kj1O52eZvKYlo2C1uoaNKty/refunds" + }, + "review": null, + "shipping": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "{payment_status}", + "transfer_data": null, + "transfer_group": null + } + ], + "has_more": false, + "url": "/v1/charges?payment_intent=pi_success" + }, + "client_secret": "pi_1DqH152eZvKYlo2CFHYZuxkP_secret_XNCxrfxMZshhdt1VmraRVGMKY", + "confirmation_method": "automatic", + "created": 1546940219, + "currency": "eur", + "customer": null, + "description": null, + "invoice": null, + "last_payment_error": "{payment_error}", + "livemode": false, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_options": {}, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "redaction": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "{status}", + "transfer_data": null, + "transfer_group": null +} \ No newline at end of file diff --git a/tests/stripe/Stripe/responses/payment_intent_requires_payment_method.json b/tests/stripe/Stripe/responses/payment_intent_requires_payment_method.json new file mode 100644 index 0000000000..14c3edca62 --- /dev/null +++ b/tests/stripe/Stripe/responses/payment_intent_requires_payment_method.json @@ -0,0 +1,47 @@ +{ + "id": "pi_1DqH152eZvKYlo2CFHYZuxkP", + "object": "payment_intent", + "amount": 2000, + "amount_capturable": 0, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [], + "has_more": false, + "url": "/v1/charges?payment_intent=pi_1DqH152eZvKYlo2CFHYZuxkP" + }, + "client_secret": "pi_1DqH152eZvKYlo2CFHYZuxkP_secret_XNCxrfxMZshhdt1VmraRVGMKY", + "confirmation_method": "automatic", + "created": 1546940219, + "currency": "usd", + "customer": null, + "description": null, + "invoice": null, + "last_payment_error": null, + "livemode": false, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_options": {}, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "redaction": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null +} \ No newline at end of file diff --git a/tests/stripe/TestCase.php b/tests/stripe/TestCase.php new file mode 100644 index 0000000000..4d0566ecbb --- /dev/null +++ b/tests/stripe/TestCase.php @@ -0,0 +1,62 @@ +disableLogging(); + + $mockClient = new MockClient(); + ApiRequestor::setHttpClient($mockClient); + } + + protected function getPackageProviders($app) + { + return [ + LunarServiceProvider::class, + BlinkServiceProvider::class, + StripePaymentsServiceProvider::class, + LivewireServiceProvider::class, + MediaLibraryServiceProvider::class, + ActivitylogServiceProvider::class, + ConverterServiceProvider::class, + NestedSetServiceProvider::class, + ]; + } + + protected function getEnvironmentSetUp($app) + { + // perform environment setup + } + + /** + * Define database migrations. + * + * @return void + */ + protected function defineDatabaseMigrations() + { + $this->loadLaravelMigrations(); + } +} diff --git a/tests/stripe/Unit/Actions/StoreChargesTest.php b/tests/stripe/Unit/Actions/StoreChargesTest.php new file mode 100644 index 0000000000..5f8be979e7 --- /dev/null +++ b/tests/stripe/Unit/Actions/StoreChargesTest.php @@ -0,0 +1,47 @@ +createOrder(); + + $paymentIntent = \Lunar\Stripe\Facades\StripeFacade::getClient() + ->paymentIntents + ->retrieve('PI_CAPTURE'); + + $charges = collect($paymentIntent->charges->data); + + $order = app(\Lunar\Stripe\Actions\StoreCharges::class)->store($order, $charges); + + expect($order->transactions)->toHaveCount(1); + + $charge = $charges->first(); + $transaction = $order->transactions->first(); + + expect($transaction->type)->toBe('capture'); + expect($transaction->amount->value)->toBe($charge->amount); + expect($transaction->reference)->toBe($charge->id); +})->group('lunar.stripe.actions'); + +it('updates existing transactions', function () { + $cart = \Lunar\Tests\Stripe\Utils\CartBuilder::build(); + + $order = $cart->createOrder(); + + $paymentIntent = \Lunar\Stripe\Facades\StripeFacade::getClient() + ->paymentIntents + ->retrieve('PI_CAPTURE'); + + $charges = collect($paymentIntent->charges->data); + + $order = app(\Lunar\Stripe\Actions\StoreCharges::class)->store($order, $charges); + + expect($order->transactions)->toHaveCount(1); + + $order = app(\Lunar\Stripe\Actions\StoreCharges::class)->store($order, $charges); + + expect($order->transactions)->toHaveCount(1); + +})->group('lunar.stripe.actions'); diff --git a/tests/stripe/Unit/Actions/UpdateOrderFromIntentTest.php b/tests/stripe/Unit/Actions/UpdateOrderFromIntentTest.php new file mode 100644 index 0000000000..62f40852d4 --- /dev/null +++ b/tests/stripe/Unit/Actions/UpdateOrderFromIntentTest.php @@ -0,0 +1,19 @@ +createOrder(); + + $paymentIntent = \Lunar\Stripe\Facades\StripeFacade::getClient() + ->paymentIntents + ->retrieve('PI_REQUIRES_ACTION'); + + $updatedOrder = \Lunar\Stripe\Actions\UpdateOrderFromIntent::execute($order, $paymentIntent); + + expect($updatedOrder->status)->toBe($order->status); + expect($updatedOrder->placed_at)->toBeNull(); +})->group('lunar.stripe.actions'); diff --git a/tests/stripe/Unit/Http/Middleware/StripeWebookMiddlewareTest.php b/tests/stripe/Unit/Http/Middleware/StripeWebookMiddlewareTest.php new file mode 100644 index 0000000000..c09e8070c3 --- /dev/null +++ b/tests/stripe/Unit/Http/Middleware/StripeWebookMiddlewareTest.php @@ -0,0 +1,23 @@ +group('lunar.stripe.middleware'); + +it('can handle valid event', function () { + $this->app->bind(\Lunar\Stripe\Concerns\ConstructsWebhookEvent::class, function ($app) { + return new class implements \Lunar\Stripe\Concerns\ConstructsWebhookEvent + { + public function constructEvent(string $jsonPayload, string $signature, string $secret) + { + return \Stripe\Event::constructFrom([]); + } + }; + }); + + $request = \Illuminate\Http\Request::create('/strip-webhook', 'POST'); + $request->headers->set('Stripe-Signature', 'foobar'); + $middleware = new \Lunar\Stripe\Http\Middleware\StripeWebhookMiddleware([]); + + $request = $middleware->handle($request, fn ($request) => $request); + + expect($request->status())->toBe(200); +}); diff --git a/tests/stripe/Unit/Managers/StripeManagerTest.php b/tests/stripe/Unit/Managers/StripeManagerTest.php new file mode 100644 index 0000000000..7aa5e77e9c --- /dev/null +++ b/tests/stripe/Unit/Managers/StripeManagerTest.php @@ -0,0 +1,14 @@ +calculate()); + + expect($cart->refresh()->meta['payment_intent'])->toBe('pi_1DqH152eZvKYlo2CFHYZuxkP'); +}); diff --git a/tests/stripe/Unit/StripePaymentTypeTest.php b/tests/stripe/Unit/StripePaymentTypeTest.php new file mode 100644 index 0000000000..0ed2667230 --- /dev/null +++ b/tests/stripe/Unit/StripePaymentTypeTest.php @@ -0,0 +1,113 @@ +cart($cart)->withData([ + 'payment_intent' => 'PI_CAPTURE', + ])->authorize(); + + expect($response)->toBeInstanceOf(PaymentAuthorize::class); + expect($response->success)->toBeTrue(); + expect($cart->refresh()->completedOrder->placed_at)->not()->toBeNull(); + expect($cart->meta['payment_intent'])->toEqual('PI_CAPTURE'); + + assertDatabaseHas((new Transaction)->getTable(), [ + 'order_id' => $cart->refresh()->completedOrder->id, + 'type' => 'capture', + ]); +}); + +it('can handle failed payments', function () { + $cart = CartBuilder::build(); + + $payment = new StripePaymentType; + + $response = $payment->cart($cart)->withData([ + 'payment_intent' => 'PI_FAIL', + ])->authorize(); + + $order = $cart->refresh()->draftOrder; + + expect($response)->toBeInstanceOf(PaymentAuthorize::class); + expect($response->success)->toBeFalse(); + expect($cart->refresh()->completedOrder)->toBeNull(); + expect($cart->refresh()->draftOrder)->not()->toBeNull(); + + assertDatabaseHas((new Transaction)->getTable(), [ + 'order_id' => $order->id, + 'type' => 'capture', + 'success' => false, + ]); +})->group('foo'); + +it('can retrieve existing payment intent', function () { + $cart = CartBuilder::build([ + 'meta' => [ + 'payment_intent' => 'PI_FOOBAR', + ], + ]); + + StripeFacade::createIntent($cart->calculate()); + + expect($cart->refresh()->meta['payment_intent'])->toBe('PI_FOOBAR'); +}); + +it('will fail if cart already has an order', function () { + $cart = CartBuilder::build(); + $order = $cart->createOrder(); + $order->update([ + 'placed_at' => now(), + ]); + + $payment = new StripePaymentType; + + $response = $payment->cart($cart)->withData([ + 'payment_intent' => 'PI_CAPTURE', + ])->authorize(); + + expect($response)->toBeInstanceOf(PaymentAuthorize::class); + expect($response->success)->toBeFalse(); + expect($response->message)->toBe('Carts can only have one order associated to them.'); +}); + +it('will fail if payment intent status is requires_payment_method', function () { + $cart = CartBuilder::build(); + + $payment = new StripePaymentType; + + $response = $payment->cart($cart)->withData([ + 'payment_intent' => 'PI_REQUIRES_PAYMENT_METHOD', + ])->authorize(); + + expect($response)->toBeInstanceOf(PaymentAuthorize::class); + expect($response->success)->toBeFalse(); + + expect($cart->refresh()->completedOrder)->toBeNull(); +}); + +it('create a pending transaction when status is requires_action', function () { + $cart = CartBuilder::build(); + + $payment = new StripePaymentType; + + $response = $payment->cart($cart)->withData([ + 'payment_intent' => 'PI_REQUIRES_ACTION', + ])->authorize(); + + expect($response)->toBeInstanceOf(PaymentAuthorize::class); + expect($response->success)->toBeFalse(); + + expect($cart->refresh()->completedOrder)->toBeNull(); +}); diff --git a/tests/stripe/Unit/TestCase.php b/tests/stripe/Unit/TestCase.php new file mode 100644 index 0000000000..41db6b57fe --- /dev/null +++ b/tests/stripe/Unit/TestCase.php @@ -0,0 +1,16 @@ +create([ + 'default' => true, + ]); + + $currency = Currency::factory()->create([ + 'default' => true, + ]); + + $taxClass = TaxClass::factory()->create(); + + $cart = Cart::factory()->create(array_merge([ + 'currency_id' => $currency->id, + ], $cartParams)); + + ShippingManifest::addOption( + new ShippingOption( + name: 'Basic Delivery', + description: 'Basic test delivery', + identifier: 'BASDEL', + price: new Price(500, $cart->currency, 1), + taxClass: $taxClass + ) + ); + + CartAddress::factory()->create([ + 'cart_id' => $cart->id, + 'shipping_option' => 'BASDEL', + ]); + + CartAddress::factory()->create([ + 'cart_id' => $cart->id, + 'type' => 'billing', + ]); + + $variant = ProductVariant::factory()->create()->each(function ($variant) use ($currency) { + $variant->prices()->create([ + 'price' => 1.99, + 'currency_id' => $currency->id, + ]); + }); + + CartLine::factory()->create([ + 'cart_id' => $cart->id, + 'purchasable_id' => $variant, + ]); + + return $cart; + } +}