feat(accounts): fresh accounts implementation

This commit is contained in:
Darko Gjorgjijoski
2026-08-20 23:48:22 +02:00
parent 973fda1df6
commit d5dc446b24
42 changed files with 3768 additions and 0 deletions
@@ -0,0 +1,176 @@
<?php
namespace App\Domains\Accounts\Application;
use App\Domains\Accounts\Contracts\CompanyDataPurger;
use App\Domains\Accounts\Contracts\CompanyDefaultsProvisioner;
use App\Domains\Accounts\Models\Company;
use App\Domains\Accounts\Models\CompanySetting;
use Silber\Bouncer\BouncerFacade;
use Silber\Bouncer\Database\Role;
/**
* Everything that happens to a company either side of its working life: the
* furniture a fresh one is handed, and the demolition of an old one.
*
* The reference data (payment methods, units) is provisioned through the
* adapter; what stays here is the authorization scaffolding and the sheet of
* default preferences a company starts out with.
*/
class CompanyService
{
/** Name and title of the role every company is created with. */
private const OWNER_ROLE = 'owner';
private const OWNER_ROLE_TITLE = 'Owner';
/** Mail bodies quoted into the outgoing document mails. */
private const INVOICE_MAIL_BODY = 'You have received a new invoice from <b>{COMPANY_NAME}</b>.</br> Please download using the button below:';
private const ESTIMATE_MAIL_BODY = 'You have received a new estimate from <b>{COMPANY_NAME}</b>.</br> Please download using the button below:';
private const PAYMENT_MAIL_BODY = 'Thank you for the payment.</b></br> Please download your payment receipt using the button below:';
/** Address blocks printed on the documents; placeholders filled at render. */
private const BILLING_ADDRESS_FORMAT = '<h3>{BILLING_ADDRESS_NAME}</h3><p>{BILLING_ADDRESS_STREET_1}</p><p>{BILLING_ADDRESS_STREET_2}</p><p>{BILLING_CITY} {BILLING_STATE}</p><p>{BILLING_COUNTRY} {BILLING_ZIP_CODE}</p><p>{BILLING_PHONE}</p>';
private const SHIPPING_ADDRESS_FORMAT = '<h3>{SHIPPING_ADDRESS_NAME}</h3><p>{SHIPPING_ADDRESS_STREET_1}</p><p>{SHIPPING_ADDRESS_STREET_2}</p><p>{SHIPPING_CITY} {SHIPPING_STATE}</p><p>{SHIPPING_COUNTRY} {SHIPPING_ZIP_CODE}</p><p>{SHIPPING_PHONE}</p>';
private const COMPANY_ADDRESS_FORMAT = '<h3><strong>{COMPANY_NAME}</strong></h3><p>{COMPANY_ADDRESS_STREET_1}</p><p>{COMPANY_ADDRESS_STREET_2}</p><p>{COMPANY_CITY} {COMPANY_STATE}</p><p>{COMPANY_COUNTRY} {COMPANY_ZIP_CODE}</p><p>{COMPANY_PHONE}</p>';
/** The payer block on a receipt, spaced differently from the billing one. */
private const PAYMENT_CUSTOMER_ADDRESS_FORMAT = '<h3>{BILLING_ADDRESS_NAME}</h3><p>{BILLING_ADDRESS_STREET_1}</p><p>{BILLING_ADDRESS_STREET_2}</p><p>{BILLING_CITY} {BILLING_STATE} {BILLING_ZIP_CODE}</p><p>{BILLING_COUNTRY}</p><p>{BILLING_PHONE}</p>';
public function __construct(
private readonly CompanyDefaultsProvisioner $companyDefaultsProvisioner,
private readonly CompanyDataPurger $companyDataPurger,
) {}
/**
* Furnish a newly created company.
*
* Order is fixed: the owner role first, so the creator has something to be
* assigned; then the reference data; then the preference sheet, which is
* where the chosen currency lands.
*/
public function setupDefaults(Company $company, int $currencyId = 13): bool
{
$this->setupRoles($company);
$this->companyDefaultsProvisioner->provision($company);
$this->setupDefaultSettings($company, $currencyId);
return true;
}
/**
* Create the company's `owner` role and grant it the whole ability
* catalogue — every entry in the configuration, against the subject model
* the entry names.
*
* Roles live inside a company's scope, so the scope is moved onto this
* company first and left there for whatever the caller does next.
*/
public function setupRoles(Company $company): void
{
BouncerFacade::scope()->to($company->id);
$owner = BouncerFacade::role()->firstOrCreate([
'name' => self::OWNER_ROLE,
'title' => self::OWNER_ROLE_TITLE,
'scope' => $company->id,
]);
foreach (config('abilities.abilities') as $entry) {
BouncerFacade::allow($owner)->to($entry['ability'], $entry['model']);
}
}
/**
* Wind a company up.
*
* The purger clears everything filed against the company first; what is
* left here is the company's own furniture — its scoped roles, the
* memberships pointing at it, its preferences, and the row itself. The
* member accounts survive: only the link between them and the company is
* cut.
*/
public function delete(Company $company): bool
{
$this->companyDataPurger->purge($company);
Role::query()
->when($company->id, function ($query) use ($company) {
$query->where('scope', $company->id);
})
->get()
->each(function ($role) {
$role->delete();
});
$company->users()->detach();
$company->settings()->delete();
$company->delete();
return true;
}
/**
* The preference sheet a company starts out with.
*
* Two of these are historical rather than sensible and are kept on
* purpose: the time zone defaults to `Asia/Kolkata`, and outgoing mail is
* addressed from the project's own no-reply address until an owner changes
* it. `bulk_exchange_rate_configured` starts out done, which keeps fresh
* companies out of the exchange-rate backfill.
*/
private function setupDefaultSettings(Company $company, int $currencyId): void
{
CompanySetting::setSettings([
'invoice_mail_body' => self::INVOICE_MAIL_BODY,
'estimate_mail_body' => self::ESTIMATE_MAIL_BODY,
'payment_mail_body' => self::PAYMENT_MAIL_BODY,
'invoice_company_address_format' => self::COMPANY_ADDRESS_FORMAT,
'invoice_shipping_address_format' => self::SHIPPING_ADDRESS_FORMAT,
'invoice_billing_address_format' => self::BILLING_ADDRESS_FORMAT,
'estimate_company_address_format' => self::COMPANY_ADDRESS_FORMAT,
'estimate_shipping_address_format' => self::SHIPPING_ADDRESS_FORMAT,
'estimate_billing_address_format' => self::BILLING_ADDRESS_FORMAT,
'payment_company_address_format' => self::COMPANY_ADDRESS_FORMAT,
'payment_from_customer_address_format' => self::PAYMENT_CUSTOMER_ADDRESS_FORMAT,
'currency' => $currencyId,
'time_zone' => 'Asia/Kolkata',
'language' => 'en',
'fiscal_year' => '1-12',
'carbon_date_format' => 'Y/m/d',
'moment_date_format' => 'YYYY/MM/DD',
'carbon_time_format' => 'H:i',
'moment_time_format' => 'HH:mm',
'invoice_use_time' => 'NO',
'notification_email' => 'noreply@invoiceshelf.com',
'notify_invoice_viewed' => 'NO',
'notify_estimate_viewed' => 'NO',
'tax_per_item' => 'NO',
'discount_per_item' => 'NO',
'invoice_email_attachment' => 'NO',
'estimate_email_attachment' => 'NO',
'payment_email_attachment' => 'NO',
'retrospective_edits' => 'allow',
'invoice_number_format' => '{{SERIES:INV}}{{DELIMITER:-}}{{SEQUENCE:6}}',
'credit_note_number_format' => '{{SERIES:CN}}{{DELIMITER:-}}{{SEQUENCE:6}}',
'estimate_number_format' => '{{SERIES:EST}}{{DELIMITER:-}}{{SEQUENCE:6}}',
'payment_number_format' => '{{SERIES:PAY}}{{DELIMITER:-}}{{SEQUENCE:6}}',
'estimate_set_expiry_date_automatically' => 'YES',
'estimate_expiry_date_days' => 7,
'invoice_set_due_date_automatically' => 'YES',
'invoice_due_date_days' => 7,
'bulk_exchange_rate_configured' => 'YES',
'estimate_convert_action' => 'no_action',
'automatically_expire_public_links' => 'YES',
'link_expiry_days' => 7,
], $company->id);
}
}
@@ -0,0 +1,123 @@
<?php
namespace App\Domains\Accounts\Application;
use App\Domains\Accounts\Contracts\MemberReferencesCleaner;
use App\Domains\Accounts\Models\User;
use Illuminate\Support\Collection;
use Silber\Bouncer\BouncerFacade;
/**
* Every write behind the member endpoints: filing a staff account, pointing it
* at a set of companies, and erasing one outright.
*
* A submitted membership list is authoritative rather than additive — a company
* left off the list is detached — and each entry names the single role the
* account is to hold inside that company, displacing whatever it held there
* before.
*
* Handing out a role means moving Bouncer's scope onto the company first. The
* scope is left wherever the last company in the list put it; nothing here puts
* it back. Kept as it stands.
*/
class MemberService
{
public function __construct(
private readonly MemberReferencesCleaner $memberReferencesCleaner,
) {}
/**
* File a new account and place it in the listed companies.
*
* Its language preference is written as the sentinel `default`, so the new
* member reads the app in whatever language their company is set to rather
* than in a frozen copy of the language whoever added them was using.
*
* @param array<string, mixed> $attributes
* @param iterable<int, array{id: int, role: string}> $companies
*/
public function create(array $attributes, iterable $companies): User
{
$member = User::create($attributes);
$member->setSettings(['language' => 'default']);
$memberships = collect($companies);
$member->companies()->sync($memberships->pluck('id'));
$this->grantRoles($member, $memberships);
return $member;
}
/**
* Overwrite an account and re-point it at the listed companies.
*
* Memberships are replaced wholesale, so an edit that omits a company both
* detaches the account from it and leaves the roles it held there behind —
* the role sync below only visits companies still on the list.
*
* @param array<string, mixed> $attributes
* @param iterable<int, array{id: int, role: string}> $companies
*/
public function update(User $user, array $attributes, iterable $companies): User
{
$user->update($attributes);
$memberships = collect($companies);
$user->companies()->sync($memberships->pluck('id'));
$this->grantRoles($user, $memberships);
return $user;
}
/**
* Erase the named accounts, one after another.
*
* An id naming nobody is skipped rather than reported. Everything the
* account authored outlives it: invoices, estimates, contacts, recurring
* invoices, expenses, payments and catalog entries are left standing with
* no author against them, and only the preferences rows and the account
* itself actually go.
*
* @param array<int, int|string> $ids
*/
public function delete(array $ids): bool
{
foreach ($ids as $id) {
$member = User::find($id);
if ($member === null) {
continue;
}
$this->memberReferencesCleaner->clear($member);
if ($member->settings()->exists()) {
$member->settings()->delete();
}
$member->delete();
}
return true;
}
/**
* Give the account exactly the one role each company named, discarding any
* role it already held in that company.
*
* @param Collection<int, array{id: int, role: string}> $memberships
*/
private function grantRoles(User $member, Collection $memberships): void
{
foreach ($memberships as $membership) {
BouncerFacade::scope()->to($membership['id']);
BouncerFacade::sync($member)->roles([$membership['role']]);
}
}
}
@@ -0,0 +1,126 @@
<?php
namespace App\Domains\Accounts\Http\Controllers\Auth;
use App\Domains\Accounts\Application\InvitationService;
use App\Domains\Accounts\Http\Requests\LoginRequest;
use App\Domains\Accounts\Models\CompanyInvitation;
use App\Domains\Accounts\Models\User;
use App\Platform\Http\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
/**
* Bearer-token endpoints for staff accounts.
*
* These sit on the public API prefix and serve the admin SPA as well as any
* external client: trade a password for a personal access token, hand that
* token back in, or ask whether the current request still carries one.
*
* Nothing here is tenant-scoped. A staff address is unique installation-wide,
* so the company is resolved after sign-in from the request header, never
* chosen at the door the way the customer portal does it.
*/
class AuthController extends Controller
{
/**
* One answer covers both an address nobody holds and a mistyped password,
* so the two cannot be told apart from the reply.
*/
private const REJECTED = 'The provided credentials are incorrect.';
/**
* Trade an email/password pair for a personal access token.
*
* The token is named after the calling device, which is what later makes
* per-device revocation meaningful.
*/
public function login(LoginRequest $request)
{
$staff = $this->staffHolding($request->username);
if ($staff === null || ! Hash::check($request->password, $staff->password)) {
throw ValidationException::withMessages(['email' => [self::REJECTED]]);
}
// Deliberately reached only once the pair has been proven, so nobody
// can spend an invitation by guessing at somebody else's password.
$this->redeemPendingInvitation($request, $staff);
$minted = $staff->createToken($request->device_name);
return response()->json([
'type' => 'Bearer',
'token' => $minted->plainTextToken,
]);
}
/**
* Drop the token that carried this request.
*
* Quirk kept as is: exactly one token is revoked, never the account's
* whole set, so the caller's other devices stay signed in. And a caller
* authenticated by session cookie rather than a bearer token holds a
* transient token that has nothing to delete, so that request errors out
* instead of closing the session.
*/
public function logout(Request $request)
{
$carrier = $request->user()->currentAccessToken();
$carrier->delete();
return response()->json(['success' => true]);
}
/**
* Report whether the caller is signed in.
*
* The bare boolean body is deliberate: the route already sits behind the
* API guard, so the SPA reads this purely as a liveness ping.
*/
public function check()
{
return Auth::check();
}
/**
* Find the staff account holding the submitted address.
*
* The comparison runs against the lower-cased column so that capitalising
* an address differently from how it was stored still gets the account
* in, on every database engine the app supports.
*/
private function staffHolding($submitted): ?User
{
return User::query()
->whereRaw('LOWER(email) = ?', [strtolower($submitted)])
->first();
}
/**
* Accept an invitation carried alongside the credentials, when one is
* still live.
*
* A token that is unknown, already spent or past its expiry is passed
* over in silence. Sign-in itself is never held up by it.
*/
private function redeemPendingInvitation(LoginRequest $request, User $staff): void
{
$offered = $request->input('invitation_token');
if (! $offered) {
return;
}
$invitation = CompanyInvitation::query()
->where('token', $offered)
->pending()
->first();
if ($invitation !== null) {
app(InvitationService::class)->accept($invitation, $staff);
}
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Domains\Accounts\Http\Controllers\Auth;
use App\Platform\Http\Controller;
use Illuminate\Foundation\Auth\SendsPasswordResetEmails as IssuesResetLinks;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
/**
* Mails a staff account a link for choosing a new password.
*
* The imported trait drives the whole flow: validate the address, ask a
* broker for a link, branch on what the broker says. Only the two replies are
* swapped out below, because the admin SPA expects JSON rather than a redirect
* back to a Blade form. The broker is left alone, so links are minted by the
* default `users` broker against the staff table.
*
* Request volume is capped at the route, not here: the endpoint is registered
* behind a ten-per-two-minutes throttle.
*/
class ForgotPasswordController extends Controller
{
use IssuesResetLinks;
/**
* Every reason a link might not go out collapses into this one sentence.
*/
private const UNDELIVERABLE = 'Email could not be sent to this email address.';
/**
* Confirm that a link reached the mailer.
*
* The broker's own status key rides along in `data`, which is what the
* SPA logs when a send is investigated after the fact.
*
* @param string $response
*/
protected function sendResetLinkResponse(Request $request, $response)
{
return response()->json(['message' => 'Password reset email sent.', 'data' => $response]);
}
/**
* Report that no link went out.
*
* Quirk kept as is: this refusal is a probing oracle. An address nobody
* holds fails here while a known address succeeds, so the difference
* between 403 and 200 tells a caller which staff addresses exist. The
* throttle on the route is the only thing narrowing that.
*
* @param string $response
*/
protected function sendResetLinkFailedResponse(Request $request, $response)
{
return response()->json(['error' => self::UNDELIVERABLE], Response::HTTP_FORBIDDEN);
}
}
@@ -0,0 +1,67 @@
<?php
namespace App\Domains\Accounts\Http\Controllers\Auth;
use App\Platform\Http\Controller;
use App\Providers\AppServiceProvider;
use Illuminate\Foundation\Auth\AuthenticatesUsers as OpensSessions;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* Cookie-session sign-in for the admin shell.
*
* This is the second, older way into the admin app, distinct from the bearer
* flow next door: it opens a `web` session instead of minting a token. The
* imported trait supplies the whole of it (field validation, the lockout
* counter, the guard attempt and the success reply), so the only things
* declared here are where a browser lands afterwards and how signing out is
* handled.
*
* Quirk kept as is: the two sign-in doors disagree about their input. This one
* validates `email` (the trait's default field name), while the token endpoint
* takes `username`, so the same payload will not satisfy both.
*/
class LoginController extends Controller
{
use OpensSessions;
/**
* Where a browser is sent once a session has been opened.
*
* Read by the trait through `redirectPath()`; only reached when the
* caller did not ask for JSON, which the SPA always does.
*
* @var string
*/
protected $redirectTo = AppServiceProvider::HOME;
/**
* Fence the sign-in action off from anyone already holding a session.
*
* Signing out is the one action exempted, for the obvious reason that it
* is only ever useful while signed in. Note the guest guard redirects an
* already-authenticated caller to the dashboard rather than refusing.
*/
public function __construct()
{
$this->middleware('guest', ['except' => 'logout']);
}
/**
* Close the session opened by this controller.
*
* The trait's own version is replaced because it answers with a redirect
* or a 204; this one returns nothing at all, which the framework renders
* as an empty 200. Flushing the session and then rotating the CSRF token
* is what stops the emptied session from being reused.
*/
public function logout(Request $request): void
{
Auth::guard('web')->logout();
$session = $request->session();
$session->invalidate();
$session->regenerateToken();
}
}
@@ -0,0 +1,96 @@
<?php
namespace App\Domains\Accounts\Http\Controllers\Auth;
use App\Platform\Http\Controller;
use App\Providers\AppServiceProvider;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Foundation\Auth\ResetsPasswords as ConsumesResetTokens;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
/**
* Spends a reset token and stores the password chosen with it.
*
* Token lookup, expiry checking and the success/failure branch all come from
* the imported trait, as do the input rules: a token, an address, and a
* password that must be confirmed and satisfy the framework's default
* strength rules. The broker is left at its default, so tokens are checked
* against the staff table.
*
* Three things are narrowed below: the replies become JSON and plain text,
* the write hands the password over unhashed, and the account is deliberately
* left signed out afterwards.
*/
class ResetPasswordController extends Controller
{
use ConsumesResetTokens;
/**
* Covers every way a token can fail: absent, expired, forged, or minted
* for a different address than the one submitted with it.
*/
private const REFUSED = 'Failed, Invalid Token.';
/**
* Where the trait would send a browser after a successful reset.
*
* Inert in practice, since both replies below are written by hand, but
* the trait reads the property, so it stays declared.
*
* @var string
*/
protected $redirectTo = AppServiceProvider::HOME;
/**
* Store the password chosen behind the token.
*
* Two deliberate departures from the trait. The value is handed over raw,
* because the model hashes it on assignment and running it through the
* hasher here would store a hash of a hash. And no session is opened
* afterwards. The trait would sign the account straight in; here it comes
* back through the login form instead.
*
* @param mixed $user the account the token was minted for
* @param string $password
* @return void
*/
protected function resetPassword($user, $password)
{
// Assigning the attribute is what runs the model's hashing mutator.
$user->setAttribute('password', $password);
$rotated = Str::random(60);
$user->setRememberToken($rotated);
$user->save();
Event::dispatch(new PasswordReset($user));
}
/**
* Confirm the token was spent and the password replaced.
*
* @param string $response
*/
protected function sendResetResponse(Request $request, $response)
{
return response()->json(['message' => 'Password reset successfully.']);
}
/**
* Refuse a token that did not check out.
*
* Quirk kept as is: unlike the JSON everything else on this prefix
* answers with, the refusal is a bare plain-text body carrying a 403, so
* a client parsing the reply has to special-case this one path.
*
* @param string $response
*/
protected function sendResetFailedResponse(Request $request, $response)
{
return response(self::REFUSED, Response::HTTP_FORBIDDEN);
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Domains\Accounts\Http\Controllers\Company;
use App\Platform\Http\Controller;
use Illuminate\Http\Request;
/**
* Publishes the catalog of abilities a role can be granted.
*
* The catalog is configuration rather than data: the same fixed list for every
* company and every caller, which is why nothing here is scoped or filtered.
* The role editor draws its checkboxes from it, so the entries travel exactly
* as declared -- same order, subjects and dependencies included.
*/
class AbilitiesController extends Controller
{
/**
* The whole catalog, straight out of configuration.
*/
public function __invoke(Request $request)
{
return response()->json([
'abilities' => config('abilities.abilities'),
]);
}
}
@@ -0,0 +1,106 @@
<?php
namespace App\Domains\Accounts\Http\Controllers\Company;
use App\Domains\Accounts\Contracts\CompanyAddressWriter;
use App\Domains\Accounts\Contracts\CompanyLogoManager;
use App\Domains\Accounts\Http\Requests\CompanyLogoRequest;
use App\Domains\Accounts\Http\Requests\CompanyRequest;
use App\Domains\Accounts\Http\Resources\CompanyResource;
use App\Domains\Accounts\Models\Company;
use App\Platform\Http\Controller;
use Illuminate\Http\Request;
/**
* The profile of the company the request header points at.
*
* Neither endpoint takes a routed model: both resolve the company from the
* `company` header and then ask the same gate whether the caller is the
* account recorded against it as owner. Ability grants buy nothing here, and
* neither does the platform-administrator flag.
*/
class CompanyController extends Controller
{
public function __construct(
private readonly CompanyAddressWriter $companyAddressWriter,
private readonly CompanyLogoManager $companyLogoManager,
) {}
/**
* Rewrite the company record and upsert its postal address.
*
* The address block is read from the raw payload rather than the validated
* set: only its country carries a rule, so the validated set would shrink
* the address down to that single column. Kept as it stands.
*
* A payload carrying no address block at all still reaches the writer — as
* an empty array, which is what the cast of a missing key produces.
*/
public function updateCompany(CompanyRequest $request)
{
$company = $this->companyFromHeader($request);
$this->authorize('manage company', $company);
$company->update($request->getCompanyPayload());
$address = (array) $request->input('address');
$this->companyAddressWriter->upsert($company, $address);
return new CompanyResource($company);
}
/**
* Replace or drop the company logo.
*
* Two independent switches, in this order: the removal flag wipes whatever
* is on file, and a submitted image is then stored — so a payload carrying
* both ends up with the new image. The image arrives as a JSON envelope
* holding a file name and a data URI, already checked by the form request,
* and an envelope that decodes to nothing is simply ignored.
*/
public function uploadCompanyLogo(CompanyLogoRequest $request)
{
$company = $this->companyFromHeader($request);
$this->authorize('manage company', $company);
if ($this->removalRequested($request)) {
$this->companyLogoManager->clear($company);
}
$envelope = json_decode((string) $request->input('company_logo'));
if ($envelope) {
$this->companyLogoManager->replaceBase64($company, $envelope->data, $envelope->name);
}
return response()->json([
'success' => true,
]);
}
/**
* The company named by the request header, or null when the header names
* nothing on file — the gate is then asked about a company that is not
* there, exactly as before.
*/
private function companyFromHeader(Request $request): ?Company
{
return Company::query()->find($request->header('company'));
}
/**
* Whether the caller asked for the current logo to be dropped.
*
* Present-and-not-null, then cast to a boolean: `"0"` and the empty string
* read as no, but the string `"false"` reads as yes. Kept as it stands.
*/
private function removalRequested(Request $request): bool
{
$flag = $request->input('is_company_logo_removed');
return $flag !== null && (bool) $flag;
}
}
@@ -0,0 +1,136 @@
<?php
namespace App\Domains\Accounts\Http\Controllers\Company;
use App\Domains\Accounts\Http\Requests\GetSettingsRequest;
use App\Domains\Accounts\Http\Requests\UpdateSettingsRequest;
use App\Domains\Accounts\Models\Company;
use App\Domains\Accounts\Models\CompanySetting;
use App\Domains\Accounts\Models\User;
use App\Platform\Http\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Silber\Bouncer\BouncerFacade;
/**
* The preference store of the company named by the request header, plus the
* two questions that hang off it: whether the books have been opened, and who
* the company now belongs to.
*
* Reading preferences is open to any member; everything else is owner-only,
* and ownership here is positional — the account recorded against the company,
* not a role or an ability.
*/
class CompanySettingsController extends Controller
{
/**
* The named preferences of the active company.
*
* Options with no row on file are absent from the reply rather than null,
* so the map that comes back can be shorter than the one asked for — and
* empty when none of them exist, which serialises as an empty list.
*/
public function show(GetSettingsRequest $request): JsonResponse
{
$wanted = (array) $request->input('settings');
return response()->json(
CompanySetting::getSettings($wanted, $request->header('company'))
);
}
/**
* Write a batch of preferences, upserting option by option.
*
* One of them is guarded: the trading currency is frozen as soon as the
* company has anything on its books, and an attempt to move it is refused
* with a plain 200 carrying `success: false` — no status code, no error
* bag. The comparison against the stored value is strict, so submitting
* the current currency as a number when the store holds it as a string
* counts as a change and trips the guard.
*/
public function update(UpdateSettingsRequest $request): JsonResponse
{
$company = Company::query()->find($request->header('company'));
$this->authorize('manage company', $company);
$submitted = $request->input('settings');
if ($this->movesCurrency($submitted, $company) && $company->hasTransactions()) {
return response()->json([
'success' => false,
'message' => 'Cannot update company currency after transactions are created.',
]);
}
CompanySetting::setSettings($submitted, $request->header('company'));
return response()->json([
'success' => true,
]);
}
/**
* Whether the company has anything on its books yet — the flag the SPA
* uses to grey out the currency selector before the write is attempted.
*/
public function checkTransactions(Request $request): JsonResponse
{
$company = Company::query()->find($request->header('company'));
$this->authorize('manage company', $company);
return response()->json([
'has_transactions' => $company->hasTransactions(),
]);
}
/**
* Hand the active company to one of its members.
*
* The target has to be a member already; a stranger is turned away with a
* 200 carrying `success: false`, in the same shape as the currency guard.
* On success the owner column moves and the target's roles in this company
* are replaced by `owner` alone. Nothing is taken away from the outgoing
* owner beyond the column itself — their role assignments stay, and with
* them everything those roles allow.
*/
public function transferOwnership(Request $request, User $user): JsonResponse
{
$company = Company::query()->find($request->header('company'));
$this->authorize('transfer company ownership', $company);
if (! $user->hasCompany($company->id)) {
return response()->json([
'success' => false,
'message' => 'User does not belong to this company.',
]);
}
$company->update(['owner_id' => $user->id]);
BouncerFacade::scope()->to($company->id);
BouncerFacade::sync($user)->roles(['owner']);
return response()->json([
'success' => true,
]);
}
/**
* Whether the submitted batch carries a currency different from the one on
* file. A batch without a currency key never trips the guard, even when
* the company is trading.
*/
private function movesCurrency(mixed $submitted, Company $company): bool
{
if (! Arr::exists($submitted, 'currency')) {
return false;
}
return CompanySetting::getSetting('currency', $company->id) !== $submitted['currency'];
}
}
@@ -0,0 +1,149 @@
<?php
namespace App\Domains\Accounts\Http\Controllers\Company;
use App\Domains\Accounts\Application\MemberService;
use App\Domains\Accounts\Http\Requests\DeleteMemberRequest;
use App\Domains\Accounts\Http\Requests\MemberRequest;
use App\Domains\Accounts\Http\Resources\UserResource;
use App\Domains\Accounts\Models\User;
use App\Platform\Http\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Owner-side administration of the staff accounts inside the active company.
*
* The controller settles who may act and hands everything else on: the form
* request shapes the payload and the service owns every write. Ownership is
* positional and re-read on each call, so the same person administers members
* under one `company` header and is refused under the next.
*
* Note that the single-account routes bind their model installation-wide — an
* id belonging to another tenant resolves perfectly well and is turned away by
* the policy rather than by a missing row, which is a 403 where a 404 might be
* expected. Kept as it stands.
*/
class MembersController extends Controller
{
public function __construct(
private readonly MemberService $memberService,
) {}
/**
* A page of colleagues, newest first.
*
* The requester is struck from the rows but still counted in the envelope,
* so a company of three people shows two members underneath the number
* three. Kept as it stands.
*
* Page size defaults to ten, and unlike the other listings there is no
* sentinel for "everything" — a limit of `all` reaches the paginator as-is.
*
* @return JsonResponse
*/
public function index(Request $request)
{
$this->authorize('viewAny', User::class);
$perPage = $request->has('limit') ? $request->limit : 10;
$viewer = $request->user();
$members = User::whereCompany()
->applyFilters($request->all())
->where('id', '<>', $viewer->id)
->latest()
->paginate($perPage);
return UserResource::collection($members)->additional([
'meta' => ['user_total_count' => User::whereCompany()->count()],
]);
}
/**
* Open a staff account and place it in the companies the form listed.
*
* Note the gate: only the active company is weighed, so an owner may file
* an account into any company whose id they care to submit.
*
* @return JsonResponse
*/
public function store(MemberRequest $request)
{
$this->authorize('create', User::class);
$member = $this->memberService->create(
$request->getUserPayload(),
$request->validated('companies'),
);
return new UserResource($member);
}
/**
* One colleague, provided they share the active company with the caller.
*
* @return JsonResponse
*/
public function show(User $member)
{
$this->authorize('view', $member);
return new UserResource($member);
}
/**
* Overwrite a colleague's account and re-point their memberships.
*
* @return JsonResponse
*/
public function update(MemberRequest $request, User $member)
{
$this->authorize('update', $member);
$this->memberService->update(
$member,
$request->getUserPayload(),
$request->validated('companies'),
);
return new UserResource($member);
}
/**
* Erase a batch of accounts.
*
* The submitted ids were checked against the users table installation-wide,
* then narrowed to members of the active company here, so an id belonging
* to somebody else's tenant clears validation and is quietly dropped from
* the batch — the call still answers success. Kept as it stands.
*
* The gate is the bulk ability rather than the per-account policy, so it
* asks nothing about the individual targets; the narrowing above is what
* keeps one company out of another's accounts.
*
* @param Request $request
* @return JsonResponse
*/
public function delete(DeleteMemberRequest $request)
{
$this->authorize('delete multiple users', User::class);
$submitted = $request->users;
if ($submitted) {
$targets = User::whereCompany()
->whereIn('id', $submitted)
->pluck('id')
->toArray();
if ($targets) {
$this->memberService->delete($targets);
}
}
return response()->json([
'success' => true,
]);
}
}
@@ -0,0 +1,134 @@
<?php
namespace App\Domains\Accounts\Http\Controllers\Company;
use App\Domains\Accounts\Http\Requests\RoleRequest;
use App\Domains\Accounts\Http\Resources\RoleResource;
use App\Domains\Accounts\Models\User;
use App\Platform\Http\Controller;
use Illuminate\Http\Request;
use Silber\Bouncer\BouncerFacade;
use Silber\Bouncer\Database\Role;
/**
* The roles a company defines for its own members.
*
* Every action is owner-only by way of the role policy. None of the queries or
* writes below name a company: roles sit inside the Bouncer scope that the
* bouncer middleware derived from the `company` header, and reads and writes
* alike inherit it. That is also why the listing's `company_id` filter can only
* ever narrow the active company's roles -- pointing it at another company
* intersects to nothing rather than crossing the tenant boundary.
*/
class RolesController extends Controller
{
/**
* Machine-readable key reported when a role is still held by someone.
*/
private const IN_USE_ERROR = 'role_attached_to_users';
/**
* Human-readable counterpart of the in-use key.
*/
private const IN_USE_MESSAGE = 'Roles Attached to user';
/**
* Every role visible in the active scope.
*
* Kept as is: `orderByField` is handed to the database untouched, unlike
* the allow-listed sorts elsewhere in the app.
*/
public function index(Request $request)
{
$this->authorize('viewAny', Role::class);
$query = Role::query();
if ($request->has('orderByField')) {
$query->orderBy($request['orderByField'], $request['orderBy']);
}
if ($request->company_id) {
$query->where('scope', $request->company_id);
}
return RoleResource::collection($query->get());
}
/**
* Define a role and settle its abilities in one go.
*/
public function store(RoleRequest $request)
{
$this->authorize('create', Role::class);
$role = Role::query()->create($request->getRolePayload());
$this->writeCatalogGrants($role, $request->abilities);
return RoleResource::make($role);
}
/**
* One role with its current grants.
*/
public function show(Role $role)
{
$this->authorize('view', $role);
return RoleResource::make($role);
}
/**
* Rename a role and rewrite its grants.
*/
public function update(RoleRequest $request, Role $role)
{
$this->authorize('update', $role);
$role->fill($request->getRolePayload())->save();
$this->writeCatalogGrants($role, $request->abilities);
return RoleResource::make($role);
}
/**
* Drop a role, unless somebody in this company still holds it.
*/
public function destroy(Role $role)
{
$this->authorize('delete', $role);
if (User::whereIs($role->name)->exists()) {
return respondJson(self::IN_USE_ERROR, self::IN_USE_MESSAGE);
}
$role->delete();
return response()->json(['success' => true]);
}
/**
* Walk the whole ability catalog and make the role match the submission.
*
* The submission is read as a set of names: a catalog entry named in it is
* granted, every other entry is revoked, so a role never keeps a grant the
* caller left out. Names that match no catalog entry are simply never
* looked at.
*/
private function writeCatalogGrants($role, $submitted): void
{
$wanted = array_column($submitted, 'ability');
foreach (config('abilities.abilities') as $entry) {
if (in_array($entry['ability'], $wanted)) {
BouncerFacade::allow($role)->to($entry['ability'], $entry['model']);
continue;
}
BouncerFacade::disallow($role)->to($entry['ability'], $entry['model']);
}
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Accounts\Http\Middleware;
use Illuminate\Auth\Middleware\Authenticate as FrameworkAuthenticate;
use Illuminate\Http\Request;
/**
* The `auth` alias for staff routes.
*
* Everything about deciding who is signed in is inherited; the one thing
* settled here is where a browser that is not signed in gets sent.
*/
class Authenticate extends FrameworkAuthenticate
{
/**
* Name the page an unauthenticated caller should be bounced to.
*
* API clients get nothing back, which is what makes the framework raise a
* 401 for them instead of a redirect. The parent already suppresses the
* redirect for JSON callers, so the branch below is belt and braces.
*
* @param Request $request
* @return string|null
*/
protected function redirectTo($request)
{
return $request->expectsJson() ? null : route('login');
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Domains\Accounts\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\Response;
/**
* Decides which company the rest of the request runs against.
*
* The caller names a workspace in the `company` header. A header that names
* nothing the caller belongs to is not an error here: it is quietly overwritten
* with the first company on the caller's membership list, so everything
* downstream may read the header without vetting it again. Two situations skip
* the rewrite -- an install whose membership table has not been created yet, and
* the platform administrator arriving with no header at all (admin mode).
*/
class CompanyMiddleware
{
public function handle(Request $request, Closure $next): Response
{
if (! Schema::hasTable('user_company')) {
return $next($request);
}
$actor = $request->user();
if ($actor === null) {
return $next($request);
}
$fallback = $actor->companies()->first();
if ($fallback === null) {
return $next($request);
}
$requested = $request->header('company');
if ($actor->isSuperAdmin() && ! $requested) {
return $next($request);
}
if (! $requested || ! $actor->hasCompany($requested)) {
$request->headers->set('company', $fallback->id);
}
return $next($request);
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Domains\Accounts\Http\Middleware;
use App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* The `guest` alias: keeps signed-in staff off the sign-in pages.
*
* Rather than refusing, it forwards the caller to the dashboard, so hitting
* the login page a second time in the same browser simply lands where they
* were already headed.
*/
class RedirectIfAuthenticated
{
/**
* Pass guests through; send anyone already holding a session home.
*
* With no guard named on the route the default one is consulted, which
* means a customer-portal session does not count as being signed in here.
*
* @param Request $request
* @param string|null $guard guard alias named on the route, if any
* @return mixed
*/
public function handle($request, Closure $next, $guard = null)
{
return Auth::guard($guard)->check()
? redirect(RouteServiceProvider::HOME)
: $next($request);
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Domains\Accounts\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* The mirror of the guest alias: keeps signed-out visitors off the admin shell.
*
* It guards the page that boots the SPA, so a visitor without a session is
* shown the sign-in page instead of an app that would immediately fail every
* request it makes.
*
* Quirk kept as is: the name says "unauthorized", but nothing about
* permissions is examined. The only question asked is whether anyone is
* signed in at all.
*/
class RedirectIfUnauthorized
{
/**
* Hard-coded rather than resolved from the named route, so it stays put
* even if the route's name changes.
*/
private const SIGN_IN_PATH = '/login';
/**
* Let a signed-in caller through, and send everyone else to sign in.
*
* As with the guest alias, an unnamed guard means the default one, so
* this asks about staff sessions only.
*
* @return mixed
*/
public function handle(Request $request, Closure $next, $guard = null): Response
{
if (! Auth::guard($guard)->check()) {
return redirect(self::SIGN_IN_PATH);
}
return $next($request);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Domains\Accounts\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Silber\Bouncer\Bouncer;
use Symfony\Component\HttpFoundation\Response;
/**
* Points Bouncer at the company the request is acting in.
*
* Role and ability rows carry the id of the company they were defined for, and
* for the rest of this request Bouncer reads only the rows carrying the id set
* here (plus the unscoped ones -- that tolerance lives in the scope class
* registered by the app service provider, not here). The header is taken at
* face value because the company middleware has already replaced anything the
* caller has no claim to. A request with no header falls back to the caller's
* first company; a caller who belongs to nothing is left unscoped.
*/
class ScopeBouncer
{
public function __construct(protected Bouncer $bouncer) {}
public function handle(Request $request, Closure $next): Response
{
$scope = $request->header('company');
if (! $scope) {
$fallback = $request->user()->companies()->first();
if ($fallback === null) {
return $next($request);
}
$scope = $fallback->id;
}
$this->bouncer->scope()->to($scope);
return $next($request);
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Domains\Accounts\Http\Requests;
use App\Rules\Base64Mime;
use Illuminate\Foundation\Http\FormRequest;
/**
* Validates a profile picture on its way in.
*
* The endpoint takes a picture by either of two routes and neither is demanded:
* a multipart upload under `admin_avatar`, or a base64 JSON blob under
* `avatar`. The same call also carries the removal flag, which needs no
* picture at all — hence both fields being optional.
*/
class AvatarRequest extends FormRequest
{
/** Picture formats accepted, whichever route the picture arrives by. */
private const ACCEPTED_FORMATS = ['gif', 'jpg', 'png'];
/** Ceiling on an uploaded file, in kilobytes. */
private const MAX_KILOBYTES = 20000;
/**
* The caller is editing their own profile, so there is nothing to weigh up.
*/
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'admin_avatar' => [
'nullable',
'file',
'mimes:'.implode(',', self::ACCEPTED_FORMATS),
'max:'.self::MAX_KILOBYTES,
],
'avatar' => [
'nullable',
new Base64Mime(self::ACCEPTED_FORMATS),
],
];
}
}
@@ -0,0 +1,104 @@
<?php
namespace App\Domains\Accounts\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
/**
* The form behind opening a new company.
*
* Two things are actually demanded — a name nobody in the installation has
* taken, and a currency — plus a country whenever an address block is in play.
* Everything else about the address is waved through unchecked.
*/
class CompaniesRequest extends FormRequest
{
/** The address block, every field of it optional bar the country. */
private const OPTIONAL_ADDRESS_FIELDS = [
'name',
'address_street_1',
'address_street_2',
'city',
'state',
];
private const TRAILING_ADDRESS_FIELDS = [
'zip',
'phone',
'fax',
];
/** Columns lifted off the validated payload onto the new company row. */
private const COMPANY_FIELDS = [
'name',
'vat_id',
'tax_id',
];
/**
* Whether the caller may open a company at all is a gate question, asked
* in the controller; nothing is decided at this layer.
*/
public function authorize(): bool
{
return true;
}
/**
* Assembled in declaration order so the error bag keeps the order it has
* always come back in: name, currency, then the address block with the
* country sitting between the optional fields either side of it.
*
* The country rule is unconditional — a payload with no address block at
* all is rejected for the missing country.
*
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
$rules = [
'name' => [
'required',
Rule::unique('companies'),
'string',
],
'currency' => [
'required',
],
];
foreach (self::OPTIONAL_ADDRESS_FIELDS as $field) {
$rules['address.'.$field] = ['nullable'];
}
$rules['address.country_id'] = ['required'];
foreach (self::TRAILING_ADDRESS_FIELDS as $field) {
$rules['address.'.$field] = ['nullable'];
}
return $rules;
}
/**
* The row to insert: the allow-listed columns, with the caller stamped on
* as owner and a slug derived from the name.
*
* The two tax identifiers carry no rule of their own, so they are never
* part of the validated payload and can never be written through here.
* Listed all the same, as found.
*/
public function getCompanyPayload()
{
return array_merge(
Arr::only($this->validated(), self::COMPANY_FIELDS),
[
'owner_id' => $this->user()->id,
'slug' => Str::slug($this->name),
]
);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Domains\Accounts\Http\Requests;
use App\Rules\Base64Mime;
use Illuminate\Foundation\Http\FormRequest;
/**
* The envelope carrying a new company logo.
*
* One optional field: a JSON document holding a file name and a data URI. When
* it is there it has to name a raster image the branding collection accepts,
* and the bytes behind the data URI have to agree with that name.
*
* The removal flag travels in the same payload but is deliberately unchecked —
* the controller reads it straight off the request.
*/
class CompanyLogoRequest extends FormRequest
{
/** Image types the logo may be. */
private const ALLOWED_TYPES = ['gif', 'jpg', 'png'];
/**
* Ownership of the company is settled by the gate in the controller.
*/
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'company_logo' => [
'nullable',
new Base64Mime(self::ALLOWED_TYPES),
],
];
}
}
@@ -0,0 +1,89 @@
<?php
namespace App\Domains\Accounts\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Unique;
/**
* The form behind editing the company the request header points at.
*
* The name has to stay unique across the whole installation, the company being
* edited excepted — and it is excepted by the header value, not by a routed
* model, because this endpoint has no route parameter to work from.
*/
class CompanyRequest extends FormRequest
{
/** Columns lifted off the validated payload onto the company row. */
private const COMPANY_FIELDS = [
'name',
'vat_id',
'tax_id',
];
/**
* Ownership is settled by the gate in the controller, so every caller that
* got this far is let through.
*/
public function authorize(): bool
{
return true;
}
/**
* The two tax identifiers are declared but unchecked; the country of the
* address block is required whether or not an address was submitted.
*
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'name' => [
'required',
$this->unclaimedName(),
],
'vat_id' => [
'nullable',
],
'tax_id' => [
'nullable',
],
'address.country_id' => [
'required',
],
];
}
/**
* The name has to be free across the whole installation, with one company
* excused: the one named by the request header.
*
* The exception is keyed on the header value rather than on a loaded model
* — nothing here checks that the header names a company that exists, so a
* header pointing at nothing simply excuses no row at all.
*/
private function unclaimedName(): Unique
{
return Rule::unique('companies')->ignore($this->header('company'), 'id');
}
/**
* The columns to write, with a slug rebuilt from the submitted name.
*
* Both tax identifiers are declared as nullable rules here, so unlike the
* creation form they do survive into the validated payload and are written
* through. The slug is derived from the raw input rather than the
* validated set, which is the same string either way.
*/
public function getCompanyPayload()
{
return array_merge(
Arr::only($this->validated(), self::COMPANY_FIELDS),
['slug' => Str::slug($this->name)]
);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Domains\Accounts\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* Guards the bulk removal of staff accounts.
*
* Every submitted id has to name an account, but the check reaches across the
* whole installation rather than the active company: an id belonging to another
* tenant clears validation here and is then dropped by the controller's
* company-scoped lookup, so the batch skips it without complaint. Kept as it
* stands.
*/
class DeleteMemberRequest extends FormRequest
{
/**
* Nothing is settled at this layer — the controller asks Bouncer for the
* `delete multiple users` ability before anything is touched.
*/
public function authorize(): bool
{
return true;
}
/**
* A non-empty `users` list, each entry naming a row that exists.
*
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'users' => ['required'],
'users.*' => ['required', Rule::exists('users', 'id')],
];
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Domains\Accounts\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* The query behind reading preferences: a list of option names to look up.
*
* The list itself has to be there, and each entry has to be a non-empty
* string. Nothing checks that an option actually exists — unknown names are
* simply absent from the reply.
*/
class GetSettingsRequest extends FormRequest
{
/**
* Reading preferences is open to every member of the company, so nothing
* is refused here.
*/
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'settings' => [
'required',
],
'settings.*' => [
'required',
'string',
],
];
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Domains\Accounts\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* Shape check for the token sign-in payload.
*
* Three fields have to be present: the address being claimed, the password
* offered for it, and a label for the device the token is being minted for.
* Whether the pair is actually correct is settled by the controller, not here,
* so nothing in this class can be used to probe for existing accounts.
*
* Quirk kept as is: presence is all that is asked for. The address is not
* required to look like an email, nor even to be a string, so a payload that
* sends the field as an array passes validation and only comes apart further
* down when the value is lower-cased for lookup.
*/
class LoginRequest extends FormRequest
{
/**
* The sign-in door is open to anyone standing in front of it.
*/
public function authorize(): bool
{
return true;
}
/**
* All three fields are mandatory and otherwise unconstrained.
*/
public function rules(): array
{
return [
'username' => ['required'],
'password' => ['required'],
'device_name' => ['required'],
];
}
}
@@ -0,0 +1,82 @@
<?php
namespace App\Domains\Accounts\Http\Requests;
use App\Rules\IdnEmail;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* Validates the staff-account form, which serves both filing a new member and
* editing one that already exists.
*
* The two verbs part company in exactly two places: on an edit the uniqueness
* check steps over the row being edited, and the password turns optional so a
* form saved with the field left alone keeps the hash already on file.
*
* The address is unique across the whole installation rather than within the
* company, so a collision tells one tenant that an address is already spoken
* for somewhere else entirely. Kept as it stands.
*/
class MemberRequest extends FormRequest
{
/** The columns copied out of the payload onto the account row. */
private const ACCOUNT_FIELDS = [
'name',
'email',
'phone',
'password',
];
/**
* Nothing is decided at this layer; the member policy runs in the
* controller, so every caller that got this far is let through.
*/
public function authorize(): bool
{
return true;
}
/**
* The membership list is only checked for shape — each entry has to name a
* company and a role, but neither is looked up here.
*
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
$editing = $this->getMethod() == 'PUT';
$address = Rule::unique('users');
if ($editing) {
$address->ignore($this->member);
}
return [
'name' => ['required'],
'email' => ['required', new IdnEmail, $address],
'phone' => ['nullable'],
'password' => $editing ? ['nullable', 'min:8'] : ['required', 'min:8'],
'companies' => ['required'],
'companies.*.id' => ['required'],
'companies.*.role' => ['required'],
];
}
/**
* The account row on its own, stamped with whoever is filing it.
*
* On an edit the stamp is written again, so the column records the last
* person to save the form rather than the one who opened the account.
*
* @return array<string, mixed>
*/
public function getUserPayload()
{
return collect($this->validated())
->only(self::ACCOUNT_FIELDS)
->merge(['creator_id' => $this->user()->id])
->toArray();
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Domains\Accounts\Http\Requests;
use App\Rules\IdnEmail;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
/**
* Validates the signed-in account editing its own profile.
*
* A display name is the only thing genuinely demanded. The password field is
* optional, so a form saved with it left alone keeps the hash already on file,
* and the address has to stay unique installation-wide with the caller's own
* row stepped over — otherwise re-saving an unchanged form would collide with
* itself.
*/
class ProfileRequest extends FormRequest
{
/**
* Everyone signed in may edit their own profile; there is no target to
* weigh up, so the gate is open.
*/
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'name' => ['required'],
'password' => ['nullable', 'min:8'],
'email' => [
'required',
new IdnEmail,
Rule::unique('users')->ignore(Auth::id(), 'id'),
],
];
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Domains\Accounts\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* Payload rules for defining or renaming a company role.
*
* Role names are unique per company rather than per install, so the uniqueness
* check is narrowed by hand to the scope named in the `company` header. A
* rename excuses the role from its own name, but only when the verb is PUT --
* kept as is, an otherwise identical PATCH collides with the stored name.
*/
class RoleRequest extends FormRequest
{
/**
* Access is settled by the role policy in the controller, not here.
*/
public function authorize(): bool
{
return true;
}
/**
* What a role submission has to satisfy before it is written.
*/
public function rules(): array
{
$name = Rule::unique('roles')->where('scope', $this->header('company'));
if ($this->getMethod() === 'PUT') {
$name->ignore($this->route('role')->id, 'id');
}
return [
'name' => ['required', 'string', $name],
'abilities' => ['required'],
'abilities.*' => ['required'],
];
}
/**
* The submitted attributes, minus the abilities, stamped with the scope.
*
* Everything the caller sent survives the trip; the role model's own
* fillable list decides what is actually written.
*/
public function getRolePayload()
{
$attributes = $this->except('abilities');
$attributes['scope'] = $this->header('company');
return $attributes;
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Domains\Accounts\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* The payload behind writing preferences: one map of option names to values.
*
* Only its presence is checked. Neither the option names nor the values are
* constrained in any way, so anything the caller sends is upserted as-is —
* bar the currency, which the controller guards once the books are open.
*/
class UpdateSettingsRequest extends FormRequest
{
/**
* Owner-only, but the gate that says so runs in the controller.
*/
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'settings' => [
'required',
],
];
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Domains\Accounts\Http\Resources;
use App\Domains\Contacts\Http\Resources\AddressResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
/**
* A company as the admin API publishes it.
*
* Identity, branding and the public handle, plus the postal address when one is
* on file and the owning account when the caller has already loaded it. Two
* further fields describe authorization inside this company: the roles defined
* in its scope, and the title of the role the signed-in account holds there.
*/
class CompanyResource extends JsonResource
{
/**
* @param Request $request
*/
public function toArray($request): array
{
$company = $this->resource;
return [
'id' => $company->id,
'name' => $company->name,
'vat_id' => $company->vat_id,
'tax_id' => $company->tax_id,
'logo' => $company->logo,
'logo_path' => $company->logo_path,
'unique_hash' => $company->unique_hash,
'owner_id' => $company->owner_id,
'slug' => $company->slug,
'created_at' => $company->created_at,
'updated_at' => $company->updated_at,
'address' => $this->when(
$company->address()->exists(),
fn () => new AddressResource($company->address)
),
'owner' => $this->when(
$company->relationLoaded('owner'),
fn () => new UserResource($company->owner)
),
'roles' => RoleResource::collection($company->roles),
'user_role' => $this->assignedRoleTitle(),
];
}
/**
* Title of the role the signed-in account holds inside this company.
*
* Read off the assignment table by company id, so it stays right for a
* company other than the active one. Null when nobody is signed in, and
* null when the account has no assignment here.
*/
private function assignedRoleTitle(): ?string
{
$viewer = Auth::user();
if ($viewer === null) {
return null;
}
return DB::query()
->from('assigned_roles')
->join('roles', 'assigned_roles.role_id', '=', 'roles.id')
->where([
['assigned_roles.entity_type', '=', $viewer->getMorphClass()],
['assigned_roles.entity_id', '=', $viewer->id],
['assigned_roles.scope', '=', $this->id],
])
->value('roles.title');
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Domains\Accounts\Http\Resources\CustomerPortal;
use App\Domains\Contacts\Http\Resources\CustomerPortal\AddressResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* The company behind the portal, as the customer portal publishes it.
*
* Only what the portal chrome renders: identity, the public handle, both forms
* of the logo, the owning account's id, and the postal address when one is on
* file. Nothing about roles or settings is exposed here.
*/
class CompanyResource extends JsonResource
{
/**
* @param Request $request
*/
public function toArray($request): array
{
$company = $this->resource;
return [
'id' => $company->id,
'name' => $company->name,
'slug' => $company->slug,
'logo' => $company->logo,
'logo_path' => $company->logo_path,
'unique_hash' => $company->unique_hash,
'owner_id' => $company->owner_id,
'address' => $this->when(
$company->address()->exists(),
fn () => new AddressResource($company->address)
),
];
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Domains\Accounts\Http\Resources\CustomerPortal;
use App\Domains\Money\Http\Resources\CustomerPortal\CurrencyResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* A staff account as the customer portal publishes it.
*
* The same profile fields as the admin view, minus the platform-administrator
* flag: the portal only ever needs to know whether the account owns the company
* it is looking at. Roles, the company-formatted creation date, and the currency
* and companies when those exist, are carried along as well.
*/
class UserResource extends JsonResource
{
/**
* @param Request $request
*/
public function toArray($request): array
{
$user = $this->resource;
return [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'phone' => $user->phone,
'role' => $user->role,
'contact_name' => $user->contact_name,
'company_name' => $user->company_name,
'website' => $user->website,
'enable_portal' => $user->enable_portal,
'currency_id' => $user->currency_id,
'facebook_id' => $user->facebook_id,
'google_id' => $user->google_id,
'github_id' => $user->github_id,
'created_at' => $user->created_at,
'updated_at' => $user->updated_at,
'avatar' => $user->avatar,
'is_owner' => $user->isOwner(),
'roles' => $user->roles,
'formatted_created_at' => $user->formattedCreatedAt,
'currency' => $this->when(
$user->currency()->exists(),
fn () => new CurrencyResource($user->currency)
),
'companies' => $this->when(
$user->companies()->exists(),
fn () => CompanyResource::collection($user->companies)
),
];
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Domains\Accounts\Http\Resources;
use App\Domains\Accounts\Models\CompanySetting;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* A single company role, shaped for the role editor.
*
* The abilities travel as the live grant set read back through Bouncer rather
* than as whatever was last submitted, so the payload always reports what the
* store actually holds.
*/
class RoleResource extends JsonResource
{
/**
* @param Request $request
*/
public function toArray($request): array
{
$role = $this->resource;
$createdAt = $this->getFormattedAt();
return [
'id' => $role->id,
'name' => $role->name,
'title' => $role->title,
'level' => $role->level,
'formatted_created_at' => $createdAt,
'abilities' => $role->getAbilities(),
];
}
/**
* The creation date in the date format of the company owning the role.
*
* The format follows the role's own scope, not the company the reader is
* looking in from.
*/
public function getFormattedAt()
{
$format = CompanySetting::getSetting('carbon_date_format', $this->scope);
return Carbon::parse($this->created_at)->translatedFormat($format);
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Domains\Accounts\Http\Resources;
use App\Domains\Money\Http\Resources\CurrencyResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* A staff account as the admin API publishes it.
*
* Besides the stored columns it answers the two authorisation questions the SPA
* asks about the caller (whether they own the active company, and whether they
* are the platform administrator) and carries the roles held in the current
* scope, the creation date already formatted for the active company, and the
* currency and companies whenever those exist. The avatar is whatever the model
* exposes: a media URL, or the literal number zero when none is on file.
*/
class UserResource extends JsonResource
{
/**
* @param Request $request
*/
public function toArray($request): array
{
$user = $this->resource;
return [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'phone' => $user->phone,
'role' => $user->role,
'contact_name' => $user->contact_name,
'company_name' => $user->company_name,
'website' => $user->website,
'enable_portal' => $user->enable_portal,
'currency_id' => $user->currency_id,
'facebook_id' => $user->facebook_id,
'google_id' => $user->google_id,
'github_id' => $user->github_id,
'created_at' => $user->created_at,
'updated_at' => $user->updated_at,
'avatar' => $user->avatar,
'is_owner' => $user->isOwner(),
'is_super_admin' => $user->isSuperAdmin(),
'roles' => $user->roles,
'formatted_created_at' => $user->formattedCreatedAt,
'currency' => $this->when(
$user->currency()->exists(),
fn () => new CurrencyResource($user->currency)
),
'companies' => $this->when(
$user->companies()->exists(),
fn () => CompanyResource::collection($user->companies)
),
];
}
}
+303
View File
@@ -0,0 +1,303 @@
<?php
namespace App\Domains\Accounts\Models;
use App\Domains\Catalog\Models\Item;
use App\Domains\Catalog\Models\Unit;
use App\Domains\Contacts\Models\Address;
use App\Domains\Contacts\Models\Customer;
use App\Domains\Metadata\Models\CustomField;
use App\Domains\Metadata\Models\CustomFieldValue;
use App\Domains\Money\Models\ExchangeRateLog;
use App\Domains\Money\Models\ExchangeRateProvider;
use App\Domains\Purchases\Models\Expense;
use App\Domains\Purchases\Models\ExpenseCategory;
use App\Domains\Receivables\Models\Payment;
use App\Domains\Receivables\Models\PaymentMethod;
use App\Domains\Sales\Models\Estimate;
use App\Domains\Sales\Models\Invoice;
use App\Domains\Sales\Models\RecurringInvoice;
use App\Domains\Taxation\Models\TaxType;
use App\Platform\Storage\Models\FileDisk;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Silber\Bouncer\Database\Role;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
/**
* A tenant.
*
* Nearly every record in the application hangs off a company, and the roles
* that grant access to those records are scoped to the company's id.
*/
class Company extends Model implements HasMedia
{
use HasFactory;
use InteractsWithMedia;
protected $table = 'companies';
protected $guarded = [
'id',
];
protected $appends = ['logo', 'logo_path'];
/**
* A company keeps a single branding image on the public disk.
*/
public function registerMediaCollections(): void
{
$this->addMediaCollection('logo')->useDisk('public')->singleFile();
}
/*
|--------------------------------------------------------------------------
| Relationships
|--------------------------------------------------------------------------
*/
/**
* The account holding positional ownership of this company.
*/
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_id');
}
/**
* Every account with a membership in this company.
*/
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'user_company', 'company_id', 'user_id');
}
/**
* The postal address printed on this company's documents.
*/
public function address(): HasOne
{
return $this->hasOne(Address::class);
}
/**
* Per-company preference rows, addressed by their `option` column.
*/
public function settings(): HasMany
{
return $this->hasMany(CompanySetting::class);
}
/**
* Contacts filed under this company.
*/
public function customers(): HasMany
{
return $this->hasMany(Customer::class);
}
/**
* Catalog entries filed under this company.
*/
public function items(): HasMany
{
return $this->hasMany(Item::class);
}
/**
* Units of measure available to this company's catalog.
*/
public function units(): HasMany
{
return $this->hasMany(Unit::class);
}
/**
* Tax rates this company can apply.
*/
public function taxTypes(): HasMany
{
return $this->hasMany(TaxType::class);
}
/**
* Invoices issued by this company.
*/
public function invoices(): HasMany
{
return $this->hasMany(Invoice::class);
}
/**
* Estimates issued by this company.
*/
public function estimates(): HasMany
{
return $this->hasMany(Estimate::class);
}
/**
* Recurring invoice schedules owned by this company.
*/
public function recurringInvoices(): HasMany
{
return $this->hasMany(RecurringInvoice::class);
}
/**
* Payments received by this company.
*/
public function payments(): HasMany
{
return $this->hasMany(Payment::class);
}
/**
* Ways this company accepts being paid.
*/
public function paymentMethods(): HasMany
{
return $this->hasMany(PaymentMethod::class);
}
/**
* Expenses booked against this company.
*/
public function expenses(): HasMany
{
return $this->hasMany(Expense::class);
}
/**
* Buckets this company sorts its expenses into.
*/
public function expenseCategories(): HasMany
{
return $this->hasMany(ExpenseCategory::class);
}
/**
* Custom field definitions declared by this company.
*/
public function customFields(): HasMany
{
return $this->hasMany(CustomField::class);
}
/**
* Answers recorded for this company's custom fields.
*/
public function customFieldValues(): HasMany
{
return $this->hasMany(CustomFieldValue::class);
}
/**
* Recorded exchange rate lookups for this company.
*/
public function exchangeRateLogs(): HasMany
{
return $this->hasMany(ExchangeRateLog::class);
}
/**
* Configured exchange rate sources for this company.
*/
public function exchangeRateProviders(): HasMany
{
return $this->hasMany(ExchangeRateProvider::class);
}
/*
|--------------------------------------------------------------------------
| Accessors
|--------------------------------------------------------------------------
*/
/**
* The roles defined inside this company's authorization scope.
*/
public function getRolesAttribute()
{
return Role::query()->where('scope', $this->id)->get();
}
/**
* Publicly reachable address of the branding image, null when none is
* attached.
*/
public function getLogoAttribute()
{
$logo = $this->logoMedia();
return $logo ? $logo->getFullUrl() : null;
}
/**
* Where the branding image lives.
*
* A local filesystem path while the default file disk is a system disk,
* and a public address for every other kind of disk - the asymmetry is
* deliberate, PDF rendering needs the path and the SPA needs the address.
* The default disk is resolved whether or not an image is attached.
*/
public function getLogoPathAttribute()
{
$logo = $this->logoMedia();
$isSystem = FileDisk::query()->where('set_as_default', true)->first()->isSystem();
if (! $logo) {
return null;
}
return $isSystem ? $logo->getPath() : $logo->getFullUrl();
}
/*
|--------------------------------------------------------------------------
| Helpers
|--------------------------------------------------------------------------
*/
/**
* Whether any business record has been filed under this company yet.
*
* Contacts, catalog entries, invoices, estimates, expenses, payments and
* recurring schedules all count; the first one found ends the search.
*/
public function hasTransactions(): bool
{
$ledgers = [
'customers',
'items',
'invoices',
'estimates',
'expenses',
'payments',
'recurringInvoices',
];
foreach ($ledgers as $ledger) {
if ($this->{$ledger}()->exists()) {
return true;
}
}
return false;
}
/**
* The one media row behind the branding collection, if there is one.
*/
private function logoMedia()
{
return $this->getMedia('logo')->first();
}
}
@@ -0,0 +1,102 @@
<?php
namespace App\Domains\Accounts\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Collection;
/**
* One preference belonging to a company.
*
* The store is a plain key/value table addressed by the `option` column. There
* is no global layer underneath it: a read either finds a row for the company
* asked about or comes back empty, and a write upserts on the option/company
* pair.
*/
class CompanySetting extends Model
{
use HasFactory;
protected $table = 'company_settings';
protected $fillable = ['company_id', 'option', 'value'];
/**
* Company this preference belongs to.
*/
public function company(): BelongsTo
{
return $this->belongsTo(Company::class);
}
/**
* Narrow a query to one company's rows.
*/
public function scopeWhereCompany($query, $company_id)
{
$query->where('company_id', $company_id);
}
/**
* Write a batch of preferences for one company, replacing the value of any
* option already on file and inserting the rest.
*/
public static function setSettings(array $settings, mixed $company_id): void
{
foreach ($settings as $option => $value) {
self::updateOrCreate(
['option' => $option, 'company_id' => $company_id],
['option' => $option, 'company_id' => $company_id, 'value' => $value]
);
}
}
/**
* Every preference on file for a company, keyed by option name.
*/
public static function getAllSettings(mixed $company_id): Collection
{
return self::flatten(
static::whereCompany($company_id)->get()
);
}
/**
* The named preferences only; options with no row on file are left out.
*/
public static function getSettings(array $settings, mixed $company_id): Collection
{
return self::flatten(
static::whereIn('option', $settings)->whereCompany($company_id)->get()
);
}
/**
* One preference value, or null when the company has no row for it.
*/
public static function getSetting(string $key, mixed $company_id): mixed
{
$setting = static::query()
->where('option', $key)
->whereCompany($company_id)
->first();
if ($setting) {
return $setting->value;
} else {
return null;
}
}
/**
* Reduce preference rows to an option => value collection.
*/
private static function flatten(Collection $rows): Collection
{
return $rows->mapWithKeys(function ($row) {
return [$row['option'] => $row['value']];
});
}
}
+621
View File
@@ -0,0 +1,621 @@
<?php
namespace App\Domains\Accounts\Models;
use App\Domains\Accounts\Notifications\MailResetPasswordNotification;
use App\Domains\Catalog\Models\Item;
use App\Domains\Contacts\Models\Address;
use App\Domains\Contacts\Models\Customer;
use App\Domains\Metadata\Concerns\HasCustomFields;
use App\Domains\Money\Models\Currency;
use App\Domains\Purchases\Models\Expense;
use App\Domains\Receivables\Models\Payment;
use App\Domains\Sales\Models\Estimate;
use App\Domains\Sales\Models\Invoice;
use App\Domains\Sales\Models\RecurringInvoice;
use App\Support\SafeOrderBy;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Schema;
use Laravel\Sanctum\HasApiTokens;
use Silber\Bouncer\Database\HasRolesAndAbilities;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
/**
* A staff account.
*
* Staff reach the application through one or more companies joined by the
* `user_company` pivot, and what they may do inside each of them comes from
* Bouncer roles scoped to that company. The pre-Bouncer `role` column has one
* job left: flagging the platform administrator. Ownership is positional, read
* from the active company's owner column rather than from any flag stored here.
*/
class User extends Authenticatable implements HasMedia
{
use HasApiTokens;
use HasCustomFields;
use HasFactory;
use HasRolesAndAbilities;
use InteractsWithMedia;
use Notifiable;
protected $table = 'users';
/**
* Everything but the primary key may be mass assigned.
*
* @var array
*/
protected $guarded = [
'id',
];
/**
* Secrets stripped from every serialized payload.
*
* @var array
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* @var array
*/
protected $with = [
'currency',
];
/**
* Computed attributes, listed in the order they are serialized.
*
* @var array
*/
protected $appends = [
'formattedCreatedAt',
'avatar',
];
/**
* A staff account keeps a single avatar image on the public disk.
*/
public function registerMediaCollections(): void
{
$this->addMediaCollection('admin_avatar')->useDisk('public')->singleFile();
}
/*
|--------------------------------------------------------------------------
| Relationships
|--------------------------------------------------------------------------
*/
/**
* Preferred currency, eager loaded on every query.
*/
public function currency(): BelongsTo
{
return $this->belongsTo(Currency::class, 'currency_id');
}
/**
* Whoever created this account, when it was not self-registered.
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'creator_id');
}
/**
* Companies this account is a member of.
*/
public function companies(): BelongsToMany
{
return $this->belongsToMany(Company::class, 'user_company', 'user_id', 'company_id');
}
/**
* Per-user preference rows, addressed by their `key` column.
*/
public function settings(): HasMany
{
return $this->hasMany(UserSetting::class, 'user_id');
}
/**
* Contacts this account authored.
*/
public function customers(): HasMany
{
return $this->hasMany(Customer::class, 'creator_id');
}
/**
* Catalog entries this account authored.
*/
public function items(): HasMany
{
return $this->hasMany(Item::class, 'creator_id');
}
/**
* Estimates this account authored.
*/
public function estimates(): HasMany
{
return $this->hasMany(Estimate::class, 'creator_id');
}
/**
* Invoices this account authored.
*/
public function invoices(): HasMany
{
return $this->hasMany(Invoice::class, 'creator_id');
}
/**
* Recurring invoice schedules this account authored.
*/
public function recurringInvoices(): HasMany
{
return $this->hasMany(RecurringInvoice::class, 'creator_id');
}
/**
* Payments this account recorded.
*/
public function payments(): HasMany
{
return $this->hasMany(Payment::class, 'creator_id');
}
/**
* Expenses this account recorded.
*/
public function expenses(): HasMany
{
return $this->hasMany(Expense::class, 'creator_id');
}
/**
* Every postal address filed against this account.
*/
public function addresses(): HasMany
{
return $this->hasMany(Address::class);
}
/**
* The address flagged for billing.
*/
public function billingAddress(): HasOne
{
return $this->hasOne(Address::class)->where('type', Address::BILLING_TYPE);
}
/**
* The address flagged for shipping.
*/
public function shippingAddress(): HasOne
{
return $this->hasOne(Address::class)->where('type', Address::SHIPPING_TYPE);
}
/*
|--------------------------------------------------------------------------
| Accessors and mutators
|--------------------------------------------------------------------------
*/
/**
* Hash a password on assignment.
*
* A blank value is skipped so that saving a form which left the field empty
* keeps the hash already on file.
*/
public function setPasswordAttribute(string $value): void
{
if ($value === '') {
return;
}
$this->attributes['password'] = bcrypt($value);
}
/**
* Public URL of the avatar, or the number zero when none is attached.
*/
public function getAvatarAttribute()
{
$image = $this->getMedia('admin_avatar')->first();
return $image ? asset($image->getUrl()) : 0;
}
/**
* Signup timestamp rendered with the date format of the company the
* request is acting on.
*/
public function getFormattedCreatedAtAttribute($value)
{
return Carbon::parse($this->created_at)->format($this->contextDateFormat());
}
/*
|--------------------------------------------------------------------------
| Query scopes
|--------------------------------------------------------------------------
*/
/**
* Sort by a caller-supplied column, sanitised before it reaches SQL.
*/
public function scopeWhereOrder($query, $orderByField, $orderBy)
{
return SafeOrderBy::apply($query, $orderByField, $orderBy, 'created_at');
}
/**
* Keep only accounts matching every whitespace-separated term, a term
* counting as matched when it turns up in the name, the email or the phone.
*/
public function scopeWhereSearch($query, $search)
{
$terms = explode(' ', $search);
foreach ($terms as $term) {
$needle = self::wildcard($term);
$query->where(function ($match) use ($needle) {
$match->where('name', 'LIKE', $needle)
->orWhere('email', 'LIKE', $needle)
->orWhere('phone', 'LIKE', $needle);
});
}
}
/**
* Partial match on the contact person.
*/
public function scopeWhereContactName($query, $contactName)
{
return $query->where('contact_name', 'LIKE', self::wildcard($contactName));
}
/**
* Partial match on the name the account is displayed under.
*/
public function scopeWhereDisplayName($query, $displayName)
{
return $query->where('name', 'LIKE', self::wildcard($displayName));
}
/**
* Partial match on the phone number.
*/
public function scopeWherePhone($query, $phone)
{
return $query->where('phone', 'LIKE', self::wildcard($phone));
}
/**
* Partial match on the email address.
*/
public function scopeWhereEmail($query, $email)
{
return $query->where('email', 'LIKE', self::wildcard($email));
}
/**
* Keep only members of the company the request is acting on.
*/
public function scopeWhereCompany($query)
{
$company = request()->header('company');
return $query->whereHas('companies', function ($membership) use ($company) {
$membership->where('company_id', $company);
});
}
/**
* Widen a listing to also take in the platform administrator.
*/
public function scopeWhereSuperAdmin($query)
{
$query->orWhere('role', 'super admin');
}
/**
* Return the whole result set for the sentinel limit "all", otherwise a
* page of the requested size.
*/
public function scopePaginateData($query, $limit)
{
return $limit == 'all' ? $query->get() : $query->paginate($limit);
}
/**
* Run every listed filter that carries a value.
*/
public function scopeApplyFilters($query, array $filters)
{
$scopes = [
'search' => 'whereSearch',
'display_name' => 'whereDisplayName',
'email' => 'whereEmail',
'phone' => 'wherePhone',
];
foreach ($scopes as $filter => $scope) {
$value = $filters[$filter] ?? null;
if ($value) {
$query->{$scope}($value);
}
}
$role = $filters['role'] ?? null;
if ($role) {
$query->whereHas('roles', function ($assigned) use ($role) {
$assigned->where('roles.id', $role);
});
}
$sortField = $filters['orderByField'] ?? null;
$sortDirection = $filters['orderBy'] ?? null;
if ($sortField || $sortDirection) {
$query->whereOrder($sortField ?: 'name', $sortDirection ?: 'asc');
}
}
/**
* Restrict to accounts who authored an invoice inside a date range, when
* the caller supplied both ends of it.
*/
public function scopeApplyInvoiceFilters($query, array $filters)
{
$from = $filters['from_date'] ?? null;
$to = $filters['to_date'] ?? null;
if ($from && $to) {
$query->invoicesBetween(
Carbon::createFromFormat('Y-m-d', $from),
Carbon::createFromFormat('Y-m-d', $to)
);
}
}
/**
* Restrict to accounts holding at least one invoice dated inside the
* inclusive range.
*/
public function scopeInvoicesBetween($query, $start, $end)
{
$range = [$start->format('Y-m-d'), $end->format('Y-m-d')];
$query->whereHas('invoices', function ($invoices) use ($range) {
$invoices->whereBetween('invoice_date', $range);
});
}
/*
|--------------------------------------------------------------------------
| Settings
|--------------------------------------------------------------------------
*/
/**
* Write a batch of preferences, replacing the value of any key already on
* file and inserting the rest.
*/
public function setSettings(array $settings): void
{
foreach ($settings as $option => $value) {
$this->settings()->updateOrCreate(['key' => $option], ['key' => $option, 'value' => $value]);
}
}
/**
* Every preference on file for this account, keyed by setting name.
*/
public function getAllSettings(): Collection
{
return $this->flattenSettings($this->settings()->get());
}
/**
* The named preferences only; keys with no row on file are left out.
*/
public function getSettings(array $settings): Collection
{
return $this->flattenSettings($this->settings()->whereIn('key', $settings)->get());
}
/*
|--------------------------------------------------------------------------
| Identity and access
|--------------------------------------------------------------------------
*/
/**
* Resolve an account from the identifier a token grant was asked for.
*/
public function findForPassport(string $username): ?self
{
return $this->newQuery()->where('email', $username)->first();
}
/**
* Start a session from a request-like object carrying the credentials.
*/
public static function login(object $request): bool
{
$credentials = [
'email' => $request->email,
'password' => $request->password,
];
return Auth::attempt($credentials, $request->remember);
}
/**
* Deliver a password reset link pointing at the SPA reset screen.
*/
public function sendPasswordResetNotification($token)
{
$notification = new MailResetPasswordNotification($token);
$this->notify($notification);
}
/**
* Whether this account is the platform administrator.
*/
public function isSuperAdmin(): bool
{
return $this->role === 'super admin';
}
/**
* Whether the pre-Bouncer `role` column marks this account as privileged.
*/
public function isSuperAdminOrAdmin(): bool
{
return $this->hasLegacyAdminRole();
}
/**
* Whether this account is a member of the given company.
*/
public function hasCompany(int $company_id): bool
{
return $this->companies()->pluck('company_id')->contains($company_id);
}
/**
* Whether this account owns the company the request is acting on.
*
* Ownership is positional: it is read from the company's owner column, so
* transferring ownership flips authorization immediately. Installs that
* have not run the migration adding that column fall back to the old role
* strings.
*/
public function isOwner(): bool
{
if (! Schema::hasColumn('companies', 'owner_id')) {
return $this->hasLegacyAdminRole();
}
$active = Company::find(request()->header('company'));
return $active && $this->id == $active->owner_id;
}
/**
* Decide whether a navigation entry's requirements are met.
*
* Entries reserved for the platform administrator are settled by that gate
* alone. Owners of the active company clear everything else. Everyone else
* needs the named ability, checked against the entry's subject model first
* and against the bare ability afterwards; an entry naming no ability at
* all is open.
*/
public function checkAccess(object $data): bool
{
$meta = $data->data;
if (! empty($meta['super_admin_only'])) {
return $this->isSuperAdmin();
}
if ($this->isOwner()) {
return true;
}
if ($meta['owner_only']) {
return false;
}
if (empty($meta['ability'])) {
return true;
}
if (! empty($meta['model']) && $this->can($meta['ability'], $meta['model'])) {
return true;
}
return $this->can($meta['ability']);
}
/*
|--------------------------------------------------------------------------
| Internals
|--------------------------------------------------------------------------
*/
/**
* Date format of the company the request is acting on, falling back to the
* first company this account belongs to and to an ISO-style date when it
* belongs to none.
*/
private function contextDateFormat(): mixed
{
$scope = request()->header('company');
$configured = $scope
&& CompanySetting::query()->where('company_id', $scope)->exists();
if (! $configured) {
$home = $this->companies()->first();
if (! $home) {
return 'Y-m-d';
}
$scope = $home->id;
}
return CompanySetting::getSetting('carbon_date_format', $scope);
}
/**
* The pre-Bouncer administrator test, kept for installs whose companies
* table has not gained its ownership column yet.
*/
private function hasLegacyAdminRole(): bool
{
return in_array($this->role, ['super admin', 'admin']);
}
/**
* Reduce preference rows to a name => value collection.
*/
private function flattenSettings(Collection $rows): Collection
{
return $rows->mapWithKeys(function ($row) {
return [$row['key'] => $row['value']];
});
}
/**
* Wrap a term for a substring LIKE comparison.
*/
private static function wildcard($term): string
{
return '%'.$term.'%';
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Accounts\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* One preference belonging to a staff account.
*
* The store mirrors the company one, addressed by a `key` column instead of an
* `option` column. The sentinel value "default" on the `language` key means
* "follow the company", so promoting a member never freezes a copy of the
* inviter's language.
*/
class UserSetting extends Model
{
use HasFactory;
protected $table = 'user_settings';
protected $guarded = ['id'];
/**
* Account this preference belongs to.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Domains\Accounts\Notifications;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
/**
* The password reset mail sent to staff accounts.
*
* The framework's own version links to a named route; this one points at the
* SPA reset screen and quotes the configured token lifetime.
*/
class MailResetPasswordNotification extends ResetPassword
{
use Queueable;
/**
* Carry the reset token through to the parent notification.
*
* @return void
*/
public function __construct($token)
{
parent::__construct($token);
}
/**
* This notification goes out over mail only.
*/
public function via($notifiable): array
{
return ['mail'];
}
/**
* Build the reset mail, linking at the SPA screen that takes the token.
*/
public function toMail($notifiable): MailMessage
{
$resetUrl = url('/reset-password/'.$this->token);
return (new MailMessage)
->subject('Reset Password Notification')
->line('Hello! You are receiving this email because we received a password reset request for your account.')
->action('Reset Password', $resetUrl)
->line('This password reset link will expire in '.config('auth.passwords.users.expire').' minutes')
->line('If you did not request a password reset, no further action is required.');
}
/**
* Nothing is stored for the database channel.
*/
public function toArray($notifiable): array
{
return [
//
];
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Domains\Accounts\Policies;
use App\Domains\Accounts\Models\Company;
use App\Domains\Accounts\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
/**
* Who may bring companies into being, hand them over, or wind them up.
*
* Two different questions are being asked here, and they do not agree with
* each other. Creation looks at the company the request header points at:
* whoever owns *that* company may open another one, which means an ordinary
* member who owns nothing anywhere is refused. Transfer and deletion instead
* look at the company being acted on and want the actor to be its recorded
* owner, header or no header.
*
* Nothing in either question consults the platform administrator flag that
* account gets no shortcut past these.
*/
class CompanyPolicy
{
use HandlesAuthorization;
/**
* Opening a new company.
*
* Judged against the active company, not the one about to exist.
*/
public function create(User $user): bool
{
return $user->isOwner();
}
/**
* Winding a company up.
*/
public function delete(User $user, Company $company): bool
{
return $this->ownsOutright($user, $company);
}
/**
* Handing a company to somebody else.
*
* Declared without a return type, as found.
*/
public function transferOwnership(User $user, Company $company)
{
return $this->ownsOutright($user, $company);
}
/**
* The actor is the company's recorded owner.
*
* Compared loosely, so an owner column holding a numeric string still
* matches the id it names.
*/
private function ownsOutright(User $user, Company $company): bool
{
return $user->id == $company->owner_id;
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Domains\Accounts\Policies;
use App\Domains\Accounts\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
/**
* The bare owner check, reached as a gate that takes no subject.
*
* Callers that guard something belonging to the active company but have no
* row to hand over ask this one. It resolves to ownership of the company in
* the `company` header and nothing else: no ability is consulted, and the
* platform administrator is not waved through.
*/
class OwnerPolicy
{
use HandlesAuthorization;
/**
* Declared without a return type, as found.
*/
public function managedByOwner(User $user)
{
return $user->isOwner();
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Domains\Accounts\Policies;
use App\Domains\Accounts\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
use Silber\Bouncer\Database\Role;
/**
* Who may work with per-company roles.
*
* One question answers all seven entries: does the actor own the company named
* in the `company` header? There is no second half here. Where a role is
* handed in it is never looked at, so the role's own scope plays no part in
* the decision confining a role to its company is left to the scoping that
* Bouncer applies while the query runs, not to this class.
*/
class RolePolicy
{
use HandlesAuthorization;
/**
* Browsing the roles of the active company.
*/
public function viewAny(User $user): bool
{
return $user->isOwner();
}
/**
* Reading one role. The role itself is not examined.
*/
public function view(User $user, Role $role): bool
{
return $user->isOwner();
}
/**
* Defining a role.
*/
public function create(User $user): bool
{
return $user->isOwner();
}
/**
* Renaming a role or resyncing its abilities.
*/
public function update(User $user, Role $role): bool
{
return $user->isOwner();
}
/**
* Dropping a role. Whether anybody still holds it is settled downstream,
* not here.
*/
public function delete(User $user, Role $role): bool
{
return $user->isOwner();
}
/**
* Bringing a role back unreachable, as roles are not soft-deleted.
*/
public function restore(User $user, Role $role): bool
{
return $user->isOwner();
}
/**
* Erasing a role for good unreachable for the same reason.
*/
public function forceDelete(User $user, Role $role): bool
{
return $user->isOwner();
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Accounts\Policies;
use App\Domains\Accounts\Models\Company;
use App\Domains\Accounts\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
/**
* Who may rewrite a company's own record and its settings.
*
* The question is positional and asked of the company being edited: is this
* actor the account recorded against it as owner? Holding an ability, or being
* the platform administrator, buys nothing here. The comparison is loose, so a
* numeric string in the owner column still matches the id it names.
*/
class SettingsPolicy
{
use HandlesAuthorization;
/**
* Editing the company profile, its settings, or its mail configuration.
*
* Declared without a return type, as found.
*/
public function manageCompany(User $user, Company $company)
{
return $user->id == $company->owner_id;
}
}
@@ -0,0 +1,139 @@
<?php
namespace App\Domains\Accounts\Policies;
use App\Domains\Accounts\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
/**
* Who may administer staff accounts.
*
* Ownership here is positional and evaluated per request: the actor counts as
* an owner only while the company named in the `company` header is the company
* they own, so the same person is an owner on one call and nobody on the next.
*
* Decisions aimed at an existing account carry a second half the account has
* to sit inside that same header company which keeps the owner of one tenant
* from reading or overwriting an account that lives in another. Only view,
* update and delete carry that half; the remaining entries stop after the
* ownership question, which is noted where it happens.
*/
class UserPolicy
{
use HandlesAuthorization;
/**
* Browsing the member list.
*/
public function viewAny(User $user): bool
{
return $user->isOwner();
}
/**
* Reading one member.
*/
public function view(User $user, User $model): bool
{
return $this->mayActOn($user, $model);
}
/**
* Adding a member.
*
* No target exists yet, so ownership of the header company is the whole
* decision.
*/
public function create(User $user): bool
{
return $user->isOwner();
}
/**
* Editing one member.
*/
public function update(User $user, User $model): bool
{
return $this->mayActOn($user, $model);
}
/**
* Removing one member.
*
* Nothing routes here today member removal arrives through the bulk gate
* below but the tenant half is applied all the same.
*/
public function delete(User $user, User $model): bool
{
return $this->mayActOn($user, $model);
}
/**
* Bringing back a removed member.
*
* Unreachable: accounts are erased outright rather than soft-deleted. Note
* that the target is ignored, so ownership alone would answer this.
*/
public function restore(User $user, User $model): bool
{
return $user->isOwner();
}
/**
* Erasing a member for good.
*
* Unreachable for the same reason, and likewise blind to the target.
*/
public function forceDelete(User $user, User $model): bool
{
return $user->isOwner();
}
/**
* Inviting a member.
*
* Nothing calls this. The declaration is kept as found, return type
* included that is, without one and the target goes unexamined.
*/
public function invite(User $user, User $model)
{
return $user->isOwner();
}
/**
* Removing members in bulk.
*
* Reached as a gate rather than through a model, so there is no target to
* confine: ownership of the header company opens the whole operation, and
* the ids it is handed are resolved installation-wide.
*/
public function deleteMultiple(User $user)
{
return $user->isOwner();
}
/**
* Both halves: own the header company, and have the target inside it.
*/
private function mayActOn(User $user, User $target): bool
{
return $user->isOwner() && $this->isMemberOfActiveCompany($target);
}
/**
* Membership of the company carried by the request header.
*
* Without this the target would be looked up by installation-wide id, and
* one company's owner could reach another company's people.
*/
private function isMemberOfActiveCompany(User $target): bool
{
$activeCompanyId = request()->header('company');
if (! $activeCompanyId) {
return false;
}
return $target->companies()->whereKey($activeCompanyId)->exists();
}
}