Team functionality (#459)
* add api enpoints for adding, removing, updating user to workspace and leaving workspace * feat: updates client site workspace settings * refactor and add domain setting ui in modal * move workspace user functionality to its own component * adds tests * fix linting * updates select input to FlatSelectInput * moves workspace user role edit to seperated component * move user adding to its own component * adds check to usure users exist before checking is admin * fix loading users * feat: invite user to team functionality * fix token coulmn * fix self host mode changes * tests for user invite * Refactor back-end * Rename variables * Improve some styling elements + refactor workspace settings * More styling * More UI polishing * More UI fixes * PHP linting * Implemented most of the logic for team-functionnality * Fix user avatar URL * WIP remove users on cancellation * Finished pricing for team functionality * Fix tests * Fix linting * Added pricing_enabled helper * Fix pricing_enabled shortcut * Debug CI * Disable pricing when testing --------- Co-authored-by: LL-Etiane <lukongleinyuyetiane@gmail.com> Co-authored-by: Lukong Etiane <83535251+LL-Etiane@users.noreply.github.com> Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
namespace App\Events\Billing;
|
||||
|
||||
use App\Models\Billing\Subscription;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
23
app/Events/Billing/SubscriptionUpdated.php
Normal file
23
app/Events/Billing/SubscriptionUpdated.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Billing;
|
||||
|
||||
use App\Models\Billing\Subscription;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class SubscriptionUpdated
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithSockets;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct(public Subscription $subscription)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Auth;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\User;
|
||||
use App\Models\UserInvite;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Foundation\Auth\RegistersUsers;
|
||||
@@ -62,6 +63,7 @@ class RegisterController extends Controller
|
||||
'hear_about_us' => 'required|string',
|
||||
'agree_terms' => ['required', Rule::in([true])],
|
||||
'appsumo_license' => ['nullable'],
|
||||
'invite_token' => ['nullable', 'string'],
|
||||
], [
|
||||
'agree_terms' => 'Please agree with the terms and conditions.',
|
||||
]);
|
||||
@@ -69,15 +71,11 @@ class RegisterController extends Controller
|
||||
|
||||
/**
|
||||
* Create a new user instance after a valid registration.
|
||||
*
|
||||
* @return \App\User
|
||||
*/
|
||||
protected function create(array $data)
|
||||
{
|
||||
$workspace = Workspace::create([
|
||||
'name' => 'My Workspace',
|
||||
'icon' => '🧪',
|
||||
]);
|
||||
$this->checkRegistrationAllowed($data);
|
||||
[$workspace, $role] = $this->getWorkspaceAndRole($data);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $data['name'],
|
||||
@@ -89,7 +87,7 @@ class RegisterController extends Controller
|
||||
// Add relation with user
|
||||
$user->workspaces()->sync([
|
||||
$workspace->id => [
|
||||
'role' => 'admin',
|
||||
'role' => $role,
|
||||
],
|
||||
], false);
|
||||
|
||||
@@ -97,4 +95,45 @@ class RegisterController extends Controller
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function checkRegistrationAllowed(array $data)
|
||||
{
|
||||
if (config('app.self_hosted') && !array_key_exists('invite_token', $data)) {
|
||||
response()->json(['message' => 'Registration is not allowed in self host mode'], 400)->throwResponse();
|
||||
}
|
||||
}
|
||||
|
||||
private function getWorkspaceAndRole(array $data)
|
||||
{
|
||||
if (!array_key_exists('invite_token', $data)) {
|
||||
return [
|
||||
Workspace::create([
|
||||
'name' => 'My Workspace',
|
||||
'icon' => '🧪',
|
||||
]),
|
||||
User::ROLE_ADMIN
|
||||
];
|
||||
}
|
||||
|
||||
$userInvite = UserInvite::where('email', $data['email'])
|
||||
->where('token', $data['invite_token'])
|
||||
->first();
|
||||
|
||||
if (!$userInvite) {
|
||||
response()->json(['message' => 'Invite token is invalid.'], 400)->throwResponse();
|
||||
}
|
||||
if ($userInvite->hasExpired()) {
|
||||
response()->json(['message' => 'Invite token has expired.'], 400)->throwResponse();
|
||||
}
|
||||
|
||||
if ($userInvite->status == UserInvite::ACCEPTED_STATUS) {
|
||||
response()->json(['message' => 'Invite is already accepted.'], 400)->throwResponse();
|
||||
}
|
||||
|
||||
$userInvite->markAsAccepted();
|
||||
return [
|
||||
$userInvite->workspace,
|
||||
$userInvite->role,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\Subscriptions\UpdateStripeDetailsRequest;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use App\Service\BillingHelper;
|
||||
use App\Service\UserHelper;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Laravel\Cashier\Subscription;
|
||||
|
||||
@@ -36,7 +37,7 @@ class SubscriptionController extends Controller
|
||||
}
|
||||
|
||||
$checkoutBuilder = $user
|
||||
->newSubscription($pricing, $this->getPricing($pricing)[$plan])
|
||||
->newSubscription($pricing, BillingHelper::getPricing($pricing)[$plan])
|
||||
->allowPromotionCodes();
|
||||
|
||||
if ($trial != null) {
|
||||
@@ -60,10 +61,18 @@ class SubscriptionController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function getUsersCount()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
return [
|
||||
'count' => (new UserHelper(Auth::user()))->getActiveMembersCount() - 1,
|
||||
];
|
||||
}
|
||||
|
||||
public function updateStripeDetails(UpdateStripeDetailsRequest $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (! $user->hasStripeId()) {
|
||||
if (!$user->hasStripeId()) {
|
||||
$user->createAsStripeCustomer();
|
||||
}
|
||||
$user->updateStripeCustomer([
|
||||
@@ -79,7 +88,7 @@ class SubscriptionController extends Controller
|
||||
public function billingPortal()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
if (! Auth::user()->has_customer_id) {
|
||||
if (!Auth::user()->has_customer_id) {
|
||||
return $this->error([
|
||||
'message' => 'Please subscribe before accessing your billing portal.',
|
||||
]);
|
||||
@@ -89,9 +98,4 @@ class SubscriptionController extends Controller
|
||||
'portal_url' => Auth::user()->billingPortalUrl(front_url('/home')),
|
||||
]);
|
||||
}
|
||||
|
||||
private function getPricing($product = 'default')
|
||||
{
|
||||
return App::environment() == 'production' ? config('pricing.production.'.$product.'.pricing') : config('pricing.test.'.$product.'.pricing');
|
||||
}
|
||||
}
|
||||
|
||||
60
app/Http/Controllers/UserInviteController.php
Normal file
60
app/Http/Controllers/UserInviteController.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\UserInvite;
|
||||
use App\Models\Workspace;
|
||||
use App\Service\WorkspaceHelper;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UserInviteController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
public function listInvites(Request $request, $workspaceId)
|
||||
{
|
||||
$workspace = Workspace::findOrFail($workspaceId);
|
||||
$this->authorize('view', $workspace);
|
||||
|
||||
return (new WorkspaceHelper($workspace))->getAllInvites();
|
||||
}
|
||||
|
||||
public function resendInvite($workspaceId, $inviteId)
|
||||
{
|
||||
$workspace = Workspace::findOrFail($workspaceId);
|
||||
$this->authorize('adminAction', $workspace);
|
||||
$userInvite = $workspace->invites()->find($inviteId);
|
||||
if (!$userInvite) {
|
||||
return $this->error(['success' => false, 'message' => 'Invite not found for this workspace.']);
|
||||
}
|
||||
|
||||
if($userInvite->status == UserInvite::ACCEPTED_STATUS) {
|
||||
return $this->error(['success' => false, 'message' => 'Invite already accepted.']);
|
||||
}
|
||||
|
||||
$userInvite->sendEmail();
|
||||
|
||||
return $this->success(['message' => 'Invite email resent successfully.']);
|
||||
}
|
||||
|
||||
public function cancelInvite($workspaceId, $inviteId)
|
||||
{
|
||||
$workspace = Workspace::findOrFail($workspaceId);
|
||||
$this->authorize('adminAction', $workspace);
|
||||
$userInvite = $workspace->invites()->find($inviteId);
|
||||
if (!$userInvite) {
|
||||
return $this->error(['success' => false, 'message' => 'Invite not found for this workspace.']);
|
||||
}
|
||||
|
||||
if($userInvite->status == UserInvite::ACCEPTED_STATUS) {
|
||||
return $this->error(['success' => false, 'message' => 'Invite already accepted.']);
|
||||
}
|
||||
|
||||
$userInvite->delete();
|
||||
|
||||
return $this->success(['message' => 'Invite deleted successfully.']);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Webhook;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\Billing\RemoveWorkspaceGuests;
|
||||
use App\Models\License;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -56,7 +57,8 @@ class AppSumoController extends Controller
|
||||
|
||||
private function handleDeactivateEvent($request)
|
||||
{
|
||||
$this->deactivateLicense($request->license_key);
|
||||
$license = $this->deactivateLicense($request->license_key);
|
||||
RemoveWorkspaceGuests::dispatch($license->user);
|
||||
}
|
||||
|
||||
private function createLicense(array $licenseData): License
|
||||
|
||||
@@ -41,17 +41,17 @@ class StripeController extends WebhookController
|
||||
|
||||
$subscription->type = $subscription->type ?? $data['metadata']['name'] ?? $this->newSubscriptionName($payload);
|
||||
|
||||
$firstItem = $data['items']['data'][0];
|
||||
$mainItem = $this->getMainSubscriptionLineItem($data['items']['data']);
|
||||
$isSinglePrice = count($data['items']['data']) === 1;
|
||||
|
||||
// Price...
|
||||
$subscription->stripe_price = $isSinglePrice ? $firstItem['price']['id'] : null;
|
||||
$subscription->stripe_price = $isSinglePrice ? $mainItem['price']['id'] : null;
|
||||
|
||||
// Type - previously (Name)
|
||||
$subscription->type = $this->getSubscriptionName($data['plan']['product']);
|
||||
$subscription->type = $this->getSubscriptionName($mainItem['price']['product']);
|
||||
|
||||
// Quantity...
|
||||
$subscription->quantity = $isSinglePrice && isset($firstItem['quantity']) ? $firstItem['quantity'] : null;
|
||||
$subscription->quantity = $isSinglePrice && isset($mainItem['quantity']) ? $mainItem['quantity'] : null;
|
||||
|
||||
// Trial ending date...
|
||||
if (isset($data['trial_end'])) {
|
||||
@@ -115,6 +115,13 @@ class StripeController extends WebhookController
|
||||
return $this->successMethod();
|
||||
}
|
||||
|
||||
private function getMainSubscriptionLineItem(array $items)
|
||||
{
|
||||
return collect($items)->first(function ($item) {
|
||||
return in_array($this->getSubscriptionName($item['price']['product']), ['default']);
|
||||
});
|
||||
}
|
||||
|
||||
private function getSubscriptionName(string $stripeProductId)
|
||||
{
|
||||
$config = App::environment() == 'production' ? config('pricing.production') : config('pricing.test');
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace App\Http\Controllers;
|
||||
use App\Http\Requests\Workspace\CustomDomainRequest;
|
||||
use App\Http\Resources\WorkspaceResource;
|
||||
use App\Models\Workspace;
|
||||
use App\Service\WorkspaceHelper;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
@@ -23,14 +22,6 @@ class WorkspaceController extends Controller
|
||||
return WorkspaceResource::collection(Auth::user()->workspaces);
|
||||
}
|
||||
|
||||
public function listUsers(Request $request, $workspaceId)
|
||||
{
|
||||
$workspace = Workspace::findOrFail($workspaceId);
|
||||
$this->authorize('view', $workspace);
|
||||
|
||||
return (new WorkspaceHelper($workspace))->getAllUsers();
|
||||
}
|
||||
|
||||
public function saveCustomDomain(CustomDomainRequest $request)
|
||||
{
|
||||
$request->workspace->custom_domains = $request->customDomains;
|
||||
|
||||
127
app/Http/Controllers/WorkspaceUserController.php
Normal file
127
app/Http/Controllers/WorkspaceUserController.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\Billing\WorkspaceUsersUpdated;
|
||||
use App\Models\UserInvite;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\User;
|
||||
use App\Service\WorkspaceHelper;
|
||||
|
||||
class WorkspaceUserController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
public function listUsers(Request $request, $workspaceId)
|
||||
{
|
||||
$workspace = Workspace::findOrFail($workspaceId);
|
||||
$this->authorize('view', $workspace);
|
||||
|
||||
return (new WorkspaceHelper($workspace))->getAllUsers();
|
||||
}
|
||||
|
||||
public function addUser(Request $request, $workspaceId)
|
||||
{
|
||||
$workspace = Workspace::findOrFail($workspaceId);
|
||||
$this->authorize('inviteUser', $workspace);
|
||||
|
||||
$this->validate($request, [
|
||||
'email' => 'required|email',
|
||||
'role' => 'required|in:admin,user',
|
||||
]);
|
||||
|
||||
$user = User::where('email', $request->email)->first();
|
||||
if (!$user) {
|
||||
return $this->inviteUser($workspace, $request->email, $request->role);
|
||||
}
|
||||
|
||||
if ($workspace->users->contains($user->id)) {
|
||||
return $this->success([
|
||||
'message' => 'User is already in workspace.'
|
||||
]);
|
||||
}
|
||||
|
||||
// User found - add user to workspace
|
||||
$workspace->users()->sync([
|
||||
$user->id => [
|
||||
'role' => $request->role,
|
||||
],
|
||||
], false);
|
||||
WorkspaceUsersUpdated::dispatch($workspace);
|
||||
|
||||
return $this->success([
|
||||
'message' => 'User has been successfully added to workspace.'
|
||||
]);
|
||||
}
|
||||
|
||||
private function inviteUser(Workspace $workspace, string $email, string $role)
|
||||
{
|
||||
if (
|
||||
UserInvite::where('email', $email)
|
||||
->where('workspace_id', $workspace->id)
|
||||
->notExpired()
|
||||
->pending()
|
||||
->exists()) {
|
||||
return $this->success([
|
||||
'message' => 'User has already been invited.'
|
||||
]);
|
||||
}
|
||||
|
||||
// Send new invite
|
||||
UserInvite::inviteUser($email, $role, $workspace, now()->addDays(7));
|
||||
|
||||
return $this->success([
|
||||
'message' => 'Registration invitation email sent to user.'
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateUserRole(Request $request, $workspaceId, $userId)
|
||||
{
|
||||
$workspace = Workspace::findOrFail($workspaceId);
|
||||
$user = User::findOrFail($userId);
|
||||
$this->authorize('adminAction', $workspace);
|
||||
|
||||
$this->validate($request, [
|
||||
'role' => 'required|in:admin,user',
|
||||
]);
|
||||
|
||||
$workspace->users()->sync([
|
||||
$user->id => [
|
||||
'role' => $request->role,
|
||||
],
|
||||
], false);
|
||||
|
||||
return $this->success([
|
||||
'message' => 'User role changed successfully.'
|
||||
]);
|
||||
}
|
||||
|
||||
public function removeUser(Request $request, $workspaceId, $userId)
|
||||
{
|
||||
$workspace = Workspace::findOrFail($workspaceId);
|
||||
$this->authorize('adminAction', $workspace);
|
||||
|
||||
$workspace->users()->detach($userId);
|
||||
WorkspaceUsersUpdated::dispatch($workspace);
|
||||
|
||||
return $this->success([
|
||||
'message' => 'User removed from workspace successfully.'
|
||||
]);
|
||||
}
|
||||
|
||||
public function leaveWorkspace(Request $request, $workspaceId)
|
||||
{
|
||||
$workspace = Workspace::findOrFail($workspaceId);
|
||||
$this->authorize('view', $workspace);
|
||||
|
||||
$workspace->users()->detach($request->user()->id);
|
||||
|
||||
return $this->success([
|
||||
'message' => 'You have left the workspace successfully.'
|
||||
]);
|
||||
}
|
||||
}
|
||||
63
app/Jobs/Billing/RemoveWorkspaceGuests.php
Normal file
63
app/Jobs/Billing/RemoveWorkspaceGuests.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Billing;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class RemoveWorkspaceGuests implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(public User $user)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
// If pricing not enabled
|
||||
if (!pricing_enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->user->is_subscribed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// User is not subscribed anymore - remove guests
|
||||
$this->user->workspaces->each(function (Workspace $workspace) {
|
||||
// Flush workspace cache to be sure we have the latest data
|
||||
$workspace->flush();
|
||||
if ($workspace->is_pro) {
|
||||
// Another user still has pro subscription
|
||||
return;
|
||||
}
|
||||
|
||||
// Detach all users from the workspace (except the owner)
|
||||
foreach ($workspace->users()->where('users.id', '!=', $this->user->id)->get() as $user) {
|
||||
\Log::info('Detaching user from workspace', [
|
||||
'workspace_id' => $workspace->id,
|
||||
'workspace_name' => $workspace->name,
|
||||
'user_id' => $user->id,
|
||||
'user_email' => $user->email,
|
||||
]);
|
||||
$workspace->users()->detach($user);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
106
app/Jobs/Billing/WorkspaceUsersUpdated.php
Normal file
106
app/Jobs/Billing/WorkspaceUsersUpdated.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Billing;
|
||||
|
||||
use App\Models\Billing\Subscription;
|
||||
use App\Models\Workspace;
|
||||
use App\Service\BillingHelper;
|
||||
use App\Service\UserHelper;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Collection;
|
||||
use Laravel\Cashier\Cashier;
|
||||
|
||||
/**
|
||||
* Update subscription with extra users when workspace users are updated.
|
||||
*/
|
||||
class WorkspaceUsersUpdated implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(public Workspace $workspace)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
// If self-hosted, no need to update billing
|
||||
if (!pricing_enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* @var User $billingOwner
|
||||
*/
|
||||
$billingOwner = $this->workspace->billingOwners()->first();
|
||||
|
||||
if (!$billingOwner || !$billingOwner->is_subscribed) {
|
||||
// If somehow billing owner is not found or not subscribed, no need to update billing
|
||||
return;
|
||||
}
|
||||
|
||||
if ($billingOwner->activeLicense()) {
|
||||
// No need to update billing if user has a fixed license
|
||||
return;
|
||||
}
|
||||
|
||||
// Now update the subscription accordingly
|
||||
$subscription = $billingOwner->subscription();
|
||||
$totalUsersCount = (new UserHelper($billingOwner))->getActiveMembersCount() - 1;
|
||||
$this->updateSubscriptionWithExtraUsers($subscription, $totalUsersCount);
|
||||
}
|
||||
|
||||
private function updateSubscriptionWithExtraUsers(Subscription $subscription, int $quantity): void
|
||||
{
|
||||
$stripe = Cashier::stripe();
|
||||
$extraUserPricing = BillingHelper::getPricing('extra_user');
|
||||
$stripeSub = $subscription->asStripeSubscription();
|
||||
$lineItems = collect($stripeSub->items);
|
||||
|
||||
// Make sure Stripe sub has the right pro-rating settings
|
||||
$stripe->subscriptions->update($stripeSub->id, [
|
||||
'proration_behavior' => 'always_invoice',
|
||||
]);
|
||||
|
||||
// Main sub info
|
||||
$mainSubscriptionItem = $this->getLineItem($lineItems, 'default');
|
||||
$subscriptionInterval = BillingHelper::getLineItemInterval($mainSubscriptionItem);
|
||||
|
||||
$extraUserLineItem = $this->getLineItem($lineItems, 'extra_user');
|
||||
if ($extraUserLineItem) {
|
||||
$stripe->subscriptionItems->update(
|
||||
$extraUserLineItem->id,
|
||||
['quantity' => $quantity]
|
||||
);
|
||||
} else {
|
||||
$stripeSub->items->create([
|
||||
'price' => $extraUserPricing[$subscriptionInterval],
|
||||
'quantity' => $quantity,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function getLineItem(Collection $lineItems, string $productName)
|
||||
{
|
||||
$productId = BillingHelper::getProductId($productName);
|
||||
if (!$productId) {
|
||||
return null;
|
||||
}
|
||||
return $lineItems->first(function ($lineItem) use ($productId) {
|
||||
return $lineItem->price->product === $productId;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
namespace App\Listeners\Billing;
|
||||
|
||||
use App\Events\SubscriptionCreated;
|
||||
use App\Events\Billing\SubscriptionCreated;
|
||||
use App\Jobs\Billing\WorkspaceUsersUpdated;
|
||||
|
||||
class HandleSubscriptionCreated
|
||||
{
|
||||
@@ -22,5 +23,9 @@ class HandleSubscriptionCreated
|
||||
$workspace->forms()->update(['no_branding' => true]);
|
||||
});
|
||||
|
||||
// Update pricing (number of users)
|
||||
if ($workspace = $user->workspaces()->first()) {
|
||||
WorkspaceUsersUpdated::dispatch($workspace);
|
||||
}
|
||||
}
|
||||
}
|
||||
32
app/Listeners/Billing/RemoveWorkspaceGuestsIfNeeded.php
Normal file
32
app/Listeners/Billing/RemoveWorkspaceGuestsIfNeeded.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners\Billing;
|
||||
|
||||
use App\Events\Billing\SubscriptionUpdated;
|
||||
use App\Jobs\Billing\RemoveWorkspaceGuests;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
||||
class RemoveWorkspaceGuestsIfNeeded implements ShouldQueue
|
||||
{
|
||||
/**
|
||||
* Create the event listener.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the event.
|
||||
*/
|
||||
public function handle(SubscriptionUpdated $event): void
|
||||
{
|
||||
/**
|
||||
* Subscription $subscription
|
||||
*/
|
||||
$subscription = $event->subscription;
|
||||
if (!$subscription->valid()) {
|
||||
RemoveWorkspaceGuests::dispatch($event->subscription->user);
|
||||
}
|
||||
}
|
||||
}
|
||||
41
app/Mail/UserInvitationEmail.php
Normal file
41
app/Mail/UserInvitationEmail.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\UserInvite;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class UserInvitationEmail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*
|
||||
* @param string $workspaceName
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(public UserInvite $invite)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the message.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function build()
|
||||
{
|
||||
$workspaceName = $this->invite->workspace->name;
|
||||
return $this
|
||||
->markdown('mail.user.invitation', [
|
||||
'workspaceName' => $workspaceName,
|
||||
'inviteLink' => $this->invite->getLink(),
|
||||
])->subject('You are invited to join ' . $workspaceName . ' on OpnForm');
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
namespace App\Models\Billing;
|
||||
|
||||
use App\Events\SubscriptionCreated;
|
||||
use App\Events\Billing\SubscriptionCreated;
|
||||
use App\Events\Billing\SubscriptionUpdated;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Laravel\Cashier\Subscription as CashierSubscription;
|
||||
|
||||
@@ -12,6 +13,7 @@ class Subscription extends CashierSubscription
|
||||
|
||||
protected $dispatchesEvents = [
|
||||
'created' => SubscriptionCreated::class,
|
||||
'updated' => SubscriptionUpdated::class,
|
||||
];
|
||||
|
||||
public static function booted(): void
|
||||
|
||||
@@ -8,8 +8,8 @@ use Illuminate\Database\Eloquent\Model;
|
||||
class License extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_INACTIVE = 'inactive';
|
||||
|
||||
protected $fillable = [
|
||||
@@ -32,6 +32,11 @@ class License extends Model
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function isActive()
|
||||
{
|
||||
return $this->status === self::STATUS_ACTIVE;
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_ACTIVE);
|
||||
@@ -55,6 +60,15 @@ class License extends Model
|
||||
][$this->meta['tier']];
|
||||
}
|
||||
|
||||
public function getMaxUsersLimitCountAttribute(): ?int
|
||||
{
|
||||
return [
|
||||
1 => 1,
|
||||
2 => 5,
|
||||
3 => null,
|
||||
][$this->meta['tier']];
|
||||
}
|
||||
|
||||
public static function booted(): void
|
||||
{
|
||||
static::saved(function (License $license) {
|
||||
|
||||
@@ -17,6 +17,9 @@ class User extends Authenticatable implements JWTSubject
|
||||
use HasFactory;
|
||||
use Notifiable;
|
||||
|
||||
public const ROLE_ADMIN = 'admin';
|
||||
public const ROLE_USER = 'user';
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
@@ -80,7 +83,7 @@ class User extends Authenticatable implements JWTSubject
|
||||
{
|
||||
return vsprintf('https://www.gravatar.com/avatar/%s.jpg?s=200&d=%s', [
|
||||
md5(strtolower($this->email)),
|
||||
$this->name ? urlencode("https://ui-avatars.com/api/$this->name") : 'mp',
|
||||
$this->name ? urlencode("https://ui-avatars.com/api/$this->name.jpg") : 'mp',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -235,6 +238,8 @@ class User extends Authenticatable implements JWTSubject
|
||||
foreach ($user->workspaces as $workspace) {
|
||||
if ($workspace->users()->count() == 1) {
|
||||
$workspace->delete();
|
||||
} else {
|
||||
$workspace->users()->detach($user->id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
87
app/Models/UserInvite.php
Normal file
87
app/Models/UserInvite.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Jobs\Billing\WorkspaceUsersUpdated;
|
||||
use App\Mail\UserInvitationEmail;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class UserInvite extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const PENDING_STATUS = 'pending';
|
||||
public const ACCEPTED_STATUS = 'accepted';
|
||||
|
||||
protected $fillable = [
|
||||
'email',
|
||||
'role',
|
||||
'workspace_id',
|
||||
'valid_until',
|
||||
'status',
|
||||
'token',
|
||||
];
|
||||
|
||||
public static function inviteUser(
|
||||
string $email,
|
||||
string $role,
|
||||
Workspace $workspace,
|
||||
Carbon $validUntil = null
|
||||
): self {
|
||||
// Generate a token
|
||||
do {
|
||||
$token = Str::random(100);
|
||||
} while (UserInvite::where('token', $token)->exists());
|
||||
|
||||
$invite = self::create([
|
||||
'email' => $email,
|
||||
'role' => $role,
|
||||
'workspace_id' => $workspace->id,
|
||||
'valid_until' => $validUntil ?? now()->addDays(7),
|
||||
'token' => $token,
|
||||
]);
|
||||
$invite->sendEmail();
|
||||
return $invite;
|
||||
}
|
||||
|
||||
public function getLink()
|
||||
{
|
||||
return front_url('/register?email=' . urlencode($this->email) . '&invite_token=' . urlencode($this->token));
|
||||
}
|
||||
|
||||
public function hasExpired()
|
||||
{
|
||||
return Carbon::parse($this->valid_until)->isPast();
|
||||
}
|
||||
|
||||
public function workspace()
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
public function markAsAccepted()
|
||||
{
|
||||
$this->update(['status' => self::ACCEPTED_STATUS]);
|
||||
WorkspaceUsersUpdated::dispatch($this->workspace);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function sendEmail()
|
||||
{
|
||||
Mail::to($this->email)->send(new UserInvitationEmail($this));
|
||||
}
|
||||
|
||||
public function scopeNotExpired($query)
|
||||
{
|
||||
return $query->where('valid_until', '>', now());
|
||||
}
|
||||
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('status', self::PENDING_STATUS);
|
||||
}
|
||||
}
|
||||
28
app/Models/UserWorkspace.php
Normal file
28
app/Models/UserWorkspace.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class UserWorkspace extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
protected $table = 'user_workspace';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'workspace_id',
|
||||
'role',
|
||||
];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function workspace()
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Models\Traits\CachableAttributes;
|
||||
use App\Models\Traits\CachesAttributes;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class Workspace extends Model implements CachableAttributes
|
||||
{
|
||||
@@ -50,7 +51,7 @@ class Workspace extends Model implements CachableAttributes
|
||||
|
||||
public function getMaxFileSizeAttribute()
|
||||
{
|
||||
if (is_null(config('cashier.key'))) {
|
||||
if (!pricing_enabled()) {
|
||||
return self::MAX_FILE_SIZE_PRO;
|
||||
}
|
||||
|
||||
@@ -73,7 +74,7 @@ class Workspace extends Model implements CachableAttributes
|
||||
|
||||
public function getCustomDomainCountLimitAttribute()
|
||||
{
|
||||
if (is_null(config('cashier.key'))) {
|
||||
if (!pricing_enabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -95,7 +96,7 @@ class Workspace extends Model implements CachableAttributes
|
||||
|
||||
public function getIsProAttribute()
|
||||
{
|
||||
if (is_null(config('cashier.key'))) {
|
||||
if (!pricing_enabled()) {
|
||||
return true; // If no paid plan so TRUE for ALL
|
||||
}
|
||||
|
||||
@@ -113,7 +114,7 @@ class Workspace extends Model implements CachableAttributes
|
||||
|
||||
public function getIsTrialingAttribute()
|
||||
{
|
||||
if (is_null(config('cashier.key'))) {
|
||||
if (!pricing_enabled()) {
|
||||
return false; // If no paid plan so FALSE for ALL
|
||||
}
|
||||
|
||||
@@ -131,7 +132,7 @@ class Workspace extends Model implements CachableAttributes
|
||||
|
||||
public function getIsEnterpriseAttribute()
|
||||
{
|
||||
if (is_null(config('cashier.key'))) {
|
||||
if (!pricing_enabled()) {
|
||||
return true; // If no paid plan so TRUE for ALL
|
||||
}
|
||||
|
||||
@@ -181,11 +182,21 @@ class Workspace extends Model implements CachableAttributes
|
||||
return $this->belongsToMany(User::class);
|
||||
}
|
||||
|
||||
public function invites()
|
||||
{
|
||||
return $this->hasMany(UserInvite::class);
|
||||
}
|
||||
|
||||
public function owners()
|
||||
{
|
||||
return $this->users()->wherePivot('role', 'admin');
|
||||
}
|
||||
|
||||
public function billingOwners(): Collection
|
||||
{
|
||||
return $this->owners->filter(fn ($owner) => $owner->is_subscribed);
|
||||
}
|
||||
|
||||
public function forms()
|
||||
{
|
||||
return $this->hasMany(Form::class);
|
||||
|
||||
@@ -4,7 +4,10 @@ namespace App\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\UserWorkspace;
|
||||
use App\Service\UserHelper;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class WorkspacePolicy
|
||||
{
|
||||
@@ -57,7 +60,7 @@ class WorkspacePolicy
|
||||
*/
|
||||
public function delete(User $user, Workspace $workspace)
|
||||
{
|
||||
return ! $workspace->owners->where('id', $user->id)->isEmpty() && $user->workspaces()->count() > 1;
|
||||
return !$workspace->owners->where('id', $user->id)->isEmpty() && $user->workspaces()->count() > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,4 +82,44 @@ class WorkspacePolicy
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function inviteUser(User $user, Workspace $workspace)
|
||||
{
|
||||
if (!$this->adminAction($user, $workspace)) {
|
||||
return Response::deny('You need to be an admin of this workspace to do this.');
|
||||
}
|
||||
|
||||
// If self-hosted, allow
|
||||
if (!pricing_enabled()) {
|
||||
return Response::allow();
|
||||
}
|
||||
|
||||
if (!$workspace->is_pro) {
|
||||
return Response::deny('You need a Pro subscription to invite a user.');
|
||||
}
|
||||
|
||||
// In case of special license, check license limit
|
||||
$billingOwner = $workspace->billingOwners()->first();
|
||||
if ($license = $billingOwner->activeLicense()) {
|
||||
$userActiveMembers = (new UserHelper($billingOwner))->getActiveMembersCount();
|
||||
if ($userActiveMembers >= $license->max_users_limit_count) {
|
||||
return Response::deny('You have reached the maximum number of users allowed with your license.');
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user is an admin in the workspace.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function adminAction(User $user, Workspace $workspace)
|
||||
{
|
||||
$userWorkspace = UserWorkspace::where('user_id', $user->id)
|
||||
->where('workspace_id', $workspace->id)
|
||||
->first();
|
||||
return $userWorkspace && $userWorkspace->role === 'admin';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Events\Billing\SubscriptionCreated;
|
||||
use App\Events\Billing\SubscriptionUpdated;
|
||||
use App\Events\Forms\FormSubmitted;
|
||||
use App\Events\Models\FormCreated;
|
||||
use App\Events\Models\FormIntegrationCreated;
|
||||
use App\Events\Models\FormIntegrationsEventCreated;
|
||||
use App\Events\SubscriptionCreated;
|
||||
use App\Listeners\Billing\HandleSubscriptionCreated;
|
||||
use App\Listeners\Billing\RemoveWorkspaceGuestsIfNeeded;
|
||||
use App\Listeners\Forms\FormCreationConfirmation;
|
||||
use App\Listeners\Forms\FormIntegrationCreatedHandler;
|
||||
use App\Listeners\Forms\FormIntegrationsEventListener;
|
||||
use App\Listeners\Forms\NotifyFormSubmission;
|
||||
use App\Listeners\HandleSubscriptionCreated;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||
@@ -40,8 +42,11 @@ class EventServiceProvider extends ServiceProvider
|
||||
FormIntegrationsEventListener::class,
|
||||
],
|
||||
SubscriptionCreated::class => [
|
||||
HandleSubscriptionCreated::class
|
||||
HandleSubscriptionCreated::class,
|
||||
],
|
||||
SubscriptionUpdated::class => [
|
||||
RemoveWorkspaceGuestsIfNeeded::class
|
||||
]
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
29
app/Service/BillingHelper.php
Normal file
29
app/Service/BillingHelper.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Stripe\SubscriptionItem;
|
||||
|
||||
class BillingHelper
|
||||
{
|
||||
public static function getPricing($productName = 'default')
|
||||
{
|
||||
return App::environment() == 'production' ?
|
||||
config('pricing.production.' . $productName . '.pricing') :
|
||||
config('pricing.test.' . $productName . '.pricing');
|
||||
}
|
||||
|
||||
public static function getProductId($productName = 'default')
|
||||
{
|
||||
return App::environment() == 'production' ?
|
||||
config('pricing.production.' . $productName . '.product_id') :
|
||||
config('pricing.test.' . $productName . '.product_id');
|
||||
}
|
||||
|
||||
public static function getLineItemInterval(SubscriptionItem $item)
|
||||
{
|
||||
return $item->price->recurring->interval === 'year' ? 'yearly' : 'monthly';
|
||||
;
|
||||
}
|
||||
}
|
||||
26
app/Service/UserHelper.php
Normal file
26
app/Service/UserHelper.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
class UserHelper
|
||||
{
|
||||
public function __construct(public User $user)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to get to total number of active members in each of this user's workspaces
|
||||
*/
|
||||
public function getActiveMembersCount(): ?int
|
||||
{
|
||||
$count = 1;
|
||||
foreach ($this->user->workspaces as $workspace) {
|
||||
$count += $workspace->users()->where('users.id', '!=', $this->user->id)->count();
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,8 +11,13 @@ class WorkspaceHelper
|
||||
|
||||
}
|
||||
|
||||
public function getRecords($relatedRecordIds = null)
|
||||
public function getAllUsers()
|
||||
{
|
||||
return [];
|
||||
return $this->workspace->users()->withPivot('role')->get();
|
||||
}
|
||||
|
||||
public function getAllInvites()
|
||||
{
|
||||
return $this->workspace->invites()->get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,3 +11,11 @@ if(!function_exists('front_url')) {
|
||||
return rtrim($baseUrl, '/').'/'.ltrim($path, '/');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if(!function_exists('pricing_enabled')) {
|
||||
function pricing_enabled(): bool
|
||||
{
|
||||
return App::environment() !== 'testing' && !is_null(config('cashier.key'));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user