From 634b095cc41c2be759769105adc155b06f5e3881 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Wed, 8 Jan 2025 21:08:22 +0530 Subject: [PATCH 01/30] oAuth for Stripe --- api/.env.example | 4 + .../OAuth/Drivers/OAuthStripeDriver.php | 80 +++++++++++++++++++ .../OAuth/OAuthProviderService.php | 8 +- api/app/Providers/AppServiceProvider.php | 10 +++ api/config/services.php | 5 ++ client/components/settings/ProviderModal.vue | 4 +- client/stores/oauth_providers.js | 8 +- 7 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 api/app/Integrations/OAuth/Drivers/OAuthStripeDriver.php diff --git a/api/.env.example b/api/.env.example index 445431f0c..d8105dcff 100644 --- a/api/.env.example +++ b/api/.env.example @@ -94,3 +94,7 @@ GOOGLE_AUTH_REDIRECT_URL=http://localhost:3000/oauth/google/callback GOOGLE_FONTS_API_KEY= ZAPIER_ENABLED=false + +STRIPE_CLIENT_ID= +STRIPE_CLIENT_SECRET= +STRIPE_REDIRECT_URI=http://localhost:3000/settings/connections/callback/stripe \ No newline at end of file diff --git a/api/app/Integrations/OAuth/Drivers/OAuthStripeDriver.php b/api/app/Integrations/OAuth/Drivers/OAuthStripeDriver.php new file mode 100644 index 000000000..3c69ef58d --- /dev/null +++ b/api/app/Integrations/OAuth/Drivers/OAuthStripeDriver.php @@ -0,0 +1,80 @@ +provider = Socialite::driver('stripe'); + } + + public function getRedirectUrl(): string + { + $user = Auth::user(); + + $params = [ + 'stripe_user[email]' => $user->email, + 'stripe_user[url]' => config('app.url'), + 'stripe_user[business_name]' => $user->workspace->name ?? null, + ]; + + ray('params', $params); + \Log::info('Initiating Stripe Connect flow', [ + 'user_id' => $user->id, + 'workspace_id' => $user->workspace_id + ]); + + return $this->provider + ->scopes($this->scopes ?? []) + ->with($params) + ->redirect() + ->getTargetUrl(); + } + + public function getUser(): User + { + return $this->provider + ->stateless() + ->redirectUrl($this->redirectUrl ?? config('services.stripe.redirect')) + ->user(); + } + + public function canCreateUser(): bool + { + return true; + } + + public function setRedirectUrl(string $url): OAuthDriver + { + $this->redirectUrl = $url; + return $this; + } + + public function setScopes(array $scopes): OAuthDriver + { + $this->scopes = $scopes; + return $this; + } + + public function fullScopes(): OAuthDriver + { + return $this->setScopes([ + 'read_write', // Basic account access + 'payments', // Process payments + 'payment_method', // Access to payment methods + 'transfers', // Required for platform fees/transfers + 'application_fees', // Required for platform fees + ]); + } +} diff --git a/api/app/Integrations/OAuth/OAuthProviderService.php b/api/app/Integrations/OAuth/OAuthProviderService.php index 2ef4bd7f2..4ab4e51aa 100644 --- a/api/app/Integrations/OAuth/OAuthProviderService.php +++ b/api/app/Integrations/OAuth/OAuthProviderService.php @@ -4,15 +4,19 @@ use App\Integrations\OAuth\Drivers\Contracts\OAuthDriver; use App\Integrations\OAuth\Drivers\OAuthGoogleDriver; +use App\Integrations\OAuth\Drivers\OAuthStripeDriver; enum OAuthProviderService: string { case Google = 'google'; + case Stripe = 'stripe'; public function getDriver(): OAuthDriver { - return match($this) { - self::Google => new OAuthGoogleDriver() + ray('getDriver', $this); + return match ($this) { + self::Google => new OAuthGoogleDriver(), + self::Stripe => new OAuthStripeDriver() }; } } diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index df71bc267..3f1ed68d6 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Integrations\OAuth\Drivers\OAuthStripeDriver; use App\Models\Billing\Subscription; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Facades\Schema; @@ -11,6 +12,7 @@ use Illuminate\Support\ServiceProvider; use Laravel\Cashier\Cashier; use Laravel\Dusk\DuskServiceProvider; +use Laravel\Socialite\Facades\Socialite; class AppServiceProvider extends ServiceProvider { @@ -40,6 +42,14 @@ public function boot() } Validator::includeUnvalidatedArrayKeys(); + + Socialite::extend('stripe', function ($app) { + $config = $app['config']['services.stripe']; + return Socialite::buildProvider( + OAuthStripeDriver::class, + $config + ); + }); } /** diff --git a/api/config/services.php b/api/config/services.php index eda2e4708..bc1a5bcbe 100644 --- a/api/config/services.php +++ b/api/config/services.php @@ -83,4 +83,9 @@ 'enabled' => env('ZAPIER_ENABLED', false), ], + 'stripe' => [ + 'client_id' => env('STRIPE_CLIENT_ID'), + 'client_secret' => env('STRIPE_CLIENT_SECRET'), + 'redirect' => env('STRIPE_REDIRECT_URI'), + ] ]; diff --git a/client/components/settings/ProviderModal.vue b/client/components/settings/ProviderModal.vue index 7b355eedf..d6abc1d42 100644 --- a/client/components/settings/ProviderModal.vue +++ b/client/components/settings/ProviderModal.vue @@ -25,12 +25,12 @@ Connect account -
+
{ { name: 'google', title: 'Google', - icon: 'mdi:google', + icon: 'cib:google', + enabled: true + }, + { + name: 'stripe', + title: 'Stripe', + icon: 'cib:stripe', enabled: true } ] From 182cdd65ebcebe4cc44c209ccb866c747103cb4c Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Thu, 9 Jan 2025 22:23:29 +0530 Subject: [PATCH 02/30] Stripe Payment Frontend - WIP --- client/.env.example | 1 + .../components/forms/PaymentInput.client.vue | 68 ++++++++++ .../components/open/forms/OpenFormField.vue | 6 + .../forms/fields/components/BlockOptions.vue | 128 +++++++++++++++++- client/data/blocks_types.json | 9 ++ client/package-lock.json | 28 ++-- client/package.json | 2 + client/runtimeConfig.js | 2 + client/stores/oauth_providers.js | 4 +- client/stores/working_form.js | 7 + 10 files changed, 239 insertions(+), 16 deletions(-) create mode 100644 client/components/forms/PaymentInput.client.vue diff --git a/client/.env.example b/client/.env.example index 261d3e267..801b73101 100644 --- a/client/.env.example +++ b/client/.env.example @@ -8,3 +8,4 @@ NUXT_PUBLIC_GOOGLE_ANALYTICS_CODE= NUXT_PUBLIC_H_CAPTCHA_SITE_KEY= NUXT_PUBLIC_RE_CAPTCHA_SITE_KEY= NUXT_API_SECRET=secret +STRIPE_PUBLISHABLE_KEY= \ No newline at end of file diff --git a/client/components/forms/PaymentInput.client.vue b/client/components/forms/PaymentInput.client.vue new file mode 100644 index 000000000..31882892e --- /dev/null +++ b/client/components/forms/PaymentInput.client.vue @@ -0,0 +1,68 @@ + + + \ No newline at end of file diff --git a/client/components/open/forms/OpenFormField.vue b/client/components/open/forms/OpenFormField.vue index 8dbb4ad7d..c008e7f2a 100644 --- a/client/components/open/forms/OpenFormField.vue +++ b/client/components/open/forms/OpenFormField.vue @@ -208,6 +208,9 @@ export default { if (field.type === 'phone_number' && !field.use_simple_text_input) { return 'PhoneInput' } + if (field.type === 'nf-payment') { + return 'PaymentInput' + } return { text: 'TextInput', rich_text: 'RichTextAreaInput', @@ -398,6 +401,9 @@ export default { inputProperties.unavailableCountries = field.unavailable_countries ?? [] } else if (field.type === 'text' && field.secret_input) { inputProperties.nativeType = 'password' + } else if (field.type === 'nf-payment') { + inputProperties.currency = field.currency + inputProperties.amount = field.amount } return inputProperties diff --git a/client/components/open/forms/fields/components/BlockOptions.vue b/client/components/open/forms/fields/components/BlockOptions.vue index be442e3c8..2494dc1cc 100644 --- a/client/components/open/forms/fields/components/BlockOptions.vue +++ b/client/components/open/forms/fields/components/BlockOptions.vue @@ -17,7 +17,7 @@ :field="field" :can-be-disabled="false" :can-be-hidden="true" - :can-be-required="false" + :can-be-required="['nf-payment'].includes(field.type)" />
@@ -108,6 +108,62 @@ help="You can add any html code, including iframes" />
+ +
+ + +
+ +

+ OR +

+
+ + Connect with Stripe + + + + Learn about collecting payments? + +
@@ -125,6 +181,57 @@ const props = defineProps({ } }) +const providersStore = useOAuthProvidersStore() +const crisp = useCrisp() +const stripeLoading = ref(false) + +const currencyList = ref([ + { name: 'AED - UAE Dirham', value: 'AED' }, + { name: 'AUD - Australian Dollar', value: 'AUD' }, + { name: 'BGN - Bulgarian lev', value: 'BGN' }, + { name: 'BRL - Brazilian real', value: 'BRL' }, + { name: 'CAD - Canadian dollar', value: 'CAD' }, + { name: 'CHF - Swiss franc', value: 'CHF' }, + { name: 'CNY - Yuan Renminbi', value: 'CNY' }, + { name: 'CZK - Czech Koruna', value: 'CZK' }, + { name: 'DKK - Danish Krone', value: 'DKK' }, + { name: 'EUR - Euro', value: 'EUR' }, + { name: 'GBP - Pound sterling', value: 'GBP' }, + { name: 'HKD - Hong Kong dollar', value: 'HKD' }, + { name: 'HRK - Croatian kuna', value: 'HRK' }, + { name: 'HUF - Hungarian forint', value: 'HUF' }, + { name: 'IDR - Indonesian Rupiah', value: 'IDR' }, + { name: 'ILS - Israeli Shekel', value: 'ILS' }, + { name: 'INR - Indian Rupee', value: 'INR' }, + { name: 'ISK - Icelandic króna', value: 'ISK' }, + { name: 'JPY - Japanese yen', value: 'JPY' }, + { name: 'KRW - South Korean won', value: 'KRW' }, + { name: 'MAD - Moroccan Dirham', value: 'MAD' }, + { name: 'MXN - Mexican peso', value: 'MXN' }, + { name: 'MYR - Malaysian ringgit', value: 'MYR' }, + { name: 'NOK - Norwegian krone', value: 'NOK' }, + { name: 'NZD - New Zealand dollar', value: 'NZD' }, + { name: 'PHP - Philippine peso', value: 'PHP' }, + { name: 'PLN - Polish złoty', value: 'PLN' }, + { name: 'RON - Romanian leu', value: 'RON' }, + { name: 'RSD - Serbian dinar', value: 'RSD' }, + { name: 'RUB - Russian Rouble', value: 'RUB' }, + { name: 'SAR - Saudi riyal', value: 'SAR' }, + { name: 'SEK - Swedish krona', value: 'SEK' }, + { name: 'SGD - Singapore dollar', value: 'SGD' }, + { name: 'THB - Thai baht', value: 'THB' }, + { name: 'TWD - New Taiwan dollar', value: 'TWD' }, + { name: 'UAH - Ukrainian hryvnia', value: 'UAH' }, + { name: 'USD - United States Dollar', value: 'USD' }, + { name: 'VND - Vietnamese dong', value: 'VND' }, + { name: 'ZAR - South African rand', value: 'ZAR' } +]) + +const stripeAccounts = computed(() => providersStore.getAll.filter((item) => item.provider === 'stripe').map((item) => ({ + name: item.name + ' (' + item.email + ')', + value: item.id +}))) + watch(() => props.field?.width, (val) => { if (val === undefined || val === null) { props.field.width = 'full' @@ -137,9 +244,28 @@ watch(() => props.field?.align, (val) => { } }, { immediate: true }) +watch(() => props.field?.currency, (val) => { + if (val === undefined || val === null) { + props.field.currency = 'USD' + } +}, { immediate: true }) + +watch(() => props.field?.amount, (val) => { + if (val === undefined || val === null) { + props.field.amount = 10 + } +}, { immediate: true }) + onMounted(() => { + providersStore.fetchOAuthProviders() + if (props.field?.width === undefined || props.field?.width === null) { props.field.width = 'full' } }) + +const connectStripe = () => { + stripeLoading.value = true + providersStore.connect('stripe', true, true) +} diff --git a/client/data/blocks_types.json b/client/data/blocks_types.json index 0cca9180e..e3424805a 100644 --- a/client/data/blocks_types.json +++ b/client/data/blocks_types.json @@ -196,5 +196,14 @@ "bg_class": "bg-yellow-100", "text_class": "text-yellow-900", "is_input": false + }, + "nf-payment": { + "name": "nf-payment", + "title": "Payment", + "icon": "i-heroicons-credit-card", + "default_block_name": "Payment", + "bg_class": "bg-green-100", + "text_class": "text-green-900", + "is_input": false } } diff --git a/client/package-lock.json b/client/package-lock.json index d1d57717f..c5e06b6f2 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,13 +8,13 @@ "hasInstallScript": true, "dependencies": { "@codemirror/lang-html": "^6.4.9", - "@hcaptcha/vue3-hcaptcha": "^1.3.0", "@iconify-json/material-symbols": "^1.2.4", "@nuxt/ui": "^2.19.2", "@pinia/nuxt": "^0.5.5", "@popperjs/core": "^2.11.8", "@sentry/vite-plugin": "^2.22.6", "@sentry/vue": "^7.119.2", + "@stripe/stripe-js": "^5.5.0", "@vueuse/components": "^11.2.0", "@vueuse/core": "^11.2.0", "@vueuse/integrations": "^11.2.0", @@ -48,6 +48,7 @@ "vue-json-pretty": "^2.4.0", "vue-notion": "^3.0.0", "vue-signature-pad": "^3.0.2", + "vue-stripe-js": "^1.0.4", "vuedraggable": "next", "webcam-easy": "^1.1.1" }, @@ -1338,18 +1339,6 @@ } } }, - "node_modules/@hcaptcha/vue3-hcaptcha": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@hcaptcha/vue3-hcaptcha/-/vue3-hcaptcha-1.3.0.tgz", - "integrity": "sha512-IEonS6JiYdU7uy6aeib8cYtMO4nj8utwStbA9bWHyYbOvOvhpkV+AW8vfSKh6SntYxqle/TRwhv+kU9p92CfsA==", - "license": "MIT", - "dependencies": { - "vue": "^3.2.19" - }, - "peerDependencies": { - "vue": "^3.0.0" - } - }, "node_modules/@headlessui/tailwindcss": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@headlessui/tailwindcss/-/tailwindcss-0.2.1.tgz", @@ -4507,6 +4496,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@stripe/stripe-js": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-5.5.0.tgz", + "integrity": "sha512-lkfjyAd34aeMpTKKcEVfy8IUyEsjuAT3t9EXr5yZDtdIUncnZpedl/xLV16Dkd4z+fQwixScsCCDxSMNtBOgpQ==", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@tailwindcss/aspect-ratio": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/@tailwindcss/aspect-ratio/-/aspect-ratio-0.4.2.tgz", @@ -17263,6 +17260,11 @@ "vue": "^3.2.0" } }, + "node_modules/vue-stripe-js": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/vue-stripe-js/-/vue-stripe-js-1.0.4.tgz", + "integrity": "sha512-TDfbucH1tnKug6VmZvi4xTIB41CN1CH3hC/GfPT/JWraZ4THFQUXk03R46+KY1e5P4fqr0vUzonZhZAUZHRrxg==" + }, "node_modules/vuedraggable": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz", diff --git a/client/package.json b/client/package.json index a172b888a..afa02dd35 100644 --- a/client/package.json +++ b/client/package.json @@ -42,6 +42,7 @@ "@popperjs/core": "^2.11.8", "@sentry/vite-plugin": "^2.22.6", "@sentry/vue": "^7.119.2", + "@stripe/stripe-js": "^5.5.0", "@vueuse/components": "^11.2.0", "@vueuse/core": "^11.2.0", "@vueuse/integrations": "^11.2.0", @@ -75,6 +76,7 @@ "vue-json-pretty": "^2.4.0", "vue-notion": "^3.0.0", "vue-signature-pad": "^3.0.2", + "vue-stripe-js": "^1.0.4", "vuedraggable": "next", "webcam-easy": "^1.1.1" }, diff --git a/client/runtimeConfig.js b/client/runtimeConfig.js index d094fb68e..3a0a50c4c 100644 --- a/client/runtimeConfig.js +++ b/client/runtimeConfig.js @@ -31,6 +31,8 @@ export default { SENTRY_TRACES_SAMPLE_RATE: parseNumber(process.env.SENTRY_TRACES_SAMPLE_RATE), SENTRY_REPLAY_SAMPLE_RATE: parseNumber(process.env.SENTRY_REPLAY_SAMPLE_RATE), SENTRY_ERROR_REPLAY_SAMPLE_RATE: parseNumber(process.env.SENTRY_ERROR_REPLAY_SAMPLE_RATE), + + STRIPE_PUBLISHABLE_KEY: process.env.STRIPE_PUBLISHABLE_KEY ?? null }, /** diff --git a/client/stores/oauth_providers.js b/client/stores/oauth_providers.js index 55d628bcc..3d9b317d4 100644 --- a/client/stores/oauth_providers.js +++ b/client/stores/oauth_providers.js @@ -42,7 +42,7 @@ export const useOAuthProvidersStore = defineStore("oauth_providers", () => { ) } - const connect = (service, redirect = false) => { + const connect = (service, redirect = false, newtab = false) => { contentStore.resetState() contentStore.startLoading() @@ -55,7 +55,7 @@ export const useOAuthProvidersStore = defineStore("oauth_providers", () => { } }) .then((data) => { - window.location.href = data.url + window.open(data.url, (newtab) ? '_blank' : '_self') }) .catch((error) => { try { diff --git a/client/stores/working_form.js b/client/stores/working_form.js index 9a3b2bfba..b433f4a3c 100644 --- a/client/stores/working_form.js +++ b/client/stores/working_form.js @@ -96,6 +96,13 @@ export const useWorkingFormStore = defineStore("working_form", { }, addBlock(type, index = null, openSettings = true) { + if (type === 'nf-payment') { // Can only have one payment block + if (this.content.properties.some(block => block.type === 'nf-payment')) { + useAlert().error('Only one payment block is allowed per form') + return + } + } + this.blockForm.type = type this.blockForm.name = blocksTypes[type].default_block_name const newBlock = this.prefillDefault(this.blockForm.data()) From 3b334962d4b953f8ed2e837150478c0293347de0 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Fri, 10 Jan 2025 14:24:29 +0530 Subject: [PATCH 03/30] Payment block backend validation and new package for stripe --- .../Content/FeatureFlagsController.php | 3 + api/app/Http/Requests/UserFormRequest.php | 3 +- .../OAuth/Drivers/OAuthStripeDriver.php | 12 +- .../OAuth/OAuthProviderService.php | 1 - api/app/Providers/AppServiceProvider.php | 11 +- .../Rules/PaymentBlockConfigurationRule.php | 71 +++++++++++ api/composer.json | 7 +- api/composer.lock | 117 +++++++++++++++++- api/config/services.php | 41 ++++++ .../forms/fields/components/BlockOptions.vue | 50 ++------ 10 files changed, 254 insertions(+), 62 deletions(-) create mode 100644 api/app/Rules/PaymentBlockConfigurationRule.php diff --git a/api/app/Http/Controllers/Content/FeatureFlagsController.php b/api/app/Http/Controllers/Content/FeatureFlagsController.php index 711c40b67..4be27c3a7 100644 --- a/api/app/Http/Controllers/Content/FeatureFlagsController.php +++ b/api/app/Http/Controllers/Content/FeatureFlagsController.php @@ -28,6 +28,9 @@ public function index() 'fonts' => !empty(config('services.google.fonts_api_key')), 'auth' => !empty(config('services.google.client_id')) && !empty(config('services.google.client_secret')), ], + 'stripe' => [ + 'currencies' => config('services.stripe.currencies'), + ], ], 'integrations' => [ 'zapier' => config('services.zapier.enabled'), diff --git a/api/app/Http/Requests/UserFormRequest.php b/api/app/Http/Requests/UserFormRequest.php index f8b8da295..93cfa676f 100644 --- a/api/app/Http/Requests/UserFormRequest.php +++ b/api/app/Http/Requests/UserFormRequest.php @@ -5,6 +5,7 @@ use App\Http\Requests\Workspace\CustomDomainRequest; use App\Models\Forms\Form; use App\Rules\FormPropertyLogicRule; +use App\Rules\PaymentBlockConfigurationRule; use Illuminate\Validation\Rule; /** @@ -70,7 +71,7 @@ public function rules() 'properties' => 'required|array', 'properties.*.id' => 'required', 'properties.*.name' => 'required', - 'properties.*.type' => 'required', + 'properties.*.type' => ['required', new PaymentBlockConfigurationRule($this->properties)], 'properties.*.placeholder' => 'sometimes|nullable', 'properties.*.prefill' => 'sometimes|nullable', 'properties.*.help' => 'sometimes|nullable', diff --git a/api/app/Integrations/OAuth/Drivers/OAuthStripeDriver.php b/api/app/Integrations/OAuth/Drivers/OAuthStripeDriver.php index 3c69ef58d..d24a97cef 100644 --- a/api/app/Integrations/OAuth/Drivers/OAuthStripeDriver.php +++ b/api/app/Integrations/OAuth/Drivers/OAuthStripeDriver.php @@ -6,13 +6,15 @@ use Laravel\Socialite\Contracts\User; use Laravel\Socialite\Facades\Socialite; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Log; +use SocialiteProviders\Stripe\Provider as StripeProvider; class OAuthStripeDriver implements OAuthDriver { private ?string $redirectUrl = null; private ?array $scopes = []; - protected $provider; + protected StripeProvider $provider; public function __construct() { @@ -26,13 +28,11 @@ public function getRedirectUrl(): string $params = [ 'stripe_user[email]' => $user->email, 'stripe_user[url]' => config('app.url'), - 'stripe_user[business_name]' => $user->workspace->name ?? null, + 'stripe_user[business_name]' => $user->name, ]; - ray('params', $params); - \Log::info('Initiating Stripe Connect flow', [ - 'user_id' => $user->id, - 'workspace_id' => $user->workspace_id + Log::info('Initiating Stripe Connect flow', [ + 'user_id' => $user->id ]); return $this->provider diff --git a/api/app/Integrations/OAuth/OAuthProviderService.php b/api/app/Integrations/OAuth/OAuthProviderService.php index 4ab4e51aa..995144b65 100644 --- a/api/app/Integrations/OAuth/OAuthProviderService.php +++ b/api/app/Integrations/OAuth/OAuthProviderService.php @@ -13,7 +13,6 @@ enum OAuthProviderService: string public function getDriver(): OAuthDriver { - ray('getDriver', $this); return match ($this) { self::Google => new OAuthGoogleDriver(), self::Stripe => new OAuthStripeDriver() diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index 3f1ed68d6..3e92e179a 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -2,17 +2,16 @@ namespace App\Providers; -use App\Integrations\OAuth\Drivers\OAuthStripeDriver; use App\Models\Billing\Subscription; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; use Laravel\Cashier\Cashier; use Laravel\Dusk\DuskServiceProvider; -use Laravel\Socialite\Facades\Socialite; class AppServiceProvider extends ServiceProvider { @@ -43,12 +42,8 @@ public function boot() Validator::includeUnvalidatedArrayKeys(); - Socialite::extend('stripe', function ($app) { - $config = $app['config']['services.stripe']; - return Socialite::buildProvider( - OAuthStripeDriver::class, - $config - ); + Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) { + $event->extendSocialite('stripe', \SocialiteProviders\Stripe\Provider::class); }); } diff --git a/api/app/Rules/PaymentBlockConfigurationRule.php b/api/app/Rules/PaymentBlockConfigurationRule.php new file mode 100644 index 000000000..72562d61a --- /dev/null +++ b/api/app/Rules/PaymentBlockConfigurationRule.php @@ -0,0 +1,71 @@ +properties = $properties; + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if ($value['type'] !== 'nf-payment') { + return; // If not a payment block, validation passes + } + + // Only one payment block allowed + $paymentBlocks = collect($this->properties) + ->filter(fn($prop) => $prop['type'] === 'nf-payment') + ->count(); + + if ($paymentBlocks > 1) { + $fail('Only one payment block allowed'); + return; + } + + + // Amount validation + if (!isset($value['amount']) || !is_numeric($value['amount']) || $value['amount'] < 0.5) { + $fail('Amount must be a number greater than 0.5'); + return; + } + + // Currency validation + if (!isset($value['currency']) || !in_array(strtoupper($value['currency']), array_keys(config('services.stripe.currencies')))) { + $fail('Currency must be a valid currency'); + return; + } + + // Stripe account validation + if (!isset($value['stripe_account_id']) || empty($value['stripe_account_id'])) { + $fail('Stripe account is required'); + return; + } + try { + $provider = OAuthProvider::where('provider', 'stripe') + ->where('provider_user_id', $value['stripe_account_id']) + ->first(); + + if ($provider === null) { + $fail('Failed to validate Stripe account'); + return; + } + } catch (\Exception $e) { + Log::error('Failed to validate Stripe account', [ + 'error' => $e->getMessage(), + 'account_id' => $value['stripe_account_id'] + ]); + $fail('Failed to validate Stripe account'); + return; + } + } +} diff --git a/api/composer.json b/api/composer.json index 1775050b7..d473df92e 100644 --- a/api/composer.json +++ b/api/composer.json @@ -16,6 +16,7 @@ "ext-json": "*", "aws/aws-sdk-php": "*", "doctrine/dbal": "*", + "fakerphp/faker": "^1.23", "giggsey/libphonenumber-for-php": "*", "google/apiclient": "^2.16", "guzzlehttp/guzzle": "*", @@ -33,14 +34,14 @@ "openai-php/client": "*", "propaganistas/laravel-disposable-email": "*", "sentry/sentry-laravel": "*", + "socialiteproviders/stripe": "^4.1", "spatie/laravel-data": "^4.6", + "spatie/laravel-ray": "*", "spatie/laravel-sitemap": "*", "spatie/laravel-sluggable": "*", "stevebauman/purify": "*", "tymon/jwt-auth": "*", - "vinkla/hashids": "*", - "fakerphp/faker": "^1.23", - "spatie/laravel-ray": "*" + "vinkla/hashids": "*" }, "require-dev": { "barryvdh/laravel-ide-helper": "^3.0.0", diff --git a/api/composer.lock b/api/composer.lock index 36196cf74..726f12d2b 100644 --- a/api/composer.lock +++ b/api/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": "37a321177c832a3f7c1639ad9ae1583d", + "content-hash": "344980a97a80d1594bc4679b28bd6209", "packages": [ { "name": "amphp/amp", @@ -8301,6 +8301,121 @@ ], "time": "2024-09-19T12:58:53+00:00" }, + { + "name": "socialiteproviders/manager", + "version": "v4.8.0", + "source": { + "type": "git", + "url": "https://github.com/SocialiteProviders/Manager.git", + "reference": "e93acc38f8464cc775a2b8bf09df311d1fdfefcb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/e93acc38f8464cc775a2b8bf09df311d1fdfefcb", + "reference": "e93acc38f8464cc775a2b8bf09df311d1fdfefcb", + "shasum": "" + }, + "require": { + "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0", + "laravel/socialite": "^5.5", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.2", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "SocialiteProviders\\Manager\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "SocialiteProviders\\Manager\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andy Wendt", + "email": "andy@awendt.com" + }, + { + "name": "Anton Komarev", + "email": "a.komarev@cybercog.su" + }, + { + "name": "Miguel Piedrafita", + "email": "soy@miguelpiedrafita.com" + }, + { + "name": "atymic", + "email": "atymicq@gmail.com", + "homepage": "https://atymic.dev" + } + ], + "description": "Easily add new or override built-in providers in Laravel Socialite.", + "homepage": "https://socialiteproviders.com", + "keywords": [ + "laravel", + "manager", + "oauth", + "providers", + "socialite" + ], + "support": { + "issues": "https://github.com/socialiteproviders/manager/issues", + "source": "https://github.com/socialiteproviders/manager" + }, + "time": "2025-01-03T09:40:37+00:00" + }, + { + "name": "socialiteproviders/stripe", + "version": "4.1.1", + "source": { + "type": "git", + "url": "https://github.com/SocialiteProviders/Stripe.git", + "reference": "6ba1e2ec1841db090827e034f7e659c3068e4234" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SocialiteProviders/Stripe/zipball/6ba1e2ec1841db090827e034f7e659c3068e4234", + "reference": "6ba1e2ec1841db090827e034f7e659c3068e4234", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.2 || ^8.0", + "socialiteproviders/manager": "~4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "SocialiteProviders\\Stripe\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Faust", + "email": "hello@brianfaust.de" + } + ], + "description": "Stripe OAuth2 Provider for Laravel Socialite", + "support": { + "source": "https://github.com/SocialiteProviders/Stripe/tree/4.1.1" + }, + "time": "2021-05-31T07:30:21+00:00" + }, { "name": "spatie/backtrace", "version": "1.6.2", diff --git a/api/config/services.php b/api/config/services.php index bc1a5bcbe..dda142407 100644 --- a/api/config/services.php +++ b/api/config/services.php @@ -87,5 +87,46 @@ 'client_id' => env('STRIPE_CLIENT_ID'), 'client_secret' => env('STRIPE_CLIENT_SECRET'), 'redirect' => env('STRIPE_REDIRECT_URI'), + 'currencies' => [ + 'AED' => 'AED - UAE Dirham', + 'AUD' => 'AUD - Australian Dollar', + 'BGN' => 'BGN - Bulgarian lev', + 'BRL' => 'BRL - Brazilian real', + 'CAD' => 'CAD - Canadian dollar', + 'CHF' => 'CHF - Swiss franc', + 'CNY' => 'CNY - Yuan Renminbi', + 'CZK' => 'CZK - Czech Koruna', + 'DKK' => 'DKK - Danish Krone', + 'EUR' => 'EUR - Euro', + 'GBP' => 'GBP - Pound sterling', + 'HKD' => 'HKD - Hong Kong dollar', + 'HRK' => 'HRK - Croatian kuna', + 'HUF' => 'HUF - Hungarian forint', + 'IDR' => 'IDR - Indonesian Rupiah', + 'ILS' => 'ILS - Israeli Shekel', + 'INR' => 'INR - Indian Rupee', + 'ISK' => 'ISK - Icelandic króna', + 'JPY' => 'JPY - Japanese yen', + 'KRW' => 'KRW - South Korean won', + 'MAD' => 'MAD - Moroccan Dirham', + 'MXN' => 'MXN - Mexican peso', + 'MYR' => 'MYR - Malaysian ringgit', + 'NOK' => 'NOK - Norwegian krone', + 'NZD' => 'NZD - New Zealand dollar', + 'PHP' => 'PHP - Philippine peso', + 'PLN' => 'PLN - Polish złoty', + 'RON' => 'RON - Romanian leu', + 'RSD' => 'RSD - Serbian dinar', + 'RUB' => 'RUB - Russian Rouble', + 'SAR' => 'SAR - Saudi riyal', + 'SEK' => 'SEK - Swedish krona', + 'SGD' => 'SGD - Singapore dollar', + 'THB' => 'THB - Thai baht', + 'TWD' => 'TWD - New Taiwan dollar', + 'UAH' => 'UAH - Ukrainian hryvnia', + 'USD' => 'USD - United States Dollar', + 'VND' => 'VND - Vietnamese dong', + 'ZAR' => 'ZAR - South African rand', + ], ] ]; diff --git a/client/components/open/forms/fields/components/BlockOptions.vue b/client/components/open/forms/fields/components/BlockOptions.vue index 2494dc1cc..e43b66f5e 100644 --- a/client/components/open/forms/fields/components/BlockOptions.vue +++ b/client/components/open/forms/fields/components/BlockOptions.vue @@ -131,7 +131,7 @@ />
{ + const currencies = useFeatureFlag('services.stripe.currencies') || {} + return Object.keys(currencies).map((item) => ({ + name: currencies[item], + value: item + })) +}) const stripeAccounts = computed(() => providersStore.getAll.filter((item) => item.provider === 'stripe').map((item) => ({ name: item.name + ' (' + item.email + ')', From d5cee46250e5bb7f9323262f2d023b69d886ee43 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Fri, 10 Jan 2025 14:41:23 +0530 Subject: [PATCH 04/30] change stripe scopes --- api/.env.example | 1 - api/app/Integrations/OAuth/Drivers/OAuthStripeDriver.php | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/api/.env.example b/api/.env.example index d8105dcff..5fb74c6ca 100644 --- a/api/.env.example +++ b/api/.env.example @@ -96,5 +96,4 @@ GOOGLE_FONTS_API_KEY= ZAPIER_ENABLED=false STRIPE_CLIENT_ID= -STRIPE_CLIENT_SECRET= STRIPE_REDIRECT_URI=http://localhost:3000/settings/connections/callback/stripe \ No newline at end of file diff --git a/api/app/Integrations/OAuth/Drivers/OAuthStripeDriver.php b/api/app/Integrations/OAuth/Drivers/OAuthStripeDriver.php index d24a97cef..911907aa8 100644 --- a/api/app/Integrations/OAuth/Drivers/OAuthStripeDriver.php +++ b/api/app/Integrations/OAuth/Drivers/OAuthStripeDriver.php @@ -70,11 +70,7 @@ public function setScopes(array $scopes): OAuthDriver public function fullScopes(): OAuthDriver { return $this->setScopes([ - 'read_write', // Basic account access - 'payments', // Process payments - 'payment_method', // Access to payment methods - 'transfers', // Required for platform fees/transfers - 'application_fees', // Required for platform fees + 'read_write', ]); } } From 019153ebd769b7de45b1be4176f44b1dea6780ec Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Fri, 10 Jan 2025 14:55:27 +0530 Subject: [PATCH 05/30] update PaymentBlockConfigurationRule --- api/app/Rules/PaymentBlockConfigurationRule.php | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/api/app/Rules/PaymentBlockConfigurationRule.php b/api/app/Rules/PaymentBlockConfigurationRule.php index 72562d61a..6324af212 100644 --- a/api/app/Rules/PaymentBlockConfigurationRule.php +++ b/api/app/Rules/PaymentBlockConfigurationRule.php @@ -10,6 +10,7 @@ class PaymentBlockConfigurationRule implements ValidationRule { protected array $properties; + protected array $field; public function __construct(array $properties) { @@ -18,7 +19,11 @@ public function __construct(array $properties) public function validate(string $attribute, mixed $value, Closure $fail): void { - if ($value['type'] !== 'nf-payment') { + // Set the field + $fieldIndex = explode('.', $attribute)[1]; + $this->field = $this->properties[$fieldIndex]; + + if ($this->field['type'] !== 'nf-payment') { return; // If not a payment block, validation passes } @@ -34,25 +39,25 @@ public function validate(string $attribute, mixed $value, Closure $fail): void // Amount validation - if (!isset($value['amount']) || !is_numeric($value['amount']) || $value['amount'] < 0.5) { + if (!isset($this->field['amount']) || !is_numeric($this->field['amount']) || $this->field['amount'] < 0.5) { $fail('Amount must be a number greater than 0.5'); return; } // Currency validation - if (!isset($value['currency']) || !in_array(strtoupper($value['currency']), array_keys(config('services.stripe.currencies')))) { + if (!isset($this->field['currency']) || !in_array(strtoupper($this->field['currency']), array_keys(config('services.stripe.currencies')))) { $fail('Currency must be a valid currency'); return; } // Stripe account validation - if (!isset($value['stripe_account_id']) || empty($value['stripe_account_id'])) { + if (!isset($this->field['stripe_account_id']) || empty($this->field['stripe_account_id'])) { $fail('Stripe account is required'); return; } try { $provider = OAuthProvider::where('provider', 'stripe') - ->where('provider_user_id', $value['stripe_account_id']) + ->where('provider_user_id', $this->field['stripe_account_id']) ->first(); if ($provider === null) { @@ -62,7 +67,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } catch (\Exception $e) { Log::error('Failed to validate Stripe account', [ 'error' => $e->getMessage(), - 'account_id' => $value['stripe_account_id'] + 'account_id' => $this->field['stripe_account_id'] ]); $fail('Failed to validate Stripe account'); return; From 84df0f801326a07c11033050cfe91b6c82f8e805 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Fri, 10 Jan 2025 15:03:08 +0530 Subject: [PATCH 06/30] Set loader on provider modal --- client/components/settings/ProviderModal.vue | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/client/components/settings/ProviderModal.vue b/client/components/settings/ProviderModal.vue index d6abc1d42..f161e7ab3 100644 --- a/client/components/settings/ProviderModal.vue +++ b/client/components/settings/ProviderModal.vue @@ -25,7 +25,13 @@ Connect account -
+
+ +
+
providersStore.services) +const loading = ref(false) + function connect(service) { + loading.value = true providersStore.connect(service.name) } From 808c4da9e3370684f9865cd3f6f703d09bd41010 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Fri, 10 Jan 2025 16:22:21 +0530 Subject: [PATCH 07/30] stripe oauth --- api/.env.example | 1 + api/app/Http/Controllers/Settings/OAuthProviderController.php | 2 +- api/app/Integrations/OAuth/Drivers/OAuthStripeDriver.php | 2 ++ client/components/open/forms/fields/components/BlockOptions.vue | 2 +- 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/api/.env.example b/api/.env.example index 5fb74c6ca..d8105dcff 100644 --- a/api/.env.example +++ b/api/.env.example @@ -96,4 +96,5 @@ GOOGLE_FONTS_API_KEY= ZAPIER_ENABLED=false STRIPE_CLIENT_ID= +STRIPE_CLIENT_SECRET= STRIPE_REDIRECT_URI=http://localhost:3000/settings/connections/callback/stripe \ No newline at end of file diff --git a/api/app/Http/Controllers/Settings/OAuthProviderController.php b/api/app/Http/Controllers/Settings/OAuthProviderController.php index 6f6d281bb..2085ec8cc 100644 --- a/api/app/Http/Controllers/Settings/OAuthProviderController.php +++ b/api/app/Http/Controllers/Settings/OAuthProviderController.php @@ -47,7 +47,7 @@ public function handleRedirect(OAuthProviderService $service) [ 'access_token' => $driverUser->token, 'refresh_token' => $driverUser->refreshToken, - 'name' => $driverUser->getName(), + 'name' => ($driverUser->getName()) ? $driverUser->getName() : $driverUser->getNickname(), 'email' => $driverUser->getEmail(), 'scopes' => $driverUser->approvedScopes ] diff --git a/api/app/Integrations/OAuth/Drivers/OAuthStripeDriver.php b/api/app/Integrations/OAuth/Drivers/OAuthStripeDriver.php index 911907aa8..9901ae929 100644 --- a/api/app/Integrations/OAuth/Drivers/OAuthStripeDriver.php +++ b/api/app/Integrations/OAuth/Drivers/OAuthStripeDriver.php @@ -37,6 +37,8 @@ public function getRedirectUrl(): string return $this->provider ->scopes($this->scopes ?? []) + ->stateless() + ->redirectUrl($this->redirectUrl ?? config('services.stripe.redirect')) ->with($params) ->redirect() ->getTargetUrl(); diff --git a/client/components/open/forms/fields/components/BlockOptions.vue b/client/components/open/forms/fields/components/BlockOptions.vue index e43b66f5e..7c5575db2 100644 --- a/client/components/open/forms/fields/components/BlockOptions.vue +++ b/client/components/open/forms/fields/components/BlockOptions.vue @@ -194,7 +194,7 @@ const currencyList = computed(() => { }) const stripeAccounts = computed(() => providersStore.getAll.filter((item) => item.provider === 'stripe').map((item) => ({ - name: item.name + ' (' + item.email + ')', + name: item.name + (item.email ? ' (' + item.email + ')' : ''), value: item.id }))) From 46b2a2f82ddd700cbd79494bf7c7002b4646cd9e Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Fri, 10 Jan 2025 17:46:12 +0530 Subject: [PATCH 08/30] PaymentFieldOptions as seprate component --- .../components/forms/PaymentInput.client.vue | 2 +- .../forms/fields/components/BlockOptions.vue | 94 +--------------- .../fields/components/PaymentFieldOptions.vue | 100 ++++++++++++++++++ 3 files changed, 105 insertions(+), 91 deletions(-) create mode 100644 client/components/open/forms/fields/components/PaymentFieldOptions.vue diff --git a/client/components/forms/PaymentInput.client.vue b/client/components/forms/PaymentInput.client.vue index 31882892e..445bb8619 100644 --- a/client/components/forms/PaymentInput.client.vue +++ b/client/components/forms/PaymentInput.client.vue @@ -46,7 +46,7 @@ export default { props: { ...inputProps, - currency: { type: String, default: 'usd' }, + currency: { type: String, default: 'USD' }, amount: { type: Number, default: 0 }, }, diff --git a/client/components/open/forms/fields/components/BlockOptions.vue b/client/components/open/forms/fields/components/BlockOptions.vue index 7c5575db2..76f4290af 100644 --- a/client/components/open/forms/fields/components/BlockOptions.vue +++ b/client/components/open/forms/fields/components/BlockOptions.vue @@ -109,66 +109,16 @@ />
-
- - -
- -

- OR -

-
- - Connect with Stripe - - - - Learn about collecting payments? - -
+ :field="field" + />
diff --git a/client/components/open/forms/fields/components/PaymentFieldOptions.vue b/client/components/open/forms/fields/components/PaymentFieldOptions.vue new file mode 100644 index 000000000..dfb485fdc --- /dev/null +++ b/client/components/open/forms/fields/components/PaymentFieldOptions.vue @@ -0,0 +1,100 @@ + + + \ No newline at end of file From 62c2ed494706a20a5e8ca065111acfd409dfdbd3 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Fri, 10 Jan 2025 17:53:52 +0530 Subject: [PATCH 09/30] validate Stripe account --- api/app/Rules/PaymentBlockConfigurationRule.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/api/app/Rules/PaymentBlockConfigurationRule.php b/api/app/Rules/PaymentBlockConfigurationRule.php index 6324af212..a87ca01cd 100644 --- a/api/app/Rules/PaymentBlockConfigurationRule.php +++ b/api/app/Rules/PaymentBlockConfigurationRule.php @@ -56,10 +56,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; } try { - $provider = OAuthProvider::where('provider', 'stripe') - ->where('provider_user_id', $this->field['stripe_account_id']) - ->first(); - + $provider = OAuthProvider::find($this->field['stripe_account_id']); if ($provider === null) { $fail('Failed to validate Stripe account'); return; From 040cec24b60747f0b9b41b8f5899f9e5cc60d55e Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Mon, 13 Jan 2025 23:06:36 +0530 Subject: [PATCH 10/30] Payment intent --- .../Forms/FormPaymentController.php | 97 +++++++++++++++++++ .../Rules/PaymentBlockConfigurationRule.php | 2 +- api/routes/api.php | 2 + .../open/forms/OpenCompleteForm.vue | 21 ++++ .../fields/components/PaymentFieldOptions.vue | 1 + 5 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 api/app/Http/Controllers/Forms/FormPaymentController.php diff --git a/api/app/Http/Controllers/Forms/FormPaymentController.php b/api/app/Http/Controllers/Forms/FormPaymentController.php new file mode 100644 index 000000000..2c40dd1bb --- /dev/null +++ b/api/app/Http/Controllers/Forms/FormPaymentController.php @@ -0,0 +1,97 @@ +middleware('auth'); + } + + public function createIntent(Request $request) + { + $form = $request->form; + + // Verify form exists and is accessible + if ($form->workspace === null || $form->visibility !== 'public') { + Log::warning('Attempt to create payment for invalid form', [ + 'form_id' => $form->id + ]); + return $this->error(['message' => 'Form not found.'], 404); + } + + // Get payment block (only one allowed) + $paymentBlock = collect($form->properties)->first(fn ($prop) => $prop['type'] === 'nf-payment'); + if (!$paymentBlock) { + Log::warning('Attempt to create payment for form without payment block', [ + 'form_id' => $form->id + ]); + return $this->error(['message' => 'Invalid form configuration.'], 400); + } + + // Get provider + $provider = OAuthProvider::find($paymentBlock['stripe_account_id']); + if ($provider === null) { + Log::error('Failed to find Stripe account', [ + 'stripe_account_id' => $paymentBlock['stripe_account_id'] + ]); + return $this->error(['message' => 'Failed to find Stripe account'], 400); + } + + try { + Log::info('Creating payment intent', [ + 'form_id' => $form->id, + 'amount' => $paymentBlock['amount'], + 'currency' => $paymentBlock['currency'] + ]); + + Stripe::setApiKey(config('cashier.secret')); + + $intent = PaymentIntent::create([ + 'amount' => (int) ($paymentBlock['amount'] * 100), // Stripe requires amount in cents + 'currency' => $paymentBlock['currency'], + 'payment_method_types' => ['card'], + 'metadata' => [ + 'form_id' => $form->id, + 'workspace_id' => $form->workspace_id, + 'form_name' => $form->title, + ], + ], [ + 'stripe_account' => $provider->provider_user_id + ]); + + Log::info('Payment intent created', [ + 'form_id' => $form->id, + 'intent' => $intent + ]); + + if ($intent->id) { + return $this->success([ + 'intent' => ['id' => $intent->id, 'secret' => $intent->client_secret] + ]); + } else { + return $this->error(['message' => 'Failed to create payment intent']); + } + } catch (\Stripe\Exception\CardException $e) { + Log::warning('Card payment failed', [ + 'form_id' => $form->id, + 'error_code' => $e->getStripeCode() + ]); + return $this->error(['message' => $e->getMessage()]); + } catch (\Exception $e) { + Log::error('Failed to create payment intent', [ + 'form_id' => $form->id, + 'error' => $e->getMessage() + ]); + return $this->error(['message' => 'Failed to initialize payment.']); + } + } +} diff --git a/api/app/Rules/PaymentBlockConfigurationRule.php b/api/app/Rules/PaymentBlockConfigurationRule.php index a87ca01cd..2ab8fd46d 100644 --- a/api/app/Rules/PaymentBlockConfigurationRule.php +++ b/api/app/Rules/PaymentBlockConfigurationRule.php @@ -29,7 +29,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void // Only one payment block allowed $paymentBlocks = collect($this->properties) - ->filter(fn($prop) => $prop['type'] === 'nf-payment') + ->filter(fn ($prop) => $prop['type'] === 'nf-payment') ->count(); if ($paymentBlocks > 1) { diff --git a/api/routes/api.php b/api/routes/api.php index e0a49ec31..b6d8731d4 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -22,6 +22,7 @@ use App\Http\Controllers\SubscriptionController; use App\Http\Controllers\Forms\TemplateController; use App\Http\Controllers\Auth\UserInviteController; +use App\Http\Controllers\Forms\FormPaymentController; use App\Http\Controllers\WorkspaceController; use App\Http\Controllers\WorkspaceUserController; use App\Http\Middleware\Form\ResolveFormMiddleware; @@ -290,6 +291,7 @@ Route::prefix('forms')->name('forms.')->group(function () { Route::middleware('protected-form')->group(function () { Route::post('{slug}/answer', [PublicFormController::class, 'answer'])->name('answer')->middleware(HandlePrecognitiveRequests::class); + Route::post('{slug}/payment-intent', [FormPaymentController::class, 'createIntent'])->name('payment.create-intent')->middleware(HandlePrecognitiveRequests::class); // Form content endpoints (user lists, relation lists etc.) Route::get( diff --git a/client/components/open/forms/OpenCompleteForm.vue b/client/components/open/forms/OpenCompleteForm.vue index d80f83540..7b4452e2d 100644 --- a/client/components/open/forms/OpenCompleteForm.vue +++ b/client/components/open/forms/OpenCompleteForm.vue @@ -299,6 +299,27 @@ export default { if (form.busy) return this.loading = true + const hasPaymentBlock = this.form.properties.some(prop => prop.type === 'nf-payment') + if (hasPaymentBlock) { + form.post('/forms/' + this.form.slug + '/payment-intent') + .then((paymentData) => { + console.log('paymentData', paymentData) + // TODO: Charge payment + }) + .catch((error) => { + console.error(error) + if (error.response?.data?.message) { + useAlert().error(error.response.data.message) + } + this.loading = false + onFailure() + }) + } else { + // Proceed with normal form submission + this.processFormSubmission(form) + } + }, + processFormSubmission(form, paymentData = null) { form.post('/forms/' + this.form.slug + '/answer').then((data) => { this.submittedData = form.data() useAmplitude().logEvent('form_submission', { diff --git a/client/components/open/forms/fields/components/PaymentFieldOptions.vue b/client/components/open/forms/fields/components/PaymentFieldOptions.vue index dfb485fdc..31181c2b4 100644 --- a/client/components/open/forms/fields/components/PaymentFieldOptions.vue +++ b/client/components/open/forms/fields/components/PaymentFieldOptions.vue @@ -16,6 +16,7 @@ From f40ae364b9cdd10d1f0a342f0dfa90b9f18bc621 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Fri, 17 Jan 2025 19:53:47 +0530 Subject: [PATCH 11/30] Stripe Payment as composable --- .../Forms/FormPaymentController.php | 8 +- api/routes/api.php | 2 +- .../components/forms/PaymentInput.client.vue | 148 +++++++++++++----- .../open/forms/OpenCompleteForm.vue | 32 ++-- client/composables/useStripeElements.js | 51 ++++++ 5 files changed, 182 insertions(+), 59 deletions(-) create mode 100644 client/composables/useStripeElements.js diff --git a/api/app/Http/Controllers/Forms/FormPaymentController.php b/api/app/Http/Controllers/Forms/FormPaymentController.php index 2c40dd1bb..f5e3e4869 100644 --- a/api/app/Http/Controllers/Forms/FormPaymentController.php +++ b/api/app/Http/Controllers/Forms/FormPaymentController.php @@ -29,7 +29,7 @@ public function createIntent(Request $request) } // Get payment block (only one allowed) - $paymentBlock = collect($form->properties)->first(fn ($prop) => $prop['type'] === 'nf-payment'); + $paymentBlock = collect($form->properties)->first(fn($prop) => $prop['type'] === 'nf-payment'); if (!$paymentBlock) { Log::warning('Attempt to create payment for form without payment block', [ 'form_id' => $form->id @@ -57,7 +57,7 @@ public function createIntent(Request $request) $intent = PaymentIntent::create([ 'amount' => (int) ($paymentBlock['amount'] * 100), // Stripe requires amount in cents - 'currency' => $paymentBlock['currency'], + 'currency' => strtolower($paymentBlock['currency']), 'payment_method_types' => ['card'], 'metadata' => [ 'form_id' => $form->id, @@ -81,9 +81,9 @@ public function createIntent(Request $request) return $this->error(['message' => 'Failed to create payment intent']); } } catch (\Stripe\Exception\CardException $e) { - Log::warning('Card payment failed', [ + Log::warning('Failed to create payment intent', [ 'form_id' => $form->id, - 'error_code' => $e->getStripeCode() + 'message' => $e->getMessage() ]); return $this->error(['message' => $e->getMessage()]); } catch (\Exception $e) { diff --git a/api/routes/api.php b/api/routes/api.php index b6d8731d4..22cdce69a 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -291,7 +291,7 @@ Route::prefix('forms')->name('forms.')->group(function () { Route::middleware('protected-form')->group(function () { Route::post('{slug}/answer', [PublicFormController::class, 'answer'])->name('answer')->middleware(HandlePrecognitiveRequests::class); - Route::post('{slug}/payment-intent', [FormPaymentController::class, 'createIntent'])->name('payment.create-intent')->middleware(HandlePrecognitiveRequests::class); + Route::get('{slug}/payment-intent', [FormPaymentController::class, 'createIntent'])->name('payment.create-intent')->middleware(HandlePrecognitiveRequests::class); // Form content endpoints (user lists, relation lists etc.) Route::get( diff --git a/client/components/forms/PaymentInput.client.vue b/client/components/forms/PaymentInput.client.vue index 445bb8619..eb45d5a71 100644 --- a/client/components/forms/PaymentInput.client.vue +++ b/client/components/forms/PaymentInput.client.vue @@ -5,24 +5,59 @@
-

- Amount: {{ currency }} {{ amount }} -

- - - -
-
-

Loading...

+
+ Amount to pay + {{ currency }} {{ amount }} +
+ +
+
+ +
+ + +
+
+
+
+ +
- \ No newline at end of file + +const stripeOptions = computed(() => ({ + locale: props.locale +})) + +const elementsOptions = computed(() => ({ + mode: "payment", + amount: props.amount, + currency: props.currency.toLowerCase(), + payment_method_types: ['card'], + appearance: { + theme: 'stripe', + labels: 'above', + }, + fields: { + billingDetails: { + name: 'always', // 'always', 'never', or 'auto' + email: 'always', + } + } +})) + +onMounted(async () => { + try { + const stripeInstance = await loadStripe(stripeKey) + if (!stripeInstance) { + useAlert().error('Failed to load Stripe') + } + onStripeReady({ stripe: stripeInstance, elements: stripeInstance.elements }) + } catch (error) { + console.error('Stripe initialization error:', error) + stripeState.value.isLoaded = false + } +}) + diff --git a/client/components/open/forms/OpenCompleteForm.vue b/client/components/open/forms/OpenCompleteForm.vue index 7b4452e2d..f18ea7df7 100644 --- a/client/components/open/forms/OpenCompleteForm.vue +++ b/client/components/open/forms/OpenCompleteForm.vue @@ -233,7 +233,8 @@ export default { authenticated: computed(() => authStore.check), isIframe: useIsIframe(), pendingSubmission: pendingSubmission(props.form), - confetti: useConfetti() + confetti: useConfetti(), + stripeState: useStripeElements() } }, @@ -289,7 +290,7 @@ export default { }, methods: { - submitForm (form, onFailure) { + async submitForm(form, onFailure) { if (this.creating) { this.submitted = true this.$emit('submitted', true) @@ -301,19 +302,20 @@ export default { const hasPaymentBlock = this.form.properties.some(prop => prop.type === 'nf-payment') if (hasPaymentBlock) { - form.post('/forms/' + this.form.slug + '/payment-intent') - .then((paymentData) => { - console.log('paymentData', paymentData) - // TODO: Charge payment - }) - .catch((error) => { - console.error(error) - if (error.response?.data?.message) { - useAlert().error(error.response.data.message) - } - this.loading = false - onFailure() - }) + try { + // Process the payment + const { processPayment } = useStripeElements() + const result = await processPayment(this.form.slug) + console.log('result', result) + + // If payment successful, submit the form + await this.processFormSubmission(form) + } catch (error) { + console.error(error) + useAlert().error(error.message || 'Payment failed') + this.loading = false + onFailure() + } } else { // Proceed with normal form submission this.processFormSubmission(form) diff --git a/client/composables/useStripeElements.js b/client/composables/useStripeElements.js new file mode 100644 index 000000000..a1bf3f540 --- /dev/null +++ b/client/composables/useStripeElements.js @@ -0,0 +1,51 @@ +let state = null // singleton pattern + +export const useStripeElements = () => { + if (!state) { + state = ref({ + isLoaded: false, + stripe: null, + elements: null, + card: null, + cardHolderName: '', + cardHolderEmail: '' + }) + } + + const processPayment = async (formSlug) => { + if (!state.value.stripe || !state.value.elements) { + throw new Error('Stripe not initialized') + } + + await opnFetch('/forms/' + formSlug + '/payment-intent').then(async (responseIntent) => { + if(responseIntent?.type === 'success') { + const intentSecret = responseIntent?.intent?.secret + console.log('intentSecret', intentSecret) + + const result = await state.value.stripe.confirmCardPayment(intentSecret, { + payment_method: { + card: state.value.card, + billing_details: { + name: state.value.cardHolderName, + email: state.value.cardHolderEmail + }, + } + }) + console.log('result', result) + + if (result.error) { + throw new Error(result.error.message) + } + + return result.paymentIntent + } else { + useAlert().error(responseIntent.message) + } + }) + } + + return { + state, + processPayment + } +} \ No newline at end of file From f14f932fd0b66637483f21fc389ad7c885ba8366 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Mon, 20 Jan 2025 22:28:56 +0530 Subject: [PATCH 12/30] confirmCardPayment working --- .../components/forms/PaymentInput.client.vue | 47 +++++++++++++++---- client/composables/useStripeElements.js | 15 +++--- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/client/components/forms/PaymentInput.client.vue b/client/components/forms/PaymentInput.client.vue index eb45d5a71..6fc97082b 100644 --- a/client/components/forms/PaymentInput.client.vue +++ b/client/components/forms/PaymentInput.client.vue @@ -37,7 +37,9 @@
{ - if (!stripeInstance || !elementsInstance) { +const onCardChanged = (event) => { + console.log('card changed', event) +} + +const onCardReady = (element) => { + stripeState.value.card = card.value?.stripeElement +} + +const onStripeReady = (stripeInstance) => { + if (!stripeInstance) { console.error('Stripe initialization failed') return } stripeState.value.isLoaded = true stripeState.value.stripe = stripeInstance - stripeState.value.elements = elementsInstance - stripeState.value.card = card.value - stripeState.value.cardHolderName = cardHolderName.value - stripeState.value.cardHolderEmail = cardHolderEmail.value + stripeState.value.elements = stripeElements } +watch(cardHolderName, (newValue) => { + stripeState.value.cardHolderName = newValue +}) + +watch(cardHolderEmail, (newValue) => { + stripeState.value.cardHolderEmail = newValue +}) + +watch(card, (newValue) => { + stripeState.value.card = newValue +}) + +watch(stripeState.value.intentId, (newValue) => { + console.log('intentId changed', newValue) + compVal.value = newValue +}) + const stripeOptions = computed(() => ({ locale: props.locale })) @@ -123,13 +148,19 @@ const elementsOptions = computed(() => ({ } })) +const cardOptions = computed(() => ({ + type: 'card', + hidePostalCode: true, + disableLink: true, +})) + onMounted(async () => { try { const stripeInstance = await loadStripe(stripeKey) if (!stripeInstance) { useAlert().error('Failed to load Stripe') } - onStripeReady({ stripe: stripeInstance, elements: stripeInstance.elements }) + onStripeReady(stripeInstance) } catch (error) { console.error('Stripe initialization error:', error) stripeState.value.isLoaded = false diff --git a/client/composables/useStripeElements.js b/client/composables/useStripeElements.js index a1bf3f540..29595db5e 100644 --- a/client/composables/useStripeElements.js +++ b/client/composables/useStripeElements.js @@ -8,7 +8,8 @@ export const useStripeElements = () => { elements: null, card: null, cardHolderName: '', - cardHolderEmail: '' + cardHolderEmail: '', + intentId: null }) } @@ -18,18 +19,20 @@ export const useStripeElements = () => { } await opnFetch('/forms/' + formSlug + '/payment-intent').then(async (responseIntent) => { - if(responseIntent?.type === 'success') { + if (responseIntent?.type === 'success') { + state.value.intentId = responseIntent?.intent?.id const intentSecret = responseIntent?.intent?.secret - console.log('intentSecret', intentSecret) - - const result = await state.value.stripe.confirmCardPayment(intentSecret, { + const stripeInstance = state.value?.elements?.instance + + const result = await stripeInstance.confirmCardPayment(intentSecret, { payment_method: { card: state.value.card, billing_details: { name: state.value.cardHolderName, email: state.value.cardHolderEmail }, - } + }, + receipt_email: state.value.cardHolderEmail, }) console.log('result', result) From d421bf75a8cc990266ef694b10a0ce17e6284bc7 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Tue, 21 Jan 2025 14:43:24 +0530 Subject: [PATCH 13/30] Set payment errors on form.errors --- .../components/forms/PaymentInput.client.vue | 20 +------------------ .../open/forms/OpenCompleteForm.vue | 12 ++++++++--- client/composables/useStripeElements.js | 10 ++-------- 3 files changed, 12 insertions(+), 30 deletions(-) diff --git a/client/components/forms/PaymentInput.client.vue b/client/components/forms/PaymentInput.client.vue index 6fc97082b..34dba7831 100644 --- a/client/components/forms/PaymentInput.client.vue +++ b/client/components/forms/PaymentInput.client.vue @@ -30,7 +30,6 @@ v-slot="{ elements }" :stripe-key="stripeKey" :instance-options="stripeOptions" - :elements-options="elementsOptions" >
@@ -128,24 +127,7 @@ watch(stripeState.value.intentId, (newValue) => { }) const stripeOptions = computed(() => ({ - locale: props.locale -})) - -const elementsOptions = computed(() => ({ - mode: "payment", - amount: props.amount, - currency: props.currency.toLowerCase(), - payment_method_types: ['card'], - appearance: { - theme: 'stripe', - labels: 'above', - }, - fields: { - billingDetails: { - name: 'always', // 'always', 'never', or 'auto' - email: 'always', - } - } + locale: props.locale || 'en' })) const cardOptions = computed(() => ({ diff --git a/client/components/open/forms/OpenCompleteForm.vue b/client/components/open/forms/OpenCompleteForm.vue index f18ea7df7..d76e4a817 100644 --- a/client/components/open/forms/OpenCompleteForm.vue +++ b/client/components/open/forms/OpenCompleteForm.vue @@ -300,19 +300,25 @@ export default { if (form.busy) return this.loading = true - const hasPaymentBlock = this.form.properties.some(prop => prop.type === 'nf-payment') + const hasPaymentBlock = this.form.properties.find(prop => prop.type === 'nf-payment') if (hasPaymentBlock) { try { // Process the payment const { processPayment } = useStripeElements() const result = await processPayment(this.form.slug) console.log('result', result) - + if (result && result?.error) { + form.errors.set(hasPaymentBlock.id, result.error.message) + this.loading = false + onFailure() + return + } // If payment successful, submit the form + useAlert().success('Thank you! Your payment is successful.') await this.processFormSubmission(form) } catch (error) { console.error(error) - useAlert().error(error.message || 'Payment failed') + useAlert().error(error?.message || 'Payment failed') this.loading = false onFailure() } diff --git a/client/composables/useStripeElements.js b/client/composables/useStripeElements.js index 29595db5e..8bd34bcbe 100644 --- a/client/composables/useStripeElements.js +++ b/client/composables/useStripeElements.js @@ -18,7 +18,7 @@ export const useStripeElements = () => { throw new Error('Stripe not initialized') } - await opnFetch('/forms/' + formSlug + '/payment-intent').then(async (responseIntent) => { + return await opnFetch('/forms/' + formSlug + '/payment-intent').then(async (responseIntent) => { if (responseIntent?.type === 'success') { state.value.intentId = responseIntent?.intent?.id const intentSecret = responseIntent?.intent?.secret @@ -34,13 +34,7 @@ export const useStripeElements = () => { }, receipt_email: state.value.cardHolderEmail, }) - console.log('result', result) - - if (result.error) { - throw new Error(result.error.message) - } - - return result.paymentIntent + return result } else { useAlert().error(responseIntent.message) } From 9c825b824ba7a52244a88c7d16f81b082d6bd8d8 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Tue, 21 Jan 2025 18:25:21 +0530 Subject: [PATCH 14/30] Validate card other fields --- client/components/open/forms/OpenCompleteForm.vue | 1 + client/composables/useStripeElements.js | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/client/components/open/forms/OpenCompleteForm.vue b/client/components/open/forms/OpenCompleteForm.vue index d76e4a817..33694bb98 100644 --- a/client/components/open/forms/OpenCompleteForm.vue +++ b/client/components/open/forms/OpenCompleteForm.vue @@ -309,6 +309,7 @@ export default { console.log('result', result) if (result && result?.error) { form.errors.set(hasPaymentBlock.id, result.error.message) + useAlert().error(result.error.message) this.loading = false onFailure() return diff --git a/client/composables/useStripeElements.js b/client/composables/useStripeElements.js index 8bd34bcbe..49d3ec7b6 100644 --- a/client/composables/useStripeElements.js +++ b/client/composables/useStripeElements.js @@ -18,6 +18,16 @@ export const useStripeElements = () => { throw new Error('Stripe not initialized') } + if(!state.value.cardHolderName) { + return { error: { message: 'Card holder name is required' } } + } + if(!state.value.cardHolderEmail) { + return { error: { message: 'Billing email address is required' } } + } + if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(state.value.cardHolderEmail)) { + return { error: { message: 'Invalid billing email address' } } + } + return await opnFetch('/forms/' + formSlug + '/payment-intent').then(async (responseIntent) => { if (responseIntent?.type === 'success') { state.value.intentId = responseIntent?.intent?.id From 7f8a7cdc00866ad45ab45b1accc2ae29b5edfdbe Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Wed, 22 Jan 2025 09:01:49 +0530 Subject: [PATCH 15/30] Store payment id to database and on submission add link for view payment on stripe --- .../components/forms/PaymentInput.client.vue | 10 +++-- .../open/forms/OpenCompleteForm.vue | 15 ++++--- client/components/open/tables/OpenTable.vue | 2 + .../open/tables/components/OpenPayment.vue | 39 +++++++++++++++++++ client/composables/useStripeElements.js | 1 - 5 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 client/components/open/tables/components/OpenPayment.vue diff --git a/client/components/forms/PaymentInput.client.vue b/client/components/forms/PaymentInput.client.vue index 34dba7831..5f1130a49 100644 --- a/client/components/forms/PaymentInput.client.vue +++ b/client/components/forms/PaymentInput.client.vue @@ -71,7 +71,7 @@ diff --git a/client/composables/useStripeElements.js b/client/composables/useStripeElements.js index 49d3ec7b6..f1f85e3b2 100644 --- a/client/composables/useStripeElements.js +++ b/client/composables/useStripeElements.js @@ -30,7 +30,6 @@ export const useStripeElements = () => { return await opnFetch('/forms/' + formSlug + '/payment-intent').then(async (responseIntent) => { if (responseIntent?.type === 'success') { - state.value.intentId = responseIntent?.intent?.id const intentSecret = responseIntent?.intent?.secret const stripeInstance = state.value?.elements?.instance From f67e48761518fb97fcbddd2224d88f170310e252 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Wed, 22 Jan 2025 12:15:20 +0530 Subject: [PATCH 16/30] FormPaymentController no need auth middleware --- api/app/Http/Controllers/Forms/FormPaymentController.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/api/app/Http/Controllers/Forms/FormPaymentController.php b/api/app/Http/Controllers/Forms/FormPaymentController.php index f5e3e4869..b7a23731b 100644 --- a/api/app/Http/Controllers/Forms/FormPaymentController.php +++ b/api/app/Http/Controllers/Forms/FormPaymentController.php @@ -11,11 +11,6 @@ class FormPaymentController extends Controller { - public function __construct() - { - $this->middleware('auth'); - } - public function createIntent(Request $request) { $form = $request->form; From 452ab15f4b7b0d88aa66c6efc7b9e6c9a162b8f5 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Wed, 22 Jan 2025 19:01:22 +0530 Subject: [PATCH 17/30] paymentinput error display on field --- client/components/forms/PaymentInput.client.vue | 2 +- client/components/open/forms/OpenCompleteForm.vue | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/components/forms/PaymentInput.client.vue b/client/components/forms/PaymentInput.client.vue index 5f1130a49..5186aa20b 100644 --- a/client/components/forms/PaymentInput.client.vue +++ b/client/components/forms/PaymentInput.client.vue @@ -1,5 +1,5 @@ + +const resetCard = async () => { + if (card.value?.stripeElement) { + card.value.stripeElement.unmount() + await nextTick() + card.value.stripeElement.mount(card.value.$el) + } +} + \ No newline at end of file From 0213224ec3c38591f20736233c625283d6b83387 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Tue, 28 Jan 2025 21:30:05 +0530 Subject: [PATCH 22/30] use connected account for loadstripe --- .../Forms/FormPaymentController.php | 27 +++++++++- api/routes/api.php | 3 +- .../components/forms/PaymentInput.client.vue | 50 ++++++++++++------- client/composables/useStripeElements.js | 2 +- 4 files changed, 60 insertions(+), 22 deletions(-) diff --git a/api/app/Http/Controllers/Forms/FormPaymentController.php b/api/app/Http/Controllers/Forms/FormPaymentController.php index 4a8376f23..70698491e 100644 --- a/api/app/Http/Controllers/Forms/FormPaymentController.php +++ b/api/app/Http/Controllers/Forms/FormPaymentController.php @@ -11,6 +11,31 @@ class FormPaymentController extends Controller { + public function getAccount(Request $request) + { + $form = $request->form; + + // Get payment block (only one allowed) + $paymentBlock = collect($form->properties)->first(fn($prop) => $prop['type'] === 'payment'); + if (!$paymentBlock) { + Log::warning('Form without payment block', [ + 'form_id' => $form->id + ]); + return $this->error(['message' => 'Form does not have a payment block.']); + } + + // Get provider + $provider = OAuthProvider::find($paymentBlock['stripe_account_id']); + if ($provider === null) { + Log::error('Failed to find Stripe account', [ + 'stripe_account_id' => $paymentBlock['stripe_account_id'] + ]); + return $this->error(['message' => 'Failed to find Stripe account']); + } + + return $this->success(['stripeAccount' => $provider->provider_user_id]); + } + public function createIntent(Request $request) { $form = $request->form; @@ -24,7 +49,7 @@ public function createIntent(Request $request) } // Get payment block (only one allowed) - $paymentBlock = collect($form->properties)->first(fn ($prop) => $prop['type'] === 'payment'); + $paymentBlock = collect($form->properties)->first(fn($prop) => $prop['type'] === 'payment'); if (!$paymentBlock) { Log::warning('Attempt to create payment for form without payment block', [ 'form_id' => $form->id diff --git a/api/routes/api.php b/api/routes/api.php index 22cdce69a..c73e2d211 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -291,7 +291,8 @@ Route::prefix('forms')->name('forms.')->group(function () { Route::middleware('protected-form')->group(function () { Route::post('{slug}/answer', [PublicFormController::class, 'answer'])->name('answer')->middleware(HandlePrecognitiveRequests::class); - Route::get('{slug}/payment-intent', [FormPaymentController::class, 'createIntent'])->name('payment.create-intent')->middleware(HandlePrecognitiveRequests::class); + Route::get('{slug}/stripe-connect/get-account', [FormPaymentController::class, 'getAccount'])->name('stripe-connect.get-account')->middleware(HandlePrecognitiveRequests::class); + Route::get('{slug}/stripe-connect/payment-intent', [FormPaymentController::class, 'createIntent'])->name('stripe-connect.create-intent')->middleware(HandlePrecognitiveRequests::class); // Form content endpoints (user lists, relation lists etc.) Route::get( diff --git a/client/components/forms/PaymentInput.client.vue b/client/components/forms/PaymentInput.client.vue index 643cc8a27..22b7389ae 100644 --- a/client/components/forms/PaymentInput.client.vue +++ b/client/components/forms/PaymentInput.client.vue @@ -32,7 +32,7 @@
@@ -91,10 +91,11 @@ const props = defineProps({ const emit = defineEmits([]) const { ...formInput } = useFormInput(props, { emit }) - -const stripeKey = useRuntimeConfig().public.STRIPE_PUBLISHABLE_KEY const { state: stripeState } = useStripeElements() +const route = useRoute() +const accountId = ref(null) +const publishableKey = useRuntimeConfig().public.STRIPE_PUBLISHABLE_KEY const card = ref(null) const stripeElements = ref(null) const cardHolderName = ref('') @@ -108,17 +109,6 @@ const onCardReady = (element) => { stripeState.value.card = card.value?.stripeElement } -const onStripeReady = (stripeInstance) => { - if (!stripeInstance) { - console.error('Stripe initialization failed') - return - } - - stripeState.value.isLoaded = true - stripeState.value.stripe = stripeInstance - stripeState.value.elements = stripeElements -} - watch(cardHolderName, (newValue) => { stripeState.value.cardHolderName = newValue }) @@ -152,18 +142,40 @@ const cardOptions = computed(() => ({ disableLink: true, })) +const formSlug = computed(() => { + if (route.name && route.name.startsWith("forms-slug")) { + return route.params.slug + } + return null +}) + onMounted(async () => { + initStripe() +}) + +const initStripe = async () => { + if (!formSlug.value) return try { - const stripeInstance = await loadStripe(stripeKey) - if (!stripeInstance) { - useAlert().error('Failed to load Stripe') + const response = await opnFetch('/forms/' + formSlug.value + '/stripe-connect/get-account') + if (response?.type === 'success') { + accountId.value = response?.stripeAccount + const stripeInstance = await loadStripe(publishableKey, { stripeAccount: accountId.value }) + if (!stripeInstance) { + useAlert().error('Stripe initialization failed') + return + } + + stripeState.value.isLoaded = true + stripeState.value.stripe = stripeInstance + stripeState.value.elements = stripeElements + } else { + useAlert().error(response.message) } - onStripeReady(stripeInstance) } catch (error) { console.error('Stripe initialization error:', error) stripeState.value.isLoaded = false } -}) +} const resetCard = async () => { if (card.value?.stripeElement) { diff --git a/client/composables/useStripeElements.js b/client/composables/useStripeElements.js index 47194ab78..4d58ca459 100644 --- a/client/composables/useStripeElements.js +++ b/client/composables/useStripeElements.js @@ -34,7 +34,7 @@ export const useStripeElements = () => { } } - return await opnFetch('/forms/' + formSlug + '/payment-intent').then(async (responseIntent) => { + return await opnFetch('/forms/' + formSlug + '/stripe-connect/payment-intent').then(async (responseIntent) => { if (responseIntent?.type === 'success') { const intentSecret = responseIntent?.intent?.secret const stripeInstance = state.value?.elements?.instance From b18b4e80f73f2b9bcc5dfd2e5c2c991452be3a03 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Tue, 28 Jan 2025 23:19:12 +0530 Subject: [PATCH 23/30] validate OAuthProvider before delete it --- api/app/Policies/OAuthProviderPolicy.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/api/app/Policies/OAuthProviderPolicy.php b/api/app/Policies/OAuthProviderPolicy.php index 1102ee294..2f9ecc161 100644 --- a/api/app/Policies/OAuthProviderPolicy.php +++ b/api/app/Policies/OAuthProviderPolicy.php @@ -2,6 +2,7 @@ namespace App\Policies; +use App\Integrations\OAuth\OAuthProviderService; use App\Models\Integration\FormIntegration; use App\Models\OAuthProvider; use App\Models\User; @@ -62,6 +63,20 @@ public function delete(User $user, OAuthProvider $provider) if ($integrations->count() > 0) { return $this->denyWithStatus(400, 'This connection cannot be removed because there is already an integration using it.'); } + + if ($provider->provider->value === OAuthProviderService::Stripe->value) { + $formsUsingStripe = $user->forms() + ->get() + ->filter(function ($form) use ($provider) { + return collect($form->properties) + ->some(fn ($prop) => ($prop['stripe_account_id'] ?? null) === $provider->id); + }) + ->isNotEmpty(); + if ($formsUsingStripe) { + return $this->denyWithStatus(400, 'This Stripe connection cannot be removed because it is being used in a form payment field.'); + } + } + return $provider->user()->is($user); } From f7c8f0227b560957f080fcb1800dada0e973cf0c Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Tue, 28 Jan 2025 23:20:37 +0530 Subject: [PATCH 24/30] payment improvements --- .../Controllers/Forms/FormPaymentController.php | 5 +++-- client/components/forms/PaymentInput.client.vue | 14 +++++++++----- client/components/open/forms/OpenFormField.vue | 1 + .../fields/components/PaymentFieldOptions.vue | 9 ++++++--- client/stores/working_form.js | 1 + 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/api/app/Http/Controllers/Forms/FormPaymentController.php b/api/app/Http/Controllers/Forms/FormPaymentController.php index 70698491e..941bfa2e3 100644 --- a/api/app/Http/Controllers/Forms/FormPaymentController.php +++ b/api/app/Http/Controllers/Forms/FormPaymentController.php @@ -16,7 +16,7 @@ public function getAccount(Request $request) $form = $request->form; // Get payment block (only one allowed) - $paymentBlock = collect($form->properties)->first(fn($prop) => $prop['type'] === 'payment'); + $paymentBlock = collect($form->properties)->first(fn ($prop) => $prop['type'] === 'payment'); if (!$paymentBlock) { Log::warning('Form without payment block', [ 'form_id' => $form->id @@ -49,7 +49,7 @@ public function createIntent(Request $request) } // Get payment block (only one allowed) - $paymentBlock = collect($form->properties)->first(fn($prop) => $prop['type'] === 'payment'); + $paymentBlock = collect($form->properties)->first(fn ($prop) => $prop['type'] === 'payment'); if (!$paymentBlock) { Log::warning('Attempt to create payment for form without payment block', [ 'form_id' => $form->id @@ -76,6 +76,7 @@ public function createIntent(Request $request) Stripe::setApiKey(config('cashier.secret')); $intent = PaymentIntent::create([ + 'description' => 'Form - ' . $form->title, 'amount' => (int) ($paymentBlock['amount'] * 100), // Stripe requires amount in cents 'currency' => strtolower($paymentBlock['currency']), 'payment_method_types' => ['card'], diff --git a/client/components/forms/PaymentInput.client.vue b/client/components/forms/PaymentInput.client.vue index 22b7389ae..d920843cc 100644 --- a/client/components/forms/PaymentInput.client.vue +++ b/client/components/forms/PaymentInput.client.vue @@ -17,7 +17,10 @@ }, ]" > -
+
+

Connect Stripe account to continue

+
+

{{ $t('forms.payment.success') }}

@@ -302,6 +313,9 @@ export default { return { '--form-color': this.form.color } + }, + paymentBlock() { + return this.currentFields.find(field => field.type === 'payment') } }, @@ -547,14 +561,13 @@ export default { async doPayment() { // If there is a payment block, process the payment const { state: stripeState, processPayment } = useStripeElements() - const paymentBlock = this.currentFields.find(field => field.type === 'payment') - if (paymentBlock && !stripeState.value.intentId && (paymentBlock.required || !stripeState.value.card._empty)) { + if (this.paymentBlock && !stripeState.value.intentId && (this.paymentBlock.required || !stripeState.value.card._empty)) { try { // Process the payment - const result = await processPayment(this.form.slug, paymentBlock.required) + const result = await processPayment(this.form.slug, this.paymentBlock.required) console.log('result', result) if (result && result?.error) { - this.dataForm.errors.set(paymentBlock.id, result.error.message) + this.dataForm.errors.set(this.paymentBlock.id, result.error.message) useAlert().error(result.error.message) return false } From 208629b0019e3eb02c254e8f1f0520595971b59b Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Wed, 29 Jan 2025 21:15:46 +0530 Subject: [PATCH 26/30] use stripe_currencies.json --- .../Content/FeatureFlagsController.php | 5 +- .../Rules/PaymentBlockConfigurationRule.php | 3 +- api/config/services.php | 41 ---- api/resources/data/stripe_currencies.json | 197 ++++++++++++++++++ .../components/forms/PaymentInput.client.vue | 7 +- .../fields/components/PaymentFieldOptions.vue | 8 +- client/data/stripe_currencies.json | 197 ++++++++++++++++++ 7 files changed, 407 insertions(+), 51 deletions(-) create mode 100644 api/resources/data/stripe_currencies.json create mode 100644 client/data/stripe_currencies.json diff --git a/api/app/Http/Controllers/Content/FeatureFlagsController.php b/api/app/Http/Controllers/Content/FeatureFlagsController.php index 4be27c3a7..d86a5166b 100644 --- a/api/app/Http/Controllers/Content/FeatureFlagsController.php +++ b/api/app/Http/Controllers/Content/FeatureFlagsController.php @@ -27,10 +27,7 @@ public function index() 'google' => [ 'fonts' => !empty(config('services.google.fonts_api_key')), 'auth' => !empty(config('services.google.client_id')) && !empty(config('services.google.client_secret')), - ], - 'stripe' => [ - 'currencies' => config('services.stripe.currencies'), - ], + ] ], 'integrations' => [ 'zapier' => config('services.zapier.enabled'), diff --git a/api/app/Rules/PaymentBlockConfigurationRule.php b/api/app/Rules/PaymentBlockConfigurationRule.php index 1801caa18..c71deb006 100644 --- a/api/app/Rules/PaymentBlockConfigurationRule.php +++ b/api/app/Rules/PaymentBlockConfigurationRule.php @@ -45,7 +45,8 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } // Currency validation - if (!isset($this->field['currency']) || !in_array(strtoupper($this->field['currency']), array_keys(config('services.stripe.currencies')))) { + $stripeCurrencies = json_decode(file_get_contents(resource_path('data/stripe_currencies.json')), true); + if (!isset($this->field['currency']) || !in_array(strtoupper($this->field['currency']), array_column($stripeCurrencies, 'code'))) { $fail('Currency must be a valid currency'); return; } diff --git a/api/config/services.php b/api/config/services.php index dda142407..bc1a5bcbe 100644 --- a/api/config/services.php +++ b/api/config/services.php @@ -87,46 +87,5 @@ 'client_id' => env('STRIPE_CLIENT_ID'), 'client_secret' => env('STRIPE_CLIENT_SECRET'), 'redirect' => env('STRIPE_REDIRECT_URI'), - 'currencies' => [ - 'AED' => 'AED - UAE Dirham', - 'AUD' => 'AUD - Australian Dollar', - 'BGN' => 'BGN - Bulgarian lev', - 'BRL' => 'BRL - Brazilian real', - 'CAD' => 'CAD - Canadian dollar', - 'CHF' => 'CHF - Swiss franc', - 'CNY' => 'CNY - Yuan Renminbi', - 'CZK' => 'CZK - Czech Koruna', - 'DKK' => 'DKK - Danish Krone', - 'EUR' => 'EUR - Euro', - 'GBP' => 'GBP - Pound sterling', - 'HKD' => 'HKD - Hong Kong dollar', - 'HRK' => 'HRK - Croatian kuna', - 'HUF' => 'HUF - Hungarian forint', - 'IDR' => 'IDR - Indonesian Rupiah', - 'ILS' => 'ILS - Israeli Shekel', - 'INR' => 'INR - Indian Rupee', - 'ISK' => 'ISK - Icelandic króna', - 'JPY' => 'JPY - Japanese yen', - 'KRW' => 'KRW - South Korean won', - 'MAD' => 'MAD - Moroccan Dirham', - 'MXN' => 'MXN - Mexican peso', - 'MYR' => 'MYR - Malaysian ringgit', - 'NOK' => 'NOK - Norwegian krone', - 'NZD' => 'NZD - New Zealand dollar', - 'PHP' => 'PHP - Philippine peso', - 'PLN' => 'PLN - Polish złoty', - 'RON' => 'RON - Romanian leu', - 'RSD' => 'RSD - Serbian dinar', - 'RUB' => 'RUB - Russian Rouble', - 'SAR' => 'SAR - Saudi riyal', - 'SEK' => 'SEK - Swedish krona', - 'SGD' => 'SGD - Singapore dollar', - 'THB' => 'THB - Thai baht', - 'TWD' => 'TWD - New Taiwan dollar', - 'UAH' => 'UAH - Ukrainian hryvnia', - 'USD' => 'USD - United States Dollar', - 'VND' => 'VND - Vietnamese dong', - 'ZAR' => 'ZAR - South African rand', - ], ] ]; diff --git a/api/resources/data/stripe_currencies.json b/api/resources/data/stripe_currencies.json new file mode 100644 index 000000000..503ffe35f --- /dev/null +++ b/api/resources/data/stripe_currencies.json @@ -0,0 +1,197 @@ +[ + { + "name": "AED - UAE Dirham", + "code": "AED", + "symbol": "د.إ" + }, + { + "name": "AUD - Australian Dollar", + "code": "AUD", + "symbol": "A$" + }, + { + "name": "BGN - Bulgarian Lev", + "code": "BGN", + "symbol": "лв" + }, + { + "name": "BRL - Brazilian Real", + "code": "BRL", + "symbol": "R$" + }, + { + "name": "CAD - Canadian Dollar", + "code": "CAD", + "symbol": "C$" + }, + { + "name": "CHF - Swiss Franc", + "code": "CHF", + "symbol": "CHF" + }, + { + "name": "CNY - Yuan Renminbi", + "code": "CNY", + "symbol": "¥" + }, + { + "name": "CZK - Czech Koruna", + "code": "CZK", + "symbol": "Kč" + }, + { + "name": "DKK - Danish Krone", + "code": "DKK", + "symbol": "kr" + }, + { + "name": "EUR - Euro", + "code": "EUR", + "symbol": "€" + }, + { + "name": "GBP - Pound Sterling", + "code": "GBP", + "symbol": "£" + }, + { + "name": "HKD - Hong Kong Dollar", + "code": "HKD", + "symbol": "HK$" + }, + { + "name": "HRK - Croatian Kuna", + "code": "HRK", + "symbol": "kn" + }, + { + "name": "HUF - Hungarian Forint", + "code": "HUF", + "symbol": "Ft" + }, + { + "name": "IDR - Indonesian Rupiah", + "code": "IDR", + "symbol": "Rp" + }, + { + "name": "ILS - Israeli Shekel", + "code": "ILS", + "symbol": "₪" + }, + { + "name": "INR - Indian Rupee", + "code": "INR", + "symbol": "₹" + }, + { + "name": "ISK - Icelandic Króna", + "code": "ISK", + "symbol": "kr" + }, + { + "name": "JPY - Japanese Yen", + "code": "JPY", + "symbol": "¥" + }, + { + "name": "KRW - South Korean Won", + "code": "KRW", + "symbol": "₩" + }, + { + "name": "MAD - Moroccan Dirham", + "code": "MAD", + "symbol": "د.م." + }, + { + "name": "MXN - Mexican Peso", + "code": "MXN", + "symbol": "$" + }, + { + "name": "MYR - Malaysian Ringgit", + "code": "MYR", + "symbol": "RM" + }, + { + "name": "NOK - Norwegian Krone", + "code": "NOK", + "symbol": "kr" + }, + { + "name": "NZD - New Zealand Dollar", + "code": "NZD", + "symbol": "NZ$" + }, + { + "name": "PHP - Philippine Peso", + "code": "PHP", + "symbol": "₱" + }, + { + "name": "PLN - Polish Złoty", + "code": "PLN", + "symbol": "zł" + }, + { + "name": "RON - Romanian Leu", + "code": "RON", + "symbol": "lei" + }, + { + "name": "RSD - Serbian Dinar", + "code": "RSD", + "symbol": "дин." + }, + { + "name": "RUB - Russian Rouble", + "code": "RUB", + "symbol": "₽" + }, + { + "name": "SAR - Saudi Riyal", + "code": "SAR", + "symbol": "﷼" + }, + { + "name": "SEK - Swedish Krona", + "code": "SEK", + "symbol": "kr" + }, + { + "name": "SGD - Singapore Dollar", + "code": "SGD", + "symbol": "S$" + }, + { + "name": "THB - Thai Baht", + "code": "THB", + "symbol": "฿" + }, + { + "name": "TWD - New Taiwan Dollar", + "code": "TWD", + "symbol": "NT$" + }, + { + "name": "UAH - Ukrainian Hryvnia", + "code": "UAH", + "symbol": "₴" + }, + { + "name": "USD - United States Dollar", + "code": "USD", + "symbol": "$" + }, + { + "name": "VND - Vietnamese Dong", + "code": "VND", + "symbol": "₫" + }, + { + "name": "ZAR - South African Rand", + "code": "ZAR", + "symbol": "R" + } +] \ No newline at end of file diff --git a/client/components/forms/PaymentInput.client.vue b/client/components/forms/PaymentInput.client.vue index d920843cc..b34b28e0a 100644 --- a/client/components/forms/PaymentInput.client.vue +++ b/client/components/forms/PaymentInput.client.vue @@ -30,7 +30,7 @@ >
{{ $t('forms.payment.amount_to_pay') }} - {{ currency }} {{ amount }} + {{ currencySymbol }}{{ amount }}
props.locale, async (newValue) => { await resetCard() }) +const currencySymbol = computed(() => { + return stripeCurrencies.find(item => item.code === props.currency)?.symbol +}) + const stripeOptions = computed(() => ({ locale: props.locale })) diff --git a/client/components/open/forms/fields/components/PaymentFieldOptions.vue b/client/components/open/forms/fields/components/PaymentFieldOptions.vue index d187dc07c..12b1959d3 100644 --- a/client/components/open/forms/fields/components/PaymentFieldOptions.vue +++ b/client/components/open/forms/fields/components/PaymentFieldOptions.vue @@ -63,6 +63,7 @@ From d341b54248c90d9449c2f0b3e81dbc7505b9b3f9 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Thu, 30 Jan 2025 18:48:40 +0530 Subject: [PATCH 29/30] Restrict payment block in self-hosted environments --- api/app/Rules/PaymentBlockConfigurationRule.php | 6 ++++++ client/data/blocks_types.json | 3 ++- client/stores/working_form.js | 6 ++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/api/app/Rules/PaymentBlockConfigurationRule.php b/api/app/Rules/PaymentBlockConfigurationRule.php index c71deb006..31fccd73a 100644 --- a/api/app/Rules/PaymentBlockConfigurationRule.php +++ b/api/app/Rules/PaymentBlockConfigurationRule.php @@ -27,6 +27,12 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; // If not a payment block, validation passes } + // Payment block not allowed if self hosted + if (config('app.self_hosted')) { + $fail('Payment block is not allowed on self hosted. Please use our hosted version.'); + return; + } + // Only one payment block allowed $paymentBlocks = collect($this->properties) ->filter(fn ($prop) => $prop['type'] === 'payment') diff --git a/client/data/blocks_types.json b/client/data/blocks_types.json index 3a0186acd..33b804c18 100644 --- a/client/data/blocks_types.json +++ b/client/data/blocks_types.json @@ -159,7 +159,8 @@ "default_block_name": "Payment", "bg_class": "bg-green-100", "text_class": "text-green-900", - "is_input": true + "is_input": true, + "self_hosted": false }, "nf-text": { "name": "nf-text", diff --git a/client/stores/working_form.js b/client/stores/working_form.js index a042322a8..3dbf750d4 100644 --- a/client/stores/working_form.js +++ b/client/stores/working_form.js @@ -96,6 +96,12 @@ export const useWorkingFormStore = defineStore("working_form", { }, addBlock(type, index = null, openSettings = true) { + const block = blocksTypes[type] + if (block?.self_hosted !== undefined && !block.self_hosted && useFeatureFlag('self_hosted')) { + useAlert().error(block?.title + ' is not allowed on self hosted. Please use our hosted version.') + return + } + if (type === 'payment') { // Can only have one payment block if (this.content.properties.some(block => block.type === 'payment')) { useAlert().error('Only one payment block is allowed per form') From c5ed0c49a39367ab48cd64db1c5734d953381d14 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Fri, 31 Jan 2025 09:56:23 +0530 Subject: [PATCH 30/30] validate form before process payment --- .../components/forms/PaymentInput.client.vue | 5 -- client/components/open/forms/OpenForm.vue | 54 +++++++++++-------- 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/client/components/forms/PaymentInput.client.vue b/client/components/forms/PaymentInput.client.vue index e5065f801..672f674b4 100644 --- a/client/components/forms/PaymentInput.client.vue +++ b/client/components/forms/PaymentInput.client.vue @@ -46,7 +46,6 @@ :elements="elements" :options="cardOptions" @ready="onCardReady" - @change="onCardChanged" />
{ - console.log('card changed', event) -} const onCardReady = (element) => { stripeState.value.card = card.value?.stripeElement diff --git a/client/components/open/forms/OpenForm.vue b/client/components/open/forms/OpenForm.vue index 6e8eb3382..92f036373 100644 --- a/client/components/open/forms/OpenForm.vue +++ b/client/components/open/forms/OpenForm.vue @@ -315,7 +315,7 @@ export default { } }, paymentBlock() { - return this.currentFields.find(field => field.type === 'payment') + return (this.currentFields) ? this.currentFields.find(field => field.type === 'payment') : null } }, @@ -374,14 +374,12 @@ export default { methods: { async submitForm() { - if (!this.isAutoSubmit && this.formPageIndex !== this.fieldGroups.length - 1) return - - this.dataForm.busy = true - if (!await this.doPayment()) { + if (!await this.nextPage()) { this.dataForm.busy = false return } - this.dataForm.busy = false + + if (!this.isAutoSubmit && this.formPageIndex !== this.fieldGroups.length - 1) return if (this.form.use_captcha && import.meta.client) { this.$refs.captcha?.reset() @@ -542,25 +540,37 @@ export default { }, async nextPage() { if (this.adminPreview || this.urlPrefillPreview) { - this.formPageIndex++ + if (!this.isLastPage) { + this.formPageIndex++ + } this.scrollToTop() - return false + return true } - this.dataForm.busy = true - if (!await this.doPayment()) { - this.dataForm.busy = false - return false - } - - const fieldsToValidate = this.currentFields.map(f => f.id) - this.dataForm.validate('POST', '/forms/' + this.form.slug + '/answer', {}, fieldsToValidate) - .then(() => { + try { + this.dataForm.busy = true + const fieldsToValidate = this.currentFields + .filter(f => f.type !== 'payment') + .map(f => f.id) + + await this.dataForm.validate('POST', `/forms/${this.form.slug}/answer`, {}, fieldsToValidate) + + if (!await this.doPayment()) { + return false + } + + if (!this.isLastPage) { this.formPageIndex++ - this.dataForm.busy = false this.scrollToTop() - }).catch(this.handleValidationError) - return false + } + + return true + } catch (error) { + this.handleValidationError(error) + return false + } finally { + this.dataForm.busy = false + } }, async doPayment() { // If there is a payment block, process the payment @@ -568,8 +578,9 @@ export default { if (this.paymentBlock && !stripeState.value.intentId && (this.paymentBlock.required || !stripeState.value.card._empty)) { try { // Process the payment + this.dataForm.busy = true const result = await processPayment(this.form.slug, this.paymentBlock.required) - console.log('result', result) + this.dataForm.busy = false if (result && result?.error) { this.dataForm.errors.set(this.paymentBlock.id, result.error.message) useAlert().error(result.error.message) @@ -583,6 +594,7 @@ export default { } useAlert().error('Something went wrong. Please try again.') } catch (error) { + this.dataForm.busy = false console.error(error) useAlert().error(error?.message || 'Payment failed') }