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 @@
+
+ Connect Stripe account to continue {{ $t('forms.payment.success') }}