Skip to content

Commit

Permalink
Enable pricing (#151)
Browse files Browse the repository at this point in the history
* Enable Pro plan - WIP

* no pricing page if have no paid plans

* Set pricing ids in env

* views & submissions FREE for all

* extra param for env

* form password FREE for all

* Custom Code is PRO feature

* Replace codeinput prism with codemirror

* Better form Cleaning message

* Added risky user email spam protection

* fix form cleaning

* Pricing page new UI

* form cleaner

* Polish changes

* Fixed tests

---------

Co-authored-by: Julien Nahum <[email protected]>
  • Loading branch information
formsdev and JhumanJ authored Aug 30, 2023
1 parent 29b153b commit fb79a5b
Show file tree
Hide file tree
Showing 48 changed files with 1,012 additions and 270 deletions.
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,21 @@ JWT_SECRET=
STRIPE_KEY=
STRIPE_SECRET=

STRIPE_PROD_DEFAULT_PRODUCT_ID=
STRIPE_PROD_DEFAULT_PRICING_MONTHLY=
STRIPE_PROD_DEFAULT_PRICING_YEARLY=

STRIPE_TEST_DEFAULT_PRODUCT_ID=
STRIPE_TEST_DEFAULT_PRICING_MONTHLY=
STRIPE_TEST_DEFAULT_PRICING_YEARLY=

H_CAPTCHA_SITE_KEY=
H_CAPTCHA_SECRET=

MUX_WORKSPACE_ID=
MUX_API_TOKEN=

ADMIN_EMAILS=
TEMPLATE_EDITOR_EMAILS=

OPEN_AI_API_KEY=
16 changes: 11 additions & 5 deletions app/Http/Controllers/Forms/FormController.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,21 @@ public function index($workspaceId)
$this->authorize('viewAny', Form::class);

$workspaceIsPro = $workspace->is_pro;
$forms = $workspace->forms()->with(['creator','views','submissions'])->paginate(10)->through(function (Form $form) use ($workspace, $workspaceIsPro){
$forms = $workspace->forms()->with(['creator','views','submissions'])
->orderByDesc('updated_at')
->paginate(10)->through(function (Form $form) use ($workspace, $workspaceIsPro){

// Add attributes for faster loading
$form->extra = (object) [
'loadedWorkspace' => $workspace,
'workspaceIsPro' => $workspaceIsPro,
'userIsOwner' => true,
'cleanings' => $this->formCleaner
->processForm(request(), $form)
->simulateCleaning($workspace)
->getPerformedCleanings()
];

return $form;
});
return FormResource::collection($forms);
Expand Down Expand Up @@ -91,8 +99,7 @@ public function store(StoreFormRequest $request)

return $this->success([
'message' => $this->formCleaner->hasCleaned() ? 'Form successfully created, but the Pro features you used will be disabled when sharing your form:' : 'Form created.',
'form_cleaning' => $this->formCleaner->getPerformedCleanings(),
'form' => new FormResource($form),
'form' => (new FormResource($form))->setCleanings($this->formCleaner->getPerformedCleanings()),
'users_first_form' => $request->user()->forms()->count() == 1
]);
}
Expand All @@ -116,8 +123,7 @@ public function update(UpdateFormRequest $request, string $id)

return $this->success([
'message' => $this->formCleaner->hasCleaned() ? 'Form successfully updated, but the Pro features you used will be disabled when sharing your form:' : 'Form updated.',
'form_cleaning' => $this->formCleaner->getPerformedCleanings(),
'form' => new FormResource($form)
'form' => (new FormResource($form))->setCleanings($this->formCleaner->getPerformedCleanings()),
]);
}

Expand Down
7 changes: 3 additions & 4 deletions app/Http/Controllers/Forms/FormStatsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
use App\Http\Controllers\Controller;
use App\Models\Forms\Form;
use Carbon\CarbonPeriod;
use App\Models\Forms\FormStatistic;
use Illuminate\Http\Request;

class FormStatsController extends Controller
{
Expand All @@ -15,9 +13,10 @@ public function __construct()
$this->middleware('auth');
}

public function getFormStats(Request $request)
public function getFormStats(string $formId)
{
$form = $request->form; // Added by ProForm middleware
$form = Form::findOrFail($formId);

$this->authorize('view', $form);

$formStats = $form->statistics()->where('date','>',now()->subDays(29)->startOfDay())->get();
Expand Down
5 changes: 2 additions & 3 deletions app/Http/Controllers/Forms/PublicFormController.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,8 @@ public function show(Request $request, string $slug)
$form->views()->create();
}

$formResource = new FormResource($form);
$formResource->setCleanings($formCleaner->getPerformedCleanings());
return $formResource;
return (new FormResource($form))
->setCleanings($formCleaner->getPerformedCleanings());
}

public function listUsers(Request $request)
Expand Down
28 changes: 25 additions & 3 deletions app/Http/Controllers/SubscriptionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

namespace App\Http\Controllers;

use App\Http\Requests\Subscriptions\UpdateStripeDetailsRequest;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Laravel\Cashier\Subscription;

class SubscriptionController extends Controller
{
const SUBSCRIPTION_PLANS = ['monthly_2022', 'yearly_2022'];
const SUBSCRIPTION_PLANS = ['monthly', 'yearly'];

const PRO_SUBSCRIPTION_NAME = 'default';
const ENTERPRISE_SUBSCRIPTION_NAME = 'enterprise';
Expand Down Expand Up @@ -41,21 +42,42 @@ public function checkout($pricing, $plan, $trial = null)
->allowPromotionCodes();

if ($trial != null) {
$checkoutBuilder->trialDays(3);
$checkoutBuilder->trialUntil(now()->addDays(3)->addHour());
}

$checkout = $checkoutBuilder
->collectTaxIds()
->checkout([
'success_url' => url('/subscriptions/success'),
'cancel_url' => url('/subscriptions/error'),
'billing_address_collection' => 'required',
'customer_update' => [
'address' => 'auto',
'name' => 'never',
]
]);

return $this->success([
'checkout_url' => $checkout->url
]);
}

public function updateStripeDetails(UpdateStripeDetailsRequest $request)
{
$user = Auth::user();
if (!$user->hasStripeId()) {
$user->createAsStripeCustomer();
}
$user->updateStripeCustomer([
'email' => $request->email,
'name' => $request->name,
]);

return $this->success([
'message' => 'Details saved.',
]);
}

public function billingPortal()
{
$this->middleware('auth');
Expand All @@ -69,7 +91,7 @@ public function billingPortal()
]);
}

private function getPricing($product = 'pro')
private function getPricing($product = 'default')
{
return App::environment() == 'production' ? config('pricing.production.'.$product.'.pricing') : config('pricing.test.'.$product.'.pricing');
}
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Middleware/Form/PasswordProtectedForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public function handle(Request $request, Closure $next)
'form' => $form,
]);
$userIsFormOwner = Auth::check() && Auth::user()->workspaces()->find($form->workspace_id) !== null;
if (!$userIsFormOwner && $form->is_pro && $form->has_password) {
if (!$userIsFormOwner && $form->has_password) {
if($this->hasCorrectPassword($request, $form)){
return $next($request);
}
Expand Down
22 changes: 11 additions & 11 deletions app/Http/Requests/AnswerFormRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@

class AnswerFormRequest extends FormRequest
{
const MAX_FILE_SIZE_PRO = 5000000;
const MAX_FILE_SIZE_ENTERPRISE = 20000000;
const MAX_FILE_SIZE_FREE = 5000000; // 5 MB
const MAX_FILE_SIZE_PRO = 50000000; // 50 MB

public Form $form;

Expand All @@ -26,10 +26,10 @@ public function __construct(Request $request)
{
$this->form = $request->form;

$this->maxFileSize = self::MAX_FILE_SIZE_PRO;
$this->maxFileSize = self::MAX_FILE_SIZE_FREE;
$workspace = $this->form->workspace;
if ($workspace && $workspace->is_enterprise) {
$this->maxFileSize = self::MAX_FILE_SIZE_ENTERPRISE;
if ($workspace && $workspace->is_pro) {
$this->maxFileSize = self::MAX_FILE_SIZE_PRO;
}
}

Expand All @@ -53,9 +53,9 @@ public function rules()
foreach ($this->form->properties as $property) {
$rules = [];

if (!$this->form->is_pro) { // If not pro then not check logic
/*if (!$this->form->is_pro) { // If not pro then not check logic
$property['logic'] = false;
}
}*/

// For get values instead of Id for select/multi select options
$data = $this->toArray();
Expand Down Expand Up @@ -96,12 +96,12 @@ public function rules()
}

// Validate hCaptcha
if ($this->form->is_pro && $this->form->use_captcha) {
if ($this->form->use_captcha) {
$this->requestRules['h-captcha-response'] = [new ValidHCaptcha()];
}

// Validate submission_id for edit mode
if ($this->form->editable_submissions) {
if ($this->form->is_pro && $this->form->editable_submissions) {
$this->requestRules['submission_id'] = 'string';
}

Expand Down Expand Up @@ -160,7 +160,7 @@ private function getPropertyRules($property): array
return ['numeric'];
case 'select':
case 'multi_select':
if ($this->form->is_pro && ($property['allow_creation'] ?? false)) {
if (($property['allow_creation'] ?? false)) {
return ['string'];
}
return [Rule::in($this->getSelectPropertyOptions($property))];
Expand All @@ -174,7 +174,7 @@ private function getPropertyRules($property): array
return ['url'];
case 'files':
$allowedFileTypes = [];
if($this->form->is_pro && !empty($property['allowed_file_types'])){
if(!empty($property['allowed_file_types'])){
$allowedFileTypes = explode(",", $property['allowed_file_types']);
}
$this->requestRules[$property['id'].'.*'] = [new StorageFile($this->maxFileSize, $allowedFileTypes, $this->form)];
Expand Down
21 changes: 21 additions & 0 deletions app/Http/Requests/Subscriptions/UpdateStripeDetailsRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace App\Http\Requests\Subscriptions;

use Illuminate\Foundation\Http\FormRequest;

class UpdateStripeDetailsRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules()
{
return [
'name' => 'required|string',
'email' => 'required|email',
];
}
}
13 changes: 9 additions & 4 deletions app/Http/Resources/FormResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,16 @@ public function toArray($request)

$ownerData = $this->userIsFormOwner() ? [
'creator' => new UserResource($this->creator),
'views_count' => $this->when($this->workspaceIsPro(), $this->views_count),
'submissions_count' => $this->when($this->workspaceIsPro(), $this->submissions_count),
'views_count' => $this->views_count,
'submissions_count' => $this->submissions_count,
'notifies' => $this->notifies,
'notifies_slack' => $this->notifies_slack,
'notifies_discord' => $this->notifies_discord,
'send_submission_confirmation' => $this->send_submission_confirmation,
'webhook_url' => $this->webhook_url,
'redirect_url' => $this->redirect_url,
'database_fields_update' => $this->database_fields_update,
'cleanings' => $this->cleanings,
'cleanings' => $this->getCleanigns(),
'notification_sender' => $this->notification_sender,
'notification_subject' => $this->notification_subject,
'notification_body' => $this->notification_body,
Expand Down Expand Up @@ -95,7 +95,7 @@ public function setCleanings(array $cleanings)

private function doesMissPassword(Request $request)
{
if (!$this->workspaceIsPro() || !$this->has_password) return false;
if (!$this->has_password) return false;

return !PasswordProtectedForm::hasCorrectPassword($request, $this->resource);
}
Expand Down Expand Up @@ -132,4 +132,9 @@ private function userIsFormOwner() {
&& Auth::user()->workspaces()->find($this->workspace_id) !== null
);
}

private function getCleanigns()
{
return $this->extra?->cleanings ?? $this->cleanings;
}
}
25 changes: 24 additions & 1 deletion app/Listeners/Forms/SubmissionConfirmation.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class SubmissionConfirmation implements ShouldQueue
{
use InteractsWithQueue;

const RISKY_USERS_LIMIT = 120;

/**
* Handle the event.
*
Expand All @@ -26,7 +28,13 @@ class SubmissionConfirmation implements ShouldQueue
*/
public function handle(FormSubmitted $event)
{
if (!$event->form->send_submission_confirmation) return;
if (
!$event->form->is_pro ||
!$event->form->send_submission_confirmation ||
$this->riskLimitReached($event) // To avoid phishing abuse we limit this feature for risky users
) {
return;
}

$email = $this->getRespondentEmail($event);
if (!$email) return;
Expand Down Expand Up @@ -56,6 +64,21 @@ private function getRespondentEmail(FormSubmitted $event)
return null;
}

private function riskLimitReached(FormSubmitted $event): bool
{
// This is a per-workspace limit for risky workspaces
if ($event->form->workspace->is_risky) {
if ($event->form->workspace->submissions_count >= self::RISKY_USERS_LIMIT) {
\Log::error('!!!DANGER!!! Dangerous user detected! Attempting many email sending.', [
'form_id' => $event->form->id,
'workspace_id' => $event->form->workspace->id,
]);
return true;
}
}
return false;
}

public static function validateEmail($email): bool {
return (boolean) filter_var($email, FILTER_VALIDATE_EMAIL);
}
Expand Down
9 changes: 9 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,15 @@ public function getJWTCustomClaims()
return [];
}

public function getIsRiskyAttribute()
{
return $this->created_at->isAfter(now()->subDays(3)) || // created in last 3 days
$this->subscriptions()->where(function ($q) {
$q->where('stripe_status', 'trialing')
->orWhere('stripe_status', 'active');
})->first()?->onTrial();
}

public static function boot ()
{
parent::boot();
Expand Down
Loading

0 comments on commit fb79a5b

Please sign in to comment.