diff --git a/.env b/.env index 4a2decb..4e23cff 100644 --- a/.env +++ b/.env @@ -6,3 +6,8 @@ CLIENT_SECRET_SANDBOX= SANDBOX= MERCHANT_ID= + +###> nelmio/cors-bundle ### +TRUSTED_PROXIES=127.0.0.1,localhost,REMOTE_ADDR +CORS_ALLOW_ORIGIN='^https?://(.*)(localhost|127\.0\.0\.1)(:[0-9]+)?$' +###< nelmio/cors-bundle ### \ No newline at end of file diff --git a/.gitignore b/.gitignore index 606eed1..ce399b5 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,8 @@ devenv.local.nix # PhpStorm /.idea/ + + +# js +node_modules/ +bundle/Resources/app/storefront/dist/* \ No newline at end of file diff --git a/bundle/Resources/app/storefront/build/webpack.config.js b/bundle/Resources/app/storefront/build/webpack.config.js new file mode 100644 index 0000000..e0df20c --- /dev/null +++ b/bundle/Resources/app/storefront/build/webpack.config.js @@ -0,0 +1,13 @@ +const { join, resolve } = require('path'); + +module.exports = () => { + return { + resolve: { + alias: { + '@paypal': resolve( + join(__dirname, '..', 'node_modules', '@paypal'), + ), + }, + }, + }; +}; diff --git a/bundle/Resources/app/storefront/package-lock.json b/bundle/Resources/app/storefront/package-lock.json new file mode 100644 index 0000000..e444b1f --- /dev/null +++ b/bundle/Resources/app/storefront/package-lock.json @@ -0,0 +1,44 @@ +{ + "name": "swag-paypal-storefront", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "swag-paypal-storefront", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@paypal/paypal-js": "~8" + } + }, + "node_modules/@paypal/paypal-js": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@paypal/paypal-js/-/paypal-js-8.0.0.tgz", + "integrity": "sha512-wiABljZw5IbvuWkuspmRA/xN9T78zXbEfBzz6Ea94Nxg5hoxs1l9u4rzjO30IEglmIFH3+fLsGla+PfnUzvFSw==", + "dependencies": { + "promise-polyfill": "^8.3.0" + } + }, + "node_modules/promise-polyfill": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", + "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==" + } + }, + "dependencies": { + "@paypal/paypal-js": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@paypal/paypal-js/-/paypal-js-8.0.0.tgz", + "integrity": "sha512-wiABljZw5IbvuWkuspmRA/xN9T78zXbEfBzz6Ea94Nxg5hoxs1l9u4rzjO30IEglmIFH3+fLsGla+PfnUzvFSw==", + "requires": { + "promise-polyfill": "^8.3.0" + } + }, + "promise-polyfill": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", + "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==" + } + } +} diff --git a/bundle/Resources/app/storefront/package.json b/bundle/Resources/app/storefront/package.json new file mode 100644 index 0000000..5d75ed8 --- /dev/null +++ b/bundle/Resources/app/storefront/package.json @@ -0,0 +1,10 @@ +{ + "name": "swag-paypal-storefront", + "version": "1.0.0", + "private": true, + "description": "Shopware Storefront PayPal", + "license": "MIT", + "dependencies": { + "@paypal/paypal-js": "~8" + } +} diff --git a/bundle/Resources/app/storefront/src/checkout/swag-paypal-app.wallet.js b/bundle/Resources/app/storefront/src/checkout/swag-paypal-app.wallet.js new file mode 100644 index 0000000..1bcdd51 --- /dev/null +++ b/bundle/Resources/app/storefront/src/checkout/swag-paypal-app.wallet.js @@ -0,0 +1,170 @@ +import DomAccess from 'src/helper/dom-access.helper'; +import FormSerializeUtil from 'src/utility/form/form-serialize.util'; +import AppClient from 'src/service/app-client.service'; +import PageLoadingIndicatorUtil from 'src/utility/loading-indicator/page-loading-indicator.util'; +import Plugin from 'src/plugin-system/plugin.class'; +import {loadScript} from '@paypal/paypal-js'; + +const BASE_URL = 'http://localhost:8080/storefront/'; + +export default class SwagPaypalAppWallet extends Plugin { + static options = { + /** + * Options for the PayPal script + */ + languageIso: 'en_GB', + currency: 'EUR', + intent: 'capture', + commit: true, + clientId: '', + merchantPayerId: '', + + /** + * Is set, if the plugin is used on the order edit page + */ + orderId: null, + + /** + * Usage inside the plugin + */ + confirmOrderFormSelector: '#confirmOrderForm', + confirmOrderButtonSelector: 'button[type="submit"]', + + /** + * In a productive app, we should filter this to only values that we need for PayPal order creation + */ + cart: {}, + salesChannelContext: {}, + }; + + init() { + this._client = new AppClient('SCDPayPalApp'); + + this.createButton(); + } + + async createButton() { + await this.fetchClientConfig(); + const paypal = await this.createScript(); + this.renderButton(paypal); + } + + createScript() { + return loadScript(this.getScriptOptions()); + } + + renderButton(paypal) { + this.confirmOrderForm = DomAccess.querySelector(document, this.options.confirmOrderFormSelector); + + DomAccess.querySelector(this.confirmOrderForm, this.options.confirmOrderButtonSelector).classList.add('d-none'); + + return paypal.Buttons(this.getButtonConfig()).render(this.el); + } + + + /** + * @return {Object} + */ + getScriptOptions() { + return { + components: 'buttons,messages', + 'client-id': this.options.clientId, + commit: !!this.options.commit, + locale: this.options.languageIso, + currency: this.options.currency, + intent: this.options.intent, + 'enable-funding': 'paylater,venmo', + 'merchant-id': this.options.merchantPayerId, + }; + } + + getButtonConfig() { + return { + style: { + size: 'small', + shape: 'rect', + color: 'gold', + label: 'pay', + }, + + /** + * Will be called if when the payment process starts + */ + createOrder: this.createOrder.bind(this), + + /** + * Will be called if the payment process is approved by paypal + */ + onApprove: this.onApprove.bind(this), + + /** + * Check form validity & show loading spinner on confirm click + */ + onClick: this.onClick.bind(this), + + /** + * Will be called if an error occurs during the payment process. + */ + onError: this.onError.bind(this), + }; + } + + /** + * @return {Promise} + */ + async createOrder() { + if (!this.confirmOrderForm.checkValidity()) { + throw new Error('Checkout form not valid'); + } + + const data = { + formData: FormSerializeUtil.serializeJson(this.confirmOrderForm), + cart: this.options.cart, + salesChannelContext: this.options.salesChannelContext, + orderId: this.options.orderId, + } + + const response = await this._client.post( + `${BASE_URL}order/`, + { + body: JSON.stringify(data), + } + ).then((response) => response.json()); + + return response.orderId; + } + + onApprove(data) { + PageLoadingIndicatorUtil.create(); + + const input = document.createElement('input'); + input.setAttribute('type', 'hidden'); + input.setAttribute('name', 'paypalOrderId'); + input.setAttribute('value', data.orderID); + + this.confirmOrderForm.appendChild(input); + this.confirmOrderForm.submit(); + } + + onClick(data, actions) { + if (!this.confirmOrderForm.checkValidity()) { + return actions.reject(); + } + + return actions.resolve(); + } + + onError(error) { + console.error(error); + window.location.reload(); + } + + async fetchClientConfig() { + const response = await this._client.get(`${BASE_URL}config/`); + + this.options = { + ...this.options, + ...await response.json(), + }; + } +} diff --git a/bundle/Resources/app/storefront/src/main.js b/bundle/Resources/app/storefront/src/main.js new file mode 100644 index 0000000..c75d0f2 --- /dev/null +++ b/bundle/Resources/app/storefront/src/main.js @@ -0,0 +1,7 @@ +// Register them via the existing PluginManager +const PluginManager = window.PluginManager; +PluginManager.register( + 'SwagPaypalAppWallet', + () => import('./checkout/swag-paypal-app.wallet'), + '[data-swag-paypal-app-wallet]', +); diff --git a/bundle/Resources/views/storefront/page/checkout/confirm/index.html.twig b/bundle/Resources/views/storefront/page/checkout/confirm/index.html.twig new file mode 100644 index 0000000..cbd031d --- /dev/null +++ b/bundle/Resources/views/storefront/page/checkout/confirm/index.html.twig @@ -0,0 +1,19 @@ +{% sw_extends '@Storefront/storefront/page/checkout/confirm/index.html.twig' %} + +{% block page_checkout_confirm_form_submit %} + {{ parent() }} + + {% block page_checkout_confirm_form_submit_paypal_app_spb %} + + {% if (context.paymentMethod.technicalName === 'payment_SCDPayPalApp_paypal') and page.order is not defined %} +
+
+ {% endif %} + + {% endblock %} +{% endblock %} diff --git a/bundle/manifest.xml b/bundle/manifest.xml index 012e99d..2ea5d25 100644 --- a/bundle/manifest.xml +++ b/bundle/manifest.xml @@ -5,18 +5,24 @@ SCDPayPalApp - - shopware AG (c) by shopware AG - 0.0.2 - Resources/config/plugin.png + 0.0.3 MIT http://localhost:8080/lifecycle/register devsecret + + sales_channel + customer + currency + country + language + payment_method + shipping_method + diff --git a/composer.json b/composer.json index 393bd4b..0039267 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "ext-ctype": "*", "ext-iconv": "*", "doctrine/doctrine-migrations-bundle": "^3.3", + "nelmio/cors-bundle": "^2.3", "shopware/app-bundle": "^2.0", "symfony/console": "7.0.*", "symfony/dotenv": "7.0.*", @@ -28,6 +29,15 @@ }, "sort-packages": true }, + "repositories": [ + { + "type": "path", + "url": "../app*", + "options": { + "symlink": true + } + } + ], "autoload": { "psr-4": { "Swag\\PayPalApp\\": "src/" @@ -68,5 +78,9 @@ "allow-contrib": false, "require": "7.0.*" } + }, + "require-dev": { + "symfony/debug-bundle": "7.0.*", + "symfony/web-profiler-bundle": "7.0.*" } } diff --git a/composer.lock b/composer.lock index 2e12e7a..3d53c65 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "15d75d194390171d29eb0a1afcaf63e4", + "content-hash": "7d4f4949ea726801ccdee3ed10ff7df4", "packages": [ { "name": "doctrine/cache", @@ -1567,6 +1567,68 @@ ], "time": "2023-10-27T15:32:31+00:00" }, + { + "name": "nelmio/cors-bundle", + "version": "2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nelmio/NelmioCorsBundle.git", + "reference": "78fcdb91f76b080a1008133def9c7f613833933d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nelmio/NelmioCorsBundle/zipball/78fcdb91f76b080a1008133def9c7f613833933d", + "reference": "78fcdb91f76b080a1008133def9c7f613833933d", + "shasum": "" + }, + "require": { + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.3.6", + "symfony/phpunit-bridge": "^5.4 || ^6.0 || ^7.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Nelmio\\CorsBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nelmio", + "homepage": "http://nelm.io" + }, + { + "name": "Symfony Community", + "homepage": "https://github.com/nelmio/NelmioCorsBundle/contributors" + } + ], + "description": "Adds CORS (Cross-Origin Resource Sharing) headers support in your Symfony application", + "keywords": [ + "api", + "cors", + "crossdomain" + ], + "support": { + "issues": "https://github.com/nelmio/NelmioCorsBundle/issues", + "source": "https://github.com/nelmio/NelmioCorsBundle/tree/2.4.0" + }, + "time": "2023-11-30T16:41:19+00:00" + }, { "name": "nikic/php-parser", "version": "v5.0.2", @@ -2299,17 +2361,11 @@ }, { "name": "shopware/app-php-sdk", - "version": "2.0.3", - "source": { - "type": "git", - "url": "https://github.com/shopware/app-php-sdk.git", - "reference": "62250ca75f58add692ef523e1b6e90dde28b66dd" - }, + "version": "2.99.99", "dist": { - "type": "zip", - "url": "https://api.github.com/repos/shopware/app-php-sdk/zipball/62250ca75f58add692ef523e1b6e90dde28b66dd", - "reference": "62250ca75f58add692ef523e1b6e90dde28b66dd", - "shasum": "" + "type": "path", + "url": "../app-php-sdk", + "reference": "9be771166087643aa887cf4a1ab9fe4b0ce0dddf" }, "require": { "lcobucci/clock": "^3", @@ -2340,7 +2396,21 @@ "Shopware\\App\\SDK\\": "src/" } }, - "notification-url": "https://packagist.org/downloads/", + "autoload-dev": { + "psr-4": { + "Shopware\\App\\SDK\\Tests\\": "tests/" + } + }, + "scripts": { + "test": [ + "phpunit" + ], + "check": [ + "phpunit", + "php-cs-fixer fix", + "phpstan analyse" + ] + }, "license": [ "MIT" ], @@ -2351,14 +2421,16 @@ "shopware" ], "support": { - "chat": "https://slack.shopware.com", - "docs": "https://developer.shopware.com", - "forum": "https://forum.shopware.com", "issues": "https://issues.shopware.com", - "source": "https://github.com/shopware/app-php-sdk/tree/2.0.3", - "wiki": "https://developer.shopware.com" + "forum": "https://forum.shopware.com", + "wiki": "https://developer.shopware.com", + "docs": "https://developer.shopware.com", + "chat": "https://slack.shopware.com" }, - "time": "2023-12-09T10:22:41+00:00" + "transport-options": { + "symlink": true, + "relative": true + } }, { "name": "symfony/cache", @@ -3957,16 +4029,16 @@ }, { "name": "symfony/maker-bundle", - "version": "v1.57.0", + "version": "v1.58.0", "source": { "type": "git", "url": "https://github.com/symfony/maker-bundle.git", - "reference": "2c90181911241648356b828b86b04fe3571aca0b" + "reference": "c4f8d2c5d55950e1a49e822efc83a8511bee8a36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/2c90181911241648356b828b86b04fe3571aca0b", - "reference": "2c90181911241648356b828b86b04fe3571aca0b", + "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/c4f8d2c5d55950e1a49e822efc83a8511bee8a36", + "reference": "c4f8d2c5d55950e1a49e822efc83a8511bee8a36", "shasum": "" }, "require": { @@ -4029,7 +4101,7 @@ ], "support": { "issues": "https://github.com/symfony/maker-bundle/issues", - "source": "https://github.com/symfony/maker-bundle/tree/v1.57.0" + "source": "https://github.com/symfony/maker-bundle/tree/v1.58.0" }, "funding": [ { @@ -4045,7 +4117,7 @@ "type": "tidelift" } ], - "time": "2024-03-22T12:00:21+00:00" + "time": "2024-04-06T15:08:12+00:00" }, { "name": "symfony/monolog-bridge", @@ -5463,7 +5535,505 @@ "time": "2024-03-23T06:35:46+00:00" } ], - "packages-dev": [], + "packages-dev": [ + { + "name": "symfony/debug-bundle", + "version": "v7.0.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/debug-bundle.git", + "reference": "b0db5c443883ce5c10c2265c77feb9833c3d9d6d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/debug-bundle/zipball/b0db5c443883ce5c10c2265c77feb9833c3d9d6d", + "reference": "b0db5c443883ce5c10c2265c77feb9833c3d9d6d", + "shasum": "" + }, + "require": { + "ext-xml": "*", + "php": ">=8.2", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/twig-bridge": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4" + }, + "require-dev": { + "symfony/config": "^6.4|^7.0", + "symfony/web-profiler-bundle": "^6.4|^7.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\DebugBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration of the Symfony VarDumper component and the ServerLogCommand from MonologBridge into the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/debug-bundle/tree/v7.0.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-01-23T15:02:46+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.4.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "43810bdb2ddb5400e5c5e778e27b210a0ca83b6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/43810bdb2ddb5400e5c5e778e27b210a0ca83b6b", + "reference": "43810bdb2ddb5400e5c5e778e27b210a0ca83b6b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.4-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.4.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-01-23T14:51:35+00:00" + }, + { + "name": "symfony/twig-bridge", + "version": "v7.0.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/twig-bridge.git", + "reference": "1d5745dac2e043553177a3b88a76b99c2a2f6c2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/1d5745dac2e043553177a3b88a76b99c2a2f6c2e", + "reference": "1d5745dac2e043553177a3b88a76b99c2a2f6c2e", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/translation-contracts": "^2.5|^3", + "twig/twig": "^3.0.4" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/console": "<6.4", + "symfony/form": "<6.4", + "symfony/http-foundation": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/mime": "<6.4", + "symfony/serializer": "<6.4", + "symfony/translation": "<6.4", + "symfony/workflow": "<6.4" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "symfony/asset": "^6.4|^7.0", + "symfony/asset-mapper": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/form": "^6.4|^7.0", + "symfony/html-sanitizer": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/polyfill-intl-icu": "~1.0", + "symfony/property-info": "^6.4|^7.0", + "symfony/routing": "^6.4|^7.0", + "symfony/security-acl": "^2.8|^3.0", + "symfony/security-core": "^6.4|^7.0", + "symfony/security-csrf": "^6.4|^7.0", + "symfony/security-http": "^6.4|^7.0", + "symfony/serializer": "^6.4.3|^7.0.3", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/translation": "^6.4|^7.0", + "symfony/web-link": "^6.4|^7.0", + "symfony/workflow": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0", + "twig/cssinliner-extra": "^2.12|^3", + "twig/inky-extra": "^2.12|^3", + "twig/markdown-extra": "^2.12|^3" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Twig\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides integration for Twig with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/twig-bridge/tree/v7.0.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-03-28T21:02:11+00:00" + }, + { + "name": "symfony/twig-bundle", + "version": "v7.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/twig-bundle.git", + "reference": "acab2368f53491e018bf31ef48b39df55a6812ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/acab2368f53491e018bf31ef48b39df55a6812ef", + "reference": "acab2368f53491e018bf31ef48b39df55a6812ef", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "php": ">=8.2", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/twig-bridge": "^6.4|^7.0", + "twig/twig": "^3.0.4" + }, + "conflict": { + "symfony/framework-bundle": "<6.4", + "symfony/translation": "<6.4" + }, + "require-dev": { + "symfony/asset": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/form": "^6.4|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/routing": "^6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/translation": "^6.4|^7.0", + "symfony/web-link": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\TwigBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration of Twig into the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/twig-bundle/tree/v7.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-02-15T11:33:06+00:00" + }, + { + "name": "symfony/web-profiler-bundle", + "version": "v7.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/web-profiler-bundle.git", + "reference": "542daea1345fe181cbfd52db00717174a838ea0a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/542daea1345fe181cbfd52db00717174a838ea0a", + "reference": "542daea1345fe181cbfd52db00717174a838ea0a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/config": "^6.4|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/routing": "^6.4|^7.0", + "symfony/twig-bundle": "^6.4|^7.0", + "twig/twig": "^3.0.4" + }, + "conflict": { + "symfony/form": "<6.4", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4" + }, + "require-dev": { + "symfony/browser-kit": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/css-selector": "^6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\WebProfilerBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a development tool that gives detailed information about the execution of any request", + "homepage": "https://symfony.com", + "keywords": [ + "dev" + ], + "support": { + "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-02-22T20:27:20+00:00" + }, + { + "name": "twig/twig", + "version": "v3.8.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "9d15f0ac07f44dc4217883ec6ae02fd555c6f71d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/9d15f0ac07f44dc4217883ec6ae02fd555c6f71d", + "reference": "9d15f0ac07f44dc4217883ec6ae02fd555c6f71d", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3", + "symfony/polyfill-php80": "^1.22" + }, + "require-dev": { + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.3|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.8.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2023-11-21T18:54:41+00:00" + } + ], "aliases": [], "minimum-stability": "stable", "stability-flags": [], diff --git a/config/bundles.php b/config/bundles.php index 5173c31..c2f01ed 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -10,4 +10,5 @@ Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], + Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], ]; diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 877eb25..4756e36 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -9,6 +9,9 @@ framework: #esi: true #fragments: true + trusted_proxies: '%env(TRUSTED_PROXIES)%' + trusted_headers: [ 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'x-forwarded-prefix', 'forwarded' ] + when@test: framework: test: true diff --git a/config/packages/nelmio_cors.yaml b/config/packages/nelmio_cors.yaml new file mode 100644 index 0000000..8437198 --- /dev/null +++ b/config/packages/nelmio_cors.yaml @@ -0,0 +1,8 @@ +nelmio_cors: + defaults: + origin_regex: true + allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] + allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'] + allow_headers: ['Content-Type', 'Authorization', 'shopware-app-shop-id', 'shopware-app-token'] + expose_headers: ['Link'] + max_age: 3600 diff --git a/src/Api/Client/ClientFactory.php b/src/Api/Client/ClientFactory.php index 0105e3f..78bc425 100644 --- a/src/Api/Client/ClientFactory.php +++ b/src/Api/Client/ClientFactory.php @@ -3,6 +3,7 @@ namespace Swag\PayPalApp\Api\Client; use Swag\PayPalApp\Api\Constants; +use Swag\PayPalApp\Repository\CredentialsRepository; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpClient\HttpOptions; @@ -12,24 +13,21 @@ class ClientFactory { public function __construct( private readonly AuthenticationBuilder $authenticationBuilder, - #[Autowire(env: 'MERCHANT_ID')] - private readonly string $merchantId, - #[Autowire(env: 'SANDBOX')] - private readonly bool $sandbox, + private readonly CredentialsRepository $credentialsRepository, ) { } public function createFirstPartyClient(ApiContext $context): HttpClientInterface { - $shopConfig = $this->getShopConfig($context); + $shopConfig = $this->credentialsRepository->getShopConfig($context); return HttpClient::create($this->getDefaultHttpOptions($shopConfig)->toArray()); } public function createThirdPartyClient(ApiContext $context): HttpClientInterface { - $shopConfig = $this->getShopConfig($context); + $shopConfig = $this->credentialsRepository->getShopConfig($context); $httpOptions = $this->getDefaultHttpOptions($shopConfig); $httpOptions->setHeaders([ 'PayPal-Auth-Assertion' => $this->authenticationBuilder->getAuthAssertion($shopConfig->isSandbox(), $shopConfig->getMerchantId()), @@ -39,7 +37,6 @@ public function createThirdPartyClient(ApiContext $context): HttpClientInterface return HttpClient::create($httpOptions->toArray()); } - private function getDefaultHttpOptions(CredentialsIdentifier $shopConfig): HttpOptions { $httpOptions = new HttpOptions(); @@ -48,11 +45,4 @@ private function getDefaultHttpOptions(CredentialsIdentifier $shopConfig): HttpO return $httpOptions; } - - private function getShopConfig(ApiContext $context): CredentialsIdentifier - { - // TODO: build a repository to do this - - return new CredentialsIdentifier($this->merchantId, $this->sandbox); - } } \ No newline at end of file diff --git a/src/Api/Client/CredentialsIdentifier.php b/src/Api/Client/CredentialsIdentifier.php index b073774..8eba378 100644 --- a/src/Api/Client/CredentialsIdentifier.php +++ b/src/Api/Client/CredentialsIdentifier.php @@ -5,12 +5,18 @@ class CredentialsIdentifier { public function __construct( + private readonly string $clientId, private readonly ?string $merchantId, private readonly bool $sandbox, ) { } + public function getClientId(): string + { + return $this->clientId; + } + public function getMerchantId(): ?string { return $this->merchantId; diff --git a/src/Api/Converter/Util/PurchaseUnitProvider.php b/src/Api/Converter/Util/PurchaseUnitProvider.php index 9c7553c..4db6bc9 100644 --- a/src/Api/Converter/Util/PurchaseUnitProvider.php +++ b/src/Api/Converter/Util/PurchaseUnitProvider.php @@ -78,7 +78,8 @@ public function createPurchaseUnit( private function createShipping(?Customer $customer, ?ShopwareOrder $order): ?Shipping { - $shippingAddress = \current($order?->getDeliveries() ?? [])?->getShippingOrderAddress() ?? $customer?->getActiveShippingAddress(); + $delivery = \current($order?->getDeliveries() ?? []); + $shippingAddress = $delivery ? $delivery->getShippingOrderAddress() : $customer?->getActiveShippingAddress(); if ($shippingAddress === null) { return null; } diff --git a/src/Api/Gateway/OrderGateway.php b/src/Api/Gateway/OrderGateway.php index a8f9df2..c85e92e 100644 --- a/src/Api/Gateway/OrderGateway.php +++ b/src/Api/Gateway/OrderGateway.php @@ -54,9 +54,9 @@ public function captureOrder(string $orderId, ApiContext $context): Order ); } - public function patchOrder(string $orderId, PatchCollection $patches, ApiContext $context): Order + public function patchOrder(string $orderId, PatchCollection $patches, ApiContext $context): void { - return $this->request( + $this->request( 'PATCH', self::GATEWAY_URL . '/' . $orderId, $patches, diff --git a/src/Controller/Payment/PayPalController.php b/src/Controller/Payment/PayPalController.php index dca076c..c78938d 100644 --- a/src/Controller/Payment/PayPalController.php +++ b/src/Controller/Payment/PayPalController.php @@ -14,7 +14,9 @@ use Swag\PayPalApp\Api\Converter\PayPalOrderBuilder; use Swag\PayPalApp\Api\Gateway\OrderGateway; use Swag\PayPalApp\Api\Struct\V2\Common\Link; +use Swag\PayPalApp\Api\Struct\V2\PatchCollection; use Swag\PayPalApp\Service\OrderExecuteService; +use Swag\PayPalApp\Service\PatchBuilder; use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\Routing\Attribute\Route; @@ -28,12 +30,25 @@ public function __construct( private readonly OrderGateway $orderGateway, private readonly ResponseSigner $responseSigner, private readonly OrderExecuteService $orderExecuteService, + private readonly PatchBuilder $patchBuilder, ) { } #[Route('/pay', name: 'payment.paypal.pay', methods: ['POST'])] public function pay(PaymentPayAction $payAction, ShopInterface $shop): ResponseInterface { + $orderId = $payAction->requestData['paypalOrderId']; + if ($orderId ?? null) { + $apiContext = new ApiContext($payAction->order->getSalesChannelId(), $shop, preferRepresentation: true); + + $patch = $this->patchBuilder->createPurchaseUnitPatch($payAction->order, $payAction->orderTransaction); + $this->orderGateway->patchOrder($orderId, new PatchCollection([$patch]), $apiContext); + + $response = PaymentResponse::redirect($payAction->returnUrl . '&' . \http_build_query(['token' => $orderId])); + + return $this->responseSigner->signResponse($response, $shop); + } + $order = $this->orderBuilder->getOrder($payAction, new ParameterBag($payAction->requestData)); $apiContext = new ApiContext($payAction->order->getSalesChannelId(), $shop, preferRepresentation: true); diff --git a/src/Controller/Storefront/ConfigController.php b/src/Controller/Storefront/ConfigController.php new file mode 100644 index 0000000..a5b8a42 --- /dev/null +++ b/src/Controller/Storefront/ConfigController.php @@ -0,0 +1,48 @@ +claims->getSalesChannelId(), + $action->shop, + ); + $credentials = $this->credentialsRepository->getShopConfig($apiContext); + + return new JsonResponse([ + 'clientId' => $credentials->getClientId(), + 'merchantPayerId' => $credentials->getMerchantId(), + ]); + } +} diff --git a/src/Controller/Storefront/OrderController.php b/src/Controller/Storefront/OrderController.php new file mode 100644 index 0000000..eded10d --- /dev/null +++ b/src/Controller/Storefront/OrderController.php @@ -0,0 +1,47 @@ +getContent(), true, 512, JSON_THROW_ON_ERROR); + $cart = new Cart($body['cart']); + $salesChannelContext = new SalesChannelContext($body['salesChannelContext']); + $formData = new ParameterBag($body['formData'] ?? []); + + $order = $this->orderBuilder->getOrderFromCart($cart, $salesChannelContext, $formData); + + $apiContext = new ApiContext($action->claims->getSalesChannelId(), $action->shop, preferRepresentation: true); + $order = $this->orderGateway->createOrder($order, $apiContext); + + return new JsonResponse([ + 'orderId' => $order->getId(), + ]); + } +} diff --git a/src/Repository/CredentialsRepository.php b/src/Repository/CredentialsRepository.php new file mode 100644 index 0000000..afb1af7 --- /dev/null +++ b/src/Repository/CredentialsRepository.php @@ -0,0 +1,33 @@ +sandbox ? $this->clientIdSandbox : $this->clientId, $this->merchantId, $this->sandbox); + } +} \ No newline at end of file diff --git a/src/Service/PatchBuilder.php b/src/Service/PatchBuilder.php new file mode 100644 index 0000000..933a359 --- /dev/null +++ b/src/Service/PatchBuilder.php @@ -0,0 +1,53 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Swag\PayPalApp\Service; + +use Shopware\App\SDK\Context\Cart\CartPrice; +use Shopware\App\SDK\Context\Order\Order; +use Shopware\App\SDK\Context\Order\OrderTransaction; +use Swag\PayPalApp\Api\Converter\Util\ItemListProvider; +use Swag\PayPalApp\Api\Converter\Util\PurchaseUnitProvider; +use Swag\PayPalApp\Api\Struct\V2\Patch; + +class PatchBuilder +{ + /** + * @internal + */ + public function __construct( + private readonly PurchaseUnitProvider $purchaseUnitProvider, + private readonly ItemListProvider $itemListProvider, + ) { + } + + public function createPurchaseUnitPatch( + Order $order, + OrderTransaction $orderTransaction, + ): Patch { + $purchaseUnit = $this->purchaseUnitProvider->createPurchaseUnit( + $orderTransaction->getAmount(), + $order->getShippingCosts(), + null, + $this->itemListProvider->getItemList($order->getCurrency(), $order), + $order->getCurrency(), + $order->getTaxStatus() !== CartPrice::TAX_STATE_GROSS, /* @phpstan-ignore-line */ + $order, + $orderTransaction + ); + $purchaseUnitArray = \json_decode((string) \json_encode($purchaseUnit), true); + + $purchaseUnitPatch = new Patch(); + $purchaseUnitPatch->assign([ + 'op' => Patch::OPERATION_REPLACE, + 'path' => '/purchase_units/@reference_id==\'default\'', + ]); + $purchaseUnitPatch->setValue($purchaseUnitArray); + + return $purchaseUnitPatch; + } +} diff --git a/symfony.lock b/symfony.lock index 606b526..93fba2f 100644 --- a/symfony.lock +++ b/symfony.lock @@ -26,6 +26,18 @@ "migrations/.gitignore" ] }, + "nelmio/cors-bundle": { + "version": "2.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.5", + "ref": "6bea22e6c564fba3a1391615cada1437d0bde39c" + }, + "files": [ + "config/packages/nelmio_cors.yaml" + ] + }, "php-http/discovery": { "version": "1.19", "recipe": {