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/Http/Controllers/Content/FeatureFlagsController.php b/api/app/Http/Controllers/Content/FeatureFlagsController.php index 711c40b67..d86a5166b 100644 --- a/api/app/Http/Controllers/Content/FeatureFlagsController.php +++ b/api/app/Http/Controllers/Content/FeatureFlagsController.php @@ -27,7 +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')), - ], + ] ], 'integrations' => [ 'zapier' => config('services.zapier.enabled'), diff --git a/api/app/Http/Controllers/Forms/FormPaymentController.php b/api/app/Http/Controllers/Forms/FormPaymentController.php new file mode 100644 index 000000000..941bfa2e3 --- /dev/null +++ b/api/app/Http/Controllers/Forms/FormPaymentController.php @@ -0,0 +1,118 @@ +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; + + // 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'] === 'payment'); + if (!$paymentBlock) { + Log::warning('Attempt to create payment for 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']); + } + + try { + Log::info('Creating payment intent', [ + 'form_id' => $form->id, + 'amount' => $paymentBlock['amount'], + 'currency' => $paymentBlock['currency'] + ]); + + 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'], + '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('Failed to create payment intent', [ + 'form_id' => $form->id, + 'message' => $e->getMessage() + ]); + 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/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/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/Http/Resources/FormResource.php b/api/app/Http/Resources/FormResource.php index 93dfeeb38..70d01010f 100644 --- a/api/app/Http/Resources/FormResource.php +++ b/api/app/Http/Resources/FormResource.php @@ -49,6 +49,7 @@ public function toArray($request) 'max_number_of_submissions_reached' => $this->max_number_of_submissions_reached, 'form_pending_submission_key' => $this->form_pending_submission_key, 'max_file_size' => $this->max_file_size / 1000000, + 'auto_save' => $this->getAutoSave(), ]); } @@ -112,4 +113,16 @@ private function getCleanigns() { return $this->extra?->cleanings ?? $this->cleanings; } + + private function hasPaymentBlock() + { + return array_filter($this->properties, function ($property) { + return $property['type'] === 'payment'; + }); + } + + private function getAutoSave() + { + return $this->hasPaymentBlock() ? true : $this->auto_save; + } } diff --git a/api/app/Integrations/OAuth/Drivers/OAuthStripeDriver.php b/api/app/Integrations/OAuth/Drivers/OAuthStripeDriver.php new file mode 100644 index 000000000..9901ae929 --- /dev/null +++ b/api/app/Integrations/OAuth/Drivers/OAuthStripeDriver.php @@ -0,0 +1,78 @@ +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->name, + ]; + + Log::info('Initiating Stripe Connect flow', [ + 'user_id' => $user->id + ]); + + return $this->provider + ->scopes($this->scopes ?? []) + ->stateless() + ->redirectUrl($this->redirectUrl ?? config('services.stripe.redirect')) + ->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', + ]); + } +} diff --git a/api/app/Integrations/OAuth/OAuthProviderService.php b/api/app/Integrations/OAuth/OAuthProviderService.php index 2ef4bd7f2..995144b65 100644 --- a/api/app/Integrations/OAuth/OAuthProviderService.php +++ b/api/app/Integrations/OAuth/OAuthProviderService.php @@ -4,15 +4,18 @@ 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() + return match ($this) { + self::Google => new OAuthGoogleDriver(), + self::Stripe => new OAuthStripeDriver() }; } } 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); } diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index df71bc267..3e92e179a 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -8,6 +8,7 @@ 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; @@ -40,6 +41,10 @@ public function boot() } Validator::includeUnvalidatedArrayKeys(); + + 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..31fccd73a --- /dev/null +++ b/api/app/Rules/PaymentBlockConfigurationRule.php @@ -0,0 +1,80 @@ +properties = $properties; + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + // Set the field + $fieldIndex = explode('.', $attribute)[1]; + $this->field = $this->properties[$fieldIndex]; + + if ($this->field['type'] !== 'payment') { + 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') + ->count(); + + if ($paymentBlocks > 1) { + $fail('Only one payment block allowed'); + return; + } + + + // Amount validation + 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 + $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; + } + + // Stripe account validation + if (!isset($this->field['stripe_account_id']) || empty($this->field['stripe_account_id'])) { + $fail('Stripe account is required'); + return; + } + try { + $provider = OAuthProvider::find($this->field['stripe_account_id']); + 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' => $this->field['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 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/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/api/routes/api.php b/api/routes/api.php index e0a49ec31..c73e2d211 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,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}/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/api/tests/Feature/Forms/FormPaymentTest.php b/api/tests/Feature/Forms/FormPaymentTest.php new file mode 100644 index 000000000..ebceda8b9 --- /dev/null +++ b/api/tests/Feature/Forms/FormPaymentTest.php @@ -0,0 +1,82 @@ +actingAsUser(); + $workspace = $this->createUserWorkspace($user); + + // Create OAuth provider for Stripe + $this->stripeAccount = OAuthProvider::factory()->for($user)->create([ + 'provider' => 'stripe', + 'provider_user_id' => 'acct_1LhEwZCragdZygxE' + ]); + + // Create form with payment block + $this->form = $this->createForm($user, $workspace); + $this->form->properties = array_merge($this->form->properties, [ + [ + 'type' => 'payment', + 'stripe_account_id' => $this->stripeAccount->id, + 'amount' => 99.99, + 'currency' => 'USD' + ] + ]); + $this->form->update(); +}); + +it('can get stripe account for form', function () { + $this->getJson(route('forms.stripe-connect.get-account', $this->form->slug)) + ->assertSuccessful() + ->assertJson(function (AssertableJson $json) { + return $json->has('stripeAccount') + ->where('stripeAccount', fn ($id) => str_starts_with($id, 'acct_')) + ->etc(); + }); +}); + +it('cannot create payment intent for non-public form', function () { + // Update form visibility to private + $this->form->update(['visibility' => 'private']); + + $this->getJson(route('forms.stripe-connect.create-intent', $this->form->slug)) + ->assertStatus(404) + ->assertJson([ + 'message' => 'Form not found.' + ]); +}); + +it('cannot create payment intent for form without payment block', function () { + // Remove payment block entirely + $properties = collect($this->form->properties) + ->reject(fn ($block) => $block['type'] === 'payment') + ->values() + ->all(); + + $this->form->update(['properties' => $properties]); + + $this->getJson(route('forms.stripe-connect.create-intent', $this->form->slug)) + ->assertStatus(400) + ->assertJson([ + 'message' => 'Form does not have a payment block.' + ]); +}); + +it('cannot create payment intent with invalid stripe account', function () { + // Update payment block with non-existent stripe account + $properties = collect($this->form->properties)->map(function ($block) { + if ($block['type'] === 'payment') { + $block['stripe_account_id'] = 999999; + } + return $block; + })->all(); + + $this->form->update(['properties' => $properties]); + + $this->getJson(route('forms.stripe-connect.create-intent', $this->form->slug)) + ->assertStatus(400) + ->assertJson([ + 'message' => 'Failed to find Stripe account' + ]); +}); 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..672f674b4 --- /dev/null +++ b/client/components/forms/PaymentInput.client.vue @@ -0,0 +1,196 @@ + + + \ No newline at end of file diff --git a/client/components/open/forms/OpenCompleteForm.vue b/client/components/open/forms/OpenCompleteForm.vue index d8f87d684..8db9dd158 100644 --- a/client/components/open/forms/OpenCompleteForm.vue +++ b/client/components/open/forms/OpenCompleteForm.vue @@ -291,7 +291,7 @@ export default { }, methods: { - submitForm (form, onFailure) { + async submitForm(form, onFailure) { if (this.creating) { this.submitted = true this.$emit('submitted', true) @@ -300,7 +300,6 @@ export default { if (form.busy) return this.loading = true - form.post('/forms/' + this.form.slug + '/answer').then((data) => { this.submittedData = form.data() useAmplitude().logEvent('form_submission', { diff --git a/client/components/open/forms/OpenForm.vue b/client/components/open/forms/OpenForm.vue index 29121ed7f..92f036373 100644 --- a/client/components/open/forms/OpenForm.vue +++ b/client/components/open/forms/OpenForm.vue @@ -119,6 +119,17 @@
{{ $t('forms.wrong_form_structure') }}
+
+ + +
@@ -302,6 +313,9 @@ export default { return { '--form-color': this.form.color } + }, + paymentBlock() { + return (this.currentFields) ? this.currentFields.find(field => field.type === 'payment') : null } }, @@ -359,7 +373,12 @@ export default { }, methods: { - submitForm() { + async submitForm() { + if (!await this.nextPage()) { + this.dataForm.busy = false + return + } + if (!this.isAutoSubmit && this.formPageIndex !== this.fieldGroups.length - 1) return if (this.form.use_captcha && import.meta.client) { @@ -519,21 +538,69 @@ export default { this.formPageIndex-- this.scrollToTop() }, - nextPage() { + async nextPage() { if (this.adminPreview || this.urlPrefillPreview) { - this.formPageIndex++ + if (!this.isLastPage) { + this.formPageIndex++ + } this.scrollToTop() - return false + return true } - const fieldsToValidate = this.currentFields.map(f => f.id) - this.dataForm.busy = true - 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 + const { state: stripeState, processPayment } = useStripeElements() + 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) + this.dataForm.busy = false + if (result && result?.error) { + this.dataForm.errors.set(this.paymentBlock.id, result.error.message) + useAlert().error(result.error.message) + return false + } + + if (result?.paymentIntent?.status === 'succeeded') { + stripeState.value.intentId = result.paymentIntent.id + useAlert().success('Thank you! Your payment is successful.') + return true + } + useAlert().error('Something went wrong. Please try again.') + } catch (error) { + this.dataForm.busy = false + console.error(error) + useAlert().error(error?.message || 'Payment failed') + } + return false + } + return true }, scrollToTop() { window.scrollTo({ top: 0, behavior: 'smooth' }) diff --git a/client/components/open/forms/OpenFormField.vue b/client/components/open/forms/OpenFormField.vue index c7db21cd6..c13076346 100644 --- a/client/components/open/forms/OpenFormField.vue +++ b/client/components/open/forms/OpenFormField.vue @@ -208,6 +208,7 @@ export default { if (field.type === 'phone_number' && !field.use_simple_text_input) { return 'PhoneInput' } + return { text: 'TextInput', rich_text: 'RichTextAreaInput', @@ -224,7 +225,8 @@ export default { email: 'TextInput', phone_number: 'TextInput', matrix: 'MatrixInput', - barcode: 'BarcodeInput' + barcode: 'BarcodeInput', + payment: 'PaymentInput' }[field.type] }, isPublicFormPage() { @@ -398,6 +400,11 @@ export default { inputProperties.unavailableCountries = field.unavailable_countries ?? [] } else if (field.type === 'text' && field.secret_input) { inputProperties.nativeType = 'password' + } else if (field.type === 'payment') { + inputProperties.direction = this.form.layout_rtl ? 'rtl' : 'ltr' + inputProperties.currency = field.currency + inputProperties.amount = field.amount + inputProperties.oauthProviderId = field.stripe_account_id } return inputProperties diff --git a/client/components/open/forms/components/form-components/FormSubmissionSettings.vue b/client/components/open/forms/components/form-components/FormSubmissionSettings.vue index fb945102d..8f64f9bcd 100644 --- a/client/components/open/forms/components/form-components/FormSubmissionSettings.vue +++ b/client/components/open/forms/components/form-components/FormSubmissionSettings.vue @@ -24,12 +24,21 @@ :form="form" label="Auto save form response" help="Saves form progress, allowing respondents to resume later." + class="mt-4" + :disabled="hasPaymentBlock" + /> + { if (val.submissionMode === 'default') form.value.redirect_url = null if (val.databaseAction === 'create') form.value.database_fields_update = null }, { deep: true }) + +const hasPaymentBlock = computed(() => { + return form.value.properties.some(property => property.type === 'payment') +}) diff --git a/client/components/open/forms/fields/components/FieldOptions.vue b/client/components/open/forms/fields/components/FieldOptions.vue index 0cd281d81..c3de5249f 100644 --- a/client/components/open/forms/fields/components/FieldOptions.vue +++ b/client/components/open/forms/fields/components/FieldOptions.vue @@ -194,6 +194,10 @@ @update:model-value="field = $event" /> + +
+
+ + + + +
+ +

+ OR +

+
+ + Connect with Stripe + + + + Learn about collecting payments? + +
+ + + \ No newline at end of file diff --git a/client/components/open/tables/OpenTable.vue b/client/components/open/tables/OpenTable.vue index 301593bf0..12dd7dc83 100644 --- a/client/components/open/tables/OpenTable.vue +++ b/client/components/open/tables/OpenTable.vue @@ -145,6 +145,7 @@ import OpenMatrix from "./components/OpenMatrix.vue" import OpenDate from "./components/OpenDate.vue" import OpenFile from "./components/OpenFile.vue" import OpenCheckbox from "./components/OpenCheckbox.vue" +import OpenPayment from "./components/OpenPayment.vue" import ResizableTh from "./components/ResizableTh.vue" import RecordOperations from "../components/RecordOperations.vue" import clonedeep from "clone-deep" @@ -210,6 +211,7 @@ export default { email: shallowRef(OpenText), phone_number: shallowRef(OpenText), signature: shallowRef(OpenFile), + payment: shallowRef(OpenPayment), }, } }, diff --git a/client/components/open/tables/components/OpenPayment.vue b/client/components/open/tables/components/OpenPayment.vue new file mode 100644 index 000000000..7050ba3bf --- /dev/null +++ b/client/components/open/tables/components/OpenPayment.vue @@ -0,0 +1,39 @@ + + + diff --git a/client/components/settings/ProviderModal.vue b/client/components/settings/ProviderModal.vue index 7b355eedf..f161e7ab3 100644 --- a/client/components/settings/ProviderModal.vue +++ b/client/components/settings/ProviderModal.vue @@ -25,12 +25,18 @@ Connect account -
+
+ +
+
=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 273442863..b5c442527 100644 --- a/client/runtimeConfig.js +++ b/client/runtimeConfig.js @@ -23,6 +23,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 01631d0a4..3d9b317d4 100644 --- a/client/stores/oauth_providers.js +++ b/client/stores/oauth_providers.js @@ -14,7 +14,13 @@ export const useOAuthProvidersStore = defineStore("oauth_providers", () => { { name: 'google', title: 'Google', - icon: 'mdi:google', + icon: 'cib:google', + enabled: true + }, + { + name: 'stripe', + title: 'Stripe', + icon: 'cib:stripe', enabled: true } ] @@ -36,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() @@ -49,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 59f811a95..3dbf750d4 100644 --- a/client/stores/working_form.js +++ b/client/stores/working_form.js @@ -96,6 +96,20 @@ 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') + return + } + openSettings = true // Always open settings for payment block + } + this.blockForm.type = type this.blockForm.name = blocksTypes[type].default_block_name const newBlock = this.prefillDefault(this.blockForm.data())