256 lines
9.4 KiB
PHP
256 lines
9.4 KiB
PHP
<?php
|
|||
|
|
|
||
|
|
namespace Modules\Core\Customer\Services;
|
||
|
|
|
||
|
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||
|
|
use Illuminate\Support\Arr;
|
||
|
|
use Illuminate\Support\Facades\Event;
|
||
|
|
use Lunar\Models\Address;
|
||
|
|
use Lunar\Models\Order;
|
||
|
|
use LogicException;
|
||
|
|
use Modules\Core\Customer\Events\CustomerAddressCreated;
|
||
|
|
use Modules\Core\Customer\Events\CustomerAddressDeleted;
|
||
|
|
use Modules\Core\Customer\Events\CustomerAddressUpdated;
|
||
|
|
use Modules\Core\Customer\Events\CustomerProfileUpdated;
|
||
|
|
use Modules\Core\Customer\Exceptions\AddressNotFoundException;
|
||
|
|
use Modules\Core\Customer\Exceptions\OrderNotFoundException;
|
||
|
|
use Modules\Core\Customer\Models\Customer;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The storefront-facing "My Account" API — mirrors Modules\Core\Cart\
|
||
|
|
* Services\CartService's shape, one boboko-owned service a storefront
|
||
|
|
* calls, so Lunar's own Customer/Order/Address models stay an
|
||
|
|
* implementation detail. Every method is scoped to the given
|
||
|
|
* Authenticatable's own Customer::latestCustomer() (see docs/modules.md
|
||
|
|
* "Customer/User Pairing") — there is no method here that accepts a bare
|
||
|
|
* order/address id without also requiring the owning user, precisely so
|
||
|
|
* a controller built on top of this can't accidentally leak one
|
||
|
|
* customer's data to another by trusting a client-supplied id alone.
|
||
|
|
*
|
||
|
|
* $user->latestCustomer() can be null for a User that has no paired
|
||
|
|
* Customer yet (shouldn't happen via the normal OTP-login cascade — see
|
||
|
|
* Modules\Core\Auth\Events\UserCreated — but is defended against anyway,
|
||
|
|
* since nothing stops a User row existing without one, e.g. seeded data)
|
||
|
|
* — every method returns an empty/null result rather than throwing in
|
||
|
|
* that case, since "no customer paired yet" isn't a not-found error, it's
|
||
|
|
* a legitimately empty account.
|
||
|
|
*
|
||
|
|
* Address/profile writes go through an explicit column allowlist
|
||
|
|
* (WRITABLE_ADDRESS_FIELDS/WRITABLE_PROFILE_FIELDS) rather than trusting
|
||
|
|
* Lunar\Models\Address/Customer's own $guarded = [] — that flag makes
|
||
|
|
* every column mass-assignable at the model layer, including
|
||
|
|
* customer_id on addresses, so a caller passing through an unfiltered
|
||
|
|
* request array (a real risk for a storefront controller built directly
|
||
|
|
* against this service) could otherwise reassign an address to a
|
||
|
|
* different customer entirely, or overwrite created_at/id. Arr::only()
|
||
|
|
* silently drops anything not on the allowlist rather than erroring —
|
||
|
|
* this is a safety boundary, not form validation (a storefront still
|
||
|
|
* validates its own request shape before calling this).
|
||
|
|
*
|
||
|
|
* Authorization here IS the ownership scoping itself, not a separate
|
||
|
|
* layer bolted on top — there is deliberately no Laravel Policy/Gate
|
||
|
|
* class for Order/Address, since a policy is meaningless without a
|
||
|
|
* controller calling authorize() against it, and this branch is scoped
|
||
|
|
* to backend services only (no routes/controllers — see the branch's own
|
||
|
|
* commit history). Every public method below takes Authenticatable $user
|
||
|
|
* as a required first argument and resolves everything else (Order,
|
||
|
|
* Address, Customer) strictly through that user's own
|
||
|
|
* latestCustomer() — there is no method that looks anything up by a bare
|
||
|
|
* id alone. A future storefront controller cannot "forget" the
|
||
|
|
* authorization check the way it could with a separate policy class,
|
||
|
|
* because the check IS how every lookup happens; skipping it isn't an
|
||
|
|
* option the method signatures allow.
|
||
|
|
*/
|
||
|
|
class CustomerAccountService
|
||
|
|
{
|
||
|
|
private const WRITABLE_ADDRESS_FIELDS = [
|
||
|
|
'title', 'first_name', 'last_name', 'company_name',
|
||
|
|
'line_one', 'line_two', 'line_three', 'city', 'state', 'postcode',
|
||
|
|
'delivery_instructions', 'contact_email', 'contact_phone',
|
||
|
|
'country_id', 'shipping_default', 'billing_default',
|
||
|
|
];
|
||
|
|
|
||
|
|
private const WRITABLE_PROFILE_FIELDS = [
|
||
|
|
'title', 'first_name', 'last_name', 'company_name', 'vat_no',
|
||
|
|
];
|
||
|
|
|
||
|
|
public function customer(Authenticatable $user): ?Customer
|
||
|
|
{
|
||
|
|
/** @var Customer|null */
|
||
|
|
return $user->latestCustomer();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Placed orders only (placed_at IS NOT NULL) — a draft/abandoned
|
||
|
|
* order with no placed_at is checkout-in-progress state, not
|
||
|
|
* something that belongs in order history.
|
||
|
|
*/
|
||
|
|
public function orders(Authenticatable $user, int $perPage = 15): LengthAwarePaginator
|
||
|
|
{
|
||
|
|
$customer = $this->customer($user);
|
||
|
|
|
||
|
|
if (! $customer) {
|
||
|
|
return new LengthAwarePaginator([], 0, $perPage);
|
||
|
|
}
|
||
|
|
|
||
|
|
return $customer->orders()
|
||
|
|
->whereNotNull('placed_at')
|
||
|
|
->latest('placed_at')
|
||
|
|
->paginate($perPage);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @throws OrderNotFoundException if $orderId doesn't belong to this
|
||
|
|
* customer, or belongs to a draft (never placed) order
|
||
|
|
*/
|
||
|
|
public function order(Authenticatable $user, int $orderId): Order
|
||
|
|
{
|
||
|
|
$customer = $this->customer($user);
|
||
|
|
|
||
|
|
$order = $customer
|
||
|
|
?->orders()
|
||
|
|
->whereNotNull('placed_at')
|
||
|
|
->with(['lines', 'shippingAddress', 'billingAddress', 'transactions', 'shipments'])
|
||
|
|
->find($orderId);
|
||
|
|
|
||
|
|
if (! $order) {
|
||
|
|
throw new OrderNotFoundException;
|
||
|
|
}
|
||
|
|
|
||
|
|
return $order;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function addresses(Authenticatable $user): iterable
|
||
|
|
{
|
||
|
|
$customer = $this->customer($user);
|
||
|
|
|
||
|
|
return $customer?->addresses ?? collect();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param array<string, mixed> $data Any key not in
|
||
|
|
* WRITABLE_ADDRESS_FIELDS is silently dropped — see this class's
|
||
|
|
* own docblock.
|
||
|
|
*/
|
||
|
|
public function createAddress(Authenticatable $user, array $data): Address
|
||
|
|
{
|
||
|
|
$customer = $this->customerOrFail($user);
|
||
|
|
|
||
|
|
$address = $customer->addresses()->create(Arr::only($data, self::WRITABLE_ADDRESS_FIELDS));
|
||
|
|
|
||
|
|
$this->enforceSingleDefault($customer, $address);
|
||
|
|
$address->refresh();
|
||
|
|
|
||
|
|
Event::dispatch(new CustomerAddressCreated($address, $user));
|
||
|
|
|
||
|
|
return $address;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @throws AddressNotFoundException if $addressId doesn't belong to
|
||
|
|
* this customer
|
||
|
|
*/
|
||
|
|
public function updateAddress(Authenticatable $user, int $addressId, array $data): Address
|
||
|
|
{
|
||
|
|
$address = $this->ownedAddress($user, $addressId);
|
||
|
|
$old = $address->only(array_keys(Arr::only($data, self::WRITABLE_ADDRESS_FIELDS)));
|
||
|
|
|
||
|
|
$address->update(Arr::only($data, self::WRITABLE_ADDRESS_FIELDS));
|
||
|
|
|
||
|
|
$this->enforceSingleDefault($address->customer, $address);
|
||
|
|
$address->refresh();
|
||
|
|
|
||
|
|
Event::dispatch(new CustomerAddressUpdated($address, $old, $user));
|
||
|
|
|
||
|
|
return $address;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @throws AddressNotFoundException if $addressId doesn't belong to
|
||
|
|
* this customer
|
||
|
|
*/
|
||
|
|
public function deleteAddress(Authenticatable $user, int $addressId): void
|
||
|
|
{
|
||
|
|
$address = $this->ownedAddress($user, $addressId);
|
||
|
|
$snapshot = $address->getAttributes();
|
||
|
|
|
||
|
|
$address->delete();
|
||
|
|
|
||
|
|
Event::dispatch(new CustomerAddressDeleted($snapshot, $user));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Lunar has no built-in action enforcing "at most one shipping
|
||
|
|
* default / one billing default per customer" — a raw update() could
|
||
|
|
* otherwise leave two addresses both flagged shipping_default. Runs
|
||
|
|
* after every create/update, unconditionally (cheap — at most two
|
||
|
|
* single-row UPDATEs, only fired when the just-written address
|
||
|
|
* itself is a default), clearing the flag on every OTHER address of
|
||
|
|
* the same customer.
|
||
|
|
*/
|
||
|
|
private function enforceSingleDefault(Customer $customer, Address $address): void
|
||
|
|
{
|
||
|
|
if ($address->shipping_default) {
|
||
|
|
$customer->addresses()->where('id', '!=', $address->id)->update(['shipping_default' => false]);
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($address->billing_default) {
|
||
|
|
$customer->addresses()->where('id', '!=', $address->id)->update(['billing_default' => false]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @throws AddressNotFoundException if $addressId doesn't belong to
|
||
|
|
* this customer
|
||
|
|
*/
|
||
|
|
private function ownedAddress(Authenticatable $user, int $addressId): Address
|
||
|
|
{
|
||
|
|
$customer = $this->customer($user);
|
||
|
|
|
||
|
|
$address = $customer?->addresses()->find($addressId);
|
||
|
|
|
||
|
|
if (! $address) {
|
||
|
|
throw new AddressNotFoundException;
|
||
|
|
}
|
||
|
|
|
||
|
|
return $address;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param array<string, mixed> $data Any key not in
|
||
|
|
* WRITABLE_PROFILE_FIELDS is silently dropped — see this class's
|
||
|
|
* own docblock.
|
||
|
|
*/
|
||
|
|
public function updateProfile(Authenticatable $user, array $data): Customer
|
||
|
|
{
|
||
|
|
$customer = $this->customerOrFail($user);
|
||
|
|
$old = $customer->only(array_keys(Arr::only($data, self::WRITABLE_PROFILE_FIELDS)));
|
||
|
|
|
||
|
|
$customer->update(Arr::only($data, self::WRITABLE_PROFILE_FIELDS));
|
||
|
|
$customer->refresh();
|
||
|
|
|
||
|
|
Event::dispatch(new CustomerProfileUpdated($customer, $old, $user));
|
||
|
|
|
||
|
|
return $customer;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @throws LogicException if $user has no paired Customer at all —
|
||
|
|
* distinct from AddressNotFoundException/OrderNotFoundException
|
||
|
|
* (which mean "this id isn't yours"), this means the account
|
||
|
|
* itself is in an invariant-violating state the normal OTP-login
|
||
|
|
* cascade should never produce.
|
||
|
|
*/
|
||
|
|
private function customerOrFail(Authenticatable $user): Customer
|
||
|
|
{
|
||
|
|
$customer = $this->customer($user);
|
||
|
|
|
||
|
|
if (! $customer) {
|
||
|
|
throw new LogicException('This user has no paired Customer record.');
|
||
|
|
}
|
||
|
|
|
||
|
|
return $customer;
|
||
|
|
}
|
||
|
|
}
|