Added trial and blocking to billing contract, fixed bug in running time tracker command

This commit is contained in:
Constantin Graf
2024-07-29 18:04:45 +02:00
committed by Constantin Graf
parent 7c593f8f87
commit 5b7df869ad
33 changed files with 629 additions and 187 deletions

View File

@@ -77,30 +77,31 @@ class CreateNewUser implements CreatesNewUsers
}
$currency = $ipLookupResponse->currency;
}
$user = DB::transaction(function () use ($input, $timezone, $startOfWeek, $currency) {
return tap(User::create([
$user = null;
$organization = null;
DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency) {
$user = User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
'timezone' => $timezone ?? 'UTC',
'week_start' => $startOfWeek,
]), function (User $user) use ($currency): void {
$organization = new Organization();
$organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
$organization->personal_team = true;
$organization->currency = $currency ?? 'EUR';
$organization->owner()->associate($user);
$organization->save();
]);
$organization->users()->attach(
$user, [
'role' => Role::Owner->value,
]
);
$organization = new Organization();
$organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
$organization->personal_team = true;
$organization->currency = $currency ?? 'EUR';
$organization->owner()->associate($user);
$organization->save();
$user->ownedTeams()->save($organization);
});
$organization->users()->attach(
$user, [
'role' => Role::Owner->value,
]
);
$user->ownedTeams()->save($organization);
});
$newsletterConsent = isset($input['newsletter_consent']) && (bool) $input['newsletter_consent'];

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Enums\Role;
use App\Events\AfterCreateOrganization;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
@@ -12,7 +13,6 @@ use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Jetstream\Contracts\CreatesTeams;
use Laravel\Jetstream\Events\AddingTeam;
use Laravel\Jetstream\Jetstream;
class CreateOrganization implements CreatesTeams
@@ -33,8 +33,6 @@ class CreateOrganization implements CreatesTeams
'name' => ['required', 'string', 'max:255'],
])->validateWithBag('createTeam');
AddingTeam::dispatch($user);
$organization = new Organization();
$organization->name = $input['name'];
$organization->personal_team = false;
@@ -51,6 +49,8 @@ class CreateOrganization implements CreatesTeams
$user->switchTeam($organization);
AfterCreateOrganization::dispatch($organization);
return $organization;
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Console\Commands\TimeEntry;
use App\Mail\TimeEntryStillRunningMail;
use App\Models\TimeEntry;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;
@@ -47,6 +48,9 @@ class TimeEntrySendStillRunningMailsCommand extends Command
->with([
'user',
])
->whereHas('user', function (Builder $query) {
$query->where('is_placeholder', '=', false);
})
->orderBy('created_at', 'asc')
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails) {
/** @var Collection<int, TimeEntry> $timeEntries */

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Organization;
use Illuminate\Foundation\Events\Dispatchable;
/**
* This event is fired after an organization has been created.
* This event does NOT fire when an organization is created as part of a registration.
*/
class AfterCreateOrganization
{
use Dispatchable;
public Organization $organization;
public function __construct(Organization $organization)
{
$this->organization = $organization;
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Member;
use App\Models\Organization;
use Illuminate\Foundation\Events\Dispatchable;
class MemberMadeToPlaceholder
{
use Dispatchable;
public Organization $organization;
public Member $member;
public function __construct(Member $member, Organization $organization)
{
$this->member = $member;
$this->organization = $organization;
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Member;
use App\Models\Organization;
use Illuminate\Foundation\Events\Dispatchable;
class MemberRemoved
{
use Dispatchable;
public Organization $organization;
public Member $member;
public function __construct(Member $member, Organization $organization)
{
$this->member = $member;
$this->organization = $organization;
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException
{
public const string KEY = 'organization_has_no_subscription_but_multiple_members';
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Events\MemberMadeToPlaceholder;
use App\Events\MemberRemoved;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException;
@@ -122,11 +124,30 @@ class MemberController extends Controller
}
$member->delete();
MemberRemoved::dispatch($member, $organization);
return response()
->json(null, 204);
}
/**
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization
*/
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse
{
$this->checkPermission($organization, 'members:make-placeholder', $member);
if ($member->role === Role::Owner->value) {
throw new CanNotRemoveOwnerFromOrganization();
}
$memberService->makeMemberToPlaceholder($member);
MemberMadeToPlaceholder::dispatch($member, $organization);
return response()->json(null, 204);
}
/**
* Invite a placeholder member to become a real member of the organization
*

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http;
use App\Http\Middleware\CheckOrganizationBlocked;
use App\Http\Middleware\ForceJsonResponse;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
@@ -71,5 +72,6 @@ class Kernel extends HttpKernel
'signed' => \App\Http\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class,
'check-organization-blocked' => CheckOrganizationBlocked::class,
];
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
use App\Models\Organization;
use App\Service\BillingContract;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckOrganizationBlocked
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*
* @throws OrganizationHasNoSubscriptionButMultipleMembersException
*/
public function handle(Request $request, Closure $next): Response
{
$organization = $request->route('organization');
if (! ($organization instanceof Organization)) {
throw new \LogicException('The organization must be loaded before this middleware.');
}
/** @var BillingContract $billing */
$billing = app(BillingContract::class);
if ($billing->isBlocked($organization)) {
throw new OrganizationHasNoSubscriptionButMultipleMembersException();
}
return $next($request);
}
}

View File

@@ -40,18 +40,18 @@ class HandleInertiaRequests extends Middleware
public function share(Request $request): array
{
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
$billing = null;
if ($hasBilling) {
/** @var BillingContract $billing */
$billing = app(BillingContract::class);
}
/** @var BillingContract $billing */
$billing = app(BillingContract::class);
$currentOrganization = $request->user()?->currentTeam;
return array_merge(parent::share($request), [
'has_billing_extension' => $hasBilling,
'billing' => $billing !== null ? [
'has_subscription' => $currentOrganization !== null ? $billing->hasSubscription($currentOrganization) : null,
'billing' => $billing !== null && $currentOrganization !== null ? [
'has_subscription' => $billing->hasSubscription($currentOrganization),
'has_trial' => $billing->hasTrial($currentOrganization),
'is_blocked' => $billing->isBlocked($currentOrganization),
] : null,
'flash' => [
'message' => fn () => $request->session()->get('message'),

View File

@@ -120,6 +120,14 @@ class Organization extends JetstreamTeam implements AuditableContract
->as('membership');
}
/**
* @return HasMany<Member>
*/
public function members(): HasMany
{
return $this->hasMany(Member::class);
}
/**
* @return BelongsToMany<User>
*/

View File

@@ -122,6 +122,7 @@ class JetstreamServiceProvider extends ServiceProvider
'members:view',
'members:invite-placeholder',
'members:change-ownership',
'members:make-placeholder',
'members:update',
'members:delete',
])->description('Owner users can perform any action. There is only one owner per organization.');

View File

@@ -6,10 +6,42 @@ namespace App\Service;
use App\Models\Organization;
/**
* This class is a contract for the billing system
* The billing system is responsible for managing the subscriptions of organizations
* The concrete implementation of this contract for the cloud version of solidtime is implemented in an extension
*/
class BillingContract
{
/**
* Check if the organization has a Professional subscription
* A Professional subscription is a paid subscription that allows the organization to:
* - Have more than 1 non-placeholder member
* - Access features that are not available to free organizations
*/
public function hasSubscription(Organization $organization): bool
{
return false;
}
/**
* Check if the organization has a trial subscription
* A trial subscription gives the organization the same benefits as a Professional subscription, but for a limited time
*/
public function hasTrial(Organization $organization): bool
{
return false;
}
/**
* Check if the organization is blocked
* A blocked organization is an organization that has more than 1 non-placeholder member but no subscription/trial
* This can happen if:
* - The organization's trial has expired and during the trial the organization added non-placeholder members
* - The organization's subscription has expired and the organization has more than 1 non-placeholder member
*/
public function isBlocked(Organization $organization): bool
{
return false;
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service;
use App\Enums\Role;
use App\Events\AfterCreateOrganization;
use App\Models\Member;
use App\Models\Organization;
use App\Models\ProjectMember;
@@ -71,6 +72,8 @@ class UserService
// Set the organization as the user's current organization
$user->currentOrganization()->associate($organization);
$user->save();
AfterCreateOrganization::dispatch($organization);
}
public function makeSureUserHasCurrentOrganization(User $user): void