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', 'tax_identifier', ]; 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.shipmentInfo']) ->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; } /** * The account's standing "email me a reminder if I don't finish my * order" opt-in — same meta shape Checkout\Services\CheckoutService:: * setRecoveryConsent() writes on the current CART (recovery_consent, * recovery_consent_at, recovery_consent_policy_version), written here * onto the CUSTOMER instead, so it survives across carts/sessions as a * standing account preference. The two are independent: opting out on * the customer doesn't retroactively change a cart already opted in, * and vice versa — a caller that wants both kept in sync (e.g. 3dealer * applying a customer's standing preference to the current cart too) * calls both services itself. * * A no-op (no write, no event) when $consent already matches what's * stored — unlike updateProfile()'s address/profile writes, which * always write and dispatch even when nothing actually changed. */ public function setRecoveryConsent(Authenticatable $user, bool $consent): Customer { $customer = $this->customerOrFail($user); if ((bool) data_get($customer->meta, 'recovery_consent') === $consent) { return $customer; } $customer->meta = [ ...($customer->meta?->toArray() ?? []), 'recovery_consent' => $consent, 'recovery_consent_at' => $consent ? now()->toIso8601String() : null, 'recovery_consent_policy_version' => $consent ? config('legal.privacy_policy_version') : null, ]; $customer->save(); Event::dispatch(new CustomerRecoveryConsentSet($customer, $consent, $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; } }