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 $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 $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; } }