diff --git a/README.md b/README.md new file mode 100644 index 0000000..80cc8c3 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +Your mission is to implement an entire shop system based on the specifications im specs/*. +Distribute work to sub-agents. Keep the context of the main agent clean. +You must do in one go without stopping. +You must use team mode! +You must test everything via Pest (unit, and functional tests). +You must also additional simulate user behaviour using the Playwright MPC and confirm that all acceptance criterias are met. +If you find bugs, you must fix them. +The result is a perfect shop system. All requirements are perfectly implemented. All acceptance criterias are met, tested and confirmed by you. + +Continuously keep track of the progress in specs/progress.md +Commit your progress after every relevant iteration with a meaningful message. + +When implementation is fully done, then make a full review meeting with Playwright in Chrome and showcase all features (customer- and admin-side) to me. In case bugs appear, you must fix them all and restart the review meeting. Shop is running at http://shop.test/. + +Don't re-use any existing implementation in another branch. Build it from scratch. + +You are writing production PHP (Laravel) code. + +STRICT CONSTRAINTS: + +1. PHPStan Compliance +- Must pass PHPStan at max level. +- No mixed. +- Explicit return types everywhere. +- Fully typed properties. +- No dynamic properties. +- No suppressed errors. +- No relying on docblocks to hide real type problems. + +2. Deptrac Compliance +- Respect architectural layers. +- No cross-layer violations. +- No circular dependencies. +- If a dependency is required, introduce an interface in the correct layer. +- Do not modify architecture unless explicitly instructed. + +3. Pest Testing (Mandatory) +- Every feature must include automated tests. +- Include both unit and integration tests when appropriate. +- Cover success path and failure paths. +- Cover edge cases. +- Tests must be deterministic. +- Tests must validate behavior, not implementation details. + +4. QA Self-Verification (Mandatory) + Before finalizing: +- List each acceptance criterion. +- Explicitly confirm how it is implemented. +- Explicitly confirm which test covers it. +- Validate edge cases. +- Validate negative paths. +- Ensure no undefined behavior exists. + +5. Fresh Agent Review (Mandatory) + +After implementation: + +Step 1: A NEW agent instance must review the code. +The reviewer must: +- Ignore prior reasoning. +- Re-evaluate architecture. +- Re-evaluate PHPStan compliance. +- Re-evaluate Deptrac boundaries. +- Re-evaluate test coverage. +- Act as a strict senior reviewer. + +Step 2: The reviewer must: +- Identify weaknesses. +- Identify overengineering. +- Identify missing edge cases. +- Identify architectural drift. +- Suggest concrete improvements. + +Step 3: +If issues are found: +- Fix them. +- Run review again with another fresh agent. +- Repeat until no critical issues remain. + +No feature is complete without independent review. diff --git a/app/Auth/CustomerUserProvider.php b/app/Auth/CustomerUserProvider.php new file mode 100644 index 0000000..501670a --- /dev/null +++ b/app/Auth/CustomerUserProvider.php @@ -0,0 +1,169 @@ +newScopedQuery() + ->where('id', $identifier) + ->first(); + + return $this->getGenericUser($user); + } + + public function retrieveByToken($identifier, $token) + { + $user = $this->getGenericUser( + $this->newScopedQuery()->where('id', $identifier)->first(), + ); + + return $user && $user->getRememberToken() && hash_equals($user->getRememberToken(), (string) $token) + ? $user + : null; + } + + public function updateRememberToken(UserContract $user, $token): void + { + try { + $this->newScopedQuery() + ->where($user->getAuthIdentifierName(), $user->getAuthIdentifier()) + ->update([$user->getRememberTokenName() => $token]); + } catch (\Throwable) { + // Customer remember tokens are optional in early schema iterations. + } + } + + /** + * @param array $credentials + */ + public function retrieveByCredentials(array $credentials) + { + $credentials = array_filter( + $credentials, + static fn (string $key): bool => ! str_contains($key, 'password'), + ARRAY_FILTER_USE_KEY, + ); + + if ($credentials === []) { + return null; + } + + $query = $this->newScopedQuery(); + + foreach ($credentials as $key => $value) { + if (! is_string($key)) { + continue; + } + + if (is_array($value) || $value instanceof Arrayable) { + $query->whereIn($key, $value); + } elseif ($value instanceof Closure) { + $value($query); + } else { + $query->where($key, $value); + } + } + + return $this->getGenericUser($query->first()); + } + + /** + * @param array $credentials + */ + public function rehashPasswordIfRequired(UserContract $user, array $credentials, bool $force = false): void + { + if (! is_string($credentials['password'] ?? null)) { + return; + } + + $hashedPassword = $user->getAuthPassword(); + + if (! $this->hasher->needsRehash($hashedPassword) && ! $force) { + return; + } + + $this->newScopedQuery() + ->where($user->getAuthIdentifierName(), $user->getAuthIdentifier()) + ->update(['password_hash' => $this->hasher->make($credentials['password'])]); + } + + protected function getGenericUser($user) + { + if ($user === null) { + return null; + } + + $attributes = (array) $user; + $attributes['password'] = $attributes['password_hash'] ?? null; + $attributes['remember_token'] = $attributes['remember_token'] ?? null; + + return new GenericUser($attributes); + } + + private function newScopedQuery(): Builder + { + $query = $this->connection->table($this->table); + $storeId = $this->resolveStoreId(); + + if ($storeId === null) { + return $query->whereRaw('1 = 0'); + } + + return $query->where('store_id', $storeId); + } + + private function resolveStoreId(): ?int + { + if ($this->app->bound(CurrentStore::class)) { + $store = $this->app->make(CurrentStore::class); + + if ($store instanceof CurrentStore) { + return $store->id; + } + } + + if (! $this->app->bound('current_store')) { + return null; + } + + $store = $this->app->make('current_store'); + + if ($store instanceof CurrentStore) { + return $store->id; + } + + if (is_array($store) && array_key_exists('id', $store)) { + $id = $store['id']; + + if (is_int($id) || is_float($id) || is_string($id)) { + return (int) $id; + } + } + + if (is_object($store) && isset($store->id) && (is_int($store->id) || is_float($store->id) || is_string($store->id))) { + return (int) $store->id; + } + + return null; + } +} diff --git a/app/Auth/Passwords/StoreScopedTokenRepository.php b/app/Auth/Passwords/StoreScopedTokenRepository.php new file mode 100644 index 0000000..c43b916 --- /dev/null +++ b/app/Auth/Passwords/StoreScopedTokenRepository.php @@ -0,0 +1,130 @@ +getEmailForPasswordReset(); + + $this->deleteExisting($user); + + $token = $this->createNewToken(); + + $this->getTable()->insert($this->buildPayload($email, $token, $this->resolveStoreId($user))); + + return $token; + } + + protected function deleteExisting(CanResetPasswordContract $user): int + { + return $this->getTable() + ->where('email', $user->getEmailForPasswordReset()) + ->where('store_id', $this->resolveStoreId($user)) + ->delete(); + } + + /** + * @return array{email: string, store_id: int, token: string, created_at: Carbon} + */ + protected function buildPayload(string $email, #[\SensitiveParameter] string $token, int $storeId): array + { + return [ + 'email' => $email, + 'store_id' => $storeId, + 'token' => $this->hasher->make($token), + 'created_at' => new Carbon, + ]; + } + + public function exists(CanResetPasswordContract $user, #[\SensitiveParameter] $token): bool + { + if (! is_string($token) || $token === '') { + return false; + } + + $record = $this->recordForUser($user); + + if ($record === null) { + return false; + } + + return ! $this->tokenExpired($record['created_at']) + && $this->hasher->check($token, $record['token']); + } + + public function recentlyCreatedToken(CanResetPasswordContract $user): bool + { + $record = $this->recordForUser($user); + + return $record !== null + && $this->tokenRecentlyCreated($record['created_at']); + } + + /** + * @return array{created_at: string, token: string}|null + */ + private function recordForUser(CanResetPasswordContract $user): ?array + { + $record = $this->getTable() + ->where('email', $user->getEmailForPasswordReset()) + ->where('store_id', $this->resolveStoreId($user)) + ->first(); + + if ($record === null) { + return null; + } + + $data = (array) $record; + $createdAt = $data['created_at'] ?? null; + $hashedToken = $data['token'] ?? null; + + if (! is_string($createdAt) || ! is_string($hashedToken)) { + return null; + } + + return [ + 'created_at' => $createdAt, + 'token' => $hashedToken, + ]; + } + + private function resolveStoreId(CanResetPasswordContract $user): int + { + $storeId = $user->store_id ?? null; + + if (! is_int($storeId) && ! is_float($storeId) && ! is_string($storeId)) { + throw new RuntimeException('Unable to determine store id for customer password reset token.'); + } + + return (int) $storeId; + } +} diff --git a/app/Auth/Passwords/StorefrontPasswordBrokerManager.php b/app/Auth/Passwords/StorefrontPasswordBrokerManager.php new file mode 100644 index 0000000..c98045e --- /dev/null +++ b/app/Auth/Passwords/StorefrontPasswordBrokerManager.php @@ -0,0 +1,131 @@ + $config + */ + protected function createTokenRepository(array $config) + { + if (($config['store_scoped'] ?? false) !== true) { + return parent::createTokenRepository($config); + } + + $key = $this->normalizeAppKey( + $this->config()->get('app.key'), + ); + + $connectionName = $this->nullableStringConfig($config, 'connection'); + $table = $this->stringConfig($config, 'table'); + $expireMinutes = $this->intConfig($config, 'expire', 60); + $throttleSeconds = $this->intConfig($config, 'throttle', 0); + + return new StoreScopedTokenRepository( + connection: $this->database()->connection($connectionName), + hasher: $this->hasher(), + table: $table, + hashKey: $key, + expires: $expireMinutes * 60, + throttle: $throttleSeconds, + ); + } + + private function config(): ConfigRepository + { + /** @var ConfigRepository $config */ + $config = $this->app->make('config'); + + return $config; + } + + private function database(): DatabaseManager + { + /** @var DatabaseManager $database */ + $database = $this->app->make('db'); + + return $database; + } + + private function hasher(): Hasher + { + /** @var Hasher $hasher */ + $hasher = $this->app->make('hash'); + + return $hasher; + } + + /** + * @param array $config + */ + private function stringConfig(array $config, string $key): string + { + $value = $config[$key] ?? null; + + if (! is_string($value) || trim($value) === '') { + throw new InvalidArgumentException(sprintf('Password broker config "%s" must be a non-empty string.', $key)); + } + + return $value; + } + + /** + * @param array $config + */ + private function nullableStringConfig(array $config, string $key): ?string + { + $value = $config[$key] ?? null; + + if ($value === null || $value === '') { + return null; + } + + if (! is_string($value)) { + throw new InvalidArgumentException(sprintf('Password broker config "%s" must be a string when provided.', $key)); + } + + return $value; + } + + /** + * @param array $config + */ + private function intConfig(array $config, string $key, int $default): int + { + $value = $config[$key] ?? $default; + + if (! is_int($value) && ! is_numeric($value)) { + throw new InvalidArgumentException(sprintf('Password broker config "%s" must be an integer.', $key)); + } + + return (int) $value; + } + + private function normalizeAppKey(mixed $key): string + { + if (! is_string($key) || $key === '') { + throw new InvalidArgumentException('Application key must be a non-empty string.'); + } + + if (! str_starts_with($key, 'base64:')) { + return $key; + } + + $decoded = base64_decode(substr($key, 7), true); + + if (! is_string($decoded) || $decoded === '') { + throw new InvalidArgumentException('Application key base64 payload is invalid.'); + } + + return $decoded; + } +} diff --git a/app/Enums/AppInstallationStatus.php b/app/Enums/AppInstallationStatus.php new file mode 100644 index 0000000..d4a43b4 --- /dev/null +++ b/app/Enums/AppInstallationStatus.php @@ -0,0 +1,10 @@ +id, + variantId: (int) $item->variant_id, + requestedQuantity: $requestedQuantity, + availableQuantity: (int) $item->quantity_on_hand - (int) $item->quantity_reserved, + ); + } +} diff --git a/app/Exceptions/InvalidCheckoutStateException.php b/app/Exceptions/InvalidCheckoutStateException.php new file mode 100644 index 0000000..0aea83a --- /dev/null +++ b/app/Exceptions/InvalidCheckoutStateException.php @@ -0,0 +1,131 @@ + $expectedStates + */ + public function __construct( + public readonly string $reasonCode, + public readonly int $checkoutId, + public readonly ?string $currentState, + public readonly array $expectedStates, + string $message, + ) { + parent::__construct($message); + } + + /** + * @param list $expectedStates + */ + public static function invalidTransition(Checkout $checkout, array $expectedStates): self + { + return new self( + reasonCode: 'invalid_checkout_state', + checkoutId: (int) $checkout->id, + currentState: $checkout->status?->value, + expectedStates: $expectedStates, + message: sprintf( + 'Checkout %d is in state "%s". Expected one of: %s.', + (int) $checkout->id, + $checkout->status?->value ?? 'unknown', + implode(', ', $expectedStates), + ), + ); + } + + public static function immutable(Checkout $checkout): self + { + return new self( + reasonCode: 'checkout_immutable', + checkoutId: (int) $checkout->id, + currentState: $checkout->status?->value, + expectedStates: [], + message: sprintf('Checkout %d is immutable in state "%s".', (int) $checkout->id, $checkout->status?->value ?? 'unknown'), + ); + } + + public static function emptyCart(int $cartId): self + { + return new self( + reasonCode: 'empty_cart', + checkoutId: 0, + currentState: null, + expectedStates: [], + message: sprintf('Cart %d is empty and cannot be checked out.', $cartId), + ); + } + + public static function cartNotActive(int $cartId, string $currentStatus): self + { + return new self( + reasonCode: 'cart_not_active', + checkoutId: 0, + currentState: $currentStatus, + expectedStates: ['active'], + message: sprintf('Cart %d is "%s" and cannot be checked out.', $cartId, $currentStatus), + ); + } + + public static function shippingAddressRequired(int $checkoutId): self + { + return new self( + reasonCode: 'shipping_address_required', + checkoutId: $checkoutId, + currentState: null, + expectedStates: [], + message: sprintf('Checkout %d requires a shipping address before selecting a shipping method.', $checkoutId), + ); + } + + public static function unserviceableAddress(int $checkoutId): self + { + return new self( + reasonCode: 'unserviceable_address', + checkoutId: $checkoutId, + currentState: null, + expectedStates: [], + message: sprintf('Checkout %d cannot ship to the provided address.', $checkoutId), + ); + } + + public static function invalidShippingMethod(int $checkoutId, int $shippingRateId): self + { + return new self( + reasonCode: 'invalid_shipping_method', + checkoutId: $checkoutId, + currentState: null, + expectedStates: [], + message: sprintf('Shipping method %d is not available for checkout %d.', $shippingRateId, $checkoutId), + ); + } + + public static function invalidPaymentMethod(int $checkoutId, string $paymentMethod): self + { + return new self( + reasonCode: 'invalid_payment_method', + checkoutId: $checkoutId, + currentState: null, + expectedStates: [], + message: sprintf('Payment method "%s" is invalid for checkout %d.', $paymentMethod, $checkoutId), + ); + } + + public static function missingAddressField(int $checkoutId, string $field): self + { + return new self( + reasonCode: 'missing_address_field', + checkoutId: $checkoutId, + currentState: null, + expectedStates: [], + message: sprintf('Checkout %d is missing required address field "%s".', $checkoutId, $field), + ); + } +} diff --git a/app/Exceptions/InvalidDiscountException.php b/app/Exceptions/InvalidDiscountException.php new file mode 100644 index 0000000..00b822e --- /dev/null +++ b/app/Exceptions/InvalidDiscountException.php @@ -0,0 +1,65 @@ +currentStoreModel($request); + + $ordersCount = Order::query()->where('store_id', $store->id)->count(); + $revenue = (int) Order::query()->where('store_id', $store->id)->sum('total_amount'); + $customersCount = Customer::query()->where('store_id', $store->id)->count(); + $productsCount = Product::query()->where('store_id', $store->id)->count(); + + /** @var EloquentCollection $recentOrders */ + $recentOrders = Order::query() + ->where('store_id', $store->id) + ->orderByDesc('placed_at') + ->orderByDesc('id') + ->limit(10) + ->get(); + + return view('admin.dashboard', [ + 'store' => $store, + 'ordersCount' => $ordersCount, + 'revenue' => $revenue, + 'customersCount' => $customersCount, + 'productsCount' => $productsCount, + 'recentOrders' => $recentOrders, + ]); + } + + public function productsIndex(Request $request): View + { + $store = $this->currentStoreModel($request); + + $products = Product::query() + ->where('store_id', $store->id) + ->withCount('variants') + ->orderByDesc('updated_at') + ->paginate(25) + ->withQueryString(); + + return view('admin/products/index', [ + 'store' => $store, + 'products' => $products, + ]); + } + + public function productsCreate(Request $request): View + { + $store = $this->currentStoreModel($request); + + return view('admin/products/form', [ + 'store' => $store, + 'mode' => 'create', + 'product' => null, + ]); + } + + public function productsEdit(Request $request, int $product): View + { + $store = $this->currentStoreModel($request); + $record = $this->requireProduct($store->id, $product); + + return view('admin/products/form', [ + 'store' => $store, + 'mode' => 'edit', + 'product' => $record->loadMissing('variants'), + ]); + } + + public function productsStore(Request $request): RedirectResponse + { + $store = $this->currentStoreModel($request); + $validated = $this->validateProductInput($request); + + $handle = $this->resolveUniqueHandle( + Product::class, + $store->id, + isset($validated['handle']) && is_string($validated['handle']) && trim($validated['handle']) !== '' + ? $validated['handle'] + : (string) $validated['title'], + 'product', + ); + + $priceAmount = (int) $validated['price_amount']; + $compareAtAmount = isset($validated['compare_at_amount']) ? (int) $validated['compare_at_amount'] : null; + $currency = Str::upper((string) $validated['currency']); + $requiresShipping = ! array_key_exists('requires_shipping', $validated) || (bool) $validated['requires_shipping']; + + $product = DB::transaction(function () use ( + $store, + $validated, + $handle, + $priceAmount, + $compareAtAmount, + $currency, + $requiresShipping, + ): Product { + $record = Product::query()->create([ + 'store_id' => $store->id, + 'title' => (string) $validated['title'], + 'handle' => $handle, + 'status' => (string) $validated['status'], + 'description_html' => $validated['description_html'] ?? null, + 'vendor' => $validated['vendor'] ?? null, + 'product_type' => $validated['product_type'] ?? null, + 'tags' => $this->parseTags(isset($validated['tags']) ? (string) $validated['tags'] : null), + 'published_at' => $validated['published_at'] ?? null, + ]); + + $this->upsertDefaultVariant( + $record, + $store->id, + $priceAmount, + $compareAtAmount, + $currency, + $requiresShipping, + ); + + return $record; + }); + + return redirect() + ->route('admin.products.edit', ['product' => (int) $product->id]) + ->with('status', 'Product created.'); + } + + public function productsUpdate(Request $request, int $product): RedirectResponse + { + $store = $this->currentStoreModel($request); + $record = $this->requireProduct($store->id, $product); + $validated = $this->validateProductInput($request); + + $handle = $this->resolveUniqueHandle( + Product::class, + $store->id, + isset($validated['handle']) && is_string($validated['handle']) && trim($validated['handle']) !== '' + ? $validated['handle'] + : (string) $validated['title'], + 'product', + (int) $record->id, + ); + + $priceAmount = (int) $validated['price_amount']; + $compareAtAmount = isset($validated['compare_at_amount']) ? (int) $validated['compare_at_amount'] : null; + $currency = Str::upper((string) $validated['currency']); + $requiresShipping = ! array_key_exists('requires_shipping', $validated) || (bool) $validated['requires_shipping']; + + DB::transaction(function () use ( + $record, + $validated, + $handle, + $store, + $priceAmount, + $compareAtAmount, + $currency, + $requiresShipping, + ): void { + $record->fill([ + 'title' => (string) $validated['title'], + 'handle' => $handle, + 'status' => (string) $validated['status'], + 'description_html' => $validated['description_html'] ?? null, + 'vendor' => $validated['vendor'] ?? null, + 'product_type' => $validated['product_type'] ?? null, + 'tags' => $this->parseTags(isset($validated['tags']) ? (string) $validated['tags'] : null), + 'published_at' => $validated['published_at'] ?? null, + ]); + $record->save(); + + $this->upsertDefaultVariant( + $record, + $store->id, + $priceAmount, + $compareAtAmount, + $currency, + $requiresShipping, + ); + }); + + return redirect() + ->route('admin.products.edit', ['product' => (int) $record->id]) + ->with('status', 'Product updated.'); + } + + public function productsDestroy(Request $request, int $product): RedirectResponse + { + $store = $this->currentStoreModel($request); + $record = $this->requireProduct($store->id, $product); + + $record->status = 'archived'; + $record->save(); + + return redirect() + ->route('admin.products.index') + ->with('status', 'Product archived.'); + } + + public function inventoryIndex(Request $request): View + { + $store = $this->currentStoreModel($request); + + $items = InventoryItem::query() + ->where('store_id', $store->id) + ->with('variant.product') + ->orderByDesc('updated_at') + ->paginate(25) + ->withQueryString(); + + return view('admin/inventory/index', [ + 'store' => $store, + 'items' => $items, + ]); + } + + public function collectionsIndex(Request $request): View + { + $store = $this->currentStoreModel($request); + + $collections = Collection::query() + ->where('store_id', $store->id) + ->withCount('products') + ->orderByDesc('updated_at') + ->paginate(25) + ->withQueryString(); + + return view('admin/collections/index', [ + 'store' => $store, + 'collections' => $collections, + ]); + } + + public function collectionsCreate(Request $request): View + { + $store = $this->currentStoreModel($request); + + return view('admin/collections/form', [ + 'store' => $store, + 'mode' => 'create', + 'collection' => null, + ]); + } + + public function collectionsEdit(Request $request, int $collection): View + { + $store = $this->currentStoreModel($request); + $record = $this->requireCollection($store->id, $collection); + + return view('admin/collections/form', [ + 'store' => $store, + 'mode' => 'edit', + 'collection' => $record->loadMissing('products:id'), + ]); + } + + public function collectionsStore(Request $request): RedirectResponse + { + $store = $this->currentStoreModel($request); + $validated = $this->validateCollectionInput($request); + + $handle = $this->resolveUniqueHandle( + Collection::class, + $store->id, + isset($validated['handle']) && is_string($validated['handle']) && trim($validated['handle']) !== '' + ? $validated['handle'] + : (string) $validated['title'], + 'collection', + ); + + $productIds = $this->resolveStoreProductIdsFromString( + $store->id, + isset($validated['product_ids']) ? (string) $validated['product_ids'] : '', + ); + + $collection = DB::transaction(function () use ($store, $validated, $handle, $productIds): Collection { + $record = Collection::query()->create([ + 'store_id' => $store->id, + 'title' => (string) $validated['title'], + 'handle' => $handle, + 'description_html' => $validated['description_html'] ?? null, + 'type' => (string) $validated['type'], + 'status' => (string) $validated['status'], + ]); + + $record->products()->sync($productIds); + + return $record; + }); + + return redirect() + ->route('admin.collections.edit', ['collection' => (int) $collection->id]) + ->with('status', 'Collection created.'); + } + + public function collectionsUpdate(Request $request, int $collection): RedirectResponse + { + $store = $this->currentStoreModel($request); + $record = $this->requireCollection($store->id, $collection); + $validated = $this->validateCollectionInput($request); + + $handle = $this->resolveUniqueHandle( + Collection::class, + $store->id, + isset($validated['handle']) && is_string($validated['handle']) && trim($validated['handle']) !== '' + ? $validated['handle'] + : (string) $validated['title'], + 'collection', + (int) $record->id, + ); + + $productIds = $this->resolveStoreProductIdsFromString( + $store->id, + isset($validated['product_ids']) ? (string) $validated['product_ids'] : '', + ); + + DB::transaction(function () use ($record, $validated, $handle, $productIds): void { + $record->fill([ + 'title' => (string) $validated['title'], + 'handle' => $handle, + 'description_html' => $validated['description_html'] ?? null, + 'type' => (string) $validated['type'], + 'status' => (string) $validated['status'], + ]); + $record->save(); + + $record->products()->sync($productIds); + }); + + return redirect() + ->route('admin.collections.edit', ['collection' => (int) $record->id]) + ->with('status', 'Collection updated.'); + } + + public function collectionsDestroy(Request $request, int $collection): RedirectResponse + { + $store = $this->currentStoreModel($request); + $record = $this->requireCollection($store->id, $collection); + + $record->delete(); + + return redirect() + ->route('admin.collections.index') + ->with('status', 'Collection deleted.'); + } + + public function ordersIndex(Request $request): View + { + $store = $this->currentStoreModel($request); + + $orders = Order::query() + ->where('store_id', $store->id) + ->orderByDesc('placed_at') + ->orderByDesc('id') + ->paginate(25) + ->withQueryString(); + + return view('admin/orders/index', [ + 'store' => $store, + 'orders' => $orders, + ]); + } + + public function ordersShow(Request $request, int $order): View + { + $store = $this->currentStoreModel($request); + + $record = Order::query() + ->where('store_id', $store->id) + ->whereKey($order) + ->with(['lines.variant.product', 'payments', 'fulfillments', 'customer']) + ->firstOrFail(); + + return view('admin/orders/show', [ + 'store' => $store, + 'order' => $record, + ]); + } + + public function customersIndex(Request $request): View + { + $store = $this->currentStoreModel($request); + + $customers = Customer::query() + ->where('store_id', $store->id) + ->withCount('orders') + ->orderByDesc('created_at') + ->paginate(25) + ->withQueryString(); + + return view('admin/customers/index', [ + 'store' => $store, + 'customers' => $customers, + ]); + } + + public function customersShow(Request $request, int $customer): View + { + $store = $this->currentStoreModel($request); + + $record = Customer::query() + ->where('store_id', $store->id) + ->whereKey($customer) + ->with(['addresses', 'orders']) + ->firstOrFail(); + + return view('admin/customers/show', [ + 'store' => $store, + 'customer' => $record, + ]); + } + + public function discountsIndex(Request $request): View + { + $store = $this->currentStoreModel($request); + + $discounts = Discount::query() + ->where('store_id', $store->id) + ->orderByDesc('created_at') + ->paginate(25) + ->withQueryString(); + + return view('admin/discounts/index', [ + 'store' => $store, + 'discounts' => $discounts, + ]); + } + + public function discountsCreate(Request $request): View + { + $store = $this->currentStoreModel($request); + + return view('admin/discounts/form', [ + 'store' => $store, + 'mode' => 'create', + 'discount' => null, + ]); + } + + public function discountsEdit(Request $request, int $discount): View + { + $store = $this->currentStoreModel($request); + $record = $this->requireDiscount($store->id, $discount); + + return view('admin/discounts/form', [ + 'store' => $store, + 'mode' => 'edit', + 'discount' => $record, + ]); + } + + public function discountsStore(Request $request): RedirectResponse + { + $store = $this->currentStoreModel($request); + $validated = $this->validateDiscountInput($request); + $normalizedCode = $this->normalizeDiscountCode( + isset($validated['code']) ? (string) $validated['code'] : null, + (string) $validated['type'], + ); + + if ( + $normalizedCode !== null + && $this->isDuplicateDiscountCode($store->id, $normalizedCode) + ) { + throw ValidationException::withMessages([ + 'code' => ['The code has already been taken.'], + ]); + } + + $discount = Discount::query()->create([ + 'store_id' => $store->id, + 'type' => (string) $validated['type'], + 'code' => $normalizedCode, + 'value_type' => (string) $validated['value_type'], + 'value_amount' => (int) $validated['value_amount'], + 'starts_at' => $validated['starts_at'], + 'ends_at' => $validated['ends_at'] ?? null, + 'usage_limit' => isset($validated['usage_limit']) ? (int) $validated['usage_limit'] : null, + 'usage_count' => isset($validated['usage_count']) ? (int) $validated['usage_count'] : 0, + 'rules_json' => $this->decodeRulesJson(isset($validated['rules_json']) ? (string) $validated['rules_json'] : null), + 'status' => (string) $validated['status'], + ]); + + return redirect() + ->route('admin.discounts.edit', ['discount' => (int) $discount->id]) + ->with('status', 'Discount created.'); + } + + public function discountsUpdate(Request $request, int $discount): RedirectResponse + { + $store = $this->currentStoreModel($request); + $record = $this->requireDiscount($store->id, $discount); + $validated = $this->validateDiscountInput($request); + $normalizedCode = $this->normalizeDiscountCode( + isset($validated['code']) ? (string) $validated['code'] : null, + (string) $validated['type'], + ); + + if ( + $normalizedCode !== null + && $this->isDuplicateDiscountCode($store->id, $normalizedCode, (int) $record->id) + ) { + throw ValidationException::withMessages([ + 'code' => ['The code has already been taken.'], + ]); + } + + $record->fill([ + 'type' => (string) $validated['type'], + 'code' => $normalizedCode, + 'value_type' => (string) $validated['value_type'], + 'value_amount' => (int) $validated['value_amount'], + 'starts_at' => $validated['starts_at'], + 'ends_at' => $validated['ends_at'] ?? null, + 'usage_limit' => isset($validated['usage_limit']) ? (int) $validated['usage_limit'] : null, + 'usage_count' => isset($validated['usage_count']) ? (int) $validated['usage_count'] : (int) $record->usage_count, + 'rules_json' => $this->decodeRulesJson(isset($validated['rules_json']) ? (string) $validated['rules_json'] : null), + 'status' => (string) $validated['status'], + ]); + $record->save(); + + return redirect() + ->route('admin.discounts.edit', ['discount' => (int) $record->id]) + ->with('status', 'Discount updated.'); + } + + public function discountsDestroy(Request $request, int $discount): RedirectResponse + { + $store = $this->currentStoreModel($request); + $record = $this->requireDiscount($store->id, $discount); + + $record->delete(); + + return redirect() + ->route('admin.discounts.index') + ->with('status', 'Discount deleted.'); + } + + public function settingsIndex(Request $request): View + { + $store = $this->currentStoreModel($request); + + $settings = StoreSettings::query()->whereKey($store->id)->first(); + + return view('admin/settings/index', [ + 'store' => $store, + 'settings' => $settings, + ]); + } + + public function settingsShipping(Request $request): View + { + $store = $this->currentStoreModel($request); + + $zones = ShippingZone::query() + ->where('store_id', $store->id) + ->with('rates') + ->orderBy('name') + ->get(); + + return view('admin/settings/shipping', [ + 'store' => $store, + 'zones' => $zones, + ]); + } + + public function settingsTaxes(Request $request): View + { + $store = $this->currentStoreModel($request); + $tax = TaxSetting::query()->whereKey($store->id)->first(); + + return view('admin/settings/taxes', [ + 'store' => $store, + 'tax' => $tax, + ]); + } + + public function themesIndex(Request $request): View + { + $store = $this->currentStoreModel($request); + + $themes = Theme::query() + ->where('store_id', $store->id) + ->orderByDesc('updated_at') + ->get(); + + return view('admin/themes/index', [ + 'store' => $store, + 'themes' => $themes, + ]); + } + + public function themesEditor(Request $request, int $theme): View + { + $store = $this->currentStoreModel($request); + + $record = Theme::query() + ->where('store_id', $store->id) + ->whereKey($theme) + ->with(['files', 'settings']) + ->firstOrFail(); + + return view('admin/themes/editor', [ + 'store' => $store, + 'theme' => $record, + ]); + } + + public function pagesIndex(Request $request): View + { + $store = $this->currentStoreModel($request); + + $pages = Page::query() + ->where('store_id', $store->id) + ->orderByDesc('updated_at') + ->paginate(25) + ->withQueryString(); + + return view('admin/pages/index', [ + 'store' => $store, + 'pages' => $pages, + ]); + } + + public function pagesCreate(Request $request): View + { + $store = $this->currentStoreModel($request); + + return view('admin/pages/form', [ + 'store' => $store, + 'mode' => 'create', + 'page' => null, + ]); + } + + public function pagesEdit(Request $request, int $page): View + { + $store = $this->currentStoreModel($request); + $record = $this->requirePage($store->id, $page); + + return view('admin/pages/form', [ + 'store' => $store, + 'mode' => 'edit', + 'page' => $record, + ]); + } + + public function pagesStore(Request $request): RedirectResponse + { + $store = $this->currentStoreModel($request); + $validated = $this->validatePageInput($request); + $handle = $this->resolveUniqueHandle( + Page::class, + $store->id, + isset($validated['handle']) && is_string($validated['handle']) && trim($validated['handle']) !== '' + ? $validated['handle'] + : (string) $validated['title'], + 'page', + ); + + $page = Page::query()->create([ + 'store_id' => $store->id, + 'title' => (string) $validated['title'], + 'handle' => $handle, + 'body_html' => $validated['body_html'] ?? null, + 'status' => (string) $validated['status'], + 'published_at' => $validated['published_at'] ?? null, + ]); + + return redirect() + ->route('admin.pages.edit', ['page' => (int) $page->id]) + ->with('status', 'Page created.'); + } + + public function pagesUpdate(Request $request, int $page): RedirectResponse + { + $store = $this->currentStoreModel($request); + $record = $this->requirePage($store->id, $page); + $validated = $this->validatePageInput($request); + $handle = $this->resolveUniqueHandle( + Page::class, + $store->id, + isset($validated['handle']) && is_string($validated['handle']) && trim($validated['handle']) !== '' + ? $validated['handle'] + : (string) $validated['title'], + 'page', + (int) $record->id, + ); + + $record->fill([ + 'title' => (string) $validated['title'], + 'handle' => $handle, + 'body_html' => $validated['body_html'] ?? null, + 'status' => (string) $validated['status'], + 'published_at' => $validated['published_at'] ?? null, + ]); + $record->save(); + + return redirect() + ->route('admin.pages.edit', ['page' => (int) $record->id]) + ->with('status', 'Page updated.'); + } + + public function pagesDestroy(Request $request, int $page): RedirectResponse + { + $store = $this->currentStoreModel($request); + $record = $this->requirePage($store->id, $page); + + $record->delete(); + + return redirect() + ->route('admin.pages.index') + ->with('status', 'Page deleted.'); + } + + public function navigationIndex(Request $request): View + { + $store = $this->currentStoreModel($request); + + $menus = NavigationMenu::query() + ->where('store_id', $store->id) + ->with('items') + ->orderBy('title') + ->get(); + + return view('admin/navigation/index', [ + 'store' => $store, + 'menus' => $menus, + ]); + } + + public function appsIndex(Request $request): View + { + $store = $this->currentStoreModel($request); + + $installations = AppInstallation::query() + ->where('store_id', $store->id) + ->with('app') + ->orderByDesc('installed_at') + ->get(); + + /** @var EloquentCollection $availableApps */ + $availableApps = PlatformApp::query() + ->orderBy('name') + ->limit(50) + ->get(); + + return view('admin/apps/index', [ + 'store' => $store, + 'installations' => $installations, + 'availableApps' => $availableApps, + ]); + } + + public function appsShow(Request $request, int $installation): View + { + $store = $this->currentStoreModel($request); + + $record = AppInstallation::query() + ->where('store_id', $store->id) + ->whereKey($installation) + ->with(['app', 'oauthTokens', 'webhookSubscriptions']) + ->firstOrFail(); + + return view('admin/apps/show', [ + 'store' => $store, + 'installation' => $record, + ]); + } + + public function developersIndex(Request $request): View + { + $store = $this->currentStoreModel($request); + + $installations = AppInstallation::query() + ->where('store_id', $store->id) + ->with(['oauthTokens', 'webhookSubscriptions']) + ->get(); + + return view('admin/developers/index', [ + 'store' => $store, + 'installations' => $installations, + ]); + } + + public function analyticsIndex(Request $request): View + { + $store = $this->currentStoreModel($request); + + $daily = AnalyticsDaily::query() + ->where('store_id', $store->id) + ->orderByDesc('date') + ->limit(30) + ->get(); + + return view('admin/analytics/index', [ + 'store' => $store, + 'daily' => $daily, + ]); + } + + public function searchSettings(Request $request): View + { + $store = $this->currentStoreModel($request); + + $settings = SearchSetting::query()->whereKey($store->id)->first(); + + return view('admin/search/settings', [ + 'store' => $store, + 'settings' => $settings, + ]); + } + + /** + * @return array + */ + private function validateProductInput(Request $request): array + { + /** @var array $validated */ + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['nullable', 'string', 'max:255', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'], + 'description_html' => ['nullable', 'string'], + 'vendor' => ['nullable', 'string', 'max:255'], + 'product_type' => ['nullable', 'string', 'max:255'], + 'status' => ['required', 'string', Rule::in(['draft', 'active', 'archived'])], + 'tags' => ['nullable', 'string', 'max:2000'], + 'published_at' => ['nullable', 'date'], + 'price_amount' => ['required', 'integer', 'min:0'], + 'compare_at_amount' => ['nullable', 'integer', 'min:0'], + 'currency' => ['required', 'string', 'size:3'], + 'requires_shipping' => ['nullable', 'boolean'], + ]); + + return $validated; + } + + /** + * @return array + */ + private function validateCollectionInput(Request $request): array + { + /** @var array $validated */ + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['nullable', 'string', 'max:255', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'], + 'description_html' => ['nullable', 'string'], + 'type' => ['required', 'string', Rule::in(['manual', 'automated'])], + 'status' => ['required', 'string', Rule::in(['draft', 'active', 'archived'])], + 'product_ids' => ['nullable', 'string', 'regex:/^[0-9,\s]*$/'], + ]); + + return $validated; + } + + /** + * @return array + */ + private function validateDiscountInput(Request $request): array + { + /** @var array $validated */ + $validated = $request->validate([ + 'type' => ['required', 'string', Rule::in(['code', 'automatic'])], + 'code' => ['nullable', 'required_if:type,code', 'string', 'max:50'], + 'value_type' => ['required', 'string', Rule::in(['fixed', 'percent', 'free_shipping'])], + 'value_amount' => ['required', 'integer', 'min:0'], + 'starts_at' => ['required', 'date'], + 'ends_at' => ['nullable', 'date', 'after:starts_at'], + 'usage_limit' => ['nullable', 'integer', 'min:1'], + 'usage_count' => ['nullable', 'integer', 'min:0'], + 'rules_json' => ['nullable', 'json'], + 'status' => ['required', 'string', Rule::in(['draft', 'active', 'expired', 'disabled'])], + ]); + + return $validated; + } + + /** + * @return array + */ + private function validatePageInput(Request $request): array + { + /** @var array $validated */ + $validated = $request->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['nullable', 'string', 'max:255', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'], + 'body_html' => ['nullable', 'string'], + 'status' => ['required', 'string', Rule::in(['draft', 'published', 'archived'])], + 'published_at' => ['nullable', 'date'], + ]); + + return $validated; + } + + private function upsertDefaultVariant( + Product $product, + int $storeId, + int $priceAmount, + ?int $compareAtAmount, + string $currency, + bool $requiresShipping, + ): void { + $variant = ProductVariant::query() + ->where('product_id', $product->id) + ->where('is_default', true) + ->first(); + + if (! $variant instanceof ProductVariant) { + $variant = ProductVariant::query()->create([ + 'product_id' => (int) $product->id, + 'sku' => null, + 'barcode' => null, + 'price_amount' => $priceAmount, + 'compare_at_amount' => $compareAtAmount, + 'currency' => $currency, + 'weight_g' => null, + 'requires_shipping' => $requiresShipping, + 'is_default' => true, + 'position' => 1, + 'status' => 'active', + ]); + } else { + $variant->fill([ + 'price_amount' => $priceAmount, + 'compare_at_amount' => $compareAtAmount, + 'currency' => $currency, + 'requires_shipping' => $requiresShipping, + ]); + $variant->save(); + } + + InventoryItem::query()->firstOrCreate([ + 'variant_id' => (int) $variant->id, + ], [ + 'store_id' => $storeId, + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]); + } + + private function resolveUniqueHandle( + string $modelClass, + int $storeId, + string $source, + string $fallback, + ?int $ignoreId = null, + ): string { + $base = Str::slug($source); + $base = $base !== '' ? $base : $fallback; + + $candidate = $base; + $suffix = 1; + + while (true) { + $query = $modelClass::query() + ->where('store_id', $storeId) + ->where('handle', $candidate); + + if ($ignoreId !== null) { + $query->where('id', '!=', $ignoreId); + } + + if (! $query->exists()) { + return $candidate; + } + + $candidate = $base.'-'.$suffix; + $suffix++; + } + } + + /** + * @return list + */ + private function parseTags(?string $rawTags): array + { + if ($rawTags === null || trim($rawTags) === '') { + return []; + } + + $tags = array_map( + static fn (string $tag): string => trim($tag), + explode(',', $rawTags), + ); + + $tags = array_values(array_filter($tags, static fn (string $tag): bool => $tag !== '')); + + return array_values(array_unique($tags)); + } + + /** + * @return list + */ + private function resolveStoreProductIdsFromString(int $storeId, string $rawIds): array + { + $segments = preg_split('/[\s,]+/', $rawIds); + + if (! is_array($segments) || $segments === []) { + return []; + } + + $ids = []; + + foreach ($segments as $segment) { + $trimmed = trim($segment); + + if ($trimmed === '' || ! ctype_digit($trimmed)) { + continue; + } + + $id = (int) $trimmed; + + if ($id > 0) { + $ids[] = $id; + } + } + + $ids = array_values(array_unique($ids)); + + if ($ids === []) { + return []; + } + + return Product::query() + ->where('store_id', $storeId) + ->whereIn('id', $ids) + ->pluck('id') + ->map(static fn (mixed $id): int => (int) $id) + ->values() + ->all(); + } + + /** + * @return array + */ + private function decodeRulesJson(?string $rawRules): array + { + if ($rawRules === null || trim($rawRules) === '') { + return []; + } + + $decoded = json_decode($rawRules, true); + + if (! is_array($decoded)) { + return []; + } + + /** @var array $decoded */ + return $decoded; + } + + private function normalizeDiscountCode(?string $code, string $type): ?string + { + if ($type !== 'code') { + return null; + } + + $normalized = Str::upper(trim((string) $code)); + + return $normalized !== '' ? $normalized : null; + } + + private function isDuplicateDiscountCode(int $storeId, string $code, ?int $ignoreDiscountId = null): bool + { + $query = Discount::query() + ->where('store_id', $storeId) + ->whereRaw('LOWER(code) = ?', [Str::lower($code)]); + + if ($ignoreDiscountId !== null) { + $query->where('id', '!=', $ignoreDiscountId); + } + + return $query->exists(); + } + + private function requireProduct(int $storeId, int $productId): Product + { + /** @var Product $product */ + $product = Product::query() + ->where('store_id', $storeId) + ->whereKey($productId) + ->firstOrFail(); + + return $product; + } + + private function requireCollection(int $storeId, int $collectionId): Collection + { + /** @var Collection $collection */ + $collection = Collection::query() + ->where('store_id', $storeId) + ->whereKey($collectionId) + ->firstOrFail(); + + return $collection; + } + + private function requireDiscount(int $storeId, int $discountId): Discount + { + /** @var Discount $discount */ + $discount = Discount::query() + ->where('store_id', $storeId) + ->whereKey($discountId) + ->firstOrFail(); + + return $discount; + } + + private function requirePage(int $storeId, int $pageId): Page + { + /** @var Page $page */ + $page = Page::query() + ->where('store_id', $storeId) + ->whereKey($pageId) + ->firstOrFail(); + + return $page; + } +} diff --git a/app/Http/Controllers/Admin/Auth/ForgotPasswordController.php b/app/Http/Controllers/Admin/Auth/ForgotPasswordController.php new file mode 100644 index 0000000..cad8f7d --- /dev/null +++ b/app/Http/Controllers/Admin/Auth/ForgotPasswordController.php @@ -0,0 +1,30 @@ +validate([ + 'email' => ['required', 'string', 'email'], + ]); + + Password::broker('users')->sendResetLink([ + 'email' => (string) $validated['email'], + ]); + + return back()->with('status', __('If that email exists, we have sent a password reset link.')); + } +} diff --git a/app/Http/Controllers/Admin/Auth/LoginController.php b/app/Http/Controllers/Admin/Auth/LoginController.php new file mode 100644 index 0000000..5adf140 --- /dev/null +++ b/app/Http/Controllers/Admin/Auth/LoginController.php @@ -0,0 +1,78 @@ +validate([ + 'email' => ['required', 'string', 'email'], + 'password' => ['required', 'string'], + 'remember' => ['nullable', 'boolean'], + ]); + + $attempted = Auth::guard('web')->attempt( + [ + 'email' => (string) $credentials['email'], + 'password' => (string) $credentials['password'], + ], + (bool) ($credentials['remember'] ?? false), + ); + + if (! $attempted) { + throw ValidationException::withMessages([ + 'email' => __('Invalid credentials.'), + ]); + } + + $request->session()->regenerate(); + + $storeId = $this->currentStoreId($request); + $userId = $request->user()?->getAuthIdentifier(); + + if ( + $userId === null + || ! DB::table('store_users') + ->where('store_id', $storeId) + ->where('user_id', $userId) + ->exists() + ) { + Auth::guard('web')->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + throw ValidationException::withMessages([ + 'email' => __('Invalid credentials.'), + ]); + } + + return redirect()->intended(route('admin.dashboard', absolute: false)); + } + + public function destroy(Request $request): RedirectResponse + { + Auth::guard('web')->logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('admin.auth.login'); + } +} diff --git a/app/Http/Controllers/Admin/Auth/ResetPasswordController.php b/app/Http/Controllers/Admin/Auth/ResetPasswordController.php new file mode 100644 index 0000000..c51611d --- /dev/null +++ b/app/Http/Controllers/Admin/Auth/ResetPasswordController.php @@ -0,0 +1,59 @@ + $token, + 'email' => (string) $request->query('email', ''), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'token' => ['required', 'string'], + 'email' => ['required', 'string', 'email'], + 'password' => ['required', 'string', 'confirmed', 'min:8'], + ]); + + $status = Password::broker('users')->reset( + [ + 'email' => (string) $validated['email'], + 'password' => (string) $validated['password'], + 'password_confirmation' => (string) $request->input('password_confirmation'), + 'token' => (string) $validated['token'], + ], + function (User $user, string $password): void { + $user->forceFill([ + 'password' => $password, + 'remember_token' => Str::random(60), + ])->save(); + + event(new PasswordReset($user)); + }, + ); + + if ($status === Password::PASSWORD_RESET) { + return redirect() + ->route('admin.auth.login') + ->with('status', __($status)); + } + + return back() + ->withInput($request->only('email')) + ->withErrors(['email' => __($status)]); + } +} diff --git a/app/Http/Controllers/Admin/Concerns/ResolvesAdminContext.php b/app/Http/Controllers/Admin/Concerns/ResolvesAdminContext.php new file mode 100644 index 0000000..01f737c --- /dev/null +++ b/app/Http/Controllers/Admin/Concerns/ResolvesAdminContext.php @@ -0,0 +1,45 @@ +attributes->get('current_store'); + + if ($attributeStore instanceof CurrentStore) { + return $attributeStore; + } + + if (app()->bound(CurrentStore::class)) { + $containerStore = app(CurrentStore::class); + + if ($containerStore instanceof CurrentStore) { + return $containerStore; + } + } + + abort(404, 'Store not found.'); + } + + protected function currentStoreId(Request $request): int + { + return $this->currentStore($request)->id; + } + + protected function currentStoreModel(Request $request): Store + { + $store = Store::query()->find($this->currentStoreId($request)); + + if (! $store instanceof Store) { + abort(404, 'Store not found.'); + } + + return $store; + } +} diff --git a/app/Http/Controllers/Api/Admin/CollectionController.php b/app/Http/Controllers/Api/Admin/CollectionController.php new file mode 100644 index 0000000..e73d3b2 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/CollectionController.php @@ -0,0 +1,285 @@ +scopedStoreId($request, $store); + $validated = $request->validated(); + + $query = Collection::query() + ->where('store_id', $storeId) + ->withCount('products'); + + if (isset($validated['status']) && is_string($validated['status'])) { + $query->where('status', $validated['status']); + } + + if (isset($validated['query']) && is_string($validated['query']) && trim($validated['query']) !== '') { + $term = trim($validated['query']); + $like = '%'.$term.'%'; + + $query->where(function ($builder) use ($like): void { + $builder->where('title', 'like', $like) + ->orWhere('handle', 'like', $like); + }); + } + + $query->orderByDesc('updated_at'); + + $perPage = (int) ($validated['per_page'] ?? 25); + $paginator = $query->paginate($perPage); + + return response()->json([ + 'data' => CollectionResource::collection($paginator->getCollection())->resolve(), + 'meta' => [ + 'current_page' => (int) $paginator->currentPage(), + 'per_page' => (int) $paginator->perPage(), + 'total' => (int) $paginator->total(), + 'last_page' => (int) $paginator->lastPage(), + ], + ]); + } + + public function store(StoreCollectionRequest $request, int $store): JsonResponse + { + $storeId = $this->scopedStoreId($request, $store); + $validated = $request->validated(); + + $service = $this->resolveService('App\\Services\\CollectionService'); + + if ($service !== null && method_exists($service, 'create')) { + try { + $serviceCollection = $service->create($storeId, $validated); + + if ($serviceCollection instanceof Collection) { + $serviceCollection->loadCount('products'); + + return response()->json([ + 'data' => CollectionResource::make($serviceCollection)->resolve(), + ], 201); + } + } catch (\Throwable) { + // Fall back to direct persistence while service contracts stabilize. + } + } + + $collection = Collection::query()->create([ + 'store_id' => $storeId, + 'title' => (string) $validated['title'], + 'handle' => $this->resolveUniqueHandle($storeId, (string) ($validated['handle'] ?? $validated['title'])), + 'description_html' => $validated['description_html'] ?? null, + 'type' => (string) ($validated['type'] ?? 'manual'), + 'status' => (string) ($validated['status'] ?? 'active'), + ]); + + $productIds = $this->resolveStoreProductIds($storeId, $validated['product_ids'] ?? null); + + if ($productIds !== []) { + $collection->products()->sync($productIds); + } + + $collection->loadCount('products'); + + return response()->json([ + 'data' => CollectionResource::make($collection)->resolve(), + ], 201); + } + + public function show(Request $request, int $store, int $collection): JsonResponse + { + $storeId = $this->scopedStoreId($request, $store); + $foundCollection = $this->findStoreCollection($storeId, $collection); + + if (! $foundCollection instanceof Collection) { + return response()->json([ + 'message' => 'Collection not found.', + ], 404); + } + + $foundCollection->loadCount('products'); + + return response()->json([ + 'data' => CollectionResource::make($foundCollection)->resolve(), + ]); + } + + public function update(UpdateCollectionRequest $request, int $store, int $collection): JsonResponse + { + $storeId = $this->scopedStoreId($request, $store); + $foundCollection = $this->findStoreCollection($storeId, $collection); + + if (! $foundCollection instanceof Collection) { + return response()->json([ + 'message' => 'Collection not found.', + ], 404); + } + + $validated = $request->validated(); + $service = $this->resolveService('App\\Services\\CollectionService'); + + if ($service !== null && method_exists($service, 'update')) { + try { + $serviceCollection = $service->update($foundCollection, $validated); + + if ($serviceCollection instanceof Collection) { + $serviceCollection->loadCount('products'); + + return response()->json([ + 'data' => CollectionResource::make($serviceCollection)->resolve(), + ]); + } + } catch (\Throwable) { + // Fall back to direct persistence while service contracts stabilize. + } + } + + $payload = []; + + foreach (['title', 'description_html', 'type', 'status'] as $field) { + if (array_key_exists($field, $validated)) { + $payload[$field] = $validated[$field]; + } + } + + if (array_key_exists('handle', $validated) && is_string($validated['handle']) && $validated['handle'] !== '') { + $payload['handle'] = $this->resolveUniqueHandle($storeId, $validated['handle'], (int) $foundCollection->id); + } + + if ($payload !== []) { + $foundCollection->fill($payload); + $foundCollection->save(); + } + + if (array_key_exists('product_ids', $validated)) { + $foundCollection->products()->sync($this->resolveStoreProductIds($storeId, $validated['product_ids'])); + } + + if (array_key_exists('add_product_ids', $validated)) { + $ids = $this->resolveStoreProductIds($storeId, $validated['add_product_ids']); + + if ($ids !== []) { + $foundCollection->products()->syncWithoutDetaching($ids); + } + } + + if (array_key_exists('remove_product_ids', $validated)) { + $ids = $this->resolveStoreProductIds($storeId, $validated['remove_product_ids']); + + if ($ids !== []) { + $foundCollection->products()->detach($ids); + } + } + + $foundCollection->loadCount('products'); + + return response()->json([ + 'data' => CollectionResource::make($foundCollection)->resolve(), + ]); + } + + public function destroy(Request $request, int $store, int $collection): JsonResponse + { + $storeId = $this->scopedStoreId($request, $store); + $foundCollection = $this->findStoreCollection($storeId, $collection); + + if (! $foundCollection instanceof Collection) { + return response()->json([ + 'message' => 'Collection not found.', + ], 404); + } + + $service = $this->resolveService('App\\Services\\CollectionService'); + + if ($service !== null && method_exists($service, 'delete')) { + try { + $service->delete($foundCollection); + } catch (\Throwable) { + // Fall back to direct persistence while service contracts stabilize. + } + } + + $foundCollection->delete(); + + return response()->json([ + 'message' => 'Collection deleted', + ]); + } + + private function findStoreCollection(int $storeId, int $collectionId): ?Collection + { + return Collection::query() + ->where('store_id', $storeId) + ->whereKey($collectionId) + ->first(); + } + + private function resolveUniqueHandle(int $storeId, string $source, ?int $ignoreCollectionId = null): string + { + $base = Str::slug($source); + $base = $base !== '' ? $base : 'collection'; + + $candidate = $base; + $suffix = 1; + + while (true) { + $query = Collection::query() + ->where('store_id', $storeId) + ->where('handle', $candidate); + + if ($ignoreCollectionId !== null) { + $query->where('id', '!=', $ignoreCollectionId); + } + + if (! $query->exists()) { + return $candidate; + } + + $candidate = $base.'-'.$suffix; + $suffix++; + } + } + + /** + * @return array + */ + private function resolveStoreProductIds(int $storeId, mixed $candidateIds): array + { + if (! is_array($candidateIds) || $candidateIds === []) { + return []; + } + + $ids = array_values(array_filter(array_map( + static fn (mixed $id): int => is_numeric($id) ? (int) $id : 0, + $candidateIds, + ), static fn (int $id): bool => $id > 0)); + + if ($ids === []) { + return []; + } + + return Product::query() + ->where('store_id', $storeId) + ->whereIn('id', $ids) + ->pluck('id') + ->map(fn (int $id): int => (int) $id) + ->values() + ->all(); + } +} diff --git a/app/Http/Controllers/Api/Admin/DiscountController.php b/app/Http/Controllers/Api/Admin/DiscountController.php new file mode 100644 index 0000000..8e01e10 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/DiscountController.php @@ -0,0 +1,236 @@ +scopedStoreId($request, $store); + $validated = $request->validated(); + + $query = Discount::query()->where('store_id', $storeId); + + if (isset($validated['type']) && is_string($validated['type'])) { + $query->where('type', $validated['type']); + } + + if (isset($validated['status']) && is_string($validated['status'])) { + $query->where('status', $validated['status']); + } + + $query->orderByDesc('created_at'); + + $perPage = (int) ($validated['per_page'] ?? 25); + $paginator = $query->paginate($perPage); + + return response()->json([ + 'data' => DiscountResource::collection($paginator->getCollection())->resolve(), + 'meta' => [ + 'current_page' => (int) $paginator->currentPage(), + 'per_page' => (int) $paginator->perPage(), + 'total' => (int) $paginator->total(), + 'last_page' => (int) $paginator->lastPage(), + ], + ]); + } + + public function store(StoreDiscountRequest $request, int $store): JsonResponse + { + $storeId = $this->scopedStoreId($request, $store); + $validated = $request->validated(); + + $normalizedCode = isset($validated['code']) && is_string($validated['code']) + ? Str::upper(trim($validated['code'])) + : null; + + if ($normalizedCode !== null && $normalizedCode !== '' && $this->isDuplicateCode($storeId, $normalizedCode)) { + return response()->json([ + 'message' => 'The given data was invalid.', + 'errors' => [ + 'code' => ['The code has already been taken.'], + ], + ], 422); + } + + $service = $this->resolveService('App\\Services\\DiscountService'); + + if ($service !== null && method_exists($service, 'create')) { + try { + $serviceDiscount = $service->create($storeId, $validated); + + if ($serviceDiscount instanceof Discount) { + return response()->json([ + 'data' => DiscountResource::make($serviceDiscount)->resolve(), + ], 201); + } + } catch (\Throwable) { + // Fall back to direct persistence while service contracts stabilize. + } + } + + $discount = Discount::query()->create([ + 'store_id' => $storeId, + 'type' => (string) ($validated['type'] ?? 'code'), + 'code' => $normalizedCode, + 'value_type' => (string) $validated['value_type'], + 'value_amount' => (int) $validated['value_amount'], + 'starts_at' => $validated['starts_at'], + 'ends_at' => $validated['ends_at'] ?? null, + 'usage_limit' => isset($validated['usage_limit']) ? (int) $validated['usage_limit'] : null, + 'usage_count' => (int) ($validated['usage_count'] ?? 0), + 'rules_json' => isset($validated['rules_json']) && is_array($validated['rules_json']) ? $validated['rules_json'] : [], + 'status' => (string) ($validated['status'] ?? 'active'), + ]); + + return response()->json([ + 'data' => DiscountResource::make($discount)->resolve(), + ], 201); + } + + public function show(Request $request, int $store, int $discount): JsonResponse + { + $storeId = $this->scopedStoreId($request, $store); + $foundDiscount = $this->findStoreDiscount($storeId, $discount); + + if (! $foundDiscount instanceof Discount) { + return response()->json([ + 'message' => 'Discount not found.', + ], 404); + } + + return response()->json([ + 'data' => DiscountResource::make($foundDiscount)->resolve(), + ]); + } + + public function update(UpdateDiscountRequest $request, int $store, int $discount): JsonResponse + { + $storeId = $this->scopedStoreId($request, $store); + $foundDiscount = $this->findStoreDiscount($storeId, $discount); + + if (! $foundDiscount instanceof Discount) { + return response()->json([ + 'message' => 'Discount not found.', + ], 404); + } + + $validated = $request->validated(); + + if (array_key_exists('code', $validated) && isset($validated['code'])) { + $newCode = Str::upper(trim((string) $validated['code'])); + + if ($newCode !== '' && $newCode !== (string) ($foundDiscount->code ?? '') && $this->isDuplicateCode($storeId, $newCode, (int) $foundDiscount->id)) { + return response()->json([ + 'message' => 'The given data was invalid.', + 'errors' => [ + 'code' => ['The code has already been taken.'], + ], + ], 422); + } + + $validated['code'] = $newCode !== '' ? $newCode : null; + } + + $service = $this->resolveService('App\\Services\\DiscountService'); + + if ($service !== null && method_exists($service, 'update')) { + try { + $serviceDiscount = $service->update($foundDiscount, $validated); + + if ($serviceDiscount instanceof Discount) { + return response()->json([ + 'data' => DiscountResource::make($serviceDiscount)->resolve(), + ]); + } + } catch (\Throwable) { + // Fall back to direct persistence while service contracts stabilize. + } + } + + $payload = []; + + foreach (['type', 'code', 'value_type', 'value_amount', 'starts_at', 'ends_at', 'usage_limit', 'usage_count', 'status'] as $field) { + if (array_key_exists($field, $validated)) { + $payload[$field] = $validated[$field]; + } + } + + if (array_key_exists('rules_json', $validated)) { + $payload['rules_json'] = is_array($validated['rules_json']) ? $validated['rules_json'] : []; + } + + if ($payload !== []) { + $foundDiscount->fill($payload); + $foundDiscount->save(); + } + + return response()->json([ + 'data' => DiscountResource::make($foundDiscount)->resolve(), + ]); + } + + public function destroy(Request $request, int $store, int $discount): JsonResponse + { + $storeId = $this->scopedStoreId($request, $store); + $foundDiscount = $this->findStoreDiscount($storeId, $discount); + + if (! $foundDiscount instanceof Discount) { + return response()->json([ + 'message' => 'Discount not found.', + ], 404); + } + + $service = $this->resolveService('App\\Services\\DiscountService'); + + if ($service !== null) { + try { + if (method_exists($service, 'delete')) { + $service->delete($foundDiscount); + } + } catch (\Throwable) { + // Fall back to direct persistence while service contracts stabilize. + } + } + + $foundDiscount->delete(); + + return response()->json([ + 'message' => 'Discount deleted', + ]); + } + + private function findStoreDiscount(int $storeId, int $discountId): ?Discount + { + return Discount::query() + ->where('store_id', $storeId) + ->whereKey($discountId) + ->first(); + } + + private function isDuplicateCode(int $storeId, string $code, ?int $ignoreDiscountId = null): bool + { + $query = Discount::query() + ->where('store_id', $storeId) + ->whereRaw('LOWER(code) = ?', [Str::lower($code)]); + + if ($ignoreDiscountId !== null) { + $query->where('id', '!=', $ignoreDiscountId); + } + + return $query->exists(); + } +} diff --git a/app/Http/Controllers/Api/Admin/MeController.php b/app/Http/Controllers/Api/Admin/MeController.php new file mode 100644 index 0000000..d32b7cd --- /dev/null +++ b/app/Http/Controllers/Api/Admin/MeController.php @@ -0,0 +1,94 @@ +user(); + + if ($user === null) { + return response()->json([ + 'message' => 'Unauthenticated.', + ], 401); + } + + $store = $this->currentStoreModel($request); + $role = $user->roleForStore($store); + + if ($role === null) { + return response()->json([ + 'message' => 'Membership not found for this store.', + ], 404); + } + + return response()->json([ + 'user_id' => (int) $user->getAuthIdentifier(), + 'store_id' => (int) $store->id, + 'role' => (string) $role->value, + 'email' => $user->email, + 'name' => $user->name, + 'permissions' => $this->permissionsForRole((string) $role->value), + ]); + } + + /** + * @return array + */ + private function permissionsForRole(string $role): array + { + return match ($role) { + 'owner' => [ + 'read-products', + 'write-products', + 'read-collections', + 'write-collections', + 'read-discounts', + 'write-discounts', + 'read-orders', + 'write-orders', + 'read-settings', + 'write-settings', + 'read-customers', + 'write-customers', + ], + 'admin' => [ + 'read-products', + 'write-products', + 'read-collections', + 'write-collections', + 'read-discounts', + 'write-discounts', + 'read-orders', + 'write-orders', + 'read-settings', + 'write-settings', + 'read-customers', + ], + 'staff' => [ + 'read-products', + 'write-products', + 'read-collections', + 'write-collections', + 'read-discounts', + 'write-discounts', + 'read-orders', + 'write-orders', + 'read-customers', + ], + 'support' => [ + 'read-orders', + 'read-customers', + ], + default => [], + }; + } +} diff --git a/app/Http/Controllers/Api/Admin/ProductController.php b/app/Http/Controllers/Api/Admin/ProductController.php new file mode 100644 index 0000000..7ce0ebe --- /dev/null +++ b/app/Http/Controllers/Api/Admin/ProductController.php @@ -0,0 +1,344 @@ +scopedStoreId($request, $store); + $validated = $request->validated(); + + $query = Product::query() + ->where('store_id', $storeId) + ->withCount('variants'); + + if (isset($validated['status']) && is_string($validated['status'])) { + $query->where('status', $validated['status']); + } + + if (isset($validated['query']) && is_string($validated['query']) && trim($validated['query']) !== '') { + $term = trim($validated['query']); + $like = '%'.$term.'%'; + + $query->where(function ($builder) use ($like): void { + $builder->where('title', 'like', $like) + ->orWhere('vendor', 'like', $like) + ->orWhere('handle', 'like', $like); + }); + } + + $sort = (string) ($validated['sort'] ?? 'updated_at_desc'); + + match ($sort) { + 'title_asc' => $query->orderBy('title'), + 'title_desc' => $query->orderByDesc('title'), + 'created_at_asc' => $query->orderBy('created_at'), + 'created_at_desc' => $query->orderByDesc('created_at'), + default => $query->orderByDesc('updated_at'), + }; + + $perPage = (int) ($validated['per_page'] ?? 25); + $paginator = $query->paginate($perPage); + + return response()->json([ + 'data' => ProductResource::collection($paginator->getCollection())->resolve(), + 'meta' => [ + 'current_page' => (int) $paginator->currentPage(), + 'per_page' => (int) $paginator->perPage(), + 'total' => (int) $paginator->total(), + 'last_page' => (int) $paginator->lastPage(), + ], + ]); + } + + public function store(StoreProductRequest $request, int $store): JsonResponse + { + $storeId = $this->scopedStoreId($request, $store); + $storeModel = $this->currentStoreModel($request); + $validated = $request->validated(); + + $service = $this->resolveService('App\\Services\\ProductService'); + + if ($service !== null && method_exists($service, 'create')) { + try { + $serviceProduct = $service->create($storeModel, $validated); + + if ($serviceProduct instanceof Product) { + $serviceProduct->loadCount('variants'); + + return response()->json([ + 'data' => ProductResource::make($serviceProduct)->resolve(), + ], 201); + } + } catch (\Throwable) { + // Fall back to direct persistence while service contracts stabilize. + } + } + + $handle = $this->resolveUniqueHandle( + $storeId, + isset($validated['handle']) && is_string($validated['handle']) + ? $validated['handle'] + : (string) $validated['title'], + ); + + $product = Product::query()->create([ + 'store_id' => $storeId, + 'title' => (string) $validated['title'], + 'handle' => $handle, + 'status' => (string) ($validated['status'] ?? 'draft'), + 'description_html' => $validated['description_html'] ?? null, + 'vendor' => $validated['vendor'] ?? null, + 'product_type' => $validated['product_type'] ?? null, + 'tags' => isset($validated['tags']) && is_array($validated['tags']) ? $validated['tags'] : [], + 'published_at' => $validated['published_at'] ?? null, + ]); + + $defaultVariant = ProductVariant::query()->create([ + 'product_id' => (int) $product->id, + 'sku' => null, + 'barcode' => null, + 'price_amount' => (int) ($validated['price_amount'] ?? 0), + 'compare_at_amount' => isset($validated['compare_at_amount']) ? (int) $validated['compare_at_amount'] : null, + 'currency' => (string) ($validated['currency'] ?? $storeModel->default_currency ?? 'USD'), + 'weight_g' => null, + 'requires_shipping' => (bool) ($validated['requires_shipping'] ?? true), + 'is_default' => true, + 'position' => 1, + 'status' => 'active', + ]); + + InventoryItem::query()->create([ + 'store_id' => $storeId, + 'variant_id' => (int) $defaultVariant->id, + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]); + + $product = Product::query() + ->whereKey($product->id) + ->withCount('variants') + ->firstOrFail(); + + return response()->json([ + 'data' => ProductResource::make($product)->resolve(), + ], 201); + } + + public function show(Request $request, int $store, int $product): JsonResponse + { + $storeId = $this->scopedStoreId($request, $store); + $foundProduct = $this->findStoreProduct($storeId, $product); + + if (! $foundProduct instanceof Product) { + return response()->json([ + 'message' => 'Product not found.', + ], 404); + } + + $foundProduct->loadCount('variants'); + + return response()->json([ + 'data' => ProductResource::make($foundProduct)->resolve(), + ]); + } + + public function update(UpdateProductRequest $request, int $store, int $product): JsonResponse + { + $storeId = $this->scopedStoreId($request, $store); + $foundProduct = $this->findStoreProduct($storeId, $product); + + if (! $foundProduct instanceof Product) { + return response()->json([ + 'message' => 'Product not found.', + ], 404); + } + + $validated = $request->validated(); + $service = $this->resolveService('App\\Services\\ProductService'); + + if ($service !== null && method_exists($service, 'update')) { + try { + $serviceProduct = $service->update($foundProduct, $validated); + + if ($serviceProduct instanceof Product) { + $serviceProduct->loadCount('variants'); + + return response()->json([ + 'data' => ProductResource::make($serviceProduct)->resolve(), + ]); + } + } catch (\Throwable) { + // Fall back to direct persistence while service contracts stabilize. + } + } + + if (array_key_exists('handle', $validated) && is_string($validated['handle']) && $validated['handle'] !== '') { + $validated['handle'] = $this->resolveUniqueHandle($storeId, $validated['handle'], (int) $foundProduct->id); + } + + $payload = []; + + foreach (['title', 'handle', 'description_html', 'vendor', 'product_type', 'status', 'published_at'] as $field) { + if (array_key_exists($field, $validated)) { + $payload[$field] = $validated[$field]; + } + } + + if (array_key_exists('tags', $validated)) { + $payload['tags'] = is_array($validated['tags']) ? $validated['tags'] : []; + } + + if ($payload !== []) { + $foundProduct->fill($payload); + $foundProduct->save(); + } + + if ( + array_key_exists('price_amount', $validated) + || array_key_exists('compare_at_amount', $validated) + || array_key_exists('currency', $validated) + || array_key_exists('requires_shipping', $validated) + ) { + $variant = ProductVariant::query() + ->where('product_id', $foundProduct->id) + ->where('is_default', true) + ->first(); + + if (! $variant instanceof ProductVariant) { + $variant = ProductVariant::query()->create([ + 'product_id' => (int) $foundProduct->id, + 'sku' => null, + 'barcode' => null, + 'price_amount' => 0, + 'compare_at_amount' => null, + 'currency' => (string) ($validated['currency'] ?? $foundProduct->store->default_currency ?? 'USD'), + 'weight_g' => null, + 'requires_shipping' => true, + 'is_default' => true, + 'position' => 1, + 'status' => 'active', + ]); + + InventoryItem::query()->firstOrCreate([ + 'variant_id' => (int) $variant->id, + ], [ + 'store_id' => (int) $foundProduct->store_id, + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]); + } + + $variant->fill([ + 'price_amount' => array_key_exists('price_amount', $validated) + ? (int) $validated['price_amount'] + : (int) $variant->price_amount, + 'compare_at_amount' => array_key_exists('compare_at_amount', $validated) + ? (isset($validated['compare_at_amount']) ? (int) $validated['compare_at_amount'] : null) + : $variant->compare_at_amount, + 'currency' => array_key_exists('currency', $validated) + ? (string) $validated['currency'] + : $variant->currency, + 'requires_shipping' => array_key_exists('requires_shipping', $validated) + ? (bool) $validated['requires_shipping'] + : (bool) $variant->requires_shipping, + ]); + $variant->save(); + } + + $foundProduct->loadCount('variants'); + + return response()->json([ + 'data' => ProductResource::make($foundProduct)->resolve(), + ]); + } + + public function destroy(Request $request, int $store, int $product): JsonResponse + { + $storeId = $this->scopedStoreId($request, $store); + $foundProduct = $this->findStoreProduct($storeId, $product); + + if (! $foundProduct instanceof Product) { + return response()->json([ + 'message' => 'Product not found.', + ], 404); + } + + $service = $this->resolveService('App\\Services\\ProductService'); + + if ($service !== null) { + try { + if (method_exists($service, 'archive')) { + $service->archive($foundProduct); + } elseif (method_exists($service, 'delete')) { + $service->delete($foundProduct); + } + } catch (\Throwable) { + // Fall back to direct persistence while service contracts stabilize. + } + } + + $foundProduct->status = 'archived'; + $foundProduct->save(); + + return response()->json([ + 'data' => [ + 'id' => (int) $foundProduct->id, + 'status' => (string) $this->enumValue($foundProduct->status), + 'updated_at' => $this->iso($foundProduct->updated_at), + ], + ]); + } + + private function findStoreProduct(int $storeId, int $productId): ?Product + { + return Product::query() + ->where('store_id', $storeId) + ->whereKey($productId) + ->first(); + } + + private function resolveUniqueHandle(int $storeId, string $source, ?int $ignoreProductId = null): string + { + $base = Str::slug($source); + $base = $base !== '' ? $base : 'product'; + + $candidate = $base; + $suffix = 1; + + while (true) { + $query = Product::query() + ->where('store_id', $storeId) + ->where('handle', $candidate); + + if ($ignoreProductId !== null) { + $query->where('id', '!=', $ignoreProductId); + } + + if (! $query->exists()) { + return $candidate; + } + + $candidate = $base.'-'.$suffix; + $suffix++; + } + } +} diff --git a/app/Http/Controllers/Api/Concerns/ResolvesApiContext.php b/app/Http/Controllers/Api/Concerns/ResolvesApiContext.php new file mode 100644 index 0000000..0797f4e --- /dev/null +++ b/app/Http/Controllers/Api/Concerns/ResolvesApiContext.php @@ -0,0 +1,87 @@ +attributes->get('current_store'); + + if ($attributeStore instanceof CurrentStore) { + return $attributeStore; + } + + if (app()->bound(CurrentStore::class)) { + $containerStore = app(CurrentStore::class); + + if ($containerStore instanceof CurrentStore) { + return $containerStore; + } + } + + abort(404, 'Store not found.'); + } + + protected function currentStoreId(Request $request): int + { + return $this->currentStore($request)->id; + } + + protected function scopedStoreId(Request $request, int|string|null $routeStoreId = null): int + { + $currentStoreId = $this->currentStoreId($request); + + if ($routeStoreId === null) { + return $currentStoreId; + } + + if ((int) $routeStoreId !== $currentStoreId) { + abort(404, 'Store not found.'); + } + + return $currentStoreId; + } + + protected function currentStoreModel(Request $request): Store + { + $store = Store::query()->find($this->currentStoreId($request)); + + if (! $store instanceof Store) { + abort(404, 'Store not found.'); + } + + return $store; + } + + protected function resolveService(string $serviceClass): ?object + { + if (! class_exists($serviceClass)) { + return null; + } + + try { + $service = app($serviceClass); + + return is_object($service) ? $service : null; + } catch (\Throwable) { + return null; + } + } + + protected function enumValue(mixed $value): mixed + { + return $value instanceof BackedEnum ? $value->value : $value; + } + + protected function iso(?CarbonInterface $date): ?string + { + return $date?->toISOString(); + } +} diff --git a/app/Http/Controllers/Api/Storefront/AnalyticsEventController.php b/app/Http/Controllers/Api/Storefront/AnalyticsEventController.php new file mode 100644 index 0000000..082c843 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/AnalyticsEventController.php @@ -0,0 +1,81 @@ +currentStoreId($request); + $events = $request->validated('events'); + + $service = $this->resolveService('App\\Services\\AnalyticsService'); + $accepted = 0; + $rejected = 0; + + DB::transaction(function () use ($service, $storeId, $events, &$accepted, &$rejected): void { + foreach ($events as $event) { + if (! is_array($event)) { + $rejected++; + + continue; + } + + if ($service !== null && method_exists($service, 'track')) { + try { + $service->track($storeId, $event); + $accepted++; + + continue; + } catch (\Throwable) { + // Fall back to direct persistence below. + } + } + + $clientEventId = isset($event['client_event_id']) && is_string($event['client_event_id']) + ? trim($event['client_event_id']) + : null; + + if ($clientEventId !== null && $clientEventId !== '') { + $exists = AnalyticsEvent::query() + ->where('store_id', $storeId) + ->where('client_event_id', $clientEventId) + ->exists(); + + if ($exists) { + $rejected++; + + continue; + } + } + + AnalyticsEvent::query()->create([ + 'store_id' => $storeId, + 'type' => (string) $event['type'], + 'session_id' => (string) $event['session_id'], + 'customer_id' => isset($event['customer_id']) ? (int) $event['customer_id'] : null, + 'properties_json' => isset($event['properties']) && is_array($event['properties']) ? $event['properties'] : [], + 'client_event_id' => $clientEventId, + 'occurred_at' => $event['occurred_at'], + 'created_at' => now(), + ]); + + $accepted++; + } + }); + + return response()->json([ + 'accepted' => $accepted, + 'rejected' => $rejected, + ], 202); + } +} diff --git a/app/Http/Controllers/Api/Storefront/CartController.php b/app/Http/Controllers/Api/Storefront/CartController.php new file mode 100644 index 0000000..071a15b --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/CartController.php @@ -0,0 +1,448 @@ +currentStoreModel($request); + $currency = strtoupper((string) ($request->validated('currency') ?? $store->default_currency ?? 'USD')); + + $service = $this->resolveService('App\\Services\\CartService'); + + if ($service !== null && method_exists($service, 'create')) { + try { + $serviceCart = $service->create($store, null); + + if ($serviceCart instanceof Cart) { + return response()->json($this->cartPayload($this->loadCart($serviceCart)), 201); + } + } catch (\Throwable) { + // Fall back to direct persistence while service contracts stabilize. + } + } + + $cart = Cart::query()->create([ + 'store_id' => (int) $store->id, + 'customer_id' => null, + 'currency' => $currency, + 'cart_version' => 1, + 'status' => 'active', + ]); + + return response()->json($this->cartPayload($this->loadCart($cart)), 201); + } + + public function show(Request $request, int $cart): JsonResponse + { + $foundCart = $this->findCart($this->currentStoreId($request), $cart); + + if (! $foundCart instanceof Cart) { + return response()->json([ + 'message' => 'Cart not found.', + ], 404); + } + + return response()->json($this->cartPayload($foundCart)); + } + + public function addLine(AddCartLineRequest $request, int $cart): JsonResponse + { + $storeId = $this->currentStoreId($request); + $foundCart = $this->findCart($storeId, $cart); + + if (! $foundCart instanceof Cart) { + return response()->json([ + 'message' => 'Cart not found.', + ], 404); + } + + if ($this->enumValue($foundCart->status) !== 'active') { + return response()->json([ + 'message' => 'Cart is not active.', + 'error_code' => 'cart_not_active', + ], 409); + } + + $validated = $request->validated(); + $expectedVersion = $this->expectedCartVersion($validated); + + if ($expectedVersion !== null && $expectedVersion !== (int) $foundCart->cart_version) { + return $this->cartVersionConflict($foundCart); + } + + $variantId = (int) $validated['variant_id']; + $quantity = (int) $validated['quantity']; + + $service = $this->resolveService('App\\Services\\CartService'); + + if ($service !== null && method_exists($service, 'addLine')) { + try { + $service->addLine($foundCart, $variantId, $quantity); + + return response()->json($this->cartPayload($this->loadCart($foundCart)), 201); + } catch (\Throwable) { + // Fall back to direct implementation below. + } + } + + $variant = $this->findSellableVariant($storeId, $variantId); + + if (! $variant instanceof ProductVariant) { + return response()->json([ + 'message' => 'The given data was invalid.', + 'errors' => [ + 'variant_id' => ['The selected variant is invalid.'], + ], + ], 422); + } + + $line = $foundCart->lines()->where('variant_id', $variant->id)->first(); + $targetQuantity = $line instanceof CartLine + ? (int) $line->quantity + $quantity + : $quantity; + + $inventoryError = $this->validateInventory($variant, $targetQuantity); + + if ($inventoryError instanceof JsonResponse) { + return $inventoryError; + } + + $unitPrice = (int) $variant->price_amount; + $lineSubtotal = $unitPrice * $targetQuantity; + + if ($line instanceof CartLine) { + $line->quantity = $targetQuantity; + $line->unit_price_amount = $unitPrice; + $line->line_subtotal_amount = $lineSubtotal; + $line->line_discount_amount = 0; + $line->line_total_amount = $lineSubtotal; + $line->save(); + } else { + $foundCart->lines()->create([ + 'variant_id' => (int) $variant->id, + 'quantity' => $targetQuantity, + 'unit_price_amount' => $unitPrice, + 'line_subtotal_amount' => $lineSubtotal, + 'line_discount_amount' => 0, + 'line_total_amount' => $lineSubtotal, + ]); + } + + $this->bumpCartVersion($foundCart); + + return response()->json($this->cartPayload($this->loadCart($foundCart)), 201); + } + + public function updateLine(UpdateCartLineRequest $request, int $cart, int $line): JsonResponse + { + $storeId = $this->currentStoreId($request); + $foundCart = $this->findCart($storeId, $cart); + + if (! $foundCart instanceof Cart) { + return response()->json([ + 'message' => 'Cart not found.', + ], 404); + } + + $foundLine = $foundCart->lines()->whereKey($line)->first(); + + if (! $foundLine instanceof CartLine) { + return response()->json([ + 'message' => 'Cart line not found.', + ], 404); + } + + if ($this->enumValue($foundCart->status) !== 'active') { + return response()->json([ + 'message' => 'Cart is not active.', + 'error_code' => 'cart_not_active', + ], 409); + } + + $validated = $request->validated(); + $expectedVersion = $this->expectedCartVersion($validated); + + if ($expectedVersion !== null && $expectedVersion !== (int) $foundCart->cart_version) { + return $this->cartVersionConflict($foundCart); + } + + $quantity = (int) $validated['quantity']; + + $service = $this->resolveService('App\\Services\\CartService'); + + if ($service !== null && method_exists($service, 'updateLineQuantity')) { + try { + $service->updateLineQuantity($foundCart, (int) $foundLine->id, $quantity); + + return response()->json($this->cartPayload($this->loadCart($foundCart))); + } catch (\Throwable) { + // Fall back to direct implementation below. + } + } + + $variant = $this->findSellableVariant($storeId, (int) $foundLine->variant_id); + + if (! $variant instanceof ProductVariant) { + return response()->json([ + 'message' => 'The given data was invalid.', + 'errors' => [ + 'variant_id' => ['The selected variant is invalid.'], + ], + ], 422); + } + + $inventoryError = $this->validateInventory($variant, $quantity); + + if ($inventoryError instanceof JsonResponse) { + return $inventoryError; + } + + $unitPrice = (int) $variant->price_amount; + $lineSubtotal = $unitPrice * $quantity; + + $foundLine->update([ + 'quantity' => $quantity, + 'unit_price_amount' => $unitPrice, + 'line_subtotal_amount' => $lineSubtotal, + 'line_discount_amount' => 0, + 'line_total_amount' => $lineSubtotal, + ]); + + $this->bumpCartVersion($foundCart); + + return response()->json($this->cartPayload($this->loadCart($foundCart))); + } + + public function removeLine(RemoveCartLineRequest $request, int $cart, int $line): JsonResponse + { + $foundCart = $this->findCart($this->currentStoreId($request), $cart); + + if (! $foundCart instanceof Cart) { + return response()->json([ + 'message' => 'Cart not found.', + ], 404); + } + + $foundLine = $foundCart->lines()->whereKey($line)->first(); + + if (! $foundLine instanceof CartLine) { + return response()->json([ + 'message' => 'Cart line not found.', + ], 404); + } + + if ($this->enumValue($foundCart->status) !== 'active') { + return response()->json([ + 'message' => 'Cart is not active.', + 'error_code' => 'cart_not_active', + ], 409); + } + + $validated = $request->validated(); + $expectedVersion = $this->expectedCartVersion($validated); + + if ($expectedVersion !== null && $expectedVersion !== (int) $foundCart->cart_version) { + return $this->cartVersionConflict($foundCart); + } + + $service = $this->resolveService('App\\Services\\CartService'); + + if ($service !== null && method_exists($service, 'removeLine')) { + try { + $service->removeLine($foundCart, (int) $foundLine->id); + + return response()->json($this->cartPayload($this->loadCart($foundCart))); + } catch (\Throwable) { + // Fall back to direct implementation below. + } + } + + $foundLine->delete(); + + $this->bumpCartVersion($foundCart); + + return response()->json($this->cartPayload($this->loadCart($foundCart))); + } + + private function findCart(int $storeId, int $cartId): ?Cart + { + return Cart::query() + ->where('store_id', $storeId) + ->whereKey($cartId) + ->with([ + 'lines.variant.optionValues' => fn ($query) => $query->orderBy('position'), + 'lines.variant.inventoryItem', + 'lines.variant.product.media' => fn ($query) => $query->orderBy('position'), + ]) + ->first(); + } + + private function loadCart(Cart $cart): Cart + { + $loaded = $this->findCart((int) $cart->store_id, (int) $cart->id); + + return $loaded instanceof Cart ? $loaded : $cart; + } + + private function findSellableVariant(int $storeId, int $variantId): ?ProductVariant + { + return ProductVariant::query() + ->whereKey($variantId) + ->where('status', 'active') + ->whereHas('product', function ($query) use ($storeId): void { + $query->where('store_id', $storeId) + ->where('status', 'active'); + }) + ->with('inventoryItem') + ->first(); + } + + private function bumpCartVersion(Cart $cart): void + { + $cart->cart_version = (int) $cart->cart_version + 1; + $cart->updated_at = now(); + $cart->save(); + } + + private function expectedCartVersion(array $validated): ?int + { + $value = $validated['cart_version'] ?? $validated['expected_version'] ?? null; + + return is_numeric($value) ? (int) $value : null; + } + + private function cartVersionConflict(Cart $cart): JsonResponse + { + return response()->json([ + 'message' => 'Cart version conflict.', + 'error_code' => 'cart_version_conflict', + 'cart' => $this->cartPayload($this->loadCart($cart)), + ], 409); + } + + private function validateInventory(ProductVariant $variant, int $requestedQuantity): ?JsonResponse + { + $inventory = $variant->inventoryItem; + + if ($inventory === null) { + return null; + } + + if ($this->enumValue($inventory->policy) !== 'deny') { + return null; + } + + $available = max(0, (int) $inventory->quantity_on_hand - (int) $inventory->quantity_reserved); + + if ($requestedQuantity <= $available) { + return null; + } + + return response()->json([ + 'message' => 'The given data was invalid.', + 'errors' => [ + 'quantity' => ['The selected quantity exceeds available inventory.'], + ], + ], 422); + } + + /** + * @return array + */ + private function cartPayload(Cart $cart): array + { + $lines = $cart->lines->map(function (CartLine $line): array { + $variant = $line->variant; + $product = $variant?->product; + $media = $product?->media?->first(); + + $optionValues = $variant !== null && $variant->relationLoaded('optionValues') + ? $variant->optionValues->pluck('value')->filter()->values()->all() + : []; + + $variantTitle = $optionValues !== [] + ? implode(' / ', $optionValues) + : ($variant?->is_default ? 'Default' : null); + + $availableQuantity = null; + + if ($variant?->inventoryItem !== null) { + $availableQuantity = max(0, (int) $variant->inventoryItem->quantity_on_hand - (int) $variant->inventoryItem->quantity_reserved); + } + + return [ + 'id' => (int) $line->id, + 'variant_id' => (int) $line->variant_id, + 'product_title' => $product?->title, + 'variant_title' => $variantTitle, + 'sku' => $variant?->sku, + 'quantity' => (int) $line->quantity, + 'unit_price_amount' => (int) $line->unit_price_amount, + 'line_subtotal_amount' => (int) $line->line_subtotal_amount, + 'line_discount_amount' => (int) $line->line_discount_amount, + 'line_total_amount' => (int) $line->line_total_amount, + 'image_url' => $this->imageUrl($media?->storage_key), + 'requires_shipping' => (bool) ($variant?->requires_shipping ?? false), + 'available_quantity' => $availableQuantity, + ]; + })->values(); + + $subtotal = (int) $lines->sum('line_subtotal_amount'); + $discount = (int) $lines->sum('line_discount_amount'); + $total = (int) $lines->sum('line_total_amount'); + + return [ + 'id' => (int) $cart->id, + 'store_id' => (int) $cart->store_id, + 'customer_id' => $cart->customer_id === null ? null : (int) $cart->customer_id, + 'currency' => (string) $cart->currency, + 'cart_version' => (int) $cart->cart_version, + 'status' => (string) $this->enumValue($cart->status), + 'lines' => $lines->all(), + 'totals' => [ + 'subtotal' => $subtotal, + 'discount' => $discount, + 'total' => $total, + 'currency' => (string) $cart->currency, + 'line_count' => (int) $lines->count(), + 'item_count' => (int) $lines->sum('quantity'), + ], + 'created_at' => $this->iso($cart->created_at), + 'updated_at' => $this->iso($cart->updated_at), + ]; + } + + private function imageUrl(?string $storageKey): ?string + { + if ($storageKey === null || $storageKey === '') { + return null; + } + + if (str_starts_with($storageKey, 'http://') || str_starts_with($storageKey, 'https://')) { + return $storageKey; + } + + try { + return Storage::disk((string) config('filesystems.default', 'public'))->url($storageKey); + } catch (\Throwable) { + return $storageKey; + } + } +} diff --git a/app/Http/Controllers/Api/Storefront/CheckoutController.php b/app/Http/Controllers/Api/Storefront/CheckoutController.php new file mode 100644 index 0000000..c7b0be9 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/CheckoutController.php @@ -0,0 +1,1091 @@ +currentStoreModel($request); + $validated = $request->validated(); + + $cart = $this->findCart((int) $store->id, (int) $validated['cart_id']); + + if (! $cart instanceof Cart) { + return response()->json([ + 'message' => 'Cart not found.', + ], 404); + } + + if ($cart->lines->isEmpty()) { + return response()->json([ + 'message' => 'The given data was invalid.', + 'errors' => [ + 'cart_id' => ['Cannot create checkout from an empty cart.'], + ], + ], 422); + } + + $service = $this->resolveService('App\\Services\\CheckoutService'); + + if ($service !== null) { + try { + if (method_exists($service, 'createFromCart')) { + $serviceCheckout = $service->createFromCart($cart, (string) $validated['email']); + + if ($serviceCheckout instanceof Checkout) { + return response()->json($this->checkoutPayload($this->loadCheckout($serviceCheckout)), 201); + } + } + + if (method_exists($service, 'create')) { + $serviceCheckout = $service->create($cart, (string) $validated['email']); + + if ($serviceCheckout instanceof Checkout) { + return response()->json($this->checkoutPayload($this->loadCheckout($serviceCheckout)), 201); + } + } + } catch (\Throwable) { + // Fall back to direct persistence while service contracts stabilize. + } + } + + $checkout = Checkout::query()->create([ + 'store_id' => (int) $store->id, + 'cart_id' => (int) $cart->id, + 'customer_id' => $cart->customer_id, + 'status' => 'started', + 'email' => (string) $validated['email'], + 'shipping_address_json' => null, + 'billing_address_json' => null, + 'shipping_method_id' => null, + 'discount_code' => null, + 'tax_provider_snapshot_json' => null, + 'totals_json' => $this->totalsFromCart($cart, 0, 0), + 'expires_at' => now()->addDay(), + ]); + + return response()->json($this->checkoutPayload($this->loadCheckout($checkout)), 201); + } + + public function show(Request $request, int $checkout): JsonResponse + { + $foundCheckout = $this->findCheckout($this->currentStoreId($request), $checkout); + + if (! $foundCheckout instanceof Checkout) { + return response()->json([ + 'message' => 'Checkout not found.', + ], 404); + } + + if ($this->isExpiredCheckout($foundCheckout)) { + return response()->json([ + 'message' => 'Checkout expired.', + 'error_code' => 'checkout_expired', + ], 422); + } + + $shippingMethods = $this->availableShippingMethods( + (int) $foundCheckout->store_id, + is_array($foundCheckout->shipping_address_json) ? $foundCheckout->shipping_address_json : null, + ); + + return response()->json($this->checkoutPayload($foundCheckout, $shippingMethods, $this->appliedDiscounts($foundCheckout))); + } + + public function updateAddress(UpdateCheckoutAddressRequest $request, int $checkout): JsonResponse + { + $foundCheckout = $this->findCheckout($this->currentStoreId($request), $checkout); + + if (! $foundCheckout instanceof Checkout) { + return response()->json([ + 'message' => 'Checkout not found.', + ], 404); + } + + if ($this->isExpiredCheckout($foundCheckout)) { + return response()->json([ + 'message' => 'Checkout expired.', + 'error_code' => 'checkout_expired', + ], 422); + } + + $validated = $request->validated(); + $shippingAddress = isset($validated['shipping_address']) && is_array($validated['shipping_address']) + ? $validated['shipping_address'] + : null; + + $useShippingAsBilling = (bool) ($validated['use_shipping_as_billing'] ?? true); + $billingAddress = $useShippingAsBilling + ? $shippingAddress + : ((isset($validated['billing_address']) && is_array($validated['billing_address'])) ? $validated['billing_address'] : null); + + if ($this->cartRequiresShipping($foundCheckout) && ! is_array($shippingAddress)) { + return response()->json([ + 'message' => 'The given data was invalid.', + 'errors' => [ + 'shipping_address' => ['Shipping address is required.'], + ], + ], 422); + } + + $service = $this->resolveService('App\\Services\\CheckoutService'); + + if ($service !== null && method_exists($service, 'setAddress')) { + try { + $service->setAddress($foundCheckout, [ + 'shipping_address' => $shippingAddress, + 'billing_address' => $billingAddress, + 'use_shipping_as_billing' => $useShippingAsBilling, + ]); + + $reloaded = $this->loadCheckout($foundCheckout); + $shippingMethods = $this->availableShippingMethods( + (int) $reloaded->store_id, + is_array($reloaded->shipping_address_json) ? $reloaded->shipping_address_json : null, + ); + + return response()->json($this->checkoutPayload($reloaded, $shippingMethods, $this->appliedDiscounts($reloaded))); + } catch (\Throwable) { + // Fall back to direct implementation below. + } + } + + $foundCheckout->shipping_address_json = $shippingAddress; + $foundCheckout->billing_address_json = $billingAddress; + $foundCheckout->status = 'addressed'; + + $shippingMethods = $this->availableShippingMethods((int) $foundCheckout->store_id, $shippingAddress); + + if ($this->cartRequiresShipping($foundCheckout) && $shippingMethods === []) { + return response()->json([ + 'message' => 'The given data was invalid.', + 'errors' => [ + 'shipping_address' => ['No shipping methods are available for this address.'], + ], + ], 422); + } + + $discount = $this->currentDiscount($foundCheckout); + $foundCheckout->totals_json = $this->recalculateTotals($foundCheckout, $discount); + $foundCheckout->save(); + + return response()->json($this->checkoutPayload($this->loadCheckout($foundCheckout), $shippingMethods, $this->appliedDiscounts($foundCheckout))); + } + + public function selectShippingMethod(SelectCheckoutShippingMethodRequest $request, int $checkout): JsonResponse + { + $foundCheckout = $this->findCheckout($this->currentStoreId($request), $checkout); + + if (! $foundCheckout instanceof Checkout) { + return response()->json([ + 'message' => 'Checkout not found.', + ], 404); + } + + if ($this->isExpiredCheckout($foundCheckout)) { + return response()->json([ + 'message' => 'Checkout expired.', + 'error_code' => 'checkout_expired', + ], 422); + } + + $validated = $request->validated(); + $shippingMethodId = (int) $validated['shipping_method_id']; + + $shippingMethods = $this->availableShippingMethods( + (int) $foundCheckout->store_id, + is_array($foundCheckout->shipping_address_json) ? $foundCheckout->shipping_address_json : null, + ); + + $selected = collect($shippingMethods)->firstWhere('id', $shippingMethodId); + + if ($this->cartRequiresShipping($foundCheckout) && ! is_array($selected)) { + return response()->json([ + 'message' => 'The given data was invalid.', + 'errors' => [ + 'shipping_method_id' => ['The selected shipping method is invalid.'], + ], + ], 422); + } + + $service = $this->resolveService('App\\Services\\CheckoutService'); + + if ($service !== null && method_exists($service, 'setShippingMethod')) { + try { + $service->setShippingMethod($foundCheckout, $shippingMethodId); + + $reloaded = $this->loadCheckout($foundCheckout); + + return response()->json($this->checkoutPayload($reloaded, $shippingMethods, $this->appliedDiscounts($reloaded))); + } catch (\Throwable) { + // Fall back to direct implementation below. + } + } + + $shippingAmount = is_array($selected) + ? (int) ($selected['price_amount'] ?? 0) + : 0; + + $foundCheckout->shipping_method_id = $this->cartRequiresShipping($foundCheckout) + ? $shippingMethodId + : null; + $foundCheckout->status = 'shipping_selected'; + $foundCheckout->totals_json = $this->recalculateTotals($foundCheckout, $this->currentDiscount($foundCheckout), $shippingAmount); + $foundCheckout->save(); + + return response()->json($this->checkoutPayload($this->loadCheckout($foundCheckout), $shippingMethods, $this->appliedDiscounts($foundCheckout))); + } + + public function selectPaymentMethod(SelectCheckoutPaymentMethodRequest $request, int $checkout): JsonResponse + { + $foundCheckout = $this->findCheckout($this->currentStoreId($request), $checkout); + + if (! $foundCheckout instanceof Checkout) { + return response()->json([ + 'message' => 'Checkout not found.', + ], 404); + } + + if ($this->isExpiredCheckout($foundCheckout)) { + return response()->json([ + 'message' => 'Checkout expired.', + 'error_code' => 'checkout_expired', + ], 422); + } + + if (! in_array((string) $this->enumValue($foundCheckout->status), ['addressed', 'shipping_selected', 'payment_selected'], true)) { + return response()->json([ + 'message' => 'Checkout is not ready for payment selection.', + 'error_code' => 'checkout_invalid_state', + ], 409); + } + + $validated = $request->validated(); + $paymentMethod = (string) $validated['payment_method']; + + $service = $this->resolveService('App\\Services\\CheckoutService'); + + if ($service !== null && method_exists($service, 'selectPaymentMethod')) { + try { + $service->selectPaymentMethod($foundCheckout, $paymentMethod); + + $reloaded = $this->loadCheckout($foundCheckout); + + return response()->json($this->checkoutPayload($reloaded, $this->availableShippingMethods((int) $reloaded->store_id, is_array($reloaded->shipping_address_json) ? $reloaded->shipping_address_json : null), $this->appliedDiscounts($reloaded))); + } catch (\Throwable) { + // Fall back to direct implementation below. + } + } + + $foundCheckout->payment_method = $paymentMethod; + $foundCheckout->status = 'payment_selected'; + $foundCheckout->totals_json = $this->recalculateTotals($foundCheckout, $this->currentDiscount($foundCheckout)); + $foundCheckout->save(); + + return response()->json($this->checkoutPayload($this->loadCheckout($foundCheckout), $this->availableShippingMethods((int) $foundCheckout->store_id, is_array($foundCheckout->shipping_address_json) ? $foundCheckout->shipping_address_json : null), $this->appliedDiscounts($foundCheckout))); + } + + public function applyDiscount(ApplyCheckoutDiscountRequest $request, int $checkout): JsonResponse + { + $foundCheckout = $this->findCheckout($this->currentStoreId($request), $checkout); + + if (! $foundCheckout instanceof Checkout) { + return response()->json([ + 'message' => 'Checkout not found.', + ], 404); + } + + if ($this->isExpiredCheckout($foundCheckout)) { + return response()->json([ + 'message' => 'Checkout expired.', + 'error_code' => 'checkout_expired', + ], 422); + } + + $code = (string) $request->validated('code'); + $service = $this->resolveService('App\\Services\\CheckoutService'); + + if ($service === null || ! method_exists($service, 'applyDiscount')) { + return response()->json([ + 'message' => 'Discount service is currently unavailable.', + 'error_code' => 'discount_service_unavailable', + ], 503); + } + + try { + $service->applyDiscount($foundCheckout, $code); + } catch (InvalidDiscountException $exception) { + return response()->json([ + 'message' => 'The given data was invalid.', + 'errors' => [ + 'code' => [$exception->getMessage()], + ], + ], 422); + } catch (InvalidCheckoutStateException) { + return response()->json([ + 'message' => 'Checkout is not eligible for discount changes.', + 'error_code' => 'checkout_invalid_state', + ], 409); + } catch (\Throwable) { + return response()->json([ + 'message' => 'Unable to apply discount at this time.', + 'error_code' => 'discount_apply_failed', + ], 500); + } + + $foundCheckout = $this->loadCheckout($foundCheckout); + $shippingMethods = $this->availableShippingMethods( + (int) $foundCheckout->store_id, + is_array($foundCheckout->shipping_address_json) ? $foundCheckout->shipping_address_json : null, + ); + + return response()->json($this->checkoutPayload($foundCheckout, $shippingMethods, $this->appliedDiscounts($foundCheckout))); + } + + public function removeDiscount(Request $request, int $checkout): JsonResponse + { + $foundCheckout = $this->findCheckout($this->currentStoreId($request), $checkout); + + if (! $foundCheckout instanceof Checkout) { + return response()->json([ + 'message' => 'Checkout not found.', + ], 404); + } + + if ($this->isExpiredCheckout($foundCheckout)) { + return response()->json([ + 'message' => 'Checkout expired.', + 'error_code' => 'checkout_expired', + ], 422); + } + + if (! is_string($foundCheckout->discount_code) || $foundCheckout->discount_code === '') { + return response()->json([ + 'message' => 'Discount not found on checkout.', + ], 404); + } + + $foundCheckout->discount_code = null; + $foundCheckout->totals_json = $this->recalculateTotals($foundCheckout, null); + $foundCheckout->save(); + + $shippingMethods = $this->availableShippingMethods( + (int) $foundCheckout->store_id, + is_array($foundCheckout->shipping_address_json) ? $foundCheckout->shipping_address_json : null, + ); + + return response()->json($this->checkoutPayload($this->loadCheckout($foundCheckout), $shippingMethods, [])); + } + + public function pay(PayCheckoutRequest $request, int $checkout): JsonResponse + { + $foundCheckout = $this->findCheckout($this->currentStoreId($request), $checkout); + + if (! $foundCheckout instanceof Checkout) { + return response()->json([ + 'message' => 'Checkout not found.', + ], 404); + } + + if ($this->isExpiredCheckout($foundCheckout)) { + return response()->json([ + 'message' => 'Checkout expired.', + 'error_code' => 'checkout_expired', + ], 422); + } + + if ((string) $this->enumValue($foundCheckout->status) === 'completed') { + $existingOrder = $this->findOrderByCheckoutId( + storeId: (int) $foundCheckout->store_id, + checkoutId: (int) $foundCheckout->id, + ); + + if ($existingOrder instanceof Order) { + return response()->json($this->paymentResponsePayload($foundCheckout, $existingOrder)); + } + + return response()->json([ + 'message' => 'Checkout already completed.', + 'error_code' => 'checkout_already_completed', + ], 409); + } + + if ((string) $this->enumValue($foundCheckout->status) !== 'payment_selected') { + return response()->json([ + 'message' => 'Checkout is not ready for payment.', + 'error_code' => 'checkout_invalid_state', + ], 409); + } + + $validated = $request->validated(); + $paymentMethod = (string) ($validated['payment_method'] ?? $this->enumValue($foundCheckout->payment_method)); + + if ($paymentMethod === '') { + return response()->json([ + 'message' => 'The given data was invalid.', + 'errors' => [ + 'payment_method' => ['Payment method is required.'], + ], + ], 422); + } + + $service = $this->resolveService('App\\Services\\CheckoutService'); + + if ($service !== null && method_exists($service, 'completeCheckout')) { + try { + $result = $service->completeCheckout($foundCheckout, $validated); + + if ($result instanceof Order) { + return response()->json($this->paymentResponsePayload($foundCheckout, $result)); + } + } catch (\Throwable) { + // Fall back to direct implementation below. + } + } + + $order = DB::transaction(function () use ($foundCheckout, $paymentMethod): Order { + /** @var Checkout $checkoutRecord */ + $checkoutRecord = Checkout::query() + ->whereKey($foundCheckout->id) + ->lockForUpdate() + ->firstOrFail(); + + $existingOrder = $this->findOrderByCheckoutId( + storeId: (int) $checkoutRecord->store_id, + checkoutId: (int) $checkoutRecord->id, + ); + + if ($existingOrder instanceof Order) { + return $existingOrder; + } + + $cart = $this->findCart((int) $checkoutRecord->store_id, (int) $checkoutRecord->cart_id); + + if (! $cart instanceof Cart) { + abort(404, 'Cart not found.'); + } + + $finalizedDiscount = $this->lockDiscountForFinalization($checkoutRecord); + + if ($finalizedDiscount === null && $this->hasDiscountCode($checkoutRecord)) { + $checkoutRecord->discount_code = null; + $checkoutRecord->save(); + } + + $totals = $this->recalculateTotals($checkoutRecord, $finalizedDiscount); + + $checkoutService = $this->resolveService('App\\Services\\CheckoutService'); + + if ($checkoutService instanceof CheckoutService) { + try { + $totals = $checkoutService->computeTotals($checkoutRecord)->toArray(); + } catch (InvalidDiscountException) { + $finalizedDiscount = null; + $checkoutRecord->discount_code = null; + $checkoutRecord->save(); + + try { + $totals = $checkoutService->computeTotals($checkoutRecord)->toArray(); + } catch (\Throwable) { + $totals = $this->recalculateTotals($checkoutRecord, null); + } + } catch (\Throwable) { + $totals = $this->recalculateTotals($checkoutRecord, $finalizedDiscount); + } + } + + $checkoutRecord->totals_json = $totals; + + $cart->load(['lines.variant.product']); + + /** @var Collection $cartLines */ + $cartLines = $cart->lines + ->sortBy('id') + ->values(); + + $lineDiscountAmounts = $this->lineDiscountAmountsFromCartLines($cartLines); + + $isBankTransfer = $paymentMethod === 'bank_transfer'; + + $order = Order::query()->create([ + 'store_id' => (int) $checkoutRecord->store_id, + 'customer_id' => $checkoutRecord->customer_id, + 'checkout_id' => (int) $checkoutRecord->id, + 'order_number' => $this->nextOrderNumber((int) $checkoutRecord->store_id), + 'payment_method' => $paymentMethod, + 'status' => $isBankTransfer ? 'pending' : 'paid', + 'financial_status' => $isBankTransfer ? 'pending' : 'paid', + 'fulfillment_status' => 'unfulfilled', + 'currency' => (string) ($totals['currency'] ?? $cart->currency), + 'subtotal_amount' => (int) ($totals['subtotal'] ?? 0), + 'discount_amount' => (int) ($totals['discount'] ?? 0), + 'shipping_amount' => (int) ($totals['shipping'] ?? 0), + 'tax_amount' => (int) ($totals['tax'] ?? 0), + 'total_amount' => (int) ($totals['total'] ?? 0), + 'email' => $checkoutRecord->email, + 'billing_address_json' => $checkoutRecord->billing_address_json, + 'shipping_address_json' => $checkoutRecord->shipping_address_json, + 'placed_at' => now(), + ]); + + /** @var CartLine $line */ + foreach ($cartLines as $line) { + $variant = $line->variant; + $product = $variant?->product; + $lineId = (int) $line->id; + $lineDiscountAmount = (int) ($lineDiscountAmounts[$lineId] ?? 0); + + $order->lines()->create([ + 'product_id' => $product?->id, + 'variant_id' => $variant?->id, + 'title_snapshot' => (string) ($product?->title ?? 'Product'), + 'sku_snapshot' => $variant?->sku, + 'quantity' => (int) $line->quantity, + 'unit_price_amount' => (int) $line->unit_price_amount, + 'total_amount' => (int) $line->line_total_amount, + 'tax_lines_json' => [], + 'discount_allocations_json' => $this->orderLineDiscountAllocations( + discount: $finalizedDiscount, + lineDiscountAmount: $lineDiscountAmount, + ), + ]); + } + + if ($checkoutService instanceof CheckoutService) { + $checkoutService->commitReservedInventoryForCheckout($checkoutRecord); + } + + if ($finalizedDiscount instanceof Discount) { + $finalizedDiscount->increment('usage_count'); + } + + $order->payments()->create([ + 'provider' => 'mock', + 'method' => $paymentMethod, + 'provider_payment_id' => 'mock_'.Str::lower(Str::random(16)), + 'status' => $isBankTransfer ? 'pending' : 'captured', + 'amount' => (int) $order->total_amount, + 'currency' => (string) $order->currency, + 'raw_json_encrypted' => null, + 'created_at' => now(), + ]); + + $cart->status = 'converted'; + $cart->save(); + + $checkoutRecord->status = 'completed'; + $checkoutRecord->payment_method = $paymentMethod; + $checkoutRecord->save(); + + return $order->fresh(); + }); + + $reloadedCheckout = $this->loadCheckout($foundCheckout); + + return response()->json($this->paymentResponsePayload($reloadedCheckout, $order)); + } + + private function findOrderByCheckoutId(int $storeId, int $checkoutId): ?Order + { + /** @var Order|null $order */ + $order = Order::query() + ->where('store_id', $storeId) + ->where('checkout_id', $checkoutId) + ->orderByDesc('id') + ->first(); + + return $order; + } + + private function findCart(int $storeId, int $cartId): ?Cart + { + return Cart::query() + ->where('store_id', $storeId) + ->whereKey($cartId) + ->with([ + 'lines.variant.optionValues' => fn ($query) => $query->orderBy('position'), + 'lines.variant.product', + ]) + ->first(); + } + + private function findCheckout(int $storeId, int $checkoutId): ?Checkout + { + return Checkout::query() + ->where('store_id', $storeId) + ->whereKey($checkoutId) + ->with([ + 'cart.lines.variant.optionValues' => fn ($query) => $query->orderBy('position'), + 'cart.lines.variant.product', + ]) + ->first(); + } + + private function loadCheckout(Checkout $checkout): Checkout + { + $loaded = $this->findCheckout((int) $checkout->store_id, (int) $checkout->id); + + return $loaded instanceof Checkout ? $loaded : $checkout; + } + + private function isExpiredCheckout(Checkout $checkout): bool + { + $status = (string) $this->enumValue($checkout->status); + + if ($status === 'completed') { + return false; + } + + $isExpired = $status === 'expired' || ($checkout->expires_at !== null && $checkout->expires_at->isPast()); + + if ($isExpired && $status !== 'expired') { + $checkout->status = 'expired'; + $checkout->save(); + + $service = $this->resolveService('App\\Services\\CheckoutService'); + + if ($status === 'payment_selected' && $service instanceof CheckoutService) { + $service->releaseReservedInventoryForCheckout($checkout); + } + } + + return $isExpired; + } + + private function cartRequiresShipping(Checkout $checkout): bool + { + $cart = $checkout->cart; + + if (! $cart instanceof Cart) { + return false; + } + + return $cart->lines->contains(function ($line): bool { + return (bool) ($line->variant?->requires_shipping ?? false); + }); + } + + /** + * @param array|null $address + * @return array> + */ + private function availableShippingMethods(int $storeId, ?array $address): array + { + if (! is_array($address)) { + return []; + } + + $countryCode = strtoupper((string) ($address['country_code'] ?? '')); + $provinceCode = strtoupper((string) ($address['province_code'] ?? '')); + + if ($countryCode === '') { + return []; + } + + $zones = ShippingZone::query() + ->where('store_id', $storeId) + ->with(['rates' => fn ($query) => $query->where('is_active', true)]) + ->get(); + + $methods = []; + + foreach ($zones as $zone) { + $countries = array_map('strtoupper', array_values(is_array($zone->countries_json) ? $zone->countries_json : [])); + $regions = array_map('strtoupper', array_values(is_array($zone->regions_json) ? $zone->regions_json : [])); + + if (! in_array($countryCode, $countries, true)) { + continue; + } + + if ($regions !== []) { + if ($provinceCode === '' || ! in_array($provinceCode, $regions, true)) { + continue; + } + } + + foreach ($zone->rates as $rate) { + if (! $rate instanceof ShippingRate) { + continue; + } + + $config = is_array($rate->config_json) ? $rate->config_json : []; + + $methods[] = [ + 'id' => (int) $rate->id, + 'name' => (string) $rate->name, + 'type' => (string) $this->enumValue($rate->type), + 'price_amount' => $this->shippingAmountFromRate($rate), + 'currency' => (string) ($config['currency'] ?? 'USD'), + 'estimated_days_min' => isset($config['estimated_days_min']) ? (int) $config['estimated_days_min'] : null, + 'estimated_days_max' => isset($config['estimated_days_max']) ? (int) $config['estimated_days_max'] : null, + ]; + } + } + + return $methods; + } + + private function shippingAmountFromRate(ShippingRate $rate): int + { + $config = is_array($rate->config_json) ? $rate->config_json : []; + $amount = $config['price_amount'] ?? null; + + if (! is_numeric($amount) && isset($config['tiers']) && is_array($config['tiers']) && $config['tiers'] !== []) { + $firstTier = $config['tiers'][0]; + $amount = is_array($firstTier) ? ($firstTier['price_amount'] ?? 0) : 0; + } + + return max(0, (int) ($amount ?? 0)); + } + + private function findDiscountByCode(int $storeId, string $code): ?Discount + { + return Discount::query() + ->where('store_id', $storeId) + ->whereRaw('LOWER(code) = ?', [Str::lower($code)]) + ->where('status', 'active') + ->where('starts_at', '<=', now()) + ->where(function ($query): void { + $query->whereNull('ends_at') + ->orWhere('ends_at', '>=', now()); + }) + ->first(); + } + + private function currentDiscount(Checkout $checkout): ?Discount + { + if (! $this->hasDiscountCode($checkout)) { + return null; + } + + return $this->findDiscountByCode((int) $checkout->store_id, trim((string) $checkout->discount_code)); + } + + private function hasDiscountCode(Checkout $checkout): bool + { + return is_string($checkout->discount_code) && trim($checkout->discount_code) !== ''; + } + + private function lockDiscountForFinalization(Checkout $checkout): ?Discount + { + if (! $this->hasDiscountCode($checkout)) { + return null; + } + + $discountCode = trim((string) $checkout->discount_code); + + /** @var Discount|null $discount */ + $discount = Discount::query() + ->where('store_id', (int) $checkout->store_id) + ->whereRaw('LOWER(code) = ?', [Str::lower($discountCode)]) + ->where('status', 'active') + ->where('starts_at', '<=', now()) + ->where(function ($query): void { + $query->whereNull('ends_at') + ->orWhere('ends_at', '>=', now()); + }) + ->lockForUpdate() + ->first(); + + if (! $discount instanceof Discount) { + return null; + } + + if ($discount->usage_limit !== null && (int) $discount->usage_count >= (int) $discount->usage_limit) { + return null; + } + + return $discount; + } + + /** + * @param Collection $lines + * @return array + */ + private function lineDiscountAmountsFromCartLines(Collection $lines): array + { + if ($lines->isEmpty()) { + return []; + } + + /** @var array $allocations */ + $allocations = []; + + /** @var CartLine $line */ + foreach ($lines as $line) { + $lineId = (int) $line->id; + if ($lineId <= 0) { + continue; + } + + $lineSubtotal = max(0, (int) $line->line_subtotal_amount); + $lineDiscount = max(0, min($lineSubtotal, (int) $line->line_discount_amount)); + + if ($lineDiscount <= 0) { + continue; + } + + $allocations[$lineId] = $lineDiscount; + } + + return $allocations; + } + + /** + * @return list + */ + private function orderLineDiscountAllocations(?Discount $discount, int $lineDiscountAmount): array + { + if (! $discount instanceof Discount || $lineDiscountAmount <= 0) { + return []; + } + + return [[ + 'discount_id' => (int) $discount->id, + 'code' => (string) ($discount->code ?? ''), + 'amount' => $lineDiscountAmount, + ]]; + } + + /** + * @return array> + */ + private function appliedDiscounts(Checkout $checkout): array + { + $discount = $this->currentDiscount($checkout); + + if (! $discount instanceof Discount) { + return []; + } + + $totals = is_array($checkout->totals_json) ? $checkout->totals_json : []; + + return [[ + 'code' => (string) ($discount->code ?? ''), + 'type' => (string) $this->enumValue($discount->value_type), + 'value_amount' => (int) $discount->value_amount, + 'applied_amount' => (int) ($totals['discount'] ?? 0), + 'description' => null, + ]]; + } + + /** + * @return array + */ + private function totalsFromCart(Cart $cart, int $discount, int $shipping): array + { + $subtotal = (int) $cart->lines->sum('line_subtotal_amount'); + $tax = 0; + $total = max(0, $subtotal - $discount + $shipping + $tax); + + return [ + 'subtotal' => $subtotal, + 'discount' => $discount, + 'shipping' => $shipping, + 'tax' => $tax, + 'total' => $total, + 'currency' => (string) $cart->currency, + ]; + } + + /** + * @return array + */ + private function recalculateTotals(Checkout $checkout, ?Discount $discount = null, ?int $shippingAmount = null): array + { + $cart = $checkout->cart; + + if (! $cart instanceof Cart) { + return [ + 'subtotal' => 0, + 'discount' => 0, + 'shipping' => 0, + 'tax' => 0, + 'total' => 0, + 'currency' => 'USD', + ]; + } + + $subtotal = (int) $cart->lines->sum('line_subtotal_amount'); + + if ($shippingAmount === null) { + $shippingAmount = 0; + + if ($checkout->shipping_method_id !== null) { + $rate = ShippingRate::query()->find((int) $checkout->shipping_method_id); + + if ($rate instanceof ShippingRate) { + $shippingAmount = $this->shippingAmountFromRate($rate); + } + } + } + + $discountAmount = 0; + + if ($discount instanceof Discount) { + $valueType = (string) $this->enumValue($discount->value_type); + + if ($valueType === 'percent') { + $discountAmount = (int) floor($subtotal * ((int) $discount->value_amount / 100)); + } elseif ($valueType === 'fixed') { + $discountAmount = min($subtotal, (int) $discount->value_amount); + } elseif ($valueType === 'free_shipping') { + $discountAmount = $shippingAmount; + $shippingAmount = 0; + } + } + + $tax = 0; + $total = max(0, $subtotal - $discountAmount + $shippingAmount + $tax); + + return [ + 'subtotal' => $subtotal, + 'discount' => $discountAmount, + 'shipping' => $shippingAmount, + 'tax' => $tax, + 'total' => $total, + 'currency' => (string) $cart->currency, + ]; + } + + private function nextOrderNumber(int $storeId): string + { + $max = (int) Order::query() + ->where('store_id', $storeId) + ->selectRaw("MAX(CAST(CASE WHEN order_number LIKE '#%' THEN SUBSTR(order_number, 2) ELSE order_number END AS INTEGER)) AS max_order") + ->value('max_order'); + + $next = $max > 0 ? $max + 1 : 1001; + + return '#'.$next; + } + + /** + * @param array> $shippingMethods + * @param array> $appliedDiscounts + * @return array + */ + private function checkoutPayload(Checkout $checkout, array $shippingMethods = [], array $appliedDiscounts = []): array + { + $cart = $checkout->cart; + $lines = []; + + if ($cart instanceof Cart) { + $lines = $cart->lines->map(function ($line): array { + $variant = $line->variant; + $product = $variant?->product; + + $optionValues = $variant !== null && $variant->relationLoaded('optionValues') + ? $variant->optionValues->pluck('value')->filter()->values()->all() + : []; + + $variantTitle = $optionValues !== [] + ? implode(' / ', $optionValues) + : ($variant?->is_default ? 'Default' : null); + + return [ + 'variant_id' => (int) $line->variant_id, + 'product_title' => $product?->title, + 'variant_title' => $variantTitle, + 'sku' => $variant?->sku, + 'quantity' => (int) $line->quantity, + 'unit_price_amount' => (int) $line->unit_price_amount, + 'line_total_amount' => (int) $line->line_total_amount, + ]; + })->values()->all(); + } + + $totals = is_array($checkout->totals_json) + ? $checkout->totals_json + : $this->recalculateTotals($checkout, $this->currentDiscount($checkout)); + + return [ + 'id' => (int) $checkout->id, + 'store_id' => (int) $checkout->store_id, + 'cart_id' => (int) $checkout->cart_id, + 'customer_id' => $checkout->customer_id === null ? null : (int) $checkout->customer_id, + 'status' => (string) $this->enumValue($checkout->status), + 'payment_method' => $checkout->payment_method === null ? null : (string) $this->enumValue($checkout->payment_method), + 'email' => $checkout->email, + 'shipping_address_json' => is_array($checkout->shipping_address_json) ? $checkout->shipping_address_json : null, + 'billing_address_json' => is_array($checkout->billing_address_json) ? $checkout->billing_address_json : null, + 'shipping_method_id' => $checkout->shipping_method_id === null ? null : (int) $checkout->shipping_method_id, + 'discount_code' => $checkout->discount_code, + 'lines' => $lines, + 'totals' => [ + 'subtotal' => (int) ($totals['subtotal'] ?? 0), + 'discount' => (int) ($totals['discount'] ?? 0), + 'shipping' => (int) ($totals['shipping'] ?? 0), + 'tax' => (int) ($totals['tax'] ?? 0), + 'total' => (int) ($totals['total'] ?? 0), + 'currency' => (string) ($totals['currency'] ?? ($cart?->currency ?? 'USD')), + ], + 'available_shipping_methods' => $shippingMethods, + 'applied_discounts' => $appliedDiscounts, + 'expires_at' => $this->iso($checkout->expires_at), + 'created_at' => $this->iso($checkout->created_at), + 'updated_at' => $this->iso($checkout->updated_at), + ]; + } + + /** + * @return array + */ + private function paymentResponsePayload(Checkout $checkout, Order $order): array + { + $isBankTransfer = (string) $this->enumValue($order->payment_method) === 'bank_transfer'; + + $payload = [ + 'checkout_id' => (int) $checkout->id, + 'status' => 'completed', + 'order' => [ + 'id' => (int) $order->id, + 'order_number' => (string) $order->order_number, + 'status' => (string) $this->enumValue($order->status), + 'financial_status' => (string) $this->enumValue($order->financial_status), + 'payment_method' => (string) $this->enumValue($order->payment_method), + 'total_amount' => (int) $order->total_amount, + 'currency' => (string) $order->currency, + ], + ]; + + if ($isBankTransfer) { + $payload['bank_transfer_instructions'] = [ + 'bank_name' => 'Mock Bank AG', + 'iban' => 'DE89 3704 0044 0532 0130 00', + 'bic' => 'COBADEFFXXX', + 'reference' => (string) $order->order_number, + 'amount_formatted' => number_format(((int) $order->total_amount) / 100, 2).' '.(string) $order->currency, + ]; + } + + return $payload; + } +} diff --git a/app/Http/Controllers/Api/Storefront/OrderController.php b/app/Http/Controllers/Api/Storefront/OrderController.php new file mode 100644 index 0000000..e285233 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/OrderController.php @@ -0,0 +1,111 @@ +currentStoreId($request); + $validated = $request->validated(); + + $order = Order::query() + ->where('store_id', $storeId) + ->where('order_number', $orderNumber) + ->with([ + 'lines', + 'fulfillments', + ]) + ->first(); + + if (! $order instanceof Order) { + return response()->json([ + 'message' => 'Order not found.', + ], 404); + } + + $providedToken = (string) ($validated['token'] ?? ''); + $expectedToken = $this->expectedOrderToken($order); + + if (! hash_equals($expectedToken, $providedToken)) { + return response()->json([ + 'message' => 'The given data was invalid.', + 'errors' => [ + 'token' => ['The provided order token is invalid.'], + ], + ], 422); + } + + $lines = $order->lines->map(function (OrderLine $line): array { + return [ + 'title_snapshot' => (string) $line->title_snapshot, + 'variant_title' => null, + 'sku_snapshot' => $line->sku_snapshot, + 'quantity' => (int) $line->quantity, + 'unit_price_amount' => (int) $line->unit_price_amount, + 'total_amount' => (int) $line->total_amount, + ]; + })->values()->all(); + + $fulfillments = $order->fulfillments->map(function ($fulfillment): array { + return [ + 'id' => (int) $fulfillment->id, + 'status' => (string) $this->enumValue($fulfillment->status), + 'tracking_company' => $fulfillment->tracking_company, + 'tracking_number' => $fulfillment->tracking_number, + 'tracking_url' => $fulfillment->tracking_url, + 'shipped_at' => $this->iso($fulfillment->shipped_at), + 'created_at' => $this->iso($fulfillment->created_at), + ]; + })->values()->all(); + + return response()->json([ + 'order_number' => (string) $order->order_number, + 'status' => (string) $this->enumValue($order->status), + 'financial_status' => (string) $this->enumValue($order->financial_status), + 'fulfillment_status' => (string) $this->enumValue($order->fulfillment_status), + 'email' => $order->email, + 'currency' => (string) $order->currency, + 'placed_at' => $this->iso($order->placed_at), + 'lines' => $lines, + 'totals' => [ + 'subtotal_amount' => (int) $order->subtotal_amount, + 'discount_amount' => (int) $order->discount_amount, + 'shipping_amount' => (int) $order->shipping_amount, + 'tax_amount' => (int) $order->tax_amount, + 'total_amount' => (int) $order->total_amount, + ], + 'shipping_address' => is_array($order->shipping_address_json) ? $order->shipping_address_json : null, + 'fulfillments' => $fulfillments, + ]); + } + + private function expectedOrderToken(Order $order): string + { + $key = (string) config('app.key', ''); + + if (Str::startsWith($key, 'base64:')) { + $decoded = base64_decode(Str::after($key, 'base64:'), true); + + if ($decoded !== false) { + $key = $decoded; + } + } + + return hash_hmac('sha256', implode('|', [ + (string) $order->store_id, + (string) $order->order_number, + (string) ($order->email ?? ''), + ]), $key); + } +} diff --git a/app/Http/Controllers/Api/Storefront/SearchController.php b/app/Http/Controllers/Api/Storefront/SearchController.php new file mode 100644 index 0000000..f5ee6e7 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/SearchController.php @@ -0,0 +1,366 @@ +currentStoreId($request); + $validated = $request->validated(); + + $queryTerm = trim((string) ($validated['q'] ?? $validated['query'] ?? '')); + $sort = (string) ($validated['sort'] ?? 'relevance'); + $page = (int) ($validated['page'] ?? 1); + $perPage = (int) ($validated['per_page'] ?? 24); + + $filters = $this->parseFilters($validated['filters'] ?? null); + + if ($filters === null) { + return response()->json([ + 'message' => 'The given data was invalid.', + 'errors' => [ + 'filters' => ['Filters must be a JSON object or an object-like query parameter.'], + ], + ], 422); + } + + $productsQuery = Product::query() + ->where('store_id', $storeId) + ->where('status', 'active') + ->where(function (Builder $builder) use ($queryTerm): void { + $like = '%'.$queryTerm.'%'; + + $builder->where('title', 'like', $like) + ->orWhere('handle', 'like', $like) + ->orWhere('vendor', 'like', $like) + ->orWhere('product_type', 'like', $like); + }); + + $this->applyFilters($productsQuery, $filters); + + if ($sort === 'price_asc' || $sort === 'price_desc') { + $productsQuery->withMin(['variants as min_price_amount' => fn (Builder $query) => $query->where('status', 'active')], 'price_amount') + ->orderBy('min_price_amount', $sort === 'price_asc' ? 'asc' : 'desc'); + } elseif ($sort === 'newest') { + $productsQuery->orderByDesc('published_at')->orderByDesc('id'); + } elseif ($sort === 'best_selling') { + $productsQuery->orderByDesc('updated_at')->orderByDesc('id'); + } elseif ($sort === 'title_asc') { + $productsQuery->orderBy('title'); + } else { + $productsQuery->orderByRaw('CASE WHEN title LIKE ? THEN 0 ELSE 1 END', [$queryTerm.'%']) + ->orderBy('title'); + } + + $productsQuery->with([ + 'media' => fn ($query) => $query->orderBy('position'), + 'variants' => fn ($query) => $query->where('status', 'active')->with('inventoryItem')->orderBy('price_amount'), + ]); + + $paginator = $productsQuery->paginate($perPage, ['*'], 'page', $page); + + $results = collect($paginator->items())->map(function (Product $product): array { + $variant = $product->variants->first(); + $media = $product->media->first(); + + $inStock = $product->variants->contains(function ($variant): bool { + $inventory = $variant->inventoryItem; + + if ($inventory === null) { + return true; + } + + if ($this->enumValue($inventory->policy) === 'continue') { + return true; + } + + return ((int) $inventory->quantity_on_hand - (int) $inventory->quantity_reserved) > 0; + }); + + return [ + 'id' => (int) $product->id, + 'title' => (string) $product->title, + 'handle' => (string) $product->handle, + 'vendor' => $product->vendor, + 'product_type' => $product->product_type, + 'price_amount' => $variant !== null ? (int) $variant->price_amount : 0, + 'compare_at_amount' => $variant?->compare_at_amount === null ? null : (int) $variant->compare_at_amount, + 'currency' => $variant?->currency, + 'image_url' => $this->imageUrl($media?->storage_key), + 'in_stock' => $inStock, + 'tags' => is_array($product->tags) ? $product->tags : [], + ]; + })->values(); + + $vendorCounts = []; + + foreach ($results as $result) { + $vendor = $result['vendor']; + + if (! is_string($vendor) || $vendor === '') { + continue; + } + + $vendorCounts[$vendor] = ($vendorCounts[$vendor] ?? 0) + 1; + } + + arsort($vendorCounts); + + $vendorsFacet = []; + + foreach ($vendorCounts as $vendor => $count) { + $vendorsFacet[] = [ + 'value' => $vendor, + 'count' => $count, + ]; + } + + $tagCounts = []; + $priceMin = null; + $priceMax = null; + + foreach ($results as $result) { + $price = (int) ($result['price_amount'] ?? 0); + $priceMin = $priceMin === null ? $price : min($priceMin, $price); + $priceMax = $priceMax === null ? $price : max($priceMax, $price); + + $tags = $result['tags']; + + if (! is_array($tags)) { + continue; + } + + foreach ($tags as $tag) { + if (! is_string($tag) || $tag === '') { + continue; + } + + $tagCounts[$tag] = ($tagCounts[$tag] ?? 0) + 1; + } + } + + arsort($tagCounts); + + $tagsFacet = []; + + foreach ($tagCounts as $tag => $count) { + $tagsFacet[] = [ + 'value' => $tag, + 'count' => $count, + ]; + } + + SearchQuery::query()->create([ + 'store_id' => $storeId, + 'query' => $queryTerm, + 'filters_json' => $filters, + 'results_count' => (int) $paginator->total(), + 'created_at' => now(), + ]); + + return response()->json([ + 'query' => $queryTerm, + 'data' => $results->all(), + 'results' => $results->all(), + 'facets' => [ + 'vendors' => $vendorsFacet, + 'tags' => $tagsFacet, + 'price_range' => [ + 'min' => $priceMin ?? 0, + 'max' => $priceMax ?? 0, + ], + ], + 'pagination' => [ + 'current_page' => (int) $paginator->currentPage(), + 'per_page' => (int) $paginator->perPage(), + 'total' => (int) $paginator->total(), + 'last_page' => (int) $paginator->lastPage(), + ], + ]); + } + + public function suggest(SearchSuggestRequest $request): JsonResponse + { + $storeId = $this->currentStoreId($request); + $validated = $request->validated(); + + $queryTerm = trim((string) ($validated['q'] ?? $validated['query'] ?? '')); + $limit = (int) ($validated['limit'] ?? 5); + + $products = Product::query() + ->where('store_id', $storeId) + ->where('status', 'active') + ->where(function (Builder $builder) use ($queryTerm): void { + $prefix = $queryTerm.'%'; + + $builder->where('title', 'like', $prefix) + ->orWhere('handle', 'like', $prefix); + }) + ->with([ + 'media' => fn ($query) => $query->orderBy('position'), + 'variants' => fn ($query) => $query->where('status', 'active')->orderBy('price_amount'), + ]) + ->limit($limit) + ->get(); + + $collections = Collection::query() + ->where('store_id', $storeId) + ->where('status', 'active') + ->where(function (Builder $builder) use ($queryTerm): void { + $prefix = $queryTerm.'%'; + + $builder->where('title', 'like', $prefix) + ->orWhere('handle', 'like', $prefix); + }) + ->limit($limit) + ->get(); + + $suggestions = []; + + foreach ($products as $product) { + if (count($suggestions) >= $limit) { + break; + } + + $variant = $product->variants->first(); + $media = $product->media->first(); + + $suggestions[] = [ + 'type' => 'product', + 'title' => (string) $product->title, + 'handle' => (string) $product->handle, + 'image_url' => $this->imageUrl($media?->storage_key), + 'price_amount' => $variant !== null ? (int) $variant->price_amount : null, + 'currency' => $variant?->currency, + ]; + } + + foreach ($collections as $collection) { + if (count($suggestions) >= $limit) { + break; + } + + $suggestions[] = [ + 'type' => 'collection', + 'title' => (string) $collection->title, + 'handle' => (string) $collection->handle, + 'image_url' => null, + 'price_amount' => null, + 'currency' => null, + ]; + } + + return response()->json([ + 'query' => $queryTerm, + 'suggestions' => $suggestions, + ]); + } + + /** + * @param array|null $filters + */ + private function applyFilters(Builder $query, ?array $filters): void + { + if (! is_array($filters)) { + return; + } + + if (isset($filters['collection_id']) && is_numeric($filters['collection_id'])) { + $collectionId = (int) $filters['collection_id']; + + $query->whereHas('collections', fn (Builder $builder) => $builder->where('collections.id', $collectionId)); + } + + if (isset($filters['vendor']) && is_string($filters['vendor']) && $filters['vendor'] !== '') { + $query->where('vendor', $filters['vendor']); + } + + if (isset($filters['price_min']) && is_numeric($filters['price_min'])) { + $priceMin = (int) $filters['price_min']; + $query->whereHas('variants', fn (Builder $builder) => $builder->where('price_amount', '>=', $priceMin)); + } + + if (isset($filters['price_max']) && is_numeric($filters['price_max'])) { + $priceMax = (int) $filters['price_max']; + $query->whereHas('variants', fn (Builder $builder) => $builder->where('price_amount', '<=', $priceMax)); + } + + if (($filters['in_stock'] ?? false) === true) { + $query->whereHas('variants', function (Builder $builder): void { + $builder->where(function (Builder $variantQuery): void { + $variantQuery->whereHas('inventoryItem', function (Builder $inventoryQuery): void { + $inventoryQuery->where('policy', 'continue') + ->orWhereRaw('quantity_on_hand > quantity_reserved'); + })->orWhereDoesntHave('inventoryItem'); + }); + }); + } + + if (isset($filters['tags']) && is_array($filters['tags'])) { + foreach ($filters['tags'] as $tag) { + if (! is_string($tag) || $tag === '') { + continue; + } + + $query->where('tags', 'like', '%"'.$tag.'"%'); + } + } + } + + /** + * @return array|null + */ + private function parseFilters(mixed $filters): ?array + { + if ($filters === null) { + return []; + } + + if (is_array($filters)) { + return $filters; + } + + if (! is_string($filters)) { + return null; + } + + if (trim($filters) === '') { + return []; + } + + $decoded = json_decode($filters, true); + + return is_array($decoded) ? $decoded : null; + } + + private function imageUrl(?string $storageKey): ?string + { + if ($storageKey === null || $storageKey === '') { + return null; + } + + if (str_starts_with($storageKey, 'http://') || str_starts_with($storageKey, 'https://')) { + return $storageKey; + } + + try { + return Storage::disk((string) config('filesystems.default', 'public'))->url($storageKey); + } catch (\Throwable) { + return $storageKey; + } + } +} diff --git a/app/Http/Controllers/Storefront/Account/AccountController.php b/app/Http/Controllers/Storefront/Account/AccountController.php new file mode 100644 index 0000000..29749af --- /dev/null +++ b/app/Http/Controllers/Storefront/Account/AccountController.php @@ -0,0 +1,36 @@ +id(); + + if (! is_numeric($customerId)) { + abort(401, 'Customer not authenticated.'); + } + + return (int) $customerId; + } + + protected function currentCustomer(Request $request): Customer + { + $customer = Customer::query() + ->whereKey($this->currentCustomerId()) + ->where('store_id', $this->currentStoreId($request)) + ->first(); + + if (! $customer instanceof Customer) { + abort(404, 'Customer not found.'); + } + + return $customer; + } +} diff --git a/app/Http/Controllers/Storefront/Account/AddressController.php b/app/Http/Controllers/Storefront/Account/AddressController.php new file mode 100644 index 0000000..47d46bd --- /dev/null +++ b/app/Http/Controllers/Storefront/Account/AddressController.php @@ -0,0 +1,178 @@ +currentCustomer($request); + + $addresses = $customer->addresses() + ->orderByDesc('is_default') + ->orderBy('label') + ->get(); + + return view('storefront.account.addresses.index', [ + 'customer' => $customer, + 'addresses' => $addresses, + ]); + } + + public function store(Request $request): RedirectResponse + { + $customer = $this->currentCustomer($request); + $payload = $this->validatedPayload($request); + $isDefault = (bool) ($payload['is_default'] ?? false); + + if ($isDefault) { + $customer->addresses()->update(['is_default' => false]); + } + + $customer->addresses()->create([ + 'label' => $payload['label'] ?? null, + 'address_json' => $this->addressJson($payload), + 'is_default' => $isDefault || ! $customer->addresses()->exists(), + ]); + + return redirect()->route('account.addresses.index') + ->with('status', 'Address saved.'); + } + + public function update(Request $request, int $address): RedirectResponse + { + $customer = $this->currentCustomer($request); + $record = $this->findAddress($customer, $address); + $payload = $this->validatedPayload($request); + $isDefault = (bool) ($payload['is_default'] ?? false); + + if ($isDefault) { + $customer->addresses() + ->where('id', '!=', $record->id) + ->update(['is_default' => false]); + } + + $record->fill([ + 'label' => $payload['label'] ?? null, + 'address_json' => $this->addressJson($payload), + 'is_default' => $isDefault, + ]); + $record->save(); + + if (! $customer->addresses()->where('is_default', true)->exists()) { + $firstAddress = $customer->addresses()->orderBy('id')->first(); + + if ($firstAddress instanceof CustomerAddress) { + $firstAddress->is_default = true; + $firstAddress->save(); + } + } + + return redirect()->route('account.addresses.index') + ->with('status', 'Address updated.'); + } + + public function destroy(Request $request, int $address): RedirectResponse + { + $customer = $this->currentCustomer($request); + $record = $this->findAddress($customer, $address); + $wasDefault = (bool) $record->is_default; + + $record->delete(); + + if ($wasDefault) { + $firstAddress = $customer->addresses()->orderBy('id')->first(); + + if ($firstAddress instanceof CustomerAddress) { + $firstAddress->is_default = true; + $firstAddress->save(); + } + } + + return redirect()->route('account.addresses.index') + ->with('status', 'Address removed.'); + } + + /** + * @return array + */ + private function validatedPayload(Request $request): array + { + $validated = $request->validate([ + 'label' => ['nullable', 'string', 'max:100'], + 'first_name' => ['required', 'string', 'max:100'], + 'last_name' => ['required', 'string', 'max:100'], + 'address1' => ['required', 'string', 'max:255'], + 'address2' => ['nullable', 'string', 'max:255'], + 'city' => ['required', 'string', 'max:120'], + 'province' => ['nullable', 'string', 'max:120'], + 'province_code' => ['nullable', 'string', 'max:20'], + 'postal_code' => ['required', 'string', 'max:32'], + 'country' => ['nullable', 'string', 'max:120'], + 'country_code' => ['required', 'string', 'size:2'], + 'phone' => ['nullable', 'string', 'max:50'], + 'is_default' => ['nullable', 'boolean'], + ]); + + $validated['is_default'] = filter_var($validated['is_default'] ?? false, FILTER_VALIDATE_BOOL); + + return $validated; + } + + /** + * @param array $payload + * @return array + */ + private function addressJson(array $payload): array + { + $json = []; + + foreach ([ + 'first_name', + 'last_name', + 'address1', + 'address2', + 'city', + 'province', + 'province_code', + 'postal_code', + 'country', + 'country_code', + 'phone', + ] as $field) { + $value = $payload[$field] ?? null; + + if (! is_string($value)) { + continue; + } + + $trimmed = trim($value); + + if ($trimmed === '') { + continue; + } + + $json[$field] = $field === 'country_code' || $field === 'province_code' + ? strtoupper($trimmed) + : $trimmed; + } + + return $json; + } + + private function findAddress(Customer $customer, int $addressId): CustomerAddress + { + /** @var CustomerAddress $address */ + $address = $customer->addresses() + ->whereKey($addressId) + ->firstOrFail(); + + return $address; + } +} diff --git a/app/Http/Controllers/Storefront/Account/AuthController.php b/app/Http/Controllers/Storefront/Account/AuthController.php new file mode 100644 index 0000000..1f3f061 --- /dev/null +++ b/app/Http/Controllers/Storefront/Account/AuthController.php @@ -0,0 +1,158 @@ +check()) { + return redirect()->route('account.dashboard'); + } + + return view('storefront.account.login'); + } + + public function login(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'email' => ['required', 'string', 'email', 'max:255'], + 'password' => ['required', 'string'], + 'remember' => ['nullable', 'boolean'], + ]); + + $email = strtolower(trim((string) $validated['email'])); + $remember = (bool) ($validated['remember'] ?? false); + + if (! Auth::guard('customer')->attempt([ + 'email' => $email, + 'password' => (string) $validated['password'], + ], $remember)) { + throw ValidationException::withMessages([ + 'email' => __('auth.failed'), + ]); + } + + $request->session()->regenerate(); + + return redirect()->intended(route('account.dashboard')); + } + + public function showRegister(): View|RedirectResponse + { + if (Auth::guard('customer')->check()) { + return redirect()->route('account.dashboard'); + } + + return view('storefront.account.register'); + } + + public function register(Request $request): RedirectResponse + { + $storeId = $this->currentStoreId($request); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:120'], + 'email' => [ + 'required', + 'string', + 'email', + 'max:255', + Rule::unique('customers', 'email')->where(fn ($query) => $query->where('store_id', $storeId)), + ], + 'password' => ['required', 'string', 'confirmed', 'min:8', 'max:255'], + 'marketing_opt_in' => ['nullable', 'boolean'], + ]); + + $customer = Customer::query()->create([ + 'store_id' => $storeId, + 'name' => trim((string) $validated['name']), + 'email' => strtolower(trim((string) $validated['email'])), + 'password' => (string) $validated['password'], + 'marketing_opt_in' => (bool) ($validated['marketing_opt_in'] ?? false), + ]); + + Auth::guard('customer')->login($customer); + $request->session()->regenerate(); + + return redirect()->route('account.dashboard'); + } + + public function showForgotPassword(): View + { + return view('storefront.account.forgot-password'); + } + + public function sendResetLink(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'email' => ['required', 'string', 'email', 'max:255'], + ]); + + Password::broker('customers')->sendResetLink([ + 'email' => strtolower(trim((string) $validated['email'])), + ]); + + return back()->with('status', __('If that email exists, we have sent a password reset link.')); + } + + public function showResetPassword(string $token): View + { + return view('storefront.account.reset-password', [ + 'token' => $token, + ]); + } + + public function resetPassword(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'token' => ['required', 'string'], + 'email' => ['required', 'string', 'email', 'max:255'], + 'password' => ['required', 'string', 'confirmed', 'min:8', 'max:255'], + ]); + + $status = Password::broker('customers')->reset( + [ + 'email' => strtolower(trim((string) $validated['email'])), + 'password' => (string) $validated['password'], + 'password_confirmation' => (string) $request->input('password_confirmation'), + 'token' => (string) $validated['token'], + ], + function (Customer $customer, string $password): void { + $customer->forceFill([ + 'password' => $password, + ])->save(); + + event(new PasswordReset($customer)); + }, + ); + + if ($status === Password::PASSWORD_RESET) { + return redirect()->route('account.login')->with('status', __($status)); + } + + return back() + ->withInput($request->only('email')) + ->withErrors(['email' => __($status)]); + } + + public function logout(Request $request): RedirectResponse + { + Auth::guard('customer')->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('account.login'); + } +} diff --git a/app/Http/Controllers/Storefront/Account/DashboardController.php b/app/Http/Controllers/Storefront/Account/DashboardController.php new file mode 100644 index 0000000..4f487ac --- /dev/null +++ b/app/Http/Controllers/Storefront/Account/DashboardController.php @@ -0,0 +1,31 @@ +currentCustomer($request); + + $recentOrders = Order::query() + ->where('store_id', $this->currentStoreId($request)) + ->where('customer_id', $customer->id) + ->orderByDesc('placed_at') + ->orderByDesc('id') + ->limit(5) + ->get(); + + $addressCount = $customer->addresses()->count(); + + return view('storefront.account.dashboard', [ + 'customer' => $customer, + 'recentOrders' => $recentOrders, + 'addressCount' => $addressCount, + ]); + } +} diff --git a/app/Http/Controllers/Storefront/Account/OrderController.php b/app/Http/Controllers/Storefront/Account/OrderController.php new file mode 100644 index 0000000..b6e2ead --- /dev/null +++ b/app/Http/Controllers/Storefront/Account/OrderController.php @@ -0,0 +1,49 @@ +currentCustomer($request); + + $orders = Order::query() + ->where('store_id', $this->currentStoreId($request)) + ->where('customer_id', $customer->id) + ->orderByDesc('placed_at') + ->orderByDesc('id') + ->paginate(15) + ->withQueryString(); + + return view('storefront.account.orders.index', [ + 'customer' => $customer, + 'orders' => $orders, + ]); + } + + public function show(Request $request, string $orderNumber): View + { + $customer = $this->currentCustomer($request); + $orderNumberCandidates = Order::resolveOrderNumberCandidates($orderNumber); + + $order = Order::query() + ->where('store_id', $this->currentStoreId($request)) + ->where('customer_id', $customer->id) + ->whereIn('order_number', $orderNumberCandidates) + ->with([ + 'lines.variant.product', + 'fulfillments', + ]) + ->firstOrFail(); + + return view('storefront.account.orders.show', [ + 'customer' => $customer, + 'order' => $order, + ]); + } +} diff --git a/app/Http/Controllers/Storefront/CartController.php b/app/Http/Controllers/Storefront/CartController.php new file mode 100644 index 0000000..92d5387 --- /dev/null +++ b/app/Http/Controllers/Storefront/CartController.php @@ -0,0 +1,373 @@ +currentStoreId($request); + + $cart = $this->resolveCart($request, $storeId); + $lines = $cart?->lines ?? collect(); + + $subtotal = (int) $lines->sum(fn (CartLine $line): int => (int) $line->line_subtotal_amount); + $discount = (int) $lines->sum(fn (CartLine $line): int => (int) $line->line_discount_amount); + $total = (int) $lines->sum(fn (CartLine $line): int => (int) $line->line_total_amount); + $itemCount = (int) $lines->sum(fn (CartLine $line): int => (int) $line->quantity); + + return view('storefront.cart.show', [ + 'cart' => $cart, + 'subtotal' => $subtotal, + 'discount' => $discount, + 'total' => $total, + 'itemCount' => $itemCount, + ]); + } + + public function addLine(Request $request): RedirectResponse + { + try { + $validated = Validator::make($request->all(), [ + 'variant_id' => ['required', 'integer', 'min:1'], + 'quantity' => ['required', 'integer', 'min:1', 'max:9999'], + 'cart_version' => ['nullable', 'integer', 'min:1'], + 'expected_version' => ['nullable', 'integer', 'min:1'], + ])->validate(); + } catch (ValidationException $exception) { + return redirect()->back()->withErrors($exception->errors())->withInput(); + } + + $store = $this->currentStoreModel($request); + $cart = $this->resolveCart($request, (int) $store->id); + + if (! $cart instanceof Cart) { + $cart = $this->createCart($request, $store); + } + + $expectedVersion = $this->expectedCartVersion($validated); + + try { + $this->cartService->addLine( + cart: $cart, + variantId: (int) $validated['variant_id'], + quantity: (int) $validated['quantity'], + expectedVersion: $expectedVersion, + ); + } catch (ModelNotFoundException) { + return redirect()->back()->withErrors([ + 'variant_id' => 'The selected variant is invalid.', + ])->withInput(); + } catch (InsufficientInventoryException) { + return redirect()->back()->withErrors([ + 'quantity' => 'The selected quantity exceeds available inventory.', + ])->withInput(); + } catch (CartVersionMismatchException) { + return redirect()->back()->withErrors([ + 'cart' => 'Your cart changed in another tab. Refresh and try again.', + ])->withInput(); + } catch (InvalidArgumentException $exception) { + return redirect()->back()->withErrors([ + 'quantity' => $exception->getMessage(), + ])->withInput(); + } catch (Throwable) { + return redirect()->back()->withErrors([ + 'cart' => 'Unable to add item to cart right now.', + ])->withInput(); + } + + $request->session()->put('cart_id', (int) $cart->id); + + return redirect()->back()->with('status', 'Item added to cart.'); + } + + public function updateLine(Request $request, int $lineId): RedirectResponse + { + try { + $validated = Validator::make($request->all(), [ + 'quantity' => ['required', 'integer', 'min:1', 'max:9999'], + 'cart_version' => ['required_without:expected_version', 'integer', 'min:1'], + 'expected_version' => ['required_without:cart_version', 'integer', 'min:1'], + ])->validate(); + } catch (ValidationException $exception) { + return redirect()->route('storefront.cart.show')->withErrors($exception->errors())->withInput(); + } + + $cart = $this->resolveCart($request, $this->currentStoreId($request)); + + if (! $cart instanceof Cart) { + return redirect()->route('storefront.cart.show')->withErrors([ + 'cart' => 'Cart not found.', + ]); + } + + $line = $cart->lines->firstWhere('id', $lineId); + + if (! $line instanceof CartLine) { + return redirect()->route('storefront.cart.show')->withErrors([ + 'line' => 'Cart line not found.', + ]); + } + + try { + $this->cartService->updateLineQuantity( + cart: $cart, + lineId: (int) $line->id, + quantity: (int) $validated['quantity'], + expectedVersion: $this->expectedCartVersion($validated), + ); + } catch (InsufficientInventoryException) { + return redirect()->route('storefront.cart.show')->withErrors([ + 'quantity' => 'The selected quantity exceeds available inventory.', + ])->withInput(); + } catch (CartVersionMismatchException) { + return redirect()->route('storefront.cart.show')->withErrors([ + 'cart' => 'Your cart changed in another tab. Refresh and try again.', + ])->withInput(); + } catch (ModelNotFoundException) { + return redirect()->route('storefront.cart.show')->withErrors([ + 'line' => 'Cart line not found.', + ])->withInput(); + } catch (InvalidArgumentException $exception) { + return redirect()->route('storefront.cart.show')->withErrors([ + 'quantity' => $exception->getMessage(), + ])->withInput(); + } catch (Throwable) { + return redirect()->route('storefront.cart.show')->withErrors([ + 'cart' => 'Unable to update this line right now.', + ])->withInput(); + } + + return redirect()->route('storefront.cart.show')->with('status', 'Cart line updated.'); + } + + public function removeLine(Request $request, int $lineId): RedirectResponse + { + try { + $validated = Validator::make($request->all(), [ + 'cart_version' => ['required_without:expected_version', 'integer', 'min:1'], + 'expected_version' => ['required_without:cart_version', 'integer', 'min:1'], + ])->validate(); + } catch (ValidationException $exception) { + return redirect()->route('storefront.cart.show')->withErrors($exception->errors())->withInput(); + } + + $cart = $this->resolveCart($request, $this->currentStoreId($request)); + + if (! $cart instanceof Cart) { + return redirect()->route('storefront.cart.show')->withErrors([ + 'cart' => 'Cart not found.', + ]); + } + + $line = $cart->lines->firstWhere('id', $lineId); + + if (! $line instanceof CartLine) { + return redirect()->route('storefront.cart.show')->withErrors([ + 'line' => 'Cart line not found.', + ]); + } + + try { + $this->cartService->removeLine( + cart: $cart, + lineId: (int) $line->id, + expectedVersion: $this->expectedCartVersion($validated), + ); + } catch (CartVersionMismatchException) { + return redirect()->route('storefront.cart.show')->withErrors([ + 'cart' => 'Your cart changed in another tab. Refresh and try again.', + ])->withInput(); + } catch (ModelNotFoundException) { + return redirect()->route('storefront.cart.show')->withErrors([ + 'line' => 'Cart line not found.', + ]); + } catch (InvalidArgumentException $exception) { + return redirect()->route('storefront.cart.show')->withErrors([ + 'cart' => $exception->getMessage(), + ]); + } catch (Throwable) { + return redirect()->route('storefront.cart.show')->withErrors([ + 'cart' => 'Unable to remove this line right now.', + ]); + } + + return redirect()->route('storefront.cart.show')->with('status', 'Item removed from cart.'); + } + + public function startCheckout(Request $request): RedirectResponse + { + $storeId = $this->currentStoreId($request); + $cart = $this->resolveCart($request, $storeId); + + if (! $cart instanceof Cart) { + return redirect()->route('storefront.cart.show')->withErrors([ + 'cart' => 'Cart not found.', + ]); + } + + if ($cart->lines->isEmpty()) { + return redirect()->route('storefront.cart.show')->withErrors([ + 'cart' => 'Cannot create checkout from an empty cart.', + ]); + } + + $checkoutEmail = $this->checkoutEmail($cart); + + try { + $checkout = $this->checkoutService->createFromCart($cart, $checkoutEmail); + } catch (InvalidCheckoutStateException|InvalidArgumentException $exception) { + return redirect()->route('storefront.cart.show')->withErrors([ + 'cart' => $exception->getMessage(), + ]); + } catch (Throwable) { + $checkout = Checkout::query()->create([ + 'store_id' => (int) $cart->store_id, + 'cart_id' => (int) $cart->id, + 'customer_id' => $cart->customer_id, + 'status' => 'started', + 'email' => $checkoutEmail, + 'totals_json' => [ + 'subtotal' => (int) $cart->lines->sum('line_subtotal_amount'), + 'discount' => (int) $cart->lines->sum('line_discount_amount'), + 'shipping' => 0, + 'tax' => 0, + 'total' => (int) $cart->lines->sum('line_total_amount'), + 'currency' => (string) $cart->currency, + ], + 'expires_at' => now()->addDay(), + ]); + } + + return redirect()->route('storefront.checkout.show', ['checkoutId' => $checkout->id]) + ->with('status', 'Checkout started.'); + } + + private function createCart(Request $request, Store $store): Cart + { + $customer = null; + $customerId = Auth::guard('customer')->id(); + + if (is_numeric($customerId)) { + $resolvedCustomer = Customer::query()->find((int) $customerId); + + if ($resolvedCustomer instanceof Customer) { + $customer = $resolvedCustomer; + } + } + + try { + $cart = $this->cartService->create($store, $customer); + } catch (Throwable) { + $cart = Cart::query()->create([ + 'store_id' => (int) $store->id, + 'customer_id' => $customer?->id, + 'currency' => (string) $store->default_currency, + 'cart_version' => 1, + 'status' => CartStatus::Active->value, + ]); + } + + $request->session()->put('cart_id', (int) $cart->id); + + return $cart; + } + + /** + * @param array $validated + */ + private function expectedCartVersion(array $validated): ?int + { + $value = $validated['cart_version'] ?? $validated['expected_version'] ?? null; + + return is_numeric($value) ? (int) $value : null; + } + + private function checkoutEmail(Cart $cart): ?string + { + if (is_numeric($cart->customer_id)) { + $customer = Customer::query()->find((int) $cart->customer_id); + + if ($customer instanceof Customer && is_string($customer->email) && $customer->email !== '') { + return $customer->email; + } + } + + $guardCustomer = Auth::guard('customer')->user(); + + if ($guardCustomer instanceof Customer && is_string($guardCustomer->email) && $guardCustomer->email !== '') { + return $guardCustomer->email; + } + + return null; + } + + private function resolveCart(Request $request, int $storeId): ?Cart + { + $cartId = $request->session()->get('cart_id'); + + $query = Cart::query() + ->where('store_id', $storeId) + ->where('status', CartStatus::Active->value) + ->with([ + 'lines' => fn ($lineQuery) => $lineQuery + ->orderByDesc('id') + ->with([ + 'variant' => fn ($variantQuery) => $variantQuery->with('product'), + ]), + ]); + + if (is_numeric($cartId)) { + $cart = (clone $query)->whereKey((int) $cartId)->first(); + + if ($cart instanceof Cart) { + return $cart; + } + } + + $customerId = Auth::guard('customer')->id(); + + if (! is_numeric($customerId)) { + return null; + } + + $customerCart = (clone $query) + ->where('customer_id', (int) $customerId) + ->orderByDesc('updated_at') + ->first(); + + if ($customerCart instanceof Cart) { + $request->session()->put('cart_id', (int) $customerCart->id); + } + + return $customerCart; + } +} diff --git a/app/Http/Controllers/Storefront/CheckoutController.php b/app/Http/Controllers/Storefront/CheckoutController.php new file mode 100644 index 0000000..c70261c --- /dev/null +++ b/app/Http/Controllers/Storefront/CheckoutController.php @@ -0,0 +1,803 @@ +resolveCheckout($request, $checkoutId); + $this->isExpiredCheckout($checkout); + + $checkout->loadMissing(['cart.lines.variant.product', 'customer']); + + $totals = $this->normalizeTotals($checkout); + $status = $this->checkoutStatus($checkout); + $shippingMethods = $this->availableShippingMethods($checkout); + $discount = $this->currentDiscount($checkout); + $requiresShipping = $this->cartRequiresShipping($checkout); + + return view('storefront.checkout.show', [ + 'checkout' => $checkout, + 'totals' => $totals, + 'status' => $status, + 'shippingMethods' => $shippingMethods, + 'requiresShipping' => $requiresShipping, + 'paymentMethods' => [ + PaymentMethod::CreditCard->value => 'Credit card', + PaymentMethod::Paypal->value => 'PayPal', + PaymentMethod::BankTransfer->value => 'Bank transfer', + ], + 'appliedDiscount' => $discount, + ]); + } + + public function updateAddress(Request $request, int $checkoutId): RedirectResponse + { + $checkout = $this->resolveCheckout($request, $checkoutId); + + if ($this->isExpiredCheckout($checkout)) { + return $this->redirectToCheckoutWithErrors($checkoutId, ['checkout' => 'Checkout expired.']); + } + + try { + $validated = Validator::make($request->all(), [ + 'email' => ['required', 'email', 'max:255'], + 'shipping_address' => ['nullable', 'array'], + 'shipping_address.first_name' => ['required_with:shipping_address', 'string', 'max:255'], + 'shipping_address.last_name' => ['required_with:shipping_address', 'string', 'max:255'], + 'shipping_address.address1' => ['required_with:shipping_address', 'string', 'max:500'], + 'shipping_address.address2' => ['nullable', 'string', 'max:500'], + 'shipping_address.city' => ['required_with:shipping_address', 'string', 'max:255'], + 'shipping_address.province' => ['nullable', 'string', 'max:255'], + 'shipping_address.province_code' => ['nullable', 'string', 'max:10'], + 'shipping_address.country' => ['required_with:shipping_address', 'string', 'max:255'], + 'shipping_address.country_code' => ['required_with:shipping_address', 'string', 'size:2'], + 'shipping_address.postal_code' => ['required_with:shipping_address', 'string', 'max:20'], + 'shipping_address.phone' => ['nullable', 'string', 'max:50'], + 'billing_address' => ['nullable', 'array'], + 'billing_address.first_name' => ['required_with:billing_address', 'string', 'max:255'], + 'billing_address.last_name' => ['required_with:billing_address', 'string', 'max:255'], + 'billing_address.address1' => ['required_with:billing_address', 'string', 'max:500'], + 'billing_address.address2' => ['nullable', 'string', 'max:500'], + 'billing_address.city' => ['required_with:billing_address', 'string', 'max:255'], + 'billing_address.province' => ['nullable', 'string', 'max:255'], + 'billing_address.province_code' => ['nullable', 'string', 'max:10'], + 'billing_address.country' => ['required_with:billing_address', 'string', 'max:255'], + 'billing_address.country_code' => ['required_with:billing_address', 'string', 'size:2'], + 'billing_address.postal_code' => ['required_with:billing_address', 'string', 'max:20'], + 'billing_address.phone' => ['nullable', 'string', 'max:50'], + 'use_shipping_as_billing' => ['nullable', 'boolean'], + ])->validate(); + } catch (ValidationException $exception) { + return $this->redirectToCheckoutWithErrors($checkoutId, $exception->errors(), withInput: true); + } + + $shippingAddress = is_array($validated['shipping_address'] ?? null) ? $validated['shipping_address'] : []; + $useShippingAsBilling = (bool) ($validated['use_shipping_as_billing'] ?? true); + $billingAddress = $useShippingAsBilling + ? null + : (is_array($validated['billing_address'] ?? null) ? $validated['billing_address'] : null); + + try { + $this->checkoutService->setAddress( + checkout: $checkout, + email: (string) $validated['email'], + shippingAddress: $shippingAddress, + billingAddress: $billingAddress, + useShippingAsBilling: $useShippingAsBilling, + ); + } catch (InvalidCheckoutStateException $exception) { + return $this->redirectToCheckoutWithErrors($checkoutId, $this->checkoutStateErrors($exception), withInput: true); + } catch (InvalidArgumentException $exception) { + return $this->redirectToCheckoutWithErrors($checkoutId, [ + 'email' => $exception->getMessage(), + ], withInput: true); + } catch (Throwable) { + return $this->redirectToCheckoutWithErrors($checkoutId, [ + 'checkout' => 'Unable to update the address right now.', + ], withInput: true); + } + + return redirect()->route('storefront.checkout.show', ['checkoutId' => $checkoutId]) + ->with('status', 'Address saved.'); + } + + public function selectShippingMethod(Request $request, int $checkoutId): RedirectResponse + { + $checkout = $this->resolveCheckout($request, $checkoutId); + + if ($this->isExpiredCheckout($checkout)) { + return $this->redirectToCheckoutWithErrors($checkoutId, ['checkout' => 'Checkout expired.']); + } + + try { + $validated = Validator::make($request->all(), [ + 'shipping_method_id' => ['nullable', 'integer', 'min:1'], + ])->validate(); + } catch (ValidationException $exception) { + return $this->redirectToCheckoutWithErrors($checkoutId, $exception->errors(), withInput: true); + } + + $shippingMethodId = isset($validated['shipping_method_id']) && is_numeric($validated['shipping_method_id']) + ? (int) $validated['shipping_method_id'] + : null; + + if ($this->cartRequiresShipping($checkout) && $shippingMethodId === null) { + return $this->redirectToCheckoutWithErrors($checkoutId, [ + 'shipping_method_id' => 'Please select a shipping method.', + ], withInput: true); + } + + try { + $this->checkoutService->setShippingMethod($checkout, $shippingMethodId); + } catch (InvalidCheckoutStateException $exception) { + return $this->redirectToCheckoutWithErrors($checkoutId, $this->checkoutStateErrors($exception), withInput: true); + } catch (Throwable) { + return $this->redirectToCheckoutWithErrors($checkoutId, [ + 'checkout' => 'Unable to select a shipping method right now.', + ], withInput: true); + } + + return redirect()->route('storefront.checkout.show', ['checkoutId' => $checkoutId]) + ->with('status', 'Shipping method selected.'); + } + + public function selectPaymentMethod(Request $request, int $checkoutId): RedirectResponse + { + $checkout = $this->resolveCheckout($request, $checkoutId); + + if ($this->isExpiredCheckout($checkout)) { + return $this->redirectToCheckoutWithErrors($checkoutId, ['checkout' => 'Checkout expired.']); + } + + try { + $validated = Validator::make($request->all(), [ + 'payment_method' => ['required', 'string', Rule::in([ + PaymentMethod::CreditCard->value, + PaymentMethod::Paypal->value, + PaymentMethod::BankTransfer->value, + ])], + ])->validate(); + } catch (ValidationException $exception) { + return $this->redirectToCheckoutWithErrors($checkoutId, $exception->errors(), withInput: true); + } + + try { + $this->checkoutService->selectPaymentMethod($checkout, (string) $validated['payment_method']); + } catch (InvalidCheckoutStateException $exception) { + return $this->redirectToCheckoutWithErrors($checkoutId, $this->checkoutStateErrors($exception), withInput: true); + } catch (Throwable) { + return $this->redirectToCheckoutWithErrors($checkoutId, [ + 'checkout' => 'Unable to select a payment method right now.', + ], withInput: true); + } + + return redirect()->route('storefront.checkout.show', ['checkoutId' => $checkoutId]) + ->with('status', 'Payment method selected.'); + } + + public function applyDiscount(Request $request, int $checkoutId): RedirectResponse + { + $checkout = $this->resolveCheckout($request, $checkoutId); + + if ($this->isExpiredCheckout($checkout)) { + return $this->redirectToCheckoutWithErrors($checkoutId, ['checkout' => 'Checkout expired.']); + } + + try { + $validated = Validator::make($request->all(), [ + 'code' => ['required', 'string', 'max:50'], + ])->validate(); + } catch (ValidationException $exception) { + return $this->redirectToCheckoutWithErrors($checkoutId, $exception->errors(), withInput: true); + } + + try { + $this->checkoutService->applyDiscount($checkout, (string) $validated['code']); + } catch (InvalidDiscountException $exception) { + return $this->redirectToCheckoutWithErrors($checkoutId, [ + 'code' => $exception->getMessage(), + ], withInput: true); + } catch (InvalidCheckoutStateException $exception) { + return $this->redirectToCheckoutWithErrors($checkoutId, $this->checkoutStateErrors($exception), withInput: true); + } catch (Throwable) { + return $this->redirectToCheckoutWithErrors($checkoutId, [ + 'checkout' => 'Unable to apply discount right now.', + ], withInput: true); + } + + return redirect()->route('storefront.checkout.show', ['checkoutId' => $checkoutId]) + ->with('status', 'Discount applied.'); + } + + public function removeDiscount(Request $request, int $checkoutId): RedirectResponse + { + $checkout = $this->resolveCheckout($request, $checkoutId); + + if ($this->isExpiredCheckout($checkout)) { + return $this->redirectToCheckoutWithErrors($checkoutId, ['checkout' => 'Checkout expired.']); + } + + try { + $this->checkoutService->removeDiscount($checkout); + } catch (InvalidCheckoutStateException $exception) { + return $this->redirectToCheckoutWithErrors($checkoutId, $this->checkoutStateErrors($exception)); + } catch (Throwable) { + return $this->redirectToCheckoutWithErrors($checkoutId, [ + 'checkout' => 'Unable to remove discount right now.', + ]); + } + + return redirect()->route('storefront.checkout.show', ['checkoutId' => $checkoutId]) + ->with('status', 'Discount removed.'); + } + + public function pay(Request $request, int $checkoutId): RedirectResponse + { + $checkout = $this->resolveCheckout($request, $checkoutId); + + if ($this->isExpiredCheckout($checkout)) { + return $this->redirectToCheckoutWithErrors($checkoutId, ['checkout' => 'Checkout expired.']); + } + + $status = $this->checkoutStatus($checkout); + + if ($status === CheckoutStatus::Completed->value) { + return redirect()->route('storefront.checkout.confirmation', ['checkoutId' => $checkoutId]) + ->with('status', 'Checkout already completed.'); + } + + if ($status !== CheckoutStatus::PaymentSelected->value) { + return $this->redirectToCheckoutWithErrors($checkoutId, [ + 'checkout' => 'Checkout is not ready for payment.', + ]); + } + + try { + $validated = Validator::make($request->all(), [ + 'payment_method' => ['nullable', 'string', Rule::in([ + PaymentMethod::CreditCard->value, + PaymentMethod::Paypal->value, + PaymentMethod::BankTransfer->value, + ])], + 'card_number' => ['nullable', 'string', 'max:32'], + 'card_expiry' => ['nullable', 'string', 'max:5'], + 'card_cvc' => ['nullable', 'string', 'max:4'], + 'card_holder' => ['nullable', 'string', 'max:255'], + ])->validate(); + } catch (ValidationException $exception) { + return $this->redirectToCheckoutWithErrors($checkoutId, $exception->errors(), withInput: true); + } + + $paymentMethod = (string) ($validated['payment_method'] ?? $this->enumValue($checkout->payment_method)); + + if ($paymentMethod === '') { + return $this->redirectToCheckoutWithErrors($checkoutId, [ + 'payment_method' => 'Payment method is required.', + ], withInput: true); + } + + try { + $this->completeCheckout($checkout, $paymentMethod); + } catch (InvalidCheckoutStateException $exception) { + return $this->redirectToCheckoutWithErrors($checkoutId, $this->checkoutStateErrors($exception), withInput: true); + } catch (Throwable) { + return $this->redirectToCheckoutWithErrors($checkoutId, [ + 'checkout' => 'Unable to complete payment right now.', + ], withInput: true); + } + + $request->session()->forget('cart_id'); + + return redirect()->route('storefront.checkout.confirmation', ['checkoutId' => $checkoutId]) + ->with('status', 'Payment completed.'); + } + + public function confirmation(Request $request, int $checkoutId): View + { + $checkout = $this->resolveCheckout($request, $checkoutId); + $totals = $this->normalizeTotals($checkout); + $status = $this->checkoutStatus($checkout); + $order = null; + + if ($status === CheckoutStatus::Completed->value) { + $order = $this->findOrderForCheckout($checkout); + } + + return view('storefront.checkout.confirmation', [ + 'checkout' => $checkout, + 'totals' => $totals, + 'status' => $status, + 'order' => $order, + ]); + } + + private function resolveCheckout(Request $request, int $checkoutId): Checkout + { + /** @var Checkout $checkout */ + $checkout = Checkout::query() + ->where('store_id', $this->currentStoreId($request)) + ->whereKey($checkoutId) + ->with([ + 'cart.lines.variant.product', + 'customer', + ]) + ->firstOrFail(); + + return $checkout; + } + + private function completeCheckout(Checkout $checkout, string $paymentMethod): void + { + DB::transaction(function () use ($checkout, $paymentMethod): void { + /** @var Checkout $checkoutRecord */ + $checkoutRecord = Checkout::query() + ->whereKey($checkout->id) + ->lockForUpdate() + ->with([ + 'cart.lines.variant.product', + ]) + ->firstOrFail(); + + $status = $this->checkoutStatus($checkoutRecord); + + if ($status === CheckoutStatus::Completed->value) { + return; + } + + if ($status !== CheckoutStatus::PaymentSelected->value) { + throw InvalidCheckoutStateException::invalidTransition($checkoutRecord, [CheckoutStatus::PaymentSelected->value]); + } + + $cart = $checkoutRecord->cart; + + if (! $cart instanceof Cart) { + throw new InvalidArgumentException('Cart not found for checkout.'); + } + + $finalizedDiscount = $this->lockDiscountForFinalization($checkoutRecord); + + if ($finalizedDiscount === null && $this->hasDiscountCode($checkoutRecord)) { + $checkoutRecord->discount_code = null; + $checkoutRecord->save(); + } + + try { + $pricingResult = $this->checkoutService->computeTotals($checkoutRecord); + } catch (InvalidDiscountException) { + $finalizedDiscount = null; + $checkoutRecord->discount_code = null; + $checkoutRecord->save(); + + $pricingResult = $this->checkoutService->computeTotals($checkoutRecord); + } + + $totals = $this->pricingTotals($pricingResult); + + $cart->load(['lines.variant.product']); + + /** @var Collection $cartLines */ + $cartLines = $cart->lines + ->sortBy('id') + ->values(); + + $lineDiscountAmounts = $this->lineDiscountAmountsFromCartLines($cartLines); + + $isBankTransfer = $paymentMethod === PaymentMethod::BankTransfer->value; + + $order = Order::query()->create([ + 'store_id' => (int) $checkoutRecord->store_id, + 'customer_id' => $checkoutRecord->customer_id, + 'checkout_id' => (int) $checkoutRecord->id, + 'order_number' => $this->nextOrderNumber((int) $checkoutRecord->store_id), + 'payment_method' => $paymentMethod, + 'status' => $isBankTransfer ? 'pending' : 'paid', + 'financial_status' => $isBankTransfer ? 'pending' : 'paid', + 'fulfillment_status' => 'unfulfilled', + 'currency' => (string) $totals['currency'], + 'subtotal_amount' => (int) $totals['subtotal'], + 'discount_amount' => (int) $totals['discount'], + 'shipping_amount' => (int) $totals['shipping'], + 'tax_amount' => (int) $totals['tax'], + 'total_amount' => (int) $totals['total'], + 'email' => $checkoutRecord->email, + 'billing_address_json' => $checkoutRecord->billing_address_json, + 'shipping_address_json' => $checkoutRecord->shipping_address_json, + 'placed_at' => now(), + ]); + + /** @var CartLine $line */ + foreach ($cartLines as $line) { + $variant = $line->variant; + $product = $variant?->product; + $lineId = (int) $line->id; + $lineDiscountAmount = (int) ($lineDiscountAmounts[$lineId] ?? 0); + + $order->lines()->create([ + 'product_id' => $product?->id, + 'variant_id' => $variant?->id, + 'title_snapshot' => (string) ($product?->title ?? 'Product'), + 'sku_snapshot' => $variant?->sku, + 'quantity' => (int) $line->quantity, + 'unit_price_amount' => (int) $line->unit_price_amount, + 'total_amount' => (int) $line->line_total_amount, + 'tax_lines_json' => [], + 'discount_allocations_json' => $this->orderLineDiscountAllocations( + discount: $finalizedDiscount, + lineDiscountAmount: $lineDiscountAmount, + ), + ]); + } + + $this->checkoutService->commitReservedInventoryForCheckout($checkoutRecord); + + if ($finalizedDiscount instanceof Discount) { + $finalizedDiscount->increment('usage_count'); + } + + $order->payments()->create([ + 'provider' => 'mock', + 'method' => $paymentMethod, + 'provider_payment_id' => 'mock_'.Str::lower(Str::random(16)), + 'status' => $isBankTransfer ? 'pending' : 'captured', + 'amount' => (int) $order->total_amount, + 'currency' => (string) $order->currency, + 'raw_json_encrypted' => null, + 'created_at' => now(), + ]); + + $cart->status = CartStatus::Converted; + $cart->save(); + + $checkoutRecord->status = CheckoutStatus::Completed; + $checkoutRecord->payment_method = $paymentMethod; + $checkoutRecord->totals_json = $totals; + $checkoutRecord->save(); + }); + } + + private function hasDiscountCode(Checkout $checkout): bool + { + return is_string($checkout->discount_code) && trim($checkout->discount_code) !== ''; + } + + private function lockDiscountForFinalization(Checkout $checkout): ?Discount + { + if (! $this->hasDiscountCode($checkout)) { + return null; + } + + $discountCode = trim((string) $checkout->discount_code); + + /** @var Discount|null $discount */ + $discount = Discount::query() + ->where('store_id', (int) $checkout->store_id) + ->whereRaw('lower(code) = ?', [Str::lower($discountCode)]) + ->where('status', 'active') + ->where('starts_at', '<=', now()) + ->where(function ($query): void { + $query->whereNull('ends_at') + ->orWhere('ends_at', '>=', now()); + }) + ->lockForUpdate() + ->first(); + + if (! $discount instanceof Discount) { + return null; + } + + if ($discount->usage_limit !== null && (int) $discount->usage_count >= (int) $discount->usage_limit) { + return null; + } + + return $discount; + } + + /** + * @param Collection $lines + * @return array + */ + private function lineDiscountAmountsFromCartLines(Collection $lines): array + { + if ($lines->isEmpty()) { + return []; + } + + /** @var array $allocations */ + $allocations = []; + + /** @var CartLine $line */ + foreach ($lines as $line) { + $lineId = (int) $line->id; + if ($lineId <= 0) { + continue; + } + + $lineSubtotal = max(0, (int) $line->line_subtotal_amount); + $lineDiscount = max(0, min($lineSubtotal, (int) $line->line_discount_amount)); + + if ($lineDiscount <= 0) { + continue; + } + + $allocations[$lineId] = $lineDiscount; + } + + return $allocations; + } + + /** + * @return list + */ + private function orderLineDiscountAllocations(?Discount $discount, int $lineDiscountAmount): array + { + if (! $discount instanceof Discount || $lineDiscountAmount <= 0) { + return []; + } + + return [[ + 'discount_id' => (int) $discount->id, + 'code' => (string) ($discount->code ?? ''), + 'amount' => $lineDiscountAmount, + ]]; + } + + /** + * @param array|string> $errors + */ + private function redirectToCheckoutWithErrors(int $checkoutId, array $errors, bool $withInput = false): RedirectResponse + { + $redirect = redirect()->route('storefront.checkout.show', ['checkoutId' => $checkoutId]) + ->withErrors($errors); + + if ($withInput) { + $redirect->withInput(); + } + + return $redirect; + } + + /** + * @return array + */ + private function checkoutStateErrors(InvalidCheckoutStateException $exception): array + { + return match ($exception->reasonCode) { + 'invalid_shipping_method' => ['shipping_method_id' => 'The selected shipping method is invalid.'], + 'shipping_address_required', 'missing_address_field', 'unserviceable_address' => ['shipping_address' => $exception->getMessage()], + 'invalid_payment_method' => ['payment_method' => $exception->getMessage()], + default => ['checkout' => $exception->getMessage()], + }; + } + + /** + * @return array + */ + private function availableShippingMethods(Checkout $checkout): array + { + try { + return $this->checkoutService->availableShippingMethods($checkout) + ->map(function (ShippingRateQuote $quote) use ($checkout): array { + return [ + 'id' => $quote->rateId, + 'name' => $quote->name, + 'type' => $quote->type->value, + 'amount' => $quote->amount, + 'currency' => (string) ($checkout->cart?->currency ?? 'USD'), + ]; + }) + ->values() + ->all(); + } catch (Throwable) { + return []; + } + } + + private function currentDiscount(Checkout $checkout): ?Discount + { + if (! is_string($checkout->discount_code) || trim($checkout->discount_code) === '') { + return null; + } + + /** @var Discount|null $discount */ + $discount = Discount::query() + ->where('store_id', (int) $checkout->store_id) + ->whereRaw('lower(code) = ?', [Str::lower(trim($checkout->discount_code))]) + ->where('status', 'active') + ->where('starts_at', '<=', now()) + ->where(function ($query): void { + $query->whereNull('ends_at') + ->orWhere('ends_at', '>=', now()); + }) + ->first(); + + return $discount; + } + + private function cartRequiresShipping(Checkout $checkout): bool + { + $cart = $checkout->cart; + + if (! $cart instanceof Cart) { + return false; + } + + return $cart->lines->contains(static function (CartLine $line): bool { + return (bool) ($line->variant?->requires_shipping ?? false); + }); + } + + private function isExpiredCheckout(Checkout $checkout): bool + { + $status = $this->checkoutStatus($checkout); + + if ($status === CheckoutStatus::Completed->value) { + return false; + } + + $expired = $status === CheckoutStatus::Expired->value + || ($checkout->expires_at !== null && $checkout->expires_at->isPast()); + + if ($expired && $status !== CheckoutStatus::Expired->value) { + $checkout->status = CheckoutStatus::Expired; + $checkout->save(); + + if ($status === CheckoutStatus::PaymentSelected->value) { + $this->checkoutService->releaseReservedInventoryForCheckout($checkout); + } + } + + return $expired; + } + + /** + * @param array $totals + * @return array{subtotal_amount: int, discount_amount: int, shipping_amount: int, tax_amount: int, total_amount: int, currency: string} + */ + private function totalsMap(array $totals): array + { + $currency = $totals['currency'] ?? 'USD'; + + return [ + 'subtotal_amount' => (int) ($totals['subtotal_amount'] ?? $totals['subtotal'] ?? 0), + 'discount_amount' => (int) ($totals['discount_amount'] ?? $totals['discount'] ?? 0), + 'shipping_amount' => (int) ($totals['shipping_amount'] ?? $totals['shipping'] ?? 0), + 'tax_amount' => (int) ($totals['tax_amount'] ?? $totals['tax'] ?? 0), + 'total_amount' => (int) ($totals['total_amount'] ?? $totals['total'] ?? 0), + 'currency' => is_string($currency) && $currency !== '' ? $currency : 'USD', + ]; + } + + /** + * @return array{subtotal_amount: int, discount_amount: int, shipping_amount: int, tax_amount: int, total_amount: int, currency: string} + */ + private function normalizeTotals(Checkout $checkout): array + { + $totals = is_array($checkout->totals_json) ? $checkout->totals_json : []; + + if (! isset($totals['currency'])) { + $totals['currency'] = $checkout->cart?->currency ?? 'USD'; + } + + return $this->totalsMap($totals); + } + + /** + * @return array{subtotal: int, discount: int, shipping: int, tax: int, total: int, currency: string} + */ + private function pricingTotals(PricingResult $result): array + { + $payload = $result->toArray(); + + return [ + 'subtotal' => (int) ($payload['subtotal'] ?? 0), + 'discount' => (int) ($payload['discount'] ?? 0), + 'shipping' => (int) ($payload['shipping'] ?? 0), + 'tax' => (int) ($payload['tax'] ?? 0), + 'total' => (int) ($payload['total'] ?? 0), + 'currency' => (string) ($payload['currency'] ?? 'USD'), + ]; + } + + private function checkoutStatus(Checkout $checkout): string + { + $status = $checkout->status; + + if ($status instanceof CheckoutStatus) { + return $status->value; + } + + return (string) $status; + } + + private function enumValue(mixed $value): ?string + { + if ($value instanceof PaymentMethod) { + return $value->value; + } + + if (! is_string($value)) { + return null; + } + + return $value; + } + + private function findOrderForCheckout(Checkout $checkout): ?Order + { + $order = Order::query() + ->where('store_id', (int) $checkout->store_id) + ->where('checkout_id', (int) $checkout->id) + ->orderByDesc('placed_at') + ->orderByDesc('id') + ->first(); + + if ($order instanceof Order) { + return $order; + } + + $totals = $this->normalizeTotals($checkout); + $orderQuery = Order::query() + ->where('store_id', (int) $checkout->store_id) + ->where('total_amount', (int) $totals['total_amount']) + ->whereNull('checkout_id'); + + if (is_numeric($checkout->customer_id)) { + $orderQuery->where('customer_id', (int) $checkout->customer_id); + } elseif (is_string($checkout->email) && $checkout->email !== '') { + $orderQuery->where('email', $checkout->email); + } + + /** @var Order|null $order */ + $order = $orderQuery + ->orderByDesc('placed_at') + ->orderByDesc('id') + ->first(); + + return $order; + } + + private function nextOrderNumber(int $storeId): string + { + $max = (int) Order::query() + ->where('store_id', $storeId) + ->selectRaw("MAX(CAST(CASE WHEN order_number LIKE '#%' THEN SUBSTR(order_number, 2) ELSE order_number END AS INTEGER)) AS max_order") + ->value('max_order'); + + $next = $max > 0 ? $max + 1 : 1001; + + return '#'.$next; + } +} diff --git a/app/Http/Controllers/Storefront/CollectionController.php b/app/Http/Controllers/Storefront/CollectionController.php new file mode 100644 index 0000000..d7d7665 --- /dev/null +++ b/app/Http/Controllers/Storefront/CollectionController.php @@ -0,0 +1,60 @@ +where('store_id', $this->currentStoreId($request)) + ->where('status', CollectionStatus::Active->value) + ->withCount([ + 'products as active_products_count' => fn ($query) => $query + ->where('products.status', ProductStatus::Active->value) + ->whereNotNull('products.published_at'), + ]) + ->orderBy('title') + ->paginate(18) + ->withQueryString(); + + return view('storefront.collections.index', [ + 'collections' => $collections, + ]); + } + + public function show(Request $request, string $handle): View + { + $collection = Collection::query() + ->where('store_id', $this->currentStoreId($request)) + ->where('handle', $handle) + ->where('status', CollectionStatus::Active->value) + ->firstOrFail(); + + $collection->load([ + 'products' => fn ($query) => $query + ->where('products.status', ProductStatus::Active->value) + ->whereNotNull('products.published_at') + ->with([ + 'variants' => fn ($variantQuery) => $variantQuery + ->where('status', ProductVariantStatus::Active->value) + ->orderByDesc('is_default') + ->orderBy('position') + ->orderBy('price_amount'), + ]) + ->orderBy('collection_products.position') + ->orderBy('products.title'), + ]); + + return view('storefront.collections.show', [ + 'collection' => $collection, + ]); + } +} diff --git a/app/Http/Controllers/Storefront/HomeController.php b/app/Http/Controllers/Storefront/HomeController.php new file mode 100644 index 0000000..5a8ea9a --- /dev/null +++ b/app/Http/Controllers/Storefront/HomeController.php @@ -0,0 +1,49 @@ +currentStoreId($request); + + $collections = Collection::query() + ->where('store_id', $storeId) + ->where('status', CollectionStatus::Active->value) + ->withCount('products') + ->orderByDesc('updated_at') + ->orderBy('title') + ->limit(6) + ->get(); + + $products = Product::query() + ->where('store_id', $storeId) + ->where('status', ProductStatus::Active->value) + ->whereNotNull('published_at') + ->with([ + 'variants' => fn ($query) => $query + ->where('status', ProductVariantStatus::Active->value) + ->orderByDesc('is_default') + ->orderBy('position') + ->orderBy('price_amount'), + ]) + ->orderByDesc('published_at') + ->orderByDesc('id') + ->limit(8) + ->get(); + + return view('storefront.home', [ + 'collections' => $collections, + 'products' => $products, + ]); + } +} diff --git a/app/Http/Controllers/Storefront/PageController.php b/app/Http/Controllers/Storefront/PageController.php new file mode 100644 index 0000000..da0823e --- /dev/null +++ b/app/Http/Controllers/Storefront/PageController.php @@ -0,0 +1,28 @@ +where('store_id', $this->currentStoreId($request)) + ->where('handle', $handle) + ->where('status', PageStatus::Published->value) + ->where(function ($query): void { + $query->whereNull('published_at') + ->orWhere('published_at', '<=', now()); + }) + ->firstOrFail(); + + return view('storefront.pages.show', [ + 'page' => $page, + ]); + } +} diff --git a/app/Http/Controllers/Storefront/ProductController.php b/app/Http/Controllers/Storefront/ProductController.php new file mode 100644 index 0000000..16999de --- /dev/null +++ b/app/Http/Controllers/Storefront/ProductController.php @@ -0,0 +1,37 @@ +where('store_id', $this->currentStoreId($request)) + ->where('handle', $handle) + ->where('status', ProductStatus::Active->value) + ->whereNotNull('published_at') + ->with([ + 'variants' => fn ($query) => $query + ->where('status', ProductVariantStatus::Active->value) + ->orderByDesc('is_default') + ->orderBy('position') + ->orderBy('price_amount'), + 'collections' => fn ($query) => $query + ->where('collections.status', CollectionStatus::Active->value) + ->orderBy('collections.title'), + ]) + ->firstOrFail(); + + return view('storefront.products.show', [ + 'product' => $product, + ]); + } +} diff --git a/app/Http/Controllers/Storefront/SearchController.php b/app/Http/Controllers/Storefront/SearchController.php new file mode 100644 index 0000000..83f5bf3 --- /dev/null +++ b/app/Http/Controllers/Storefront/SearchController.php @@ -0,0 +1,50 @@ +currentStoreId($request); + $queryTerm = trim((string) $request->query('q', '')); + + $products = Product::query() + ->where('store_id', $storeId) + ->where('status', ProductStatus::Active->value) + ->whereNotNull('published_at') + ->when($queryTerm !== '', function (Builder $query) use ($queryTerm): void { + $like = '%'.$queryTerm.'%'; + + $query->where(function (Builder $scopedQuery) use ($like): void { + $scopedQuery->where('title', 'like', $like) + ->orWhere('handle', 'like', $like) + ->orWhere('vendor', 'like', $like) + ->orWhere('product_type', 'like', $like); + }); + }) + ->with([ + 'variants' => fn ($variantQuery) => $variantQuery + ->where('status', ProductVariantStatus::Active->value) + ->orderByDesc('is_default') + ->orderBy('position') + ->orderBy('price_amount'), + ]) + ->orderByDesc('published_at') + ->orderBy('title') + ->paginate(16) + ->withQueryString(); + + return view('storefront.search.index', [ + 'query' => $queryTerm, + 'products' => $products, + ]); + } +} diff --git a/app/Http/Controllers/Storefront/StorefrontController.php b/app/Http/Controllers/Storefront/StorefrontController.php new file mode 100644 index 0000000..b468742 --- /dev/null +++ b/app/Http/Controllers/Storefront/StorefrontController.php @@ -0,0 +1,51 @@ +attributes->get('current_store'); + + if ($attributeStore instanceof CurrentStore) { + return $attributeStore; + } + + if (app()->bound(CurrentStore::class)) { + $containerStore = app(CurrentStore::class); + + if ($containerStore instanceof CurrentStore) { + return $containerStore; + } + } + + abort(404, 'Store not found.'); + } + + protected function currentStoreId(Request $request): int + { + return $this->currentStore($request)->id; + } + + protected function currentStoreModel(Request $request): Store + { + $store = Store::query()->find($this->currentStoreId($request)); + + if (! $store instanceof Store) { + abort(404, 'Store not found.'); + } + + return $store; + } + + protected function formatMoney(int $amount, string $currency): string + { + return number_format($amount / 100, 2, '.', ',').' '.strtoupper($currency); + } +} diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php new file mode 100644 index 0000000..ba62f63 --- /dev/null +++ b/app/Http/Middleware/Authenticate.php @@ -0,0 +1,33 @@ + $guards + */ + protected function unauthenticated($request, array $guards): never + { + throw new AuthenticationException( + 'Unauthenticated.', + $guards, + $this->redirectToPath($guards), + ); + } + + /** + * @param array $guards + */ + private function redirectToPath(array $guards): string + { + if (in_array('customer', $guards, true)) { + return route('account.login'); + } + + return route('login'); + } +} diff --git a/app/Http/Middleware/CheckStoreRole.php b/app/Http/Middleware/CheckStoreRole.php new file mode 100644 index 0000000..794c805 --- /dev/null +++ b/app/Http/Middleware/CheckStoreRole.php @@ -0,0 +1,109 @@ +resolveCurrentStore($request); + + if ($store === null) { + abort(500, 'Store context is not available.'); + } + + $user = $request->user(); + + if (! $user instanceof Authenticatable) { + abort(403, 'You do not have access to this store.'); + } + + $storeUser = DB::table('store_users') + ->where('store_id', $store->id) + ->where('user_id', $user->getAuthIdentifier()) + ->first(); + + if ($storeUser === null) { + abort(403, 'You do not have access to this store.'); + } + + $allowedRoles = $this->normalizeRoles(array_values($roles)); + $role = is_string($storeUser->role ?? null) ? strtolower($storeUser->role) : null; + + if ($allowedRoles !== [] && ($role === null || ! in_array($role, $allowedRoles, true))) { + abort(403, 'Insufficient permissions.'); + } + + $request->attributes->set('store_user', $storeUser); + + return $next($request); + } + + private function resolveCurrentStore(Request $request): ?CurrentStore + { + $attributeStore = $request->attributes->get('current_store'); + + if ($attributeStore instanceof CurrentStore) { + return $attributeStore; + } + + if (app()->bound(CurrentStore::class)) { + $containerStore = app(CurrentStore::class); + + if ($containerStore instanceof CurrentStore) { + return $containerStore; + } + } + + if (! app()->bound('current_store')) { + return null; + } + + $store = app('current_store'); + + if ($store instanceof CurrentStore) { + return $store; + } + + if (is_array($store)) { + /** @var array $normalized */ + $normalized = []; + + foreach ($store as $key => $value) { + if (is_string($key)) { + $normalized[$key] = $value; + } + } + + return CurrentStore::fromRecord($normalized); + } + + if (is_object($store)) { + return CurrentStore::fromRecord($store); + } + + return null; + } + + /** + * @param array $roles + * @return list + */ + private function normalizeRoles(array $roles): array + { + return array_values(array_filter(array_map( + static fn (string $role): string => strtolower(trim($role)), + $roles, + ))); + } +} diff --git a/app/Http/Middleware/ResolveStore.php b/app/Http/Middleware/ResolveStore.php new file mode 100644 index 0000000..e07be6a --- /dev/null +++ b/app/Http/Middleware/ResolveStore.php @@ -0,0 +1,80 @@ +getHost()); + $cacheKey = $this->cacheKey($hostname); + + /** @var array{id: int, status: string, handle: string|null, name: string|null}|null $storeData */ + $storeData = Cache::remember( + $cacheKey, + now()->addMinutes(5), + fn (): ?array => $this->lookupStore($hostname), + ); + + if ($storeData === null) { + abort(404, 'Store not found.'); + } + + $store = CurrentStore::fromRecord($storeData); + + if ($store->isSuspended()) { + abort(503, 'This store is currently unavailable.'); + } + + $request->attributes->set('current_store', $store); + app()->instance('current_store', $store); + app()->instance(CurrentStore::class, $store); + View::share('currentStore', $store); + + return $next($request); + } + + private function cacheKey(string $hostname): string + { + return sprintf('store_domain:%s', $hostname); + } + + /** + * @return array{id: int, status: string, handle: string|null, name: string|null}|null + */ + private function lookupStore(string $hostname): ?array + { + $record = DB::table('store_domains') + ->join('stores', 'stores.id', '=', 'store_domains.store_id') + ->whereRaw('LOWER(store_domains.hostname) = ?', [$hostname]) + ->select([ + 'stores.id', + 'stores.status', + 'stores.handle', + 'stores.name', + ]) + ->first(); + + if ($record === null) { + return null; + } + + return CurrentStore::fromRecord($record)->toArray(); + } +} diff --git a/app/Http/Requests/Admin/Collections/ListCollectionsRequest.php b/app/Http/Requests/Admin/Collections/ListCollectionsRequest.php new file mode 100644 index 0000000..42ee1a1 --- /dev/null +++ b/app/Http/Requests/Admin/Collections/ListCollectionsRequest.php @@ -0,0 +1,27 @@ + + */ + public function rules(): array + { + return [ + 'status' => ['nullable', 'string', Rule::in(['draft', 'active', 'archived'])], + 'query' => ['nullable', 'string', 'max:255'], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Collections/StoreCollectionRequest.php b/app/Http/Requests/Admin/Collections/StoreCollectionRequest.php new file mode 100644 index 0000000..6f25c9e --- /dev/null +++ b/app/Http/Requests/Admin/Collections/StoreCollectionRequest.php @@ -0,0 +1,30 @@ + + */ + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['nullable', 'string', 'max:255', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'], + 'description_html' => ['nullable', 'string'], + 'type' => ['nullable', 'string', Rule::in(['manual', 'automated'])], + 'status' => ['nullable', 'string', Rule::in(['draft', 'active', 'archived'])], + 'product_ids' => ['nullable', 'array'], + 'product_ids.*' => ['integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Collections/UpdateCollectionRequest.php b/app/Http/Requests/Admin/Collections/UpdateCollectionRequest.php new file mode 100644 index 0000000..c114812 --- /dev/null +++ b/app/Http/Requests/Admin/Collections/UpdateCollectionRequest.php @@ -0,0 +1,34 @@ + + */ + public function rules(): array + { + return [ + 'title' => ['sometimes', 'string', 'max:255'], + 'handle' => ['sometimes', 'nullable', 'string', 'max:255', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'], + 'description_html' => ['sometimes', 'nullable', 'string'], + 'type' => ['sometimes', 'string', Rule::in(['manual', 'automated'])], + 'status' => ['sometimes', 'string', Rule::in(['draft', 'active', 'archived'])], + 'product_ids' => ['sometimes', 'array'], + 'product_ids.*' => ['integer', 'min:1'], + 'add_product_ids' => ['sometimes', 'array'], + 'add_product_ids.*' => ['integer', 'min:1'], + 'remove_product_ids' => ['sometimes', 'array'], + 'remove_product_ids.*' => ['integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Discounts/ListDiscountsRequest.php b/app/Http/Requests/Admin/Discounts/ListDiscountsRequest.php new file mode 100644 index 0000000..0596859 --- /dev/null +++ b/app/Http/Requests/Admin/Discounts/ListDiscountsRequest.php @@ -0,0 +1,27 @@ + + */ + public function rules(): array + { + return [ + 'type' => ['nullable', 'string', Rule::in(['code', 'automatic'])], + 'status' => ['nullable', 'string', Rule::in(['draft', 'active', 'expired', 'disabled'])], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Discounts/StoreDiscountRequest.php b/app/Http/Requests/Admin/Discounts/StoreDiscountRequest.php new file mode 100644 index 0000000..5b91dfd --- /dev/null +++ b/app/Http/Requests/Admin/Discounts/StoreDiscountRequest.php @@ -0,0 +1,33 @@ + + */ + public function rules(): array + { + return [ + 'type' => ['required', 'string', Rule::in(['code', 'automatic'])], + 'code' => ['nullable', 'required_if:type,code', 'string', 'max:50'], + 'value_type' => ['required', 'string', Rule::in(['fixed', 'percent', 'free_shipping'])], + 'value_amount' => ['required', 'integer', 'min:0'], + 'starts_at' => ['required', 'date'], + 'ends_at' => ['nullable', 'date', 'after:starts_at'], + 'usage_limit' => ['nullable', 'integer', 'min:1'], + 'usage_count' => ['nullable', 'integer', 'min:0'], + 'rules_json' => ['nullable', 'array'], + 'status' => ['nullable', 'string', Rule::in(['draft', 'active', 'expired', 'disabled'])], + ]; + } +} diff --git a/app/Http/Requests/Admin/Discounts/UpdateDiscountRequest.php b/app/Http/Requests/Admin/Discounts/UpdateDiscountRequest.php new file mode 100644 index 0000000..84c2b6d --- /dev/null +++ b/app/Http/Requests/Admin/Discounts/UpdateDiscountRequest.php @@ -0,0 +1,33 @@ + + */ + public function rules(): array + { + return [ + 'type' => ['sometimes', 'string', Rule::in(['code', 'automatic'])], + 'code' => ['sometimes', 'nullable', 'string', 'max:50'], + 'value_type' => ['sometimes', 'string', Rule::in(['fixed', 'percent', 'free_shipping'])], + 'value_amount' => ['sometimes', 'integer', 'min:0'], + 'starts_at' => ['sometimes', 'date'], + 'ends_at' => ['sometimes', 'nullable', 'date'], + 'usage_limit' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'usage_count' => ['sometimes', 'integer', 'min:0'], + 'rules_json' => ['sometimes', 'nullable', 'array'], + 'status' => ['sometimes', 'string', Rule::in(['draft', 'active', 'expired', 'disabled'])], + ]; + } +} diff --git a/app/Http/Requests/Admin/Products/ListProductsRequest.php b/app/Http/Requests/Admin/Products/ListProductsRequest.php new file mode 100644 index 0000000..8727561 --- /dev/null +++ b/app/Http/Requests/Admin/Products/ListProductsRequest.php @@ -0,0 +1,28 @@ + + */ + public function rules(): array + { + return [ + 'status' => ['nullable', 'string', Rule::in(['draft', 'active', 'archived'])], + 'query' => ['nullable', 'string', 'max:255'], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + 'sort' => ['nullable', 'string', Rule::in(['title_asc', 'title_desc', 'created_at_asc', 'created_at_desc', 'updated_at_desc'])], + ]; + } +} diff --git a/app/Http/Requests/Admin/Products/StoreProductRequest.php b/app/Http/Requests/Admin/Products/StoreProductRequest.php new file mode 100644 index 0000000..eb59732 --- /dev/null +++ b/app/Http/Requests/Admin/Products/StoreProductRequest.php @@ -0,0 +1,36 @@ + + */ + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['nullable', 'string', 'max:255', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'], + 'description_html' => ['nullable', 'string'], + 'vendor' => ['nullable', 'string', 'max:255'], + 'product_type' => ['nullable', 'string', 'max:255'], + 'status' => ['nullable', 'string', Rule::in(['draft', 'active', 'archived'])], + 'tags' => ['nullable', 'array', 'max:50'], + 'tags.*' => ['string', 'max:255'], + 'published_at' => ['nullable', 'date'], + 'price_amount' => ['nullable', 'integer', 'min:0'], + 'compare_at_amount' => ['nullable', 'integer', 'min:0'], + 'currency' => ['nullable', 'string', 'size:3'], + 'requires_shipping' => ['nullable', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Products/UpdateProductRequest.php b/app/Http/Requests/Admin/Products/UpdateProductRequest.php new file mode 100644 index 0000000..51ca278 --- /dev/null +++ b/app/Http/Requests/Admin/Products/UpdateProductRequest.php @@ -0,0 +1,36 @@ + + */ + public function rules(): array + { + return [ + 'title' => ['sometimes', 'string', 'max:255'], + 'handle' => ['sometimes', 'nullable', 'string', 'max:255', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'], + 'description_html' => ['sometimes', 'nullable', 'string'], + 'vendor' => ['sometimes', 'nullable', 'string', 'max:255'], + 'product_type' => ['sometimes', 'nullable', 'string', 'max:255'], + 'status' => ['sometimes', 'string', Rule::in(['draft', 'active', 'archived'])], + 'tags' => ['sometimes', 'nullable', 'array', 'max:50'], + 'tags.*' => ['string', 'max:255'], + 'published_at' => ['sometimes', 'nullable', 'date'], + 'price_amount' => ['sometimes', 'integer', 'min:0'], + 'compare_at_amount' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'currency' => ['sometimes', 'string', 'size:3'], + 'requires_shipping' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/Storefront/Analytics/IngestAnalyticsEventsRequest.php b/app/Http/Requests/Storefront/Analytics/IngestAnalyticsEventsRequest.php new file mode 100644 index 0000000..3ef5101 --- /dev/null +++ b/app/Http/Requests/Storefront/Analytics/IngestAnalyticsEventsRequest.php @@ -0,0 +1,38 @@ + + */ + public function rules(): array + { + return [ + 'events' => ['required', 'array', 'min:1', 'max:50'], + 'events.*.type' => ['required', 'string', Rule::in([ + 'page_view', + 'product_view', + 'add_to_cart', + 'remove_from_cart', + 'checkout_started', + 'checkout_completed', + 'search', + ])], + 'events.*.session_id' => ['required', 'string', 'max:100'], + 'events.*.client_event_id' => ['nullable', 'string', 'max:100'], + 'events.*.properties' => ['nullable', 'array'], + 'events.*.occurred_at' => ['required', 'date'], + 'events.*.customer_id' => ['nullable', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/Storefront/Carts/AddCartLineRequest.php b/app/Http/Requests/Storefront/Carts/AddCartLineRequest.php new file mode 100644 index 0000000..31420d4 --- /dev/null +++ b/app/Http/Requests/Storefront/Carts/AddCartLineRequest.php @@ -0,0 +1,26 @@ + + */ + public function rules(): array + { + return [ + 'variant_id' => ['required', 'integer', 'min:1'], + 'quantity' => ['required', 'integer', 'min:1', 'max:9999'], + 'cart_version' => ['nullable', 'integer', 'min:1'], + 'expected_version' => ['nullable', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/Storefront/Carts/RemoveCartLineRequest.php b/app/Http/Requests/Storefront/Carts/RemoveCartLineRequest.php new file mode 100644 index 0000000..18c73cd --- /dev/null +++ b/app/Http/Requests/Storefront/Carts/RemoveCartLineRequest.php @@ -0,0 +1,24 @@ + + */ + public function rules(): array + { + return [ + 'cart_version' => ['required_without:expected_version', 'integer', 'min:1'], + 'expected_version' => ['required_without:cart_version', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/Storefront/Carts/StoreCartRequest.php b/app/Http/Requests/Storefront/Carts/StoreCartRequest.php new file mode 100644 index 0000000..5beb868 --- /dev/null +++ b/app/Http/Requests/Storefront/Carts/StoreCartRequest.php @@ -0,0 +1,23 @@ + + */ + public function rules(): array + { + return [ + 'currency' => ['nullable', 'string', 'size:3'], + ]; + } +} diff --git a/app/Http/Requests/Storefront/Carts/UpdateCartLineRequest.php b/app/Http/Requests/Storefront/Carts/UpdateCartLineRequest.php new file mode 100644 index 0000000..5595dbc --- /dev/null +++ b/app/Http/Requests/Storefront/Carts/UpdateCartLineRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'quantity' => ['required', 'integer', 'min:1', 'max:9999'], + 'cart_version' => ['required_without:expected_version', 'integer', 'min:1'], + 'expected_version' => ['required_without:cart_version', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/Storefront/Checkouts/ApplyCheckoutDiscountRequest.php b/app/Http/Requests/Storefront/Checkouts/ApplyCheckoutDiscountRequest.php new file mode 100644 index 0000000..2d5a077 --- /dev/null +++ b/app/Http/Requests/Storefront/Checkouts/ApplyCheckoutDiscountRequest.php @@ -0,0 +1,23 @@ + + */ + public function rules(): array + { + return [ + 'code' => ['required', 'string', 'max:50'], + ]; + } +} diff --git a/app/Http/Requests/Storefront/Checkouts/PayCheckoutRequest.php b/app/Http/Requests/Storefront/Checkouts/PayCheckoutRequest.php new file mode 100644 index 0000000..f35f9bf --- /dev/null +++ b/app/Http/Requests/Storefront/Checkouts/PayCheckoutRequest.php @@ -0,0 +1,28 @@ + + */ + public function rules(): array + { + return [ + 'payment_method' => ['nullable', 'string', Rule::in(['credit_card', 'paypal', 'bank_transfer'])], + 'card_number' => ['nullable', 'string', 'max:32'], + 'card_expiry' => ['nullable', 'string', 'max:5'], + 'card_cvc' => ['nullable', 'string', 'max:4'], + 'card_holder' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/Storefront/Checkouts/SelectCheckoutPaymentMethodRequest.php b/app/Http/Requests/Storefront/Checkouts/SelectCheckoutPaymentMethodRequest.php new file mode 100644 index 0000000..f18ec2e --- /dev/null +++ b/app/Http/Requests/Storefront/Checkouts/SelectCheckoutPaymentMethodRequest.php @@ -0,0 +1,24 @@ + + */ + public function rules(): array + { + return [ + 'payment_method' => ['required', 'string', Rule::in(['credit_card', 'paypal', 'bank_transfer'])], + ]; + } +} diff --git a/app/Http/Requests/Storefront/Checkouts/SelectCheckoutShippingMethodRequest.php b/app/Http/Requests/Storefront/Checkouts/SelectCheckoutShippingMethodRequest.php new file mode 100644 index 0000000..9f339ff --- /dev/null +++ b/app/Http/Requests/Storefront/Checkouts/SelectCheckoutShippingMethodRequest.php @@ -0,0 +1,23 @@ + + */ + public function rules(): array + { + return [ + 'shipping_method_id' => ['required', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/Storefront/Checkouts/StoreCheckoutRequest.php b/app/Http/Requests/Storefront/Checkouts/StoreCheckoutRequest.php new file mode 100644 index 0000000..12f7189 --- /dev/null +++ b/app/Http/Requests/Storefront/Checkouts/StoreCheckoutRequest.php @@ -0,0 +1,24 @@ + + */ + public function rules(): array + { + return [ + 'cart_id' => ['required', 'integer', 'min:1'], + 'email' => ['required', 'string', 'email', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/Storefront/Checkouts/UpdateCheckoutAddressRequest.php b/app/Http/Requests/Storefront/Checkouts/UpdateCheckoutAddressRequest.php new file mode 100644 index 0000000..ca8e81e --- /dev/null +++ b/app/Http/Requests/Storefront/Checkouts/UpdateCheckoutAddressRequest.php @@ -0,0 +1,49 @@ + + */ + public function rules(): array + { + return [ + 'shipping_address' => ['nullable', 'array'], + 'shipping_address.first_name' => ['required_with:shipping_address', 'string', 'max:255'], + 'shipping_address.last_name' => ['required_with:shipping_address', 'string', 'max:255'], + 'shipping_address.address1' => ['required_with:shipping_address', 'string', 'max:500'], + 'shipping_address.address2' => ['nullable', 'string', 'max:500'], + 'shipping_address.city' => ['required_with:shipping_address', 'string', 'max:255'], + 'shipping_address.province' => ['nullable', 'string', 'max:255'], + 'shipping_address.province_code' => ['nullable', 'string', 'max:10'], + 'shipping_address.country' => ['required_with:shipping_address', 'string', 'max:255'], + 'shipping_address.country_code' => ['required_with:shipping_address', 'string', 'size:2'], + 'shipping_address.postal_code' => ['required_with:shipping_address', 'string', 'max:20'], + 'shipping_address.phone' => ['nullable', 'string', 'max:50'], + + 'billing_address' => ['nullable', 'array'], + 'billing_address.first_name' => ['required_with:billing_address', 'string', 'max:255'], + 'billing_address.last_name' => ['required_with:billing_address', 'string', 'max:255'], + 'billing_address.address1' => ['required_with:billing_address', 'string', 'max:500'], + 'billing_address.address2' => ['nullable', 'string', 'max:500'], + 'billing_address.city' => ['required_with:billing_address', 'string', 'max:255'], + 'billing_address.province' => ['nullable', 'string', 'max:255'], + 'billing_address.province_code' => ['nullable', 'string', 'max:10'], + 'billing_address.country' => ['required_with:billing_address', 'string', 'max:255'], + 'billing_address.country_code' => ['required_with:billing_address', 'string', 'size:2'], + 'billing_address.postal_code' => ['required_with:billing_address', 'string', 'max:20'], + 'billing_address.phone' => ['nullable', 'string', 'max:50'], + + 'use_shipping_as_billing' => ['nullable', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/Storefront/Orders/ShowOrderRequest.php b/app/Http/Requests/Storefront/Orders/ShowOrderRequest.php new file mode 100644 index 0000000..50c1edb --- /dev/null +++ b/app/Http/Requests/Storefront/Orders/ShowOrderRequest.php @@ -0,0 +1,23 @@ + + */ + public function rules(): array + { + return [ + 'token' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/Storefront/Search/SearchRequest.php b/app/Http/Requests/Storefront/Search/SearchRequest.php new file mode 100644 index 0000000..0715aac --- /dev/null +++ b/app/Http/Requests/Storefront/Search/SearchRequest.php @@ -0,0 +1,29 @@ + + */ + public function rules(): array + { + return [ + 'q' => ['required_without:query', 'string', 'min:1', 'max:200'], + 'query' => ['required_without:q', 'string', 'min:1', 'max:200'], + 'filters' => ['nullable'], + 'sort' => ['nullable', 'string', Rule::in(['relevance', 'price_asc', 'price_desc', 'newest', 'best_selling'])], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:50'], + ]; + } +} diff --git a/app/Http/Requests/Storefront/Search/SearchSuggestRequest.php b/app/Http/Requests/Storefront/Search/SearchSuggestRequest.php new file mode 100644 index 0000000..e25044c --- /dev/null +++ b/app/Http/Requests/Storefront/Search/SearchSuggestRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'q' => ['required_without:query', 'string', 'min:1', 'max:100'], + 'query' => ['required_without:q', 'string', 'min:1', 'max:100'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:10'], + ]; + } +} diff --git a/app/Http/Resources/Admin/CollectionResource.php b/app/Http/Resources/Admin/CollectionResource.php new file mode 100644 index 0000000..06e5b27 --- /dev/null +++ b/app/Http/Resources/Admin/CollectionResource.php @@ -0,0 +1,37 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => (int) $this->id, + 'store_id' => (int) $this->store_id, + 'title' => (string) $this->title, + 'handle' => (string) $this->handle, + 'description_html' => $this->description_html, + 'type' => $this->enumValue($this->type), + 'status' => $this->enumValue($this->status), + 'products_count' => isset($this->products_count) ? (int) $this->products_count : null, + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } + + private function enumValue(mixed $value): mixed + { + return $value instanceof BackedEnum ? $value->value : $value; + } +} diff --git a/app/Http/Resources/Admin/DiscountResource.php b/app/Http/Resources/Admin/DiscountResource.php new file mode 100644 index 0000000..d0c3c6e --- /dev/null +++ b/app/Http/Resources/Admin/DiscountResource.php @@ -0,0 +1,41 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => (int) $this->id, + 'store_id' => (int) $this->store_id, + 'type' => $this->enumValue($this->type), + 'code' => $this->code, + 'value_type' => $this->enumValue($this->value_type), + 'value_amount' => (int) $this->value_amount, + 'starts_at' => $this->starts_at?->toISOString(), + 'ends_at' => $this->ends_at?->toISOString(), + 'usage_limit' => $this->usage_limit === null ? null : (int) $this->usage_limit, + 'usage_count' => (int) $this->usage_count, + 'rules_json' => is_array($this->rules_json) ? $this->rules_json : [], + 'status' => $this->enumValue($this->status), + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } + + private function enumValue(mixed $value): mixed + { + return $value instanceof BackedEnum ? $value->value : $value; + } +} diff --git a/app/Http/Resources/Admin/ProductResource.php b/app/Http/Resources/Admin/ProductResource.php new file mode 100644 index 0000000..8da2b19 --- /dev/null +++ b/app/Http/Resources/Admin/ProductResource.php @@ -0,0 +1,40 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => (int) $this->id, + 'store_id' => (int) $this->store_id, + 'title' => (string) $this->title, + 'handle' => (string) $this->handle, + 'status' => $this->enumValue($this->status), + 'description_html' => $this->description_html, + 'vendor' => $this->vendor, + 'product_type' => $this->product_type, + 'tags' => is_array($this->tags) ? $this->tags : [], + 'published_at' => $this->published_at?->toISOString(), + 'variants_count' => isset($this->variants_count) ? (int) $this->variants_count : null, + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } + + private function enumValue(mixed $value): mixed + { + return $value instanceof BackedEnum ? $value->value : $value; + } +} diff --git a/app/Jobs/ExpireAbandonedCheckouts.php b/app/Jobs/ExpireAbandonedCheckouts.php new file mode 100644 index 0000000..6181a2b --- /dev/null +++ b/app/Jobs/ExpireAbandonedCheckouts.php @@ -0,0 +1,75 @@ +select(['id']) + ->whereIn('status', [ + CheckoutStatus::Started->value, + CheckoutStatus::Addressed->value, + CheckoutStatus::ShippingSelected->value, + CheckoutStatus::PaymentSelected->value, + ]) + ->whereNotNull('expires_at') + ->where('expires_at', '<', now()) + ->orderBy('id') + ->chunkById(100, function (EloquentCollection $checkouts) use ($checkoutService): void { + /** @var Checkout $checkout */ + foreach ($checkouts as $checkout) { + $this->expireCheckout($checkoutService, (int) $checkout->id); + } + }); + } + + private function expireCheckout(CheckoutService $checkoutService, int $checkoutId): void + { + DB::transaction(function () use ($checkoutService, $checkoutId): void { + /** @var Checkout|null $checkout */ + $checkout = Checkout::query() + ->whereKey($checkoutId) + ->lockForUpdate() + ->first(); + + if (! $checkout instanceof Checkout) { + return; + } + + $status = $checkout->status; + + if (! $status instanceof CheckoutStatus) { + $status = CheckoutStatus::tryFrom((string) $status); + } + + if (! $status instanceof CheckoutStatus) { + return; + } + + if (in_array($status, [CheckoutStatus::Completed, CheckoutStatus::Expired], true)) { + return; + } + + if ($checkout->expires_at === null || ! $checkout->expires_at->isPast()) { + return; + } + + $checkout->status = CheckoutStatus::Expired; + $checkout->save(); + + if ($status === CheckoutStatus::PaymentSelected) { + $checkoutService->releaseReservedInventoryForCheckout($checkout); + } + }); + } +} diff --git a/app/Models/AnalyticsDaily.php b/app/Models/AnalyticsDaily.php new file mode 100644 index 0000000..5fe4a1c --- /dev/null +++ b/app/Models/AnalyticsDaily.php @@ -0,0 +1,47 @@ + + */ + protected $fillable = [ + 'store_id', + 'date', + 'orders_count', + 'revenue_amount', + 'aov_amount', + 'visits_count', + 'add_to_cart_count', + 'checkout_started_count', + 'checkout_completed_count', + ]; + + protected function casts(): array + { + return [ + 'date' => 'date:Y-m-d', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/AnalyticsEvent.php b/app/Models/AnalyticsEvent.php new file mode 100644 index 0000000..ab70719 --- /dev/null +++ b/app/Models/AnalyticsEvent.php @@ -0,0 +1,47 @@ + + */ + protected $fillable = [ + 'store_id', + 'type', + 'session_id', + 'customer_id', + 'properties_json', + 'client_event_id', + 'occurred_at', + 'created_at', + ]; + + protected function casts(): array + { + return [ + 'properties_json' => 'array', + 'occurred_at' => 'datetime', + 'created_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Models/App.php b/app/Models/App.php new file mode 100644 index 0000000..6851013 --- /dev/null +++ b/app/Models/App.php @@ -0,0 +1,42 @@ + + */ + protected $fillable = [ + 'name', + 'status', + 'created_at', + ]; + + protected function casts(): array + { + return [ + 'status' => AppStatus::class, + 'created_at' => 'datetime', + ]; + } + + public function installations(): HasMany + { + return $this->hasMany(AppInstallation::class); + } + + public function oauthClients(): HasMany + { + return $this->hasMany(OauthClient::class); + } +} diff --git a/app/Models/AppInstallation.php b/app/Models/AppInstallation.php new file mode 100644 index 0000000..c19977c --- /dev/null +++ b/app/Models/AppInstallation.php @@ -0,0 +1,58 @@ + + */ + protected $fillable = [ + 'store_id', + 'app_id', + 'scopes_json', + 'status', + 'installed_at', + ]; + + protected function casts(): array + { + return [ + 'scopes_json' => 'array', + 'status' => AppInstallationStatus::class, + 'installed_at' => 'datetime', + ]; + } + + public function app(): BelongsTo + { + return $this->belongsTo(App::class); + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function oauthTokens(): HasMany + { + return $this->hasMany(OauthToken::class, 'installation_id'); + } + + public function webhookSubscriptions(): HasMany + { + return $this->hasMany(WebhookSubscription::class); + } +} diff --git a/app/Models/Cart.php b/app/Models/Cart.php new file mode 100644 index 0000000..cd0569c --- /dev/null +++ b/app/Models/Cart.php @@ -0,0 +1,52 @@ + + */ + protected $fillable = [ + 'store_id', + 'customer_id', + 'currency', + 'cart_version', + 'status', + ]; + + protected function casts(): array + { + return [ + 'status' => CartStatus::class, + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function lines(): HasMany + { + return $this->hasMany(CartLine::class); + } + + public function checkouts(): HasMany + { + return $this->hasMany(Checkout::class); + } +} diff --git a/app/Models/CartLine.php b/app/Models/CartLine.php new file mode 100644 index 0000000..7bdc9e7 --- /dev/null +++ b/app/Models/CartLine.php @@ -0,0 +1,37 @@ + + */ + protected $fillable = [ + 'cart_id', + 'variant_id', + 'quantity', + 'unit_price_amount', + 'line_subtotal_amount', + 'line_discount_amount', + 'line_total_amount', + ]; + + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } +} diff --git a/app/Models/Checkout.php b/app/Models/Checkout.php new file mode 100644 index 0000000..bf23188 --- /dev/null +++ b/app/Models/Checkout.php @@ -0,0 +1,67 @@ + + */ + protected $fillable = [ + 'store_id', + 'cart_id', + 'customer_id', + 'status', + 'payment_method', + 'email', + 'shipping_address_json', + 'billing_address_json', + 'shipping_method_id', + 'discount_code', + 'tax_provider_snapshot_json', + 'totals_json', + 'expires_at', + ]; + + protected function casts(): array + { + return [ + 'status' => CheckoutStatus::class, + 'payment_method' => PaymentMethod::class, + 'shipping_address_json' => 'array', + 'billing_address_json' => 'array', + 'tax_provider_snapshot_json' => 'array', + 'totals_json' => 'array', + 'expires_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function order(): HasOne + { + return $this->hasOne(Order::class); + } +} diff --git a/app/Models/Collection.php b/app/Models/Collection.php new file mode 100644 index 0000000..20a3888 --- /dev/null +++ b/app/Models/Collection.php @@ -0,0 +1,47 @@ + + */ + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'description_html', + 'type', + 'status', + ]; + + protected function casts(): array + { + return [ + 'type' => CollectionType::class, + 'status' => CollectionStatus::class, + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class, 'collection_products') + ->using(CollectionProduct::class) + ->withPivot('position'); + } +} diff --git a/app/Models/CollectionProduct.php b/app/Models/CollectionProduct.php new file mode 100644 index 0000000..f6f6984 --- /dev/null +++ b/app/Models/CollectionProduct.php @@ -0,0 +1,23 @@ + + */ + protected $fillable = [ + 'collection_id', + 'product_id', + 'position', + ]; +} diff --git a/app/Models/Concerns/BelongsToStore.php b/app/Models/Concerns/BelongsToStore.php new file mode 100644 index 0000000..3914b2e --- /dev/null +++ b/app/Models/Concerns/BelongsToStore.php @@ -0,0 +1,65 @@ +getAttribute('store_id') !== null) { + return; + } + + $storeId = self::resolveCurrentStoreId(); + + if ($storeId !== null) { + $model->setAttribute('store_id', $storeId); + } + }); + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + private static function resolveCurrentStoreId(): ?int + { + if (app()->bound(CurrentStore::class)) { + $store = app(CurrentStore::class); + + if ($store instanceof CurrentStore) { + return $store->id; + } + } + + if (! app()->bound('current_store')) { + return null; + } + + $store = app('current_store'); + + if ($store instanceof CurrentStore) { + return $store->id; + } + + if (is_array($store) && isset($store['id'])) { + return (int) $store['id']; + } + + if (is_object($store) && isset($store->id)) { + return (int) $store->id; + } + + return null; + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php new file mode 100644 index 0000000..e27a40e --- /dev/null +++ b/app/Models/Customer.php @@ -0,0 +1,105 @@ + + */ + protected $fillable = [ + 'store_id', + 'email', + 'password_hash', + 'password', + 'name', + 'marketing_opt_in', + ]; + + /** + * @var list + */ + protected $hidden = [ + 'password_hash', + 'password', + 'remember_token', + ]; + + protected function casts(): array + { + return [ + 'marketing_opt_in' => 'boolean', + ]; + } + + public function getPasswordAttribute(): ?string + { + $value = $this->attributes['password_hash'] ?? null; + + return is_string($value) ? $value : null; + } + + public function setPasswordAttribute(?string $value): void + { + if ($value === null) { + return; + } + + $this->attributes['password_hash'] = $this->hashPassword($value); + } + + public function setPasswordHashAttribute(?string $value): void + { + if ($value === null) { + $this->attributes['password_hash'] = null; + + return; + } + + $this->attributes['password_hash'] = $this->hashPassword($value); + } + + public function getAuthPasswordName(): string + { + return 'password_hash'; + } + + private function hashPassword(string $value): string + { + return Hash::isHashed($value) ? $value : Hash::make($value); + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function addresses(): HasMany + { + return $this->hasMany(CustomerAddress::class); + } + + public function carts(): HasMany + { + return $this->hasMany(Cart::class); + } + + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + public function analyticsEvents(): HasMany + { + return $this->hasMany(AnalyticsEvent::class); + } +} diff --git a/app/Models/CustomerAddress.php b/app/Models/CustomerAddress.php new file mode 100644 index 0000000..e0d6d20 --- /dev/null +++ b/app/Models/CustomerAddress.php @@ -0,0 +1,37 @@ + + */ + protected $fillable = [ + 'customer_id', + 'label', + 'address_json', + 'is_default', + ]; + + protected function casts(): array + { + return [ + 'address_json' => 'array', + 'is_default' => 'boolean', + ]; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Models/Discount.php b/app/Models/Discount.php new file mode 100644 index 0000000..5a155d5 --- /dev/null +++ b/app/Models/Discount.php @@ -0,0 +1,49 @@ + + */ + protected $fillable = [ + 'store_id', + 'type', + 'code', + 'value_type', + 'value_amount', + 'starts_at', + 'ends_at', + 'usage_limit', + 'usage_count', + 'rules_json', + 'status', + ]; + + protected function casts(): array + { + return [ + 'type' => DiscountType::class, + 'value_type' => DiscountValueType::class, + 'status' => DiscountStatus::class, + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'rules_json' => 'array', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/Fulfillment.php b/app/Models/Fulfillment.php new file mode 100644 index 0000000..f965635 --- /dev/null +++ b/app/Models/Fulfillment.php @@ -0,0 +1,48 @@ + + */ + protected $fillable = [ + 'order_id', + 'status', + 'tracking_company', + 'tracking_number', + 'tracking_url', + 'shipped_at', + 'created_at', + ]; + + protected function casts(): array + { + return [ + 'status' => FulfillmentStatus::class, + 'shipped_at' => 'datetime', + 'created_at' => 'datetime', + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function lines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } +} diff --git a/app/Models/FulfillmentLine.php b/app/Models/FulfillmentLine.php new file mode 100644 index 0000000..113fe8f --- /dev/null +++ b/app/Models/FulfillmentLine.php @@ -0,0 +1,33 @@ + + */ + protected $fillable = [ + 'fulfillment_id', + 'order_line_id', + 'quantity', + ]; + + public function fulfillment(): BelongsTo + { + return $this->belongsTo(Fulfillment::class); + } + + public function orderLine(): BelongsTo + { + return $this->belongsTo(OrderLine::class); + } +} diff --git a/app/Models/InventoryItem.php b/app/Models/InventoryItem.php new file mode 100644 index 0000000..b02e6e9 --- /dev/null +++ b/app/Models/InventoryItem.php @@ -0,0 +1,43 @@ + + */ + protected $fillable = [ + 'store_id', + 'variant_id', + 'quantity_on_hand', + 'quantity_reserved', + 'policy', + ]; + + protected function casts(): array + { + return [ + 'policy' => InventoryPolicy::class, + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } +} diff --git a/app/Models/NavigationItem.php b/app/Models/NavigationItem.php new file mode 100644 index 0000000..cbd3aa3 --- /dev/null +++ b/app/Models/NavigationItem.php @@ -0,0 +1,39 @@ + + */ + protected $fillable = [ + 'menu_id', + 'type', + 'label', + 'url', + 'resource_id', + 'position', + ]; + + protected function casts(): array + { + return [ + 'type' => NavigationItemType::class, + ]; + } + + public function menu(): BelongsTo + { + return $this->belongsTo(NavigationMenu::class, 'menu_id'); + } +} diff --git a/app/Models/NavigationMenu.php b/app/Models/NavigationMenu.php new file mode 100644 index 0000000..c1af031 --- /dev/null +++ b/app/Models/NavigationMenu.php @@ -0,0 +1,32 @@ + + */ + protected $fillable = [ + 'store_id', + 'handle', + 'title', + ]; + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function items(): HasMany + { + return $this->hasMany(NavigationItem::class, 'menu_id'); + } +} diff --git a/app/Models/OauthClient.php b/app/Models/OauthClient.php new file mode 100644 index 0000000..7d920ce --- /dev/null +++ b/app/Models/OauthClient.php @@ -0,0 +1,36 @@ + + */ + protected $fillable = [ + 'app_id', + 'client_id', + 'client_secret_encrypted', + 'redirect_uris_json', + ]; + + protected function casts(): array + { + return [ + 'redirect_uris_json' => 'array', + ]; + } + + public function app(): BelongsTo + { + return $this->belongsTo(App::class); + } +} diff --git a/app/Models/OauthToken.php b/app/Models/OauthToken.php new file mode 100644 index 0000000..72028f9 --- /dev/null +++ b/app/Models/OauthToken.php @@ -0,0 +1,36 @@ + + */ + protected $fillable = [ + 'installation_id', + 'access_token_hash', + 'refresh_token_hash', + 'expires_at', + ]; + + protected function casts(): array + { + return [ + 'expires_at' => 'datetime', + ]; + } + + public function installation(): BelongsTo + { + return $this->belongsTo(AppInstallation::class, 'installation_id'); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 0000000..8ded4b3 --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,112 @@ + + */ + protected $fillable = [ + 'store_id', + 'customer_id', + 'checkout_id', + 'order_number', + 'payment_method', + 'status', + 'financial_status', + 'fulfillment_status', + 'currency', + 'subtotal_amount', + 'discount_amount', + 'shipping_amount', + 'tax_amount', + 'total_amount', + 'email', + 'billing_address_json', + 'shipping_address_json', + 'placed_at', + ]; + + protected function casts(): array + { + return [ + 'payment_method' => PaymentMethod::class, + 'status' => OrderStatus::class, + 'financial_status' => OrderFinancialStatus::class, + 'fulfillment_status' => OrderFulfillmentStatus::class, + 'billing_address_json' => 'array', + 'shipping_address_json' => 'array', + 'placed_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function checkout(): BelongsTo + { + return $this->belongsTo(Checkout::class); + } + + public function lines(): HasMany + { + return $this->hasMany(OrderLine::class); + } + + public function payments(): HasMany + { + return $this->hasMany(Payment::class); + } + + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } + + public function fulfillments(): HasMany + { + return $this->hasMany(Fulfillment::class); + } + + public function storefrontRouteOrderNumber(): string + { + return ltrim((string) $this->order_number, '#'); + } + + /** + * @return array + */ + public static function resolveOrderNumberCandidates(string $orderNumber): array + { + $trimmed = trim($orderNumber); + + if ($trimmed === '') { + return ['']; + } + + if (str_starts_with($trimmed, '#')) { + return [$trimmed, ltrim($trimmed, '#')]; + } + + return [$trimmed, '#'.$trimmed]; + } +} diff --git a/app/Models/OrderLine.php b/app/Models/OrderLine.php new file mode 100644 index 0000000..4ad1d4e --- /dev/null +++ b/app/Models/OrderLine.php @@ -0,0 +1,59 @@ + + */ + protected $fillable = [ + 'order_id', + 'product_id', + 'variant_id', + 'title_snapshot', + 'sku_snapshot', + 'quantity', + 'unit_price_amount', + 'total_amount', + 'tax_lines_json', + 'discount_allocations_json', + ]; + + protected function casts(): array + { + return [ + 'tax_lines_json' => 'array', + 'discount_allocations_json' => 'array', + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + public function fulfillmentLines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php new file mode 100644 index 0000000..d3eac0d --- /dev/null +++ b/app/Models/Organization.php @@ -0,0 +1,25 @@ + + */ + protected $fillable = [ + 'name', + 'billing_email', + ]; + + public function stores(): HasMany + { + return $this->hasMany(Store::class); + } +} diff --git a/app/Models/Page.php b/app/Models/Page.php new file mode 100644 index 0000000..79746fa --- /dev/null +++ b/app/Models/Page.php @@ -0,0 +1,38 @@ + + */ + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'body_html', + 'status', + 'published_at', + ]; + + protected function casts(): array + { + return [ + 'status' => PageStatus::class, + 'published_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/Payment.php b/app/Models/Payment.php new file mode 100644 index 0000000..12fd79a --- /dev/null +++ b/app/Models/Payment.php @@ -0,0 +1,53 @@ + + */ + protected $fillable = [ + 'order_id', + 'provider', + 'method', + 'provider_payment_id', + 'status', + 'amount', + 'currency', + 'raw_json_encrypted', + 'created_at', + ]; + + protected function casts(): array + { + return [ + 'provider' => PaymentProvider::class, + 'method' => PaymentMethod::class, + 'status' => PaymentStatus::class, + 'created_at' => 'datetime', + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 0000000..7d823a0 --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,66 @@ + + */ + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'status', + 'description_html', + 'vendor', + 'product_type', + 'tags', + 'published_at', + ]; + + protected function casts(): array + { + return [ + 'status' => ProductStatus::class, + 'tags' => 'array', + 'published_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function options(): HasMany + { + return $this->hasMany(ProductOption::class); + } + + public function variants(): HasMany + { + return $this->hasMany(ProductVariant::class); + } + + public function collections(): BelongsToMany + { + return $this->belongsToMany(Collection::class, 'collection_products') + ->using(CollectionProduct::class) + ->withPivot('position'); + } + + public function media(): HasMany + { + return $this->hasMany(ProductMedia::class); + } +} diff --git a/app/Models/ProductMedia.php b/app/Models/ProductMedia.php new file mode 100644 index 0000000..90c64c5 --- /dev/null +++ b/app/Models/ProductMedia.php @@ -0,0 +1,47 @@ + + */ + protected $fillable = [ + 'product_id', + 'type', + 'storage_key', + 'alt_text', + 'width', + 'height', + 'mime_type', + 'byte_size', + 'position', + 'status', + 'created_at', + ]; + + protected function casts(): array + { + return [ + 'type' => ProductMediaType::class, + 'status' => ProductMediaStatus::class, + 'created_at' => 'datetime', + ]; + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/Models/ProductOption.php b/app/Models/ProductOption.php new file mode 100644 index 0000000..4532cf6 --- /dev/null +++ b/app/Models/ProductOption.php @@ -0,0 +1,34 @@ + + */ + protected $fillable = [ + 'product_id', + 'name', + 'position', + ]; + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function values(): HasMany + { + return $this->hasMany(ProductOptionValue::class); + } +} diff --git a/app/Models/ProductOptionValue.php b/app/Models/ProductOptionValue.php new file mode 100644 index 0000000..6c318db --- /dev/null +++ b/app/Models/ProductOptionValue.php @@ -0,0 +1,40 @@ + + */ + protected $fillable = [ + 'product_option_id', + 'value', + 'position', + ]; + + public function option(): BelongsTo + { + return $this->belongsTo(ProductOption::class, 'product_option_id'); + } + + public function variants(): BelongsToMany + { + return $this->belongsToMany( + ProductVariant::class, + 'variant_option_values', + 'product_option_value_id', + 'variant_id', + ) + ->using(VariantOptionValue::class); + } +} diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php new file mode 100644 index 0000000..d5af7a1 --- /dev/null +++ b/app/Models/ProductVariant.php @@ -0,0 +1,62 @@ + + */ + protected $fillable = [ + 'product_id', + 'sku', + 'barcode', + 'price_amount', + 'compare_at_amount', + 'currency', + 'weight_g', + 'requires_shipping', + 'is_default', + 'position', + 'status', + ]; + + protected function casts(): array + { + return [ + 'requires_shipping' => 'boolean', + 'is_default' => 'boolean', + 'status' => ProductVariantStatus::class, + ]; + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function optionValues(): BelongsToMany + { + return $this->belongsToMany( + ProductOptionValue::class, + 'variant_option_values', + 'variant_id', + 'product_option_value_id', + ) + ->using(VariantOptionValue::class); + } + + public function inventoryItem(): HasOne + { + return $this->hasOne(InventoryItem::class, 'variant_id'); + } +} diff --git a/app/Models/Refund.php b/app/Models/Refund.php new file mode 100644 index 0000000..353a4cf --- /dev/null +++ b/app/Models/Refund.php @@ -0,0 +1,46 @@ + + */ + protected $fillable = [ + 'order_id', + 'payment_id', + 'amount', + 'reason', + 'status', + 'provider_refund_id', + 'created_at', + ]; + + protected function casts(): array + { + return [ + 'status' => RefundStatus::class, + 'created_at' => 'datetime', + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function payment(): BelongsTo + { + return $this->belongsTo(Payment::class); + } +} diff --git a/app/Models/Scopes/StoreScope.php b/app/Models/Scopes/StoreScope.php new file mode 100644 index 0000000..a3cbe95 --- /dev/null +++ b/app/Models/Scopes/StoreScope.php @@ -0,0 +1,53 @@ +resolveStoreId(); + + if ($storeId === null) { + return; + } + + $builder->where($model->qualifyColumn('store_id'), '=', $storeId); + } + + private function resolveStoreId(): ?int + { + if (app()->bound(CurrentStore::class)) { + $store = app(CurrentStore::class); + + if ($store instanceof CurrentStore) { + return $store->id; + } + } + + if (! app()->bound('current_store')) { + return null; + } + + $store = app('current_store'); + + if ($store instanceof CurrentStore) { + return $store->id; + } + + if (is_array($store) && isset($store['id'])) { + return (int) $store['id']; + } + + if (is_object($store) && isset($store->id)) { + return (int) $store->id; + } + + return null; + } +} diff --git a/app/Models/SearchQuery.php b/app/Models/SearchQuery.php new file mode 100644 index 0000000..ade13a3 --- /dev/null +++ b/app/Models/SearchQuery.php @@ -0,0 +1,38 @@ + + */ + protected $fillable = [ + 'store_id', + 'query', + 'filters_json', + 'results_count', + 'created_at', + ]; + + protected function casts(): array + { + return [ + 'filters_json' => 'array', + 'created_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/SearchSetting.php b/app/Models/SearchSetting.php new file mode 100644 index 0000000..bb3551a --- /dev/null +++ b/app/Models/SearchSetting.php @@ -0,0 +1,42 @@ + + */ + protected $fillable = [ + 'store_id', + 'synonyms_json', + 'stop_words_json', + 'updated_at', + ]; + + protected function casts(): array + { + return [ + 'synonyms_json' => 'array', + 'stop_words_json' => 'array', + 'updated_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/ShippingRate.php b/app/Models/ShippingRate.php new file mode 100644 index 0000000..8833505 --- /dev/null +++ b/app/Models/ShippingRate.php @@ -0,0 +1,40 @@ + + */ + protected $fillable = [ + 'zone_id', + 'name', + 'type', + 'config_json', + 'is_active', + ]; + + protected function casts(): array + { + return [ + 'type' => ShippingRateType::class, + 'config_json' => 'array', + 'is_active' => 'boolean', + ]; + } + + public function zone(): BelongsTo + { + return $this->belongsTo(ShippingZone::class, 'zone_id'); + } +} diff --git a/app/Models/ShippingZone.php b/app/Models/ShippingZone.php new file mode 100644 index 0000000..298967a --- /dev/null +++ b/app/Models/ShippingZone.php @@ -0,0 +1,43 @@ + + */ + protected $fillable = [ + 'store_id', + 'name', + 'countries_json', + 'regions_json', + ]; + + protected function casts(): array + { + return [ + 'countries_json' => 'array', + 'regions_json' => 'array', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function rates(): HasMany + { + return $this->hasMany(ShippingRate::class, 'zone_id'); + } +} diff --git a/app/Models/Store.php b/app/Models/Store.php new file mode 100644 index 0000000..a61da59 --- /dev/null +++ b/app/Models/Store.php @@ -0,0 +1,108 @@ + + */ + protected $fillable = [ + 'organization_id', + 'name', + 'handle', + 'status', + 'default_currency', + 'default_locale', + 'timezone', + ]; + + protected function casts(): array + { + return [ + 'status' => StoreStatus::class, + ]; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class); + } + + public function domains(): HasMany + { + return $this->hasMany(StoreDomain::class); + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'store_users') + ->using(StoreUser::class) + ->withPivot('role', 'created_at'); + } + + public function settings(): HasOne + { + return $this->hasOne(StoreSettings::class); + } + + public function products(): HasMany + { + return $this->hasMany(Product::class); + } + + public function collections(): HasMany + { + return $this->hasMany(Collection::class); + } + + public function customers(): HasMany + { + return $this->hasMany(Customer::class); + } + + public function carts(): HasMany + { + return $this->hasMany(Cart::class); + } + + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + public function searchSettings(): HasOne + { + return $this->hasOne(SearchSetting::class); + } + + public function searchQueries(): HasMany + { + return $this->hasMany(SearchQuery::class); + } + + public function analyticsEvents(): HasMany + { + return $this->hasMany(AnalyticsEvent::class); + } + + public function appInstallations(): HasMany + { + return $this->hasMany(AppInstallation::class); + } + + public function webhookSubscriptions(): HasMany + { + return $this->hasMany(WebhookSubscription::class); + } +} diff --git a/app/Models/StoreDomain.php b/app/Models/StoreDomain.php new file mode 100644 index 0000000..609b69d --- /dev/null +++ b/app/Models/StoreDomain.php @@ -0,0 +1,43 @@ + + */ + protected $fillable = [ + 'store_id', + 'hostname', + 'type', + 'is_primary', + 'tls_mode', + 'created_at', + ]; + + protected function casts(): array + { + return [ + 'type' => StoreDomainType::class, + 'tls_mode' => StoreDomainTlsMode::class, + 'is_primary' => 'boolean', + 'created_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/StoreSettings.php b/app/Models/StoreSettings.php new file mode 100644 index 0000000..09520fe --- /dev/null +++ b/app/Models/StoreSettings.php @@ -0,0 +1,40 @@ + + */ + protected $fillable = [ + 'store_id', + 'settings_json', + 'updated_at', + ]; + + protected function casts(): array + { + return [ + 'settings_json' => 'array', + 'updated_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/StoreUser.php b/app/Models/StoreUser.php new file mode 100644 index 0000000..2c40a80 --- /dev/null +++ b/app/Models/StoreUser.php @@ -0,0 +1,44 @@ + + */ + protected $fillable = [ + 'store_id', + 'user_id', + 'role', + 'created_at', + ]; + + protected function casts(): array + { + return [ + 'role' => StoreUserRole::class, + 'created_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/TaxSetting.php b/app/Models/TaxSetting.php new file mode 100644 index 0000000..0f7fcbd --- /dev/null +++ b/app/Models/TaxSetting.php @@ -0,0 +1,46 @@ + + */ + protected $fillable = [ + 'store_id', + 'mode', + 'provider', + 'prices_include_tax', + 'config_json', + ]; + + protected function casts(): array + { + return [ + 'mode' => TaxMode::class, + 'provider' => TaxProvider::class, + 'prices_include_tax' => 'boolean', + 'config_json' => 'array', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/Theme.php b/app/Models/Theme.php new file mode 100644 index 0000000..f7d0863 --- /dev/null +++ b/app/Models/Theme.php @@ -0,0 +1,49 @@ + + */ + protected $fillable = [ + 'store_id', + 'name', + 'version', + 'status', + 'published_at', + ]; + + protected function casts(): array + { + return [ + 'status' => ThemeStatus::class, + 'published_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function files(): HasMany + { + return $this->hasMany(ThemeFile::class); + } + + public function settings(): HasOne + { + return $this->hasOne(ThemeSetting::class); + } +} diff --git a/app/Models/ThemeFile.php b/app/Models/ThemeFile.php new file mode 100644 index 0000000..da26ce1 --- /dev/null +++ b/app/Models/ThemeFile.php @@ -0,0 +1,30 @@ + + */ + protected $fillable = [ + 'theme_id', + 'path', + 'storage_key', + 'sha256', + 'byte_size', + ]; + + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } +} diff --git a/app/Models/ThemeSetting.php b/app/Models/ThemeSetting.php new file mode 100644 index 0000000..a46fcd8 --- /dev/null +++ b/app/Models/ThemeSetting.php @@ -0,0 +1,40 @@ + + */ + protected $fillable = [ + 'theme_id', + 'settings_json', + 'updated_at', + ]; + + protected function casts(): array + { + return [ + 'settings_json' => 'array', + 'updated_at' => 'datetime', + ]; + } + + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 214bea4..d189701 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,10 +2,15 @@ namespace App\Models; +use App\Enums\StoreUserRole; +use App\Enums\UserStatus; // use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; use Laravel\Fortify\TwoFactorAuthenticatable; @@ -22,7 +27,11 @@ class User extends Authenticatable protected $fillable = [ 'name', 'email', + 'password_hash', 'password', + 'status', + 'last_login_at', + 'email_verified_at', ]; /** @@ -31,6 +40,7 @@ class User extends Authenticatable * @var list */ protected $hidden = [ + 'password_hash', 'password', 'two_factor_secret', 'two_factor_recovery_codes', @@ -46,10 +56,98 @@ protected function casts(): array { return [ 'email_verified_at' => 'datetime', - 'password' => 'hashed', + 'last_login_at' => 'datetime', + 'status' => UserStatus::class, ]; } + /** + * Keep compatibility with code that sets/reads `password`. + */ + public function getPasswordAttribute(): ?string + { + $value = $this->attributes['password_hash'] ?? null; + + return is_string($value) ? $value : null; + } + + /** + * Ensure any assigned password is stored in `password_hash`. + */ + public function setPasswordAttribute(?string $value): void + { + if ($value === null) { + return; + } + + $this->attributes['password_hash'] = $this->hashPassword($value); + } + + /** + * Ensure direct writes to password_hash stay hashed. + */ + public function setPasswordHashAttribute(?string $value): void + { + if ($value === null) { + $this->attributes['password_hash'] = null; + + return; + } + + $this->attributes['password_hash'] = $this->hashPassword($value); + } + + /** + * The auth password column name. + */ + public function getAuthPasswordName(): string + { + return 'password_hash'; + } + + private function hashPassword(string $value): string + { + return Hash::isHashed($value) ? $value : Hash::make($value); + } + + /** + * Stores this user can access. + */ + public function stores(): BelongsToMany + { + return $this->belongsToMany(Store::class, 'store_users') + ->using(StoreUser::class) + ->withPivot('role', 'created_at'); + } + + /** + * Store membership pivot rows. + */ + public function storeUsers(): HasMany + { + return $this->hasMany(StoreUser::class); + } + + /** + * Resolve role for a specific store. + */ + public function roleForStore(Store $store): ?StoreUserRole + { + $value = $this->storeUsers() + ->where('store_id', $store->id) + ->value('role'); + + if ($value instanceof StoreUserRole) { + return $value; + } + + if (is_string($value) || is_int($value)) { + return StoreUserRole::from($value); + } + + return null; + } + /** * Get the user's initials */ diff --git a/app/Models/VariantOptionValue.php b/app/Models/VariantOptionValue.php new file mode 100644 index 0000000..12b8c23 --- /dev/null +++ b/app/Models/VariantOptionValue.php @@ -0,0 +1,22 @@ + + */ + protected $fillable = [ + 'variant_id', + 'product_option_value_id', + ]; +} diff --git a/app/Models/WebhookDelivery.php b/app/Models/WebhookDelivery.php new file mode 100644 index 0000000..bfc10a2 --- /dev/null +++ b/app/Models/WebhookDelivery.php @@ -0,0 +1,43 @@ + + */ + protected $fillable = [ + 'subscription_id', + 'event_id', + 'attempt_count', + 'status', + 'last_attempt_at', + 'response_code', + 'response_body_snippet', + ]; + + protected function casts(): array + { + return [ + 'status' => WebhookDeliveryStatus::class, + 'last_attempt_at' => 'datetime', + ]; + } + + public function subscription(): BelongsTo + { + return $this->belongsTo(WebhookSubscription::class, 'subscription_id'); + } +} diff --git a/app/Models/WebhookSubscription.php b/app/Models/WebhookSubscription.php new file mode 100644 index 0000000..9847fc0 --- /dev/null +++ b/app/Models/WebhookSubscription.php @@ -0,0 +1,52 @@ + + */ + protected $fillable = [ + 'store_id', + 'app_installation_id', + 'event_type', + 'target_url', + 'signing_secret_encrypted', + 'status', + ]; + + protected function casts(): array + { + return [ + 'status' => WebhookSubscriptionStatus::class, + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function appInstallation(): BelongsTo + { + return $this->belongsTo(AppInstallation::class); + } + + public function deliveries(): HasMany + { + return $this->hasMany(WebhookDelivery::class, 'subscription_id'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8a29e6f..9eaea7a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,9 +2,16 @@ namespace App\Providers; +use App\Auth\CustomerUserProvider; +use App\Auth\Passwords\StorefrontPasswordBrokerManager; use Carbon\CarbonImmutable; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Contracts\Foundation\Application; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; @@ -15,7 +22,7 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->registerPasswordBrokerManager(); } /** @@ -24,6 +31,22 @@ public function register(): void public function boot(): void { $this->configureDefaults(); + $this->registerAuthProviders(); + $this->configureRateLimiting(); + } + + /** + * Register a custom password broker manager. + */ + protected function registerPasswordBrokerManager(): void + { + $this->app->singleton('auth.password', function (Application $app) { + return new StorefrontPasswordBrokerManager($app); + }); + + $this->app->bind('auth.password.broker', function (Application $app) { + return $app->make('auth.password')->broker(); + }); } /** @@ -47,4 +70,51 @@ protected function configureDefaults(): void : null ); } + + /** + * Register custom authentication providers. + */ + protected function registerAuthProviders(): void + { + Auth::provider('customer', function (Application $app, array $config): CustomerUserProvider { + $connection = isset($config['connection']) ? (string) $config['connection'] : null; + $table = (string) ($config['table'] ?? 'customers'); + + return new CustomerUserProvider( + connection: $app['db']->connection($connection), + hasher: $app['hash'], + table: $table, + app: $app, + ); + }); + } + + /** + * Register application rate limiters. + */ + protected function configureRateLimiting(): void + { + RateLimiter::for('login', fn (Request $request): Limit => Limit::perMinute(5)->by((string) $request->ip())); + + RateLimiter::for('api.admin', function (Request $request): Limit { + $userId = $request->user()?->getAuthIdentifier(); + + return Limit::perMinute(60)->by($userId !== null + ? sprintf('user:%s', $userId) + : sprintf('ip:%s', (string) $request->ip())); + }); + + RateLimiter::for('api.storefront', fn (Request $request): Limit => Limit::perMinute(120)->by((string) $request->ip())); + + RateLimiter::for('checkout', function (Request $request): Limit { + $sessionId = $request->hasSession() ? $request->session()->getId() : null; + + return Limit::perMinute(10)->by($sessionId !== null && $sessionId !== '' + ? sprintf('session:%s', $sessionId) + : sprintf('ip:%s', (string) $request->ip())); + }); + + RateLimiter::for('search', fn (Request $request): Limit => Limit::perMinute(30)->by((string) $request->ip())); + RateLimiter::for('analytics', fn (Request $request): Limit => Limit::perMinute(60)->by((string) $request->ip())); + } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 44e57aa..0f6bdcf 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -8,7 +8,6 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; -use Illuminate\Support\Str; use Laravel\Fortify\Fortify; class FortifyServiceProvider extends ServiceProvider @@ -62,11 +61,5 @@ private function configureRateLimiting(): void RateLimiter::for('two-factor', function (Request $request) { return Limit::perMinute(5)->by($request->session()->get('login.id')); }); - - RateLimiter::for('login', function (Request $request) { - $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip()); - - return Limit::perMinute(5)->by($throttleKey); - }); } } diff --git a/app/Services/CartService.php b/app/Services/CartService.php new file mode 100644 index 0000000..e8d5baf --- /dev/null +++ b/app/Services/CartService.php @@ -0,0 +1,284 @@ +create([ + 'store_id' => (int) $store->id, + 'customer_id' => $customer?->id, + 'currency' => (string) $store->default_currency, + 'cart_version' => 1, + 'status' => CartStatus::Active, + ]); + } + + public function addLine(Cart $cart, int $variantId, int $quantity, ?int $expectedVersion = null): CartLine + { + $this->assertPositiveQuantity($quantity); + + /** @var CartLine $line */ + $line = DB::transaction(function () use ($cart, $variantId, $quantity, $expectedVersion): CartLine { + $lockedCart = $this->lockCart($cart); + + $this->ensureMutableCart($lockedCart); + $this->assertExpectedVersion($lockedCart, $expectedVersion); + + $variant = $this->resolvePurchasableVariant((int) $lockedCart->store_id, $variantId); + + /** @var CartLine|null $existingLine */ + $existingLine = CartLine::query() + ->where('cart_id', '=', (int) $lockedCart->id) + ->where('variant_id', '=', $variantId) + ->lockForUpdate() + ->first(); + + $newQuantity = ((int) $existingLine?->quantity) + $quantity; + $this->assertInventoryAvailableForQuantity($variant, $newQuantity); + + if ($existingLine === null) { + $existingLine = CartLine::query()->create([ + 'cart_id' => (int) $lockedCart->id, + 'variant_id' => $variantId, + 'quantity' => $newQuantity, + 'unit_price_amount' => (int) $variant->price_amount, + 'line_subtotal_amount' => 0, + 'line_discount_amount' => 0, + 'line_total_amount' => 0, + ]); + } else { + $existingLine->quantity = $newQuantity; + $existingLine->unit_price_amount = (int) $variant->price_amount; + } + + $this->recalculateLineAmounts($existingLine); + $existingLine->save(); + + $this->incrementVersion($lockedCart); + $this->syncCart($cart, $lockedCart); + + return $existingLine->refresh(); + }); + + return $line; + } + + public function updateLineQuantity(Cart $cart, int $lineId, int $quantity, ?int $expectedVersion = null): ?CartLine + { + if ($quantity < 0) { + throw new InvalidArgumentException('Quantity must be zero or greater.'); + } + + if ($quantity === 0) { + $this->removeLine($cart, $lineId, $expectedVersion); + + return null; + } + + /** @var CartLine $line */ + $line = DB::transaction(function () use ($cart, $lineId, $quantity, $expectedVersion): CartLine { + $lockedCart = $this->lockCart($cart); + + $this->ensureMutableCart($lockedCart); + $this->assertExpectedVersion($lockedCart, $expectedVersion); + + /** @var CartLine $line */ + $line = CartLine::query() + ->where('id', '=', $lineId) + ->where('cart_id', '=', (int) $lockedCart->id) + ->lockForUpdate() + ->firstOrFail(); + + $variant = $this->resolvePurchasableVariant((int) $lockedCart->store_id, (int) $line->variant_id); + $this->assertInventoryAvailableForQuantity($variant, $quantity); + + $line->quantity = $quantity; + $line->unit_price_amount = (int) $variant->price_amount; + + $this->recalculateLineAmounts($line); + $line->save(); + + $this->incrementVersion($lockedCart); + $this->syncCart($cart, $lockedCart); + + return $line->refresh(); + }); + + return $line; + } + + public function removeLine(Cart $cart, int $lineId, ?int $expectedVersion = null): void + { + DB::transaction(function () use ($cart, $lineId, $expectedVersion): void { + $lockedCart = $this->lockCart($cart); + + $this->ensureMutableCart($lockedCart); + $this->assertExpectedVersion($lockedCart, $expectedVersion); + + $line = CartLine::query() + ->where('id', '=', $lineId) + ->where('cart_id', '=', (int) $lockedCart->id) + ->lockForUpdate() + ->firstOrFail(); + + $line->delete(); + + $this->incrementVersion($lockedCart); + $this->syncCart($cart, $lockedCart); + }); + } + + /** + * @return array{subtotal: int, discount: int, total: int, line_count: int, item_count: int, currency: string} + */ + public function computeCartTotals(Cart $cart): array + { + $cart->loadMissing('lines'); + + $subtotal = 0; + $discount = 0; + $itemCount = 0; + + foreach ($cart->lines as $line) { + $subtotal += (int) $line->line_subtotal_amount; + $discount += (int) $line->line_discount_amount; + $itemCount += (int) $line->quantity; + } + + return [ + 'subtotal' => $subtotal, + 'discount' => $discount, + 'total' => $subtotal - $discount, + 'line_count' => $cart->lines->count(), + 'item_count' => $itemCount, + 'currency' => (string) $cart->currency, + ]; + } + + private function assertExpectedVersion(Cart $cart, ?int $expectedVersion): void + { + if ($expectedVersion === null) { + return; + } + + $currentVersion = (int) $cart->cart_version; + + if ($currentVersion !== $expectedVersion) { + throw new CartVersionMismatchException( + cartId: (int) $cart->id, + expectedVersion: $expectedVersion, + currentVersion: $currentVersion, + ); + } + } + + private function assertPositiveQuantity(int $quantity): void + { + if ($quantity <= 0) { + throw new InvalidArgumentException('Quantity must be greater than zero.'); + } + } + + private function ensureMutableCart(Cart $cart): void + { + if ($cart->status !== CartStatus::Active) { + throw new InvalidArgumentException(sprintf('Cart %d is not active.', (int) $cart->id)); + } + } + + private function recalculateLineAmounts(CartLine $line): void + { + $lineSubtotal = (int) $line->unit_price_amount * (int) $line->quantity; + + $line->line_subtotal_amount = $lineSubtotal; + + if ((int) $line->line_discount_amount < 0) { + $line->line_discount_amount = 0; + } + + $line->line_total_amount = max(0, $lineSubtotal - (int) $line->line_discount_amount); + } + + private function incrementVersion(Cart $cart): void + { + $cart->cart_version = (int) $cart->cart_version + 1; + $cart->save(); + } + + private function lockCart(Cart $cart): Cart + { + /** @var Cart $locked */ + $locked = Cart::query() + ->whereKey($cart->getKey()) + ->lockForUpdate() + ->firstOrFail(); + + return $locked; + } + + private function syncCart(Cart $target, Cart $source): void + { + $target->cart_version = (int) $source->cart_version; + $target->status = $source->status; + $target->currency = $source->currency; + $target->updated_at = $source->updated_at; + } + + private function assertInventoryAvailableForQuantity(ProductVariant $variant, int $quantity): void + { + $inventoryItem = $variant->inventoryItem; + + if ($inventoryItem === null) { + return; + } + + if ($this->inventoryService->checkAvailability($inventoryItem, $quantity)) { + return; + } + + throw InsufficientInventoryException::forItem($inventoryItem, $quantity); + } + + private function resolvePurchasableVariant(int $storeId, int $variantId): ProductVariant + { + /** @var ProductVariant|null $variant */ + $variant = ProductVariant::query() + ->whereKey($variantId) + ->where('status', '=', ProductVariantStatus::Active) + ->whereHas('product', static function ($query) use ($storeId): void { + $query + ->where('store_id', '=', $storeId) + ->where('status', '=', ProductStatus::Active); + }) + ->with('inventoryItem') + ->first(); + + if ($variant === null) { + throw (new ModelNotFoundException)->setModel(ProductVariant::class, [$variantId]); + } + + return $variant; + } +} diff --git a/app/Services/CheckoutService.php b/app/Services/CheckoutService.php new file mode 100644 index 0000000..9e0571d --- /dev/null +++ b/app/Services/CheckoutService.php @@ -0,0 +1,415 @@ +lockCart($cart); + $lockedCart->loadMissing('lines'); + + if ($lockedCart->status !== CartStatus::Active) { + throw InvalidCheckoutStateException::cartNotActive((int) $lockedCart->id, $lockedCart->status->value); + } + + if ($lockedCart->lines->isEmpty()) { + throw InvalidCheckoutStateException::emptyCart((int) $lockedCart->id); + } + + $checkout = Checkout::query()->create([ + 'store_id' => (int) $lockedCart->store_id, + 'cart_id' => (int) $lockedCart->id, + 'customer_id' => $lockedCart->customer_id, + 'status' => CheckoutStatus::Started, + 'email' => $email, + 'expires_at' => now()->addDay(), + ]); + + $this->pricingEngine->calculate($checkout); + + return $checkout->refresh(); + }); + + return $checkout; + } + + /** + * @param array $shippingAddress + * @param array|null $billingAddress + */ + public function setAddress( + Checkout $checkout, + string $email, + array $shippingAddress, + ?array $billingAddress = null, + bool $useShippingAsBilling = true, + ): Checkout { + /** @var Checkout $updated */ + $updated = DB::transaction(function () use ($checkout, $email, $shippingAddress, $billingAddress, $useShippingAsBilling): Checkout { + $lockedCheckout = $this->lockCheckout($checkout); + $this->assertMutable($lockedCheckout); + + if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new InvalidArgumentException('A valid email address is required.'); + } + + $requiresShipping = $this->shippingCalculator->cartRequiresShipping($lockedCheckout->cart); + + $normalizedShipping = []; + + if ($requiresShipping) { + $normalizedShipping = $this->normalizeAddress($shippingAddress, (int) $lockedCheckout->id); + + if ($this->shippingCalculator->getAvailableRates($lockedCheckout->store, $normalizedShipping, $lockedCheckout->cart)->isEmpty()) { + throw InvalidCheckoutStateException::unserviceableAddress((int) $lockedCheckout->id); + } + } + + $normalizedBilling = null; + + if ($useShippingAsBilling) { + $normalizedBilling = $normalizedShipping; + } elseif ($billingAddress !== null) { + $normalizedBilling = $this->normalizeAddress($billingAddress, (int) $lockedCheckout->id, requireAllFields: false); + } + + $lockedCheckout->email = $email; + $lockedCheckout->shipping_address_json = $normalizedShipping === [] ? null : $normalizedShipping; + $lockedCheckout->billing_address_json = $normalizedBilling === [] ? null : $normalizedBilling; + $lockedCheckout->status = CheckoutStatus::Addressed; + $lockedCheckout->save(); + + $this->pricingEngine->calculate($lockedCheckout); + + return $lockedCheckout->refresh(); + }); + + return $updated; + } + + public function setShippingMethod(Checkout $checkout, ?int $shippingRateId): Checkout + { + /** @var Checkout $updated */ + $updated = DB::transaction(function () use ($checkout, $shippingRateId): Checkout { + $lockedCheckout = $this->lockCheckout($checkout); + $this->assertState($lockedCheckout, [CheckoutStatus::Addressed, CheckoutStatus::ShippingSelected]); + $this->assertMutable($lockedCheckout); + + if (! $this->shippingCalculator->cartRequiresShipping($lockedCheckout->cart)) { + $lockedCheckout->shipping_method_id = null; + $lockedCheckout->status = CheckoutStatus::ShippingSelected; + $lockedCheckout->save(); + + $this->pricingEngine->calculate($lockedCheckout); + + return $lockedCheckout->refresh(); + } + + $address = $lockedCheckout->shipping_address_json; + + if (! is_array($address)) { + throw InvalidCheckoutStateException::shippingAddressRequired((int) $lockedCheckout->id); + } + + if ($shippingRateId === null || $shippingRateId <= 0) { + throw InvalidCheckoutStateException::invalidShippingMethod((int) $lockedCheckout->id, 0); + } + + $quote = $this->shippingCalculator->selectActiveRateByZone( + store: $lockedCheckout->store, + address: $address, + cart: $lockedCheckout->cart, + shippingRateId: $shippingRateId, + checkoutId: (int) $lockedCheckout->id, + ); + + $lockedCheckout->shipping_method_id = $quote->rateId; + $lockedCheckout->status = CheckoutStatus::ShippingSelected; + $lockedCheckout->save(); + + $this->pricingEngine->calculate($lockedCheckout); + + return $lockedCheckout->refresh(); + }); + + return $updated; + } + + public function selectPaymentMethod(Checkout $checkout, PaymentMethod|string $paymentMethod): Checkout + { + /** @var Checkout $updated */ + $updated = DB::transaction(function () use ($checkout, $paymentMethod): Checkout { + $lockedCheckout = $this->lockCheckout($checkout); + $this->assertMutable($lockedCheckout); + + $resolvedPaymentMethod = $this->resolvePaymentMethod($lockedCheckout, $paymentMethod); + + if ($lockedCheckout->status === CheckoutStatus::PaymentSelected + && $lockedCheckout->payment_method === $resolvedPaymentMethod) { + return $lockedCheckout->refresh(); + } + + $this->assertState($lockedCheckout, [CheckoutStatus::ShippingSelected]); + + /** @var CartLine $line */ + foreach ($lockedCheckout->cart->lines as $line) { + $inventoryItem = $line->variant?->inventoryItem; + + if ($inventoryItem === null) { + continue; + } + + $this->inventoryService->reserve($inventoryItem, (int) $line->quantity); + } + + $lockedCheckout->payment_method = $resolvedPaymentMethod; + $lockedCheckout->expires_at = now()->addDay(); + $lockedCheckout->status = CheckoutStatus::PaymentSelected; + $lockedCheckout->save(); + + $this->pricingEngine->calculate($lockedCheckout); + + return $lockedCheckout->refresh(); + }); + + return $updated; + } + + public function applyDiscount(Checkout $checkout, string $code): Checkout + { + /** @var Checkout $updated */ + $updated = DB::transaction(function () use ($checkout, $code): Checkout { + $lockedCheckout = $this->lockCheckout($checkout); + $this->assertMutable($lockedCheckout); + + $discount = $this->discountService->validate($code, $lockedCheckout->store, $lockedCheckout->cart); + + $lockedCheckout->discount_code = (string) $discount->code; + $lockedCheckout->save(); + + $this->pricingEngine->calculate($lockedCheckout); + + return $lockedCheckout->refresh(); + }); + + return $updated; + } + + public function removeDiscount(Checkout $checkout): Checkout + { + /** @var Checkout $updated */ + $updated = DB::transaction(function () use ($checkout): Checkout { + $lockedCheckout = $this->lockCheckout($checkout); + $this->assertMutable($lockedCheckout); + + $lockedCheckout->discount_code = null; + $lockedCheckout->save(); + + $this->pricingEngine->calculate($lockedCheckout); + + return $lockedCheckout->refresh(); + }); + + return $updated; + } + + public function computeTotals(Checkout $checkout): PricingResult + { + return $this->pricingEngine->calculate($checkout); + } + + public function commitReservedInventoryForCheckout(Checkout $checkout): void + { + DB::transaction(function () use ($checkout): void { + $lockedCheckout = $this->lockCheckout($checkout); + $this->adjustReservedInventory($lockedCheckout, commit: true); + }); + } + + public function releaseReservedInventoryForCheckout(Checkout $checkout): void + { + DB::transaction(function () use ($checkout): void { + $lockedCheckout = $this->lockCheckout($checkout); + $this->adjustReservedInventory($lockedCheckout, commit: false); + }); + } + + /** + * @return Collection + */ + public function availableShippingMethods(Checkout $checkout): Collection + { + $checkout->loadMissing(['store', 'cart.lines.variant']); + + if (! is_array($checkout->shipping_address_json)) { + return collect(); + } + + return $this->shippingCalculator->getAvailableRates( + store: $checkout->store, + address: $checkout->shipping_address_json, + cart: $checkout->cart, + ); + } + + private function assertMutable(Checkout $checkout): void + { + if (in_array($checkout->status, [CheckoutStatus::Completed, CheckoutStatus::Expired], true)) { + throw InvalidCheckoutStateException::immutable($checkout); + } + } + + /** + * @param list $expectedStates + */ + private function assertState(Checkout $checkout, array $expectedStates): void + { + if (in_array($checkout->status, $expectedStates, true)) { + return; + } + + throw InvalidCheckoutStateException::invalidTransition( + checkout: $checkout, + expectedStates: array_map(static fn (CheckoutStatus $status): string => $status->value, $expectedStates), + ); + } + + /** + * @param array $address + * @return array + */ + private function normalizeAddress(array $address, int $checkoutId, bool $requireAllFields = true): array + { + $requiredFields = [ + 'first_name', + 'last_name', + 'address1', + 'city', + 'country', + 'country_code', + 'postal_code', + ]; + + if ($requireAllFields) { + foreach ($requiredFields as $field) { + $value = $address[$field] ?? null; + + if (! is_string($value) || trim($value) === '') { + throw InvalidCheckoutStateException::missingAddressField($checkoutId, $field); + } + } + } + + $normalized = []; + + foreach ($address as $key => $value) { + if (! is_string($key)) { + continue; + } + + if (! is_string($value) && ! is_numeric($value)) { + continue; + } + + $normalized[$key] = trim((string) $value); + } + + if (isset($normalized['country_code'])) { + $normalized['country_code'] = strtoupper($normalized['country_code']); + } + + if (isset($normalized['province_code'])) { + $normalized['province_code'] = strtoupper($normalized['province_code']); + } + + return $normalized; + } + + private function resolvePaymentMethod(Checkout $checkout, PaymentMethod|string $paymentMethod): PaymentMethod + { + if ($paymentMethod instanceof PaymentMethod) { + return $paymentMethod; + } + + $resolved = PaymentMethod::tryFrom($paymentMethod); + + if ($resolved === null) { + throw InvalidCheckoutStateException::invalidPaymentMethod((int) $checkout->id, $paymentMethod); + } + + return $resolved; + } + + private function adjustReservedInventory(Checkout $checkout, bool $commit): void + { + /** @var CartLine $line */ + foreach ($checkout->cart->lines as $line) { + $inventoryItem = $line->variant?->inventoryItem; + + if ($inventoryItem === null) { + continue; + } + + $reserved = max(0, (int) $inventoryItem->quantity_reserved); + $quantity = max(0, (int) $line->quantity); + $adjustment = min($reserved, $quantity); + + if ($adjustment <= 0) { + continue; + } + + if ($commit) { + $this->inventoryService->commit($inventoryItem, $adjustment); + } else { + $this->inventoryService->release($inventoryItem, $adjustment); + } + } + } + + private function lockCart(Cart $cart): Cart + { + /** @var Cart $locked */ + $locked = Cart::query() + ->whereKey($cart->getKey()) + ->lockForUpdate() + ->firstOrFail(); + + return $locked; + } + + private function lockCheckout(Checkout $checkout): Checkout + { + /** @var Checkout $locked */ + $locked = Checkout::query() + ->with(['store', 'cart.lines.variant.inventoryItem', 'cart.lines.variant.product.collections']) + ->whereKey($checkout->getKey()) + ->lockForUpdate() + ->firstOrFail(); + + return $locked; + } +} diff --git a/app/Services/DiscountService.php b/app/Services/DiscountService.php new file mode 100644 index 0000000..5453927 --- /dev/null +++ b/app/Services/DiscountService.php @@ -0,0 +1,275 @@ +where('store_id', '=', (int) $store->id) + ->whereRaw('lower(code) = ?', [mb_strtolower($normalizedCode)]) + ->first(); + + if ($discount === null) { + throw InvalidDiscountException::notFound($normalizedCode); + } + + if ($discount->status !== DiscountStatus::Active) { + throw InvalidDiscountException::expired((string) $discount->code); + } + + $now = Carbon::now(); + + if ($discount->starts_at !== null && $discount->starts_at->gt($now)) { + throw InvalidDiscountException::notYetActive((string) $discount->code); + } + + if ($discount->ends_at !== null && $discount->ends_at->lt($now)) { + throw InvalidDiscountException::expired((string) $discount->code); + } + + if ($discount->usage_limit !== null && (int) $discount->usage_count >= (int) $discount->usage_limit) { + throw InvalidDiscountException::usageLimitReached((string) $discount->code); + } + + $rules = $this->rules($discount); + $subtotal = $this->subtotal($cart); + $minimum = $this->minimumPurchaseAmount($rules); + + if ($minimum !== null && $subtotal < $minimum) { + throw InvalidDiscountException::minimumNotMet((string) $discount->code, $minimum); + } + + $cart->loadMissing('lines.variant.product.collections'); + + if ($this->hasRestrictions($rules)) { + $qualifyingLines = $this->qualifyingLines($cart->lines, $rules); + + if ($qualifyingLines->isEmpty()) { + throw InvalidDiscountException::notApplicable((string) $discount->code); + } + } + + return $discount; + } + + public function calculate(Discount $discount, Cart $cart): DiscountCalculationResult + { + $cart->loadMissing('lines.variant.product.collections'); + $rules = $this->rules($discount); + + $qualifyingLines = $this->qualifyingLines($cart->lines->sortBy('id')->values(), $rules); + $qualifyingSubtotal = (int) $qualifyingLines->sum(static fn (CartLine $line): int => (int) $line->line_subtotal_amount); + + if ($discount->value_type === DiscountValueType::FreeShipping) { + return new DiscountCalculationResult(amount: 0, lineAllocations: [], freeShipping: true); + } + + if ($qualifyingSubtotal <= 0 || $qualifyingLines->isEmpty()) { + return DiscountCalculationResult::none(); + } + + $totalDiscount = $this->calculateTotalDiscount($discount, $qualifyingSubtotal); + + if ($totalDiscount <= 0) { + return DiscountCalculationResult::none(); + } + + $allocations = []; + $remainingDiscount = $totalDiscount; + $lineCount = $qualifyingLines->count(); + + /** @var CartLine $line */ + foreach ($qualifyingLines as $index => $line) { + $lineId = (int) $line->id; + + if ($index === $lineCount - 1) { + $allocations[$lineId] = $remainingDiscount; + break; + } + + $lineSubtotal = (int) $line->line_subtotal_amount; + $proportional = (int) round(($totalDiscount * $lineSubtotal) / $qualifyingSubtotal); + $allocation = max(0, min($proportional, $remainingDiscount)); + + $allocations[$lineId] = $allocation; + $remainingDiscount -= $allocation; + } + + return new DiscountCalculationResult( + amount: $totalDiscount, + lineAllocations: $allocations, + freeShipping: false, + ); + } + + public function applyToCartLines(Cart $cart, DiscountCalculationResult $result): void + { + $cart->loadMissing('lines'); + + /** @var CartLine $line */ + foreach ($cart->lines as $line) { + $lineSubtotal = (int) $line->unit_price_amount * (int) $line->quantity; + $lineDiscount = max(0, min($lineSubtotal, $result->amountForLine((int) $line->id))); + $lineTotal = $lineSubtotal - $lineDiscount; + + $line->unit_price_amount = (int) $line->unit_price_amount; + $line->line_subtotal_amount = $lineSubtotal; + $line->line_discount_amount = $lineDiscount; + $line->line_total_amount = $lineTotal; + $line->save(); + } + } + + /** + * @return array + */ + private function rules(Discount $discount): array + { + $rules = $discount->rules_json; + + return is_array($rules) ? $rules : []; + } + + private function subtotal(Cart $cart): int + { + $cart->loadMissing('lines'); + + return (int) $cart->lines->sum(static fn (CartLine $line): int => (int) $line->line_subtotal_amount); + } + + /** + * @param array $rules + */ + private function minimumPurchaseAmount(array $rules): ?int + { + $minimum = $rules['min_purchase_amount'] ?? $rules['min_subtotal_amount'] ?? null; + + if (! is_int($minimum) && ! is_float($minimum) && ! is_string($minimum)) { + return null; + } + + return max(0, (int) $minimum); + } + + /** + * @param array $rules + */ + private function hasRestrictions(array $rules): bool + { + return $this->productIds($rules) !== [] || $this->collectionIds($rules) !== []; + } + + /** + * @param EloquentCollection $lines + * @param array $rules + * @return EloquentCollection + */ + private function qualifyingLines(EloquentCollection $lines, array $rules): EloquentCollection + { + $productIds = $this->productIds($rules); + $collectionIds = $this->collectionIds($rules); + + if ($productIds === [] && $collectionIds === []) { + return $lines; + } + + return $lines->filter(function (CartLine $line) use ($productIds, $collectionIds): bool { + $productId = (int) ($line->variant?->product_id ?? 0); + + $matchesProduct = $productIds !== [] && in_array($productId, $productIds, true); + + $matchesCollection = false; + + if ($collectionIds !== [] && $line->variant?->product !== null) { + $collectionIdSet = $line->variant->product->collections + ->pluck('id') + ->map(static fn (mixed $id): int => (int) $id) + ->all(); + + $matchesCollection = $this->containsAny($collectionIdSet, $collectionIds); + } + + return $matchesProduct || $matchesCollection; + })->values(); + } + + private function calculateTotalDiscount(Discount $discount, int $qualifyingSubtotal): int + { + if ($discount->value_type === DiscountValueType::Percent) { + return max(0, (int) round(($qualifyingSubtotal * (int) $discount->value_amount) / 100)); + } + + if ($discount->value_type === DiscountValueType::Fixed) { + return min($qualifyingSubtotal, max(0, (int) $discount->value_amount)); + } + + return 0; + } + + /** + * @param array $rules + * @return list + */ + private function productIds(array $rules): array + { + $raw = $rules['applicable_product_ids'] ?? null; + + if (! is_array($raw)) { + return []; + } + + return array_values(array_unique(array_map(static fn (mixed $value): int => (int) $value, $raw))); + } + + /** + * @param array $rules + * @return list + */ + private function collectionIds(array $rules): array + { + $raw = $rules['applicable_collection_ids'] ?? null; + + if (! is_array($raw)) { + return []; + } + + return array_values(array_unique(array_map(static fn (mixed $value): int => (int) $value, $raw))); + } + + /** + * @param list $haystack + * @param list $needles + */ + private function containsAny(array $haystack, array $needles): bool + { + foreach ($needles as $needle) { + if (in_array($needle, $haystack, true)) { + return true; + } + } + + return false; + } +} diff --git a/app/Services/HandleGenerator.php b/app/Services/HandleGenerator.php new file mode 100644 index 0000000..3069615 --- /dev/null +++ b/app/Services/HandleGenerator.php @@ -0,0 +1,43 @@ +handleExists($candidate, $table, $storeId, $excludeId)) { + $suffix++; + $candidate = sprintf('%s-%d', $baseHandle, $suffix); + } + + return $candidate; + } + + private function handleExists(string $handle, string $table, int $storeId, ?int $excludeId): bool + { + $query = DB::table($table) + ->where('store_id', '=', $storeId) + ->where('handle', '=', $handle); + + if ($excludeId !== null) { + $query->where('id', '!=', $excludeId); + } + + return $query->exists(); + } +} diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php new file mode 100644 index 0000000..e098e5c --- /dev/null +++ b/app/Services/InventoryService.php @@ -0,0 +1,143 @@ +availableQuantity($item); + } + + public function availableQuantity(InventoryItem $item): int + { + return (int) $item->quantity_on_hand - (int) $item->quantity_reserved; + } + + public function checkAvailability(InventoryItem $item, int $quantity): bool + { + if ($quantity < 0) { + throw new InvalidArgumentException('Quantity must be zero or greater.'); + } + + if ($item->policy === InventoryPolicy::Continue) { + return true; + } + + return $this->availableQuantity($item) >= $quantity; + } + + public function reserve(InventoryItem $item, int $quantity): InventoryItem + { + $this->assertPositiveQuantity($quantity); + + /** @var InventoryItem $updated */ + $updated = DB::transaction(function () use ($item, $quantity): InventoryItem { + $locked = $this->lockItem($item); + + if (! $this->checkAvailability($locked, $quantity)) { + throw InsufficientInventoryException::forItem($locked, $quantity); + } + + $locked->quantity_reserved = (int) $locked->quantity_reserved + $quantity; + $locked->save(); + + return $locked; + }); + + $this->syncItem($item, $updated); + + return $updated; + } + + public function release(InventoryItem $item, int $quantity): InventoryItem + { + $this->assertPositiveQuantity($quantity); + + /** @var InventoryItem $updated */ + $updated = DB::transaction(function () use ($item, $quantity): InventoryItem { + $locked = $this->lockItem($item); + + $locked->quantity_reserved = max(0, (int) $locked->quantity_reserved - $quantity); + $locked->save(); + + return $locked; + }); + + $this->syncItem($item, $updated); + + return $updated; + } + + public function commit(InventoryItem $item, int $quantity): InventoryItem + { + $this->assertPositiveQuantity($quantity); + + /** @var InventoryItem $updated */ + $updated = DB::transaction(function () use ($item, $quantity): InventoryItem { + $locked = $this->lockItem($item); + + $locked->quantity_on_hand = (int) $locked->quantity_on_hand - $quantity; + $locked->quantity_reserved = max(0, (int) $locked->quantity_reserved - $quantity); + $locked->save(); + + return $locked; + }); + + $this->syncItem($item, $updated); + + return $updated; + } + + public function restock(InventoryItem $item, int $quantity): InventoryItem + { + $this->assertPositiveQuantity($quantity); + + /** @var InventoryItem $updated */ + $updated = DB::transaction(function () use ($item, $quantity): InventoryItem { + $locked = $this->lockItem($item); + + $locked->quantity_on_hand = (int) $locked->quantity_on_hand + $quantity; + $locked->save(); + + return $locked; + }); + + $this->syncItem($item, $updated); + + return $updated; + } + + private function assertPositiveQuantity(int $quantity): void + { + if ($quantity <= 0) { + throw new InvalidArgumentException('Quantity must be greater than zero.'); + } + } + + private function lockItem(InventoryItem $item): InventoryItem + { + /** @var InventoryItem $locked */ + $locked = InventoryItem::query() + ->whereKey($item->getKey()) + ->lockForUpdate() + ->firstOrFail(); + + return $locked; + } + + private function syncItem(InventoryItem $target, InventoryItem $source): void + { + $target->quantity_on_hand = (int) $source->quantity_on_hand; + $target->quantity_reserved = (int) $source->quantity_reserved; + $target->policy = $source->policy; + } +} diff --git a/app/Services/PricingEngine.php b/app/Services/PricingEngine.php new file mode 100644 index 0000000..2baa738 --- /dev/null +++ b/app/Services/PricingEngine.php @@ -0,0 +1,198 @@ +lockCheckout($checkout); + + $cart = $lockedCheckout->cart; + $this->recalculateLineSubtotals($cart); + + $subtotal = $this->subtotal($cart); + $discountResult = $this->calculateDiscount($lockedCheckout); + + $this->discountService->applyToCartLines($cart, $discountResult); + + $shippingAmount = $this->shippingAmount($lockedCheckout, $cart, $discountResult); + $taxResult = $this->taxResult($lockedCheckout, $cart, $shippingAmount); + + $total = max(0, ($subtotal - $discountResult->amount) + $shippingAmount + $taxResult->totalAmount); + + $pricingResult = new PricingResult( + subtotal: $subtotal, + discount: $discountResult->amount, + shipping: $shippingAmount, + taxLines: $taxResult->taxLines, + taxTotal: $taxResult->totalAmount, + total: $total, + currency: (string) $cart->currency, + lineDiscountAllocations: $discountResult->lineAllocations, + freeShippingApplied: $discountResult->freeShipping, + ); + + $lockedCheckout->totals_json = $pricingResult->toArray(); + $lockedCheckout->tax_provider_snapshot_json = $this->buildTaxSnapshot($taxResult); + $lockedCheckout->save(); + + $this->syncCheckout($checkout, $lockedCheckout); + + return $pricingResult; + }); + + return $result; + } + + private function calculateDiscount(Checkout $checkout): DiscountCalculationResult + { + $code = is_string($checkout->discount_code) ? trim($checkout->discount_code) : ''; + + if ($code === '') { + return DiscountCalculationResult::none(); + } + + $discount = $this->discountService->validate($code, $checkout->store, $checkout->cart); + + return $this->discountService->calculate($discount, $checkout->cart); + } + + private function shippingAmount(Checkout $checkout, Cart $cart, DiscountCalculationResult $discountResult): int + { + if (! $this->shippingCalculator->cartRequiresShipping($cart)) { + return 0; + } + + if ($discountResult->freeShipping) { + return 0; + } + + if ($checkout->shipping_method_id === null) { + return 0; + } + + $address = $checkout->shipping_address_json; + + if (! is_array($address)) { + throw InvalidCheckoutStateException::shippingAddressRequired((int) $checkout->id); + } + + $quote = $this->shippingCalculator->selectActiveRateByZone( + store: $checkout->store, + address: $address, + cart: $cart, + shippingRateId: (int) $checkout->shipping_method_id, + checkoutId: (int) $checkout->id, + ); + + return $quote->amount; + } + + private function taxResult(Checkout $checkout, Cart $cart, int $shippingAmount): TaxCalculationResult + { + /** @var TaxSetting|null $taxSetting */ + $taxSetting = TaxSetting::query()->find((int) $checkout->store_id); + + if ($taxSetting === null) { + return TaxCalculationResult::zero(); + } + + $address = is_array($checkout->shipping_address_json) ? $checkout->shipping_address_json : []; + $lineAmounts = $cart->lines + ->sortBy('id') + ->map(static fn (CartLine $line): int => (int) $line->line_total_amount) + ->values() + ->all(); + + return $this->taxCalculator->calculateForAmounts( + lineAmounts: $lineAmounts, + shippingAmount: $shippingAmount, + settings: $taxSetting, + address: $address, + ); + } + + private function subtotal(Cart $cart): int + { + return (int) $cart->lines->sum(static fn (CartLine $line): int => (int) $line->line_subtotal_amount); + } + + private function recalculateLineSubtotals(Cart $cart): void + { + $cart->loadMissing('lines'); + + /** @var CartLine $line */ + foreach ($cart->lines as $line) { + $lineSubtotal = (int) $line->unit_price_amount * (int) $line->quantity; + $lineDiscount = max(0, min($lineSubtotal, (int) $line->line_discount_amount)); + $lineTotal = $lineSubtotal - $lineDiscount; + + $line->line_subtotal_amount = $lineSubtotal; + $line->line_discount_amount = $lineDiscount; + $line->line_total_amount = $lineTotal; + $line->save(); + } + } + + /** + * @return array + */ + private function buildTaxSnapshot(TaxCalculationResult $result): array + { + return [ + 'provider' => 'manual', + 'calculated_at' => now()->toISOString(), + 'rate_basis_points' => $result->rateBasisPoints, + 'lines' => array_map(static fn ($line): array => $line->toArray(), $result->taxLines), + 'tax_total' => $result->totalAmount, + ]; + } + + private function lockCheckout(Checkout $checkout): Checkout + { + /** @var Checkout $locked */ + $locked = Checkout::query() + ->with(['store', 'cart.lines.variant.product.collections']) + ->whereKey($checkout->getKey()) + ->lockForUpdate() + ->firstOrFail(); + + return $locked; + } + + private function syncCheckout(Checkout $target, Checkout $source): void + { + $target->status = $source->status; + $target->payment_method = $source->payment_method; + $target->email = $source->email; + $target->shipping_address_json = $source->shipping_address_json; + $target->billing_address_json = $source->billing_address_json; + $target->shipping_method_id = $source->shipping_method_id; + $target->discount_code = $source->discount_code; + $target->tax_provider_snapshot_json = $source->tax_provider_snapshot_json; + $target->totals_json = $source->totals_json; + $target->expires_at = $source->expires_at; + $target->updated_at = $source->updated_at; + } +} diff --git a/app/Services/ShippingCalculator.php b/app/Services/ShippingCalculator.php new file mode 100644 index 0000000..b6ffa5b --- /dev/null +++ b/app/Services/ShippingCalculator.php @@ -0,0 +1,294 @@ + $address + */ + public function getMatchingZone(Store $store, array $address): ?ShippingZone + { + $countryCode = $this->normalizeCode($address['country_code'] ?? $address['country'] ?? null); + $regionCode = $this->normalizeCode($address['province_code'] ?? $address['region_code'] ?? $address['province'] ?? null); + + if ($countryCode === null) { + return null; + } + + /** @var Collection $zones */ + $zones = ShippingZone::query() + ->where('store_id', '=', (int) $store->id) + ->orderBy('id') + ->get(); + + $bestMatch = null; + $bestSpecificity = -1; + + /** @var ShippingZone $zone */ + foreach ($zones as $zone) { + $countries = $this->normalizeCodeList($zone->countries_json); + $regions = $this->normalizeCodeList($zone->regions_json); + + $countryMatch = in_array($countryCode, $countries, true); + $regionMatch = $regionCode !== null && in_array($regionCode, $regions, true); + + if (! $countryMatch) { + continue; + } + + $specificity = $regionMatch ? 2 : 1; + + if ($specificity > $bestSpecificity) { + $bestMatch = $zone; + $bestSpecificity = $specificity; + + continue; + } + + if ($specificity === $bestSpecificity && $bestMatch !== null && (int) $zone->id < (int) $bestMatch->id) { + $bestMatch = $zone; + } + } + + return $bestMatch; + } + + /** + * @param array $address + * @return Collection + */ + public function getAvailableRates(Store $store, array $address, Cart $cart): Collection + { + if (! $this->cartRequiresShipping($cart)) { + return collect(); + } + + $zone = $this->getMatchingZone($store, $address); + + if ($zone === null) { + return collect(); + } + + /** @var Collection $rates */ + $rates = ShippingRate::query() + ->where('zone_id', '=', (int) $zone->id) + ->where('is_active', '=', true) + ->orderBy('id') + ->get(); + + $quotes = []; + + /** @var ShippingRate $rate */ + foreach ($rates as $rate) { + $amount = $this->calculate($rate, $cart); + + if ($amount === null) { + continue; + } + + $quotes[] = new ShippingRateQuote( + rateId: (int) $rate->id, + name: (string) $rate->name, + type: $rate->type, + amount: $amount, + ); + } + + return collect($quotes); + } + + /** + * @param array $address + */ + public function selectActiveRateByZone(Store $store, array $address, Cart $cart, int $shippingRateId, int $checkoutId = 0): ShippingRateQuote + { + $quote = $this->getAvailableRates($store, $address, $cart) + ->first(static fn (ShippingRateQuote $candidate): bool => $candidate->rateId === $shippingRateId); + + if ($quote === null) { + throw InvalidCheckoutStateException::invalidShippingMethod($checkoutId, $shippingRateId); + } + + return $quote; + } + + public function cartRequiresShipping(Cart $cart): bool + { + $cart->loadMissing('lines.variant'); + + /** @var CartLine $line */ + foreach ($cart->lines as $line) { + if ((bool) ($line->variant?->requires_shipping ?? false)) { + return true; + } + } + + return false; + } + + public function calculate(ShippingRate $rate, Cart $cart): ?int + { + $cart->loadMissing('lines.variant'); + $config = is_array($rate->config_json) ? $rate->config_json : []; + + return match ($rate->type) { + ShippingRateType::Flat => $this->asInt($config['amount'] ?? 0), + ShippingRateType::Weight => $this->calculateWeightRate($config, $cart), + ShippingRateType::Price => $this->calculatePriceRate($config, $this->cartSubtotal($cart)), + ShippingRateType::Carrier => $this->asInt($config['amount'] ?? 0), + }; + } + + /** + * @param array $config + */ + private function calculateWeightRate(array $config, Cart $cart): ?int + { + $ranges = is_array($config['ranges'] ?? null) ? $config['ranges'] : []; + + if ($ranges === []) { + return null; + } + + $weight = 0; + + /** @var CartLine $line */ + foreach ($cart->lines as $line) { + if (! (bool) ($line->variant?->requires_shipping ?? false)) { + continue; + } + + $lineWeight = $this->asInt($line->variant?->weight_g); + $weight += $lineWeight * (int) $line->quantity; + } + + foreach ($ranges as $range) { + if (! is_array($range)) { + continue; + } + + $min = $this->asInt($range['min_g'] ?? 0); + $max = $range['max_g'] ?? null; + $maxValue = is_numeric($max) ? (int) $max : null; + + if ($weight < $min) { + continue; + } + + if ($maxValue !== null && $weight > $maxValue) { + continue; + } + + return $this->asInt($range['amount'] ?? 0); + } + + return null; + } + + /** + * @param array $config + */ + private function calculatePriceRate(array $config, int $cartSubtotal): ?int + { + $ranges = is_array($config['ranges'] ?? null) ? $config['ranges'] : []; + + if ($ranges === []) { + return null; + } + + foreach ($ranges as $range) { + if (! is_array($range)) { + continue; + } + + $min = $this->asInt($range['min_amount'] ?? 0); + $max = $range['max_amount'] ?? null; + $maxValue = is_numeric($max) ? (int) $max : null; + + if ($cartSubtotal < $min) { + continue; + } + + if ($maxValue !== null && $cartSubtotal > $maxValue) { + continue; + } + + return $this->asInt($range['amount'] ?? 0); + } + + return null; + } + + private function cartSubtotal(Cart $cart): int + { + $subtotal = 0; + + /** @var CartLine $line */ + foreach ($cart->lines as $line) { + $subtotal += (int) $line->unit_price_amount * (int) $line->quantity; + } + + return $subtotal; + } + + private function normalizeCode(mixed $value): ?string + { + if (! is_string($value)) { + return null; + } + + $normalized = strtoupper(trim($value)); + + return $normalized === '' ? null : $normalized; + } + + /** + * @return list + */ + private function normalizeCodeList(mixed $values): array + { + if (! is_array($values)) { + return []; + } + + $normalized = []; + + foreach ($values as $value) { + $code = $this->normalizeCode($value); + + if ($code === null) { + continue; + } + + $normalized[] = $code; + } + + return array_values(array_unique($normalized)); + } + + private function asInt(mixed $value): int + { + if (is_int($value)) { + return $value; + } + + if (is_float($value) || is_string($value)) { + return (int) $value; + } + + return 0; + } +} diff --git a/app/Services/TaxCalculator.php b/app/Services/TaxCalculator.php new file mode 100644 index 0000000..78c08a6 --- /dev/null +++ b/app/Services/TaxCalculator.php @@ -0,0 +1,272 @@ + $address + */ + public function calculate(int $amount, TaxSetting $settings, array $address = []): int + { + if ($amount <= 0) { + return 0; + } + + $rateBasisPoints = $this->resolveRateBasisPoints($settings, $address); + + if ($rateBasisPoints <= 0) { + return 0; + } + + if ($settings->prices_include_tax) { + return $this->extractInclusive($amount, $rateBasisPoints); + } + + return $this->addExclusive($amount, $rateBasisPoints); + } + + public function extractInclusive(int $grossAmount, int $rateBasisPoints): int + { + if ($grossAmount <= 0 || $rateBasisPoints <= 0) { + return 0; + } + + $netAmount = intdiv($grossAmount * 10000, 10000 + $rateBasisPoints); + + return max(0, $grossAmount - $netAmount); + } + + public function addExclusive(int $netAmount, int $rateBasisPoints): int + { + if ($netAmount <= 0 || $rateBasisPoints <= 0) { + return 0; + } + + return (int) round(($netAmount * $rateBasisPoints) / 10000); + } + + /** + * @param list $lineAmounts + * @param array $address + */ + public function calculateForAmounts(array $lineAmounts, int $shippingAmount, TaxSetting $settings, array $address = []): TaxCalculationResult + { + if ($settings->mode !== TaxMode::Manual) { + return TaxCalculationResult::zero(); + } + + $rateBasisPoints = $this->resolveRateBasisPoints($settings, $address); + + if ($rateBasisPoints <= 0) { + return TaxCalculationResult::zero(); + } + + $taxLines = []; + $taxTotal = 0; + + foreach ($lineAmounts as $lineAmount) { + if ($lineAmount <= 0) { + continue; + } + + $lineTax = $settings->prices_include_tax + ? $this->extractInclusive($lineAmount, $rateBasisPoints) + : $this->addExclusive($lineAmount, $rateBasisPoints); + + if ($lineTax <= 0) { + continue; + } + + $taxLines[] = new TaxLine(name: 'Line Tax', rate: $rateBasisPoints, amount: $lineTax); + $taxTotal += $lineTax; + } + + if ($shippingAmount > 0 && $this->isShippingTaxable($settings)) { + $shippingTax = $settings->prices_include_tax + ? $this->extractInclusive($shippingAmount, $rateBasisPoints) + : $this->addExclusive($shippingAmount, $rateBasisPoints); + + if ($shippingTax > 0) { + $taxLines[] = new TaxLine(name: 'Shipping Tax', rate: $rateBasisPoints, amount: $shippingTax); + $taxTotal += $shippingTax; + } + } + + return new TaxCalculationResult( + taxLines: $taxLines, + totalAmount: $taxTotal, + rateBasisPoints: $rateBasisPoints, + ); + } + + /** + * @param array $address + */ + public function resolveRateBasisPoints(TaxSetting $settings, array $address = []): int + { + if ($settings->mode !== TaxMode::Manual) { + return 0; + } + + $config = is_array($settings->config_json) ? $settings->config_json : []; + + $defaultRate = $this->asInt($config['default_rate_bps'] ?? 0); + + $regionCode = $this->normalizeCode($address['province_code'] ?? $address['region_code'] ?? $address['province'] ?? null); + $countryCode = $this->normalizeCode($address['country_code'] ?? $address['country'] ?? null); + + $ratesByRegion = $config['rates_by_region'] ?? null; + + if (is_array($ratesByRegion) && $regionCode !== null) { + $regionRate = $ratesByRegion[$regionCode] ?? null; + + if ($regionRate !== null) { + return $this->asInt($regionRate); + } + } + + $ratesByCountry = $config['rates_by_country'] ?? null; + + if (is_array($ratesByCountry) && $countryCode !== null) { + $countryRate = $ratesByCountry[$countryCode] ?? null; + + if ($countryRate !== null) { + return $this->asInt($countryRate); + } + } + + $zoneRate = $this->resolveZoneRate($config, $countryCode, $regionCode); + + if ($zoneRate !== null) { + return $zoneRate; + } + + return max(0, $defaultRate); + } + + private function isShippingTaxable(TaxSetting $settings): bool + { + $config = is_array($settings->config_json) ? $settings->config_json : []; + $value = $config['shipping_taxable'] ?? true; + + if (is_bool($value)) { + return $value; + } + + if (is_numeric($value)) { + return (int) $value === 1; + } + + if (is_string($value)) { + $normalized = strtolower(trim($value)); + + return in_array($normalized, ['1', 'true', 'yes', 'on'], true); + } + + return true; + } + + /** + * @param array $config + */ + private function resolveZoneRate(array $config, ?string $countryCode, ?string $regionCode): ?int + { + $zoneRates = $config['zone_rates'] ?? $config['zones'] ?? null; + + if (! is_array($zoneRates) || $countryCode === null) { + return null; + } + + $bestRate = null; + $bestSpecificity = -1; + + foreach ($zoneRates as $entry) { + if (! is_array($entry)) { + continue; + } + + $countries = $this->normalizeCodeList($entry['countries'] ?? []); + $regions = $this->normalizeCodeList($entry['regions'] ?? []); + + if (! in_array($countryCode, $countries, true)) { + continue; + } + + $regionMatch = $regionCode !== null && in_array($regionCode, $regions, true); + $specificity = $regionMatch ? 2 : 1; + + if ($specificity < $bestSpecificity) { + continue; + } + + $rate = $this->asInt($entry['rate_bps'] ?? 0); + + if ($rate < 0) { + continue; + } + + if ($specificity > $bestSpecificity || $bestRate === null) { + $bestRate = $rate; + $bestSpecificity = $specificity; + } + } + + return $bestRate; + } + + private function asInt(mixed $value): int + { + if (is_int($value)) { + return $value; + } + + if (is_float($value) || is_string($value)) { + return (int) $value; + } + + return 0; + } + + private function normalizeCode(mixed $value): ?string + { + if (! is_string($value)) { + return null; + } + + $normalized = strtoupper(trim($value)); + + return $normalized === '' ? null : $normalized; + } + + /** + * @return list + */ + private function normalizeCodeList(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + $codes = []; + + foreach ($value as $item) { + $code = $this->normalizeCode($item); + + if ($code === null) { + continue; + } + + $codes[] = $code; + } + + return array_values(array_unique($codes)); + } +} diff --git a/app/Support/Tenant/CurrentStore.php b/app/Support/Tenant/CurrentStore.php new file mode 100644 index 0000000..bb86620 --- /dev/null +++ b/app/Support/Tenant/CurrentStore.php @@ -0,0 +1,79 @@ +|object $record + */ + public static function fromRecord(array|object $record): self + { + $data = is_array($record) ? $record : (array) $record; + + if (! array_key_exists('id', $data)) { + throw new InvalidArgumentException('Store record must contain an id.'); + } + + $idValue = $data['id']; + + if (! is_int($idValue) && ! is_string($idValue) && ! is_float($idValue)) { + throw new InvalidArgumentException('Store record id must be numeric.'); + } + + $status = is_string($data['status'] ?? null) ? $data['status'] : 'active'; + $handle = self::normalizeNullableString($data['handle'] ?? null); + $name = self::normalizeNullableString($data['name'] ?? null); + + return new self( + id: (int) $idValue, + status: $status, + handle: $handle, + name: $name, + ); + } + + private static function normalizeNullableString(mixed $value): ?string + { + if ($value === null) { + return null; + } + + if (is_string($value)) { + return $value; + } + + if (is_int($value) || is_float($value)) { + return (string) $value; + } + + return null; + } + + public function isSuspended(): bool + { + return $this->status === 'suspended'; + } + + /** + * @return array{id: int, status: string, handle: string|null, name: string|null} + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'status' => $this->status, + 'handle' => $this->handle, + 'name' => $this->name, + ]; + } +} diff --git a/app/ValueObjects/DiscountCalculationResult.php b/app/ValueObjects/DiscountCalculationResult.php new file mode 100644 index 0000000..d755102 --- /dev/null +++ b/app/ValueObjects/DiscountCalculationResult.php @@ -0,0 +1,44 @@ + + */ +final readonly class DiscountCalculationResult implements Arrayable +{ + /** + * @param array $lineAllocations + */ + public function __construct( + public int $amount, + public array $lineAllocations, + public bool $freeShipping = false, + ) {} + + public static function none(): self + { + return new self(amount: 0, lineAllocations: [], freeShipping: false); + } + + public function amountForLine(int $lineId): int + { + return $this->lineAllocations[$lineId] ?? 0; + } + + /** + * @return array{amount: int, line_allocations: array, free_shipping: bool} + */ + public function toArray(): array + { + return [ + 'amount' => $this->amount, + 'line_allocations' => $this->lineAllocations, + 'free_shipping' => $this->freeShipping, + ]; + } +} diff --git a/app/ValueObjects/PricingResult.php b/app/ValueObjects/PricingResult.php new file mode 100644 index 0000000..4889646 --- /dev/null +++ b/app/ValueObjects/PricingResult.php @@ -0,0 +1,57 @@ + + */ +final readonly class PricingResult implements Arrayable +{ + /** + * @param list $taxLines + * @param array $lineDiscountAllocations + */ + public function __construct( + public int $subtotal, + public int $discount, + public int $shipping, + public array $taxLines, + public int $taxTotal, + public int $total, + public string $currency, + public array $lineDiscountAllocations = [], + public bool $freeShippingApplied = false, + ) {} + + /** + * @return array{ + * subtotal: int, + * discount: int, + * shipping: int, + * tax: int, + * tax_lines: list, + * total: int, + * currency: string, + * line_discount_allocations: array, + * free_shipping_applied: bool + * } + */ + public function toArray(): array + { + return [ + 'subtotal' => $this->subtotal, + 'discount' => $this->discount, + 'shipping' => $this->shipping, + 'tax' => $this->taxTotal, + 'tax_lines' => array_map(static fn (TaxLine $line): array => $line->toArray(), $this->taxLines), + 'total' => $this->total, + 'currency' => $this->currency, + 'line_discount_allocations' => $this->lineDiscountAllocations, + 'free_shipping_applied' => $this->freeShippingApplied, + ]; + } +} diff --git a/app/ValueObjects/ShippingRateQuote.php b/app/ValueObjects/ShippingRateQuote.php new file mode 100644 index 0000000..abfe920 --- /dev/null +++ b/app/ValueObjects/ShippingRateQuote.php @@ -0,0 +1,34 @@ + + */ +final readonly class ShippingRateQuote implements Arrayable +{ + public function __construct( + public int $rateId, + public string $name, + public ShippingRateType $type, + public int $amount, + ) {} + + /** + * @return array{id: int, name: string, type: string, amount: int} + */ + public function toArray(): array + { + return [ + 'id' => $this->rateId, + 'name' => $this->name, + 'type' => $this->type->value, + 'amount' => $this->amount, + ]; + } +} diff --git a/app/ValueObjects/TaxCalculationResult.php b/app/ValueObjects/TaxCalculationResult.php new file mode 100644 index 0000000..8e4fcd5 --- /dev/null +++ b/app/ValueObjects/TaxCalculationResult.php @@ -0,0 +1,39 @@ + + */ +final readonly class TaxCalculationResult implements Arrayable +{ + /** + * @param list $taxLines + */ + public function __construct( + public array $taxLines, + public int $totalAmount, + public int $rateBasisPoints, + ) {} + + public static function zero(): self + { + return new self(taxLines: [], totalAmount: 0, rateBasisPoints: 0); + } + + /** + * @return array{tax_lines: list, total_amount: int, rate_basis_points: int} + */ + public function toArray(): array + { + return [ + 'tax_lines' => array_map(static fn (TaxLine $line): array => $line->toArray(), $this->taxLines), + 'total_amount' => $this->totalAmount, + 'rate_basis_points' => $this->rateBasisPoints, + ]; + } +} diff --git a/app/ValueObjects/TaxLine.php b/app/ValueObjects/TaxLine.php new file mode 100644 index 0000000..6349c3f --- /dev/null +++ b/app/ValueObjects/TaxLine.php @@ -0,0 +1,31 @@ + + */ +final readonly class TaxLine implements Arrayable +{ + public function __construct( + public string $name, + public int $rate, + public int $amount, + ) {} + + /** + * @return array{name: string, rate: int, amount: int} + */ + public function toArray(): array + { + return [ + 'name' => $this->name, + 'rate' => $this->rate, + 'amount' => $this->amount, + ]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c183276..79b4767 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,9 @@ withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'auth' => Authenticate::class, + 'store.resolve' => ResolveStore::class, + 'role.check' => CheckStoreRole::class, + ]); + + // Customer auth provider is store-scoped, so tenant resolution must run before auth middleware. + $middleware->prependToPriorityList(AuthenticatesRequests::class, ResolveStore::class); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/composer.json b/composer.json index 1f848aa..2c7da50 100644 --- a/composer.json +++ b/composer.json @@ -12,12 +12,15 @@ "php": "^8.2", "laravel/fortify": "^1.30", "laravel/framework": "^12.0", + "laravel/sanctum": "^4.3", "laravel/tinker": "^2.10.1", "livewire/flux": "^2.9.0", "livewire/livewire": "^4.0" }, "require-dev": { + "deptrac/deptrac": "^4.6", "fakerphp/faker": "^1.23", + "larastan/larastan": "^3.0", "laravel/boost": "^1.0", "laravel/pail": "^1.2.2", "laravel/pint": "^1.24", @@ -25,7 +28,8 @@ "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.6", "pestphp/pest": "^4.3", - "pestphp/pest-plugin-laravel": "^4.0" + "pestphp/pest-plugin-laravel": "^4.0", + "phpstan/phpstan": "^2.1" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index e4255db..0aa103a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e4aa7ad38dac6834e5ff6bf65b1cdf23", + "content-hash": "f99c1733c325aa09e4ecbf40a03e3dcf", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1501,6 +1501,69 @@ }, "time": "2026-02-06T12:17:10+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.3.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-02-07T17:19:31+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.9", @@ -6521,6 +6584,236 @@ ], "time": "2026-02-05T09:14:44+00:00" }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "deptrac/deptrac", + "version": "4.6.0", + "source": { + "type": "git", + "url": "https://github.com/deptrac/deptrac.git", + "reference": "b27bfc5291a1cbe1c0ebf514716b1d25c68c63fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/deptrac/deptrac/zipball/b27bfc5291a1cbe1c0ebf514716b1d25c68c63fd", + "reference": "b27bfc5291a1cbe1c0ebf514716b1d25c68c63fd", + "shasum": "" + }, + "require": { + "composer/xdebug-handler": "^3.0", + "jetbrains/phpstorm-stubs": "2024.3 || 2025.3", + "nikic/php-parser": "^5", + "php": "^8.2", + "phpdocumentor/graphviz": "^2.1", + "phpdocumentor/type-resolver": "^1.9.0 || ^2.0.0", + "phpstan/phpdoc-parser": "^1.5.0 || ^2.1.0", + "phpstan/phpstan": "^2.0", + "psr/container": "^2.0", + "psr/event-dispatcher": "^1.0", + "symfony/config": "^6.4 || ^7.4 || ^8.0", + "symfony/console": "^6.4 || ^7.4 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.4 || ^8.0", + "symfony/event-dispatcher": "^6.4 || ^7.4 || ^8.0", + "symfony/event-dispatcher-contracts": "^3.4", + "symfony/filesystem": "^6.4 || ^7.4 || ^8.0", + "symfony/finder": "^6.4 || ^7.4 || ^8.0", + "symfony/yaml": "^6.4 || ^7.4 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8", + "ergebnis/composer-normalize": "^2.45", + "ext-libxml": "*", + "symfony/stopwatch": "^6.4 || ^7.4 || ^8.0" + }, + "suggest": { + "ext-dom": "For using the JUnit output formatter" + }, + "bin": [ + "deptrac" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": true, + "target-directory": "tools" + } + }, + "autoload": { + "psr-4": { + "Deptrac\\Deptrac\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tim Glabisch" + }, + { + "name": "Simon Mönch" + }, + { + "name": "Denis Brumann" + } + ], + "description": "Deptrac is a static code analysis tool that helps to enforce rules for dependencies between software layers.", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "issues": "https://github.com/deptrac/deptrac/issues", + "source": "https://github.com/deptrac/deptrac/tree/4.6.0" + }, + "time": "2026-02-02T09:44:37+00:00" + }, { "name": "doctrine/deprecations", "version": "1.1.6", @@ -6815,6 +7108,47 @@ }, "time": "2025-04-30T06:54:44+00:00" }, + { + "name": "iamcal/sql-parser", + "version": "v0.7", + "source": { + "type": "git", + "url": "https://github.com/iamcal/SQLParser.git", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8", + "shasum": "" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^1.0", + "phpunit/phpunit": "^5|^6|^7|^8|^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "iamcal\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cal Henderson", + "email": "cal@iamcal.com" + } + ], + "description": "MySQL schema parser", + "support": { + "issues": "https://github.com/iamcal/SQLParser/issues", + "source": "https://github.com/iamcal/SQLParser/tree/v0.7" + }, + "time": "2026-01-28T22:20:33+00:00" + }, { "name": "jean85/pretty-package-versions", "version": "2.1.1", @@ -6876,53 +7210,187 @@ "time": "2025-03-19T14:43:43+00:00" }, { - "name": "laravel/boost", - "version": "v1.0.18", + "name": "jetbrains/phpstorm-stubs", + "version": "v2025.3", "source": { "type": "git", - "url": "https://github.com/laravel/boost.git", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab" + "url": "https://github.com/JetBrains/phpstorm-stubs", + "reference": "d1ee5e570343bd4276a3d5959e6e1c2530b006d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", + "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/d1ee5e570343bd4276a3d5959e6e1c2530b006d0", + "reference": "d1ee5e570343bd4276a3d5959e6e1c2530b006d0", "shasum": "" }, - "require": { - "guzzlehttp/guzzle": "^7.9", - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "laravel/mcp": "^0.1.0", - "laravel/prompts": "^0.1.9|^0.3", - "laravel/roster": "^0.2", - "php": "^8.1|^8.2" - }, "require-dev": { - "laravel/pint": "^1.14|^1.23", - "mockery/mockery": "^1.6", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "pestphp/pest": "^2.0|^3.0", - "phpstan/phpstan": "^2.0" + "friendsofphp/php-cs-fixer": "^v3.86", + "nikic/php-parser": "^v5.6", + "phpdocumentor/reflection-docblock": "^5.6", + "phpunit/phpunit": "^12.3" }, "type": "library", - "extra": { - "laravel": { - "providers": [ - "Laravel\\Boost\\BoostServiceProvider" - ] - }, - "branch-alias": { - "dev-master": "1.x-dev" - } - }, "autoload": { - "psr-4": { - "Laravel\\Boost\\": "src/" - } - }, + "files": [ + "PhpStormStubsMap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "PHP runtime & extensions header files for PhpStorm", + "homepage": "https://www.jetbrains.com/phpstorm", + "keywords": [ + "autocomplete", + "code", + "inference", + "inspection", + "jetbrains", + "phpstorm", + "stubs", + "type" + ], + "time": "2025-09-18T15:47:24+00:00" + }, + { + "name": "larastan/larastan", + "version": "v3.9.2", + "source": { + "type": "git", + "url": "https://github.com/larastan/larastan.git", + "reference": "2e9ed291bdc1969e7f270fb33c9cdf3c912daeb2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/larastan/larastan/zipball/2e9ed291bdc1969e7f270fb33c9cdf3c912daeb2", + "reference": "2e9ed291bdc1969e7f270fb33c9cdf3c912daeb2", + "shasum": "" + }, + "require": { + "ext-json": "*", + "iamcal/sql-parser": "^0.7.0", + "illuminate/console": "^11.44.2 || ^12.4.1", + "illuminate/container": "^11.44.2 || ^12.4.1", + "illuminate/contracts": "^11.44.2 || ^12.4.1", + "illuminate/database": "^11.44.2 || ^12.4.1", + "illuminate/http": "^11.44.2 || ^12.4.1", + "illuminate/pipeline": "^11.44.2 || ^12.4.1", + "illuminate/support": "^11.44.2 || ^12.4.1", + "php": "^8.2", + "phpstan/phpstan": "^2.1.32" + }, + "require-dev": { + "doctrine/coding-standard": "^13", + "laravel/framework": "^11.44.2 || ^12.7.2", + "mockery/mockery": "^1.6.12", + "nikic/php-parser": "^5.4", + "orchestra/canvas": "^v9.2.2 || ^10.0.1", + "orchestra/testbench-core": "^9.12.0 || ^10.1", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpunit/phpunit": "^10.5.35 || ^11.5.15" + }, + "suggest": { + "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", + "phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Larastan\\Larastan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Can Vural", + "email": "can9119@gmail.com" + } + ], + "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", + "keywords": [ + "PHPStan", + "code analyse", + "code analysis", + "larastan", + "laravel", + "package", + "php", + "static analysis" + ], + "support": { + "issues": "https://github.com/larastan/larastan/issues", + "source": "https://github.com/larastan/larastan/tree/v3.9.2" + }, + "funding": [ + { + "url": "https://github.com/canvural", + "type": "github" + } + ], + "time": "2026-01-30T15:16:32+00:00" + }, + { + "name": "laravel/boost", + "version": "v1.0.18", + "source": { + "type": "git", + "url": "https://github.com/laravel/boost.git", + "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/boost/zipball/df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", + "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.9", + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "laravel/mcp": "^0.1.0", + "laravel/prompts": "^0.1.9|^0.3", + "laravel/roster": "^0.2", + "php": "^8.1|^8.2" + }, + "require-dev": { + "laravel/pint": "^1.14|^1.23", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Boost\\BoostServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Boost\\": "src/" + } + }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" @@ -8096,6 +8564,59 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpdocumentor/graphviz", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/GraphViz.git", + "reference": "115999dc7f31f2392645aa825a94a6b165e1cedf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/GraphViz/zipball/115999dc7f31f2392645aa825a94a6b165e1cedf", + "reference": "115999dc7f31f2392645aa825a94a6b165e1cedf", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "ext-simplexml": "*", + "mockery/mockery": "^1.2", + "phpstan/phpstan": "^0.12", + "phpunit/phpunit": "^8.2 || ^9.2", + "psalm/phar": "^4.15" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\GraphViz\\": "src/phpDocumentor/GraphViz", + "phpDocumentor\\GraphViz\\PHPStan\\": "./src/phpDocumentor/PHPStan" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "mike.vanriel@naenius.com" + } + ], + "description": "Wrapper for Graphviz", + "support": { + "issues": "https://github.com/phpDocumentor/GraphViz/issues", + "source": "https://github.com/phpDocumentor/GraphViz/tree/2.1.0" + }, + "time": "2021-12-13T19:03:21+00:00" + }, { "name": "phpdocumentor/reflection-common", "version": "2.2.0", @@ -8318,6 +8839,59 @@ }, "time": "2026-01-25T14:56:51+00:00" }, + { + "name": "phpstan/phpstan", + "version": "2.1.39", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c6f73a2af4cbcd99c931d0fb8f08548cc0fa8224", + "reference": "c6f73a2af4cbcd99c931d0fb8f08548cc0fa8224", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-02-11T14:48:56+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "12.5.3", @@ -9718,6 +10292,315 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "symfony/config", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "8f45af92f08f82902827a8b6f403aaf49d893539" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/8f45af92f08f82902827a8b6f403aaf49d893539", + "reference": "8f45af92f08f82902827a8b6f403aaf49d893539", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-13T13:06:50+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v8.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "40a6c455ade7e3bf25900d6b746d40cfa2573e26" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/40a6c455ade7e3bf25900d6b746d40cfa2573e26", + "reference": "40a6c455ade7e3bf25900d6b746d40cfa2573e26", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^3.6", + "symfony/var-exporter": "^7.4|^8.0" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows you to standardize and centralize the way objects are constructed in your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dependency-injection/tree/v8.0.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-27T16:18:07+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v8.0.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "d937d400b980523dc9ee946bb69972b5e619058d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d", + "reference": "d937d400b980523dc9ee946bb69972b5e619058d", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v8.0.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-12-01T09:13:36+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v8.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04", + "reference": "7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "symfony/property-access": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v8.0.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-05T18:53:00+00:00" + }, { "name": "symfony/yaml", "version": "v8.0.1", diff --git a/config/auth.php b/config/auth.php index 7d1eb0d..1518f72 100644 --- a/config/auth.php +++ b/config/auth.php @@ -40,6 +40,11 @@ 'driver' => 'session', 'provider' => 'users', ], + + 'customer' => [ + 'driver' => 'session', + 'provider' => 'customers', + ], ], /* @@ -65,6 +70,11 @@ 'model' => env('AUTH_MODEL', App\Models\User::class), ], + 'customers' => [ + 'driver' => 'customer', + 'table' => env('AUTH_CUSTOMERS_TABLE', 'customers'), + ], + // 'users' => [ // 'driver' => 'database', // 'table' => 'users', @@ -97,6 +107,14 @@ 'expire' => 60, 'throttle' => 60, ], + + 'customers' => [ + 'provider' => 'customers', + 'table' => env('AUTH_CUSTOMER_PASSWORD_RESET_TOKEN_TABLE', 'customer_password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + 'store_scoped' => true, + ], ], /* diff --git a/database/factories/AnalyticsEventFactory.php b/database/factories/AnalyticsEventFactory.php new file mode 100644 index 0000000..da396be --- /dev/null +++ b/database/factories/AnalyticsEventFactory.php @@ -0,0 +1,30 @@ + + */ +class AnalyticsEventFactory extends Factory +{ + protected $model = AnalyticsEvent::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'type' => fake()->randomElement(['page_view', 'product_view', 'add_to_cart', 'checkout_started']), + 'session_id' => fake()->uuid(), + 'customer_id' => Customer::factory(), + 'properties_json' => ['source' => 'factory'], + 'client_event_id' => fake()->uuid(), + 'occurred_at' => now(), + 'created_at' => now(), + ]; + } +} diff --git a/database/factories/AppFactory.php b/database/factories/AppFactory.php new file mode 100644 index 0000000..5ebf4f0 --- /dev/null +++ b/database/factories/AppFactory.php @@ -0,0 +1,24 @@ + + */ +class AppFactory extends Factory +{ + protected $model = App::class; + + public function definition(): array + { + return [ + 'name' => fake()->company().' Integration', + 'status' => AppStatus::Active, + 'created_at' => now(), + ]; + } +} diff --git a/database/factories/CartFactory.php b/database/factories/CartFactory.php new file mode 100644 index 0000000..58bbec2 --- /dev/null +++ b/database/factories/CartFactory.php @@ -0,0 +1,28 @@ + + */ +class CartFactory extends Factory +{ + protected $model = Cart::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'customer_id' => Customer::factory(), + 'currency' => 'EUR', + 'cart_version' => 1, + 'status' => CartStatus::Active, + ]; + } +} diff --git a/database/factories/CollectionFactory.php b/database/factories/CollectionFactory.php new file mode 100644 index 0000000..9d51757 --- /dev/null +++ b/database/factories/CollectionFactory.php @@ -0,0 +1,32 @@ + + */ +class CollectionFactory extends Factory +{ + protected $model = Collection::class; + + public function definition(): array + { + $title = implode(' ', fake()->words(2)); + + return [ + 'store_id' => Store::factory(), + 'title' => $title, + 'handle' => Str::slug($title).'-'.fake()->unique()->numberBetween(100, 999), + 'description_html' => '

'.fake()->sentence().'

', + 'type' => CollectionType::Manual, + 'status' => CollectionStatus::Active, + ]; + } +} diff --git a/database/factories/CustomerFactory.php b/database/factories/CustomerFactory.php new file mode 100644 index 0000000..468b67c --- /dev/null +++ b/database/factories/CustomerFactory.php @@ -0,0 +1,27 @@ + + */ +class CustomerFactory extends Factory +{ + protected $model = Customer::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'email' => fake()->unique()->safeEmail(), + 'password_hash' => Hash::make('password'), + 'name' => fake()->name(), + 'marketing_opt_in' => false, + ]; + } +} diff --git a/database/factories/DiscountFactory.php b/database/factories/DiscountFactory.php new file mode 100644 index 0000000..8293918 --- /dev/null +++ b/database/factories/DiscountFactory.php @@ -0,0 +1,36 @@ + + */ +class DiscountFactory extends Factory +{ + protected $model = Discount::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'type' => DiscountType::Code, + 'code' => strtoupper(Str::random(10)), + 'value_type' => DiscountValueType::Percent, + 'value_amount' => fake()->numberBetween(5, 30), + 'starts_at' => now()->subDay(), + 'ends_at' => now()->addMonth(), + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => [], + 'status' => DiscountStatus::Active, + ]; + } +} diff --git a/database/factories/InventoryItemFactory.php b/database/factories/InventoryItemFactory.php new file mode 100644 index 0000000..a8fa690 --- /dev/null +++ b/database/factories/InventoryItemFactory.php @@ -0,0 +1,49 @@ + + */ +class InventoryItemFactory extends Factory +{ + protected $model = InventoryItem::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'variant_id' => ProductVariant::factory(), + 'quantity_on_hand' => fake()->numberBetween(0, 100), + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ]; + } + + public function outOfStock(): static + { + return $this->state(fn (array $attributes): array => [ + 'quantity_on_hand' => 0, + ]); + } + + public function continuePolicy(): static + { + return $this->state(fn (array $attributes): array => [ + 'policy' => InventoryPolicy::Continue, + ]); + } + + public function lowStock(): static + { + return $this->state(fn (array $attributes): array => [ + 'quantity_on_hand' => fake()->numberBetween(1, 3), + ]); + } +} diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php new file mode 100644 index 0000000..007a9a3 --- /dev/null +++ b/database/factories/OrderFactory.php @@ -0,0 +1,43 @@ + + */ +class OrderFactory extends Factory +{ + protected $model = Order::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'customer_id' => Customer::factory(), + 'order_number' => fake()->unique()->bothify('######'), + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Pending, + 'financial_status' => OrderFinancialStatus::Pending, + 'fulfillment_status' => OrderFulfillmentStatus::Unfulfilled, + 'currency' => 'EUR', + 'subtotal_amount' => 1999, + 'discount_amount' => 0, + 'shipping_amount' => 499, + 'tax_amount' => 380, + 'total_amount' => 2878, + 'email' => fake()->safeEmail(), + 'billing_address_json' => [], + 'shipping_address_json' => [], + 'placed_at' => now(), + ]; + } +} diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php new file mode 100644 index 0000000..82a89eb --- /dev/null +++ b/database/factories/OrganizationFactory.php @@ -0,0 +1,22 @@ + + */ +class OrganizationFactory extends Factory +{ + protected $model = Organization::class; + + public function definition(): array + { + return [ + 'name' => fake()->company(), + 'billing_email' => fake()->companyEmail(), + ]; + } +} diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php new file mode 100644 index 0000000..e02e329 --- /dev/null +++ b/database/factories/ProductFactory.php @@ -0,0 +1,70 @@ + + */ +class ProductFactory extends Factory +{ + protected $model = Product::class; + + public function definition(): array + { + $title = implode(' ', fake()->words(3)); + + return [ + 'store_id' => Store::factory(), + 'title' => $title, + 'handle' => Str::slug($title).'-'.fake()->unique()->numberBetween(100, 999), + 'status' => ProductStatus::Active, + 'description_html' => '

'.fake()->paragraph().'

'.fake()->paragraph().'

', + 'vendor' => fake()->company(), + 'product_type' => fake()->randomElement(['Shirts', 'Pants', 'Shoes', 'Accessories', 'Electronics', 'Books']), + 'tags' => fake()->randomElements(['new', 'sale', 'trending', 'popular', 'limited'], fake()->numberBetween(1, 3)), + 'published_at' => now(), + ]; + } + + public function draft(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => ProductStatus::Draft, + 'published_at' => null, + ]); + } + + public function archived(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => ProductStatus::Archived, + ]); + } + + public function withVariants(int $count = 3): static + { + return $this->afterCreating(function (Product $product) use ($count): void { + ProductVariant::factory()->count($count)->create([ + 'product_id' => $product->id, + ]); + }); + } + + public function withDefaultVariant(int $price = 1999): static + { + return $this->afterCreating(function (Product $product) use ($price): void { + ProductVariant::factory()->default()->create([ + 'product_id' => $product->id, + 'price_amount' => $price, + 'currency' => 'EUR', + ]); + }); + } +} diff --git a/database/factories/ProductVariantFactory.php b/database/factories/ProductVariantFactory.php new file mode 100644 index 0000000..55dd990 --- /dev/null +++ b/database/factories/ProductVariantFactory.php @@ -0,0 +1,63 @@ + + */ +class ProductVariantFactory extends Factory +{ + protected $model = ProductVariant::class; + + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'sku' => fake()->unique()->bothify('SKU-####-???'), + 'barcode' => fake()->ean13(), + 'price_amount' => fake()->numberBetween(999, 19999), + 'compare_at_amount' => null, + 'currency' => 'EUR', + 'weight_g' => fake()->numberBetween(100, 5000), + 'requires_shipping' => true, + 'is_default' => false, + 'position' => 0, + 'status' => ProductVariantStatus::Active, + ]; + } + + public function onSale(): static + { + return $this->state(fn (array $attributes): array => [ + 'compare_at_amount' => fake()->numberBetween(20000, 39999), + 'price_amount' => fake()->numberBetween(9999, 19999), + ]); + } + + public function digital(): static + { + return $this->state(fn (array $attributes): array => [ + 'requires_shipping' => false, + 'weight_g' => 0, + ]); + } + + public function default(): static + { + return $this->state(fn (array $attributes): array => [ + 'is_default' => true, + ]); + } + + public function archived(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => ProductVariantStatus::Archived, + ]); + } +} diff --git a/database/factories/SearchQueryFactory.php b/database/factories/SearchQueryFactory.php new file mode 100644 index 0000000..0084356 --- /dev/null +++ b/database/factories/SearchQueryFactory.php @@ -0,0 +1,26 @@ + + */ +class SearchQueryFactory extends Factory +{ + protected $model = SearchQuery::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'query' => fake()->words(2, true), + 'filters_json' => ['in_stock' => true], + 'results_count' => fake()->numberBetween(0, 40), + 'created_at' => now(), + ]; + } +} diff --git a/database/factories/StoreDomainFactory.php b/database/factories/StoreDomainFactory.php new file mode 100644 index 0000000..6dc4ffa --- /dev/null +++ b/database/factories/StoreDomainFactory.php @@ -0,0 +1,50 @@ + + */ +class StoreDomainFactory extends Factory +{ + protected $model = StoreDomain::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'hostname' => fake()->unique()->domainName(), + 'type' => StoreDomainType::Storefront, + 'is_primary' => true, + 'tls_mode' => StoreDomainTlsMode::Managed, + 'created_at' => now(), + ]; + } + + public function admin(): static + { + return $this->state(fn (array $attributes): array => [ + 'type' => StoreDomainType::Admin, + ]); + } + + public function api(): static + { + return $this->state(fn (array $attributes): array => [ + 'type' => StoreDomainType::Api, + ]); + } + + public function secondary(): static + { + return $this->state(fn (array $attributes): array => [ + 'is_primary' => false, + ]); + } +} diff --git a/database/factories/StoreFactory.php b/database/factories/StoreFactory.php new file mode 100644 index 0000000..49b1baa --- /dev/null +++ b/database/factories/StoreFactory.php @@ -0,0 +1,39 @@ + + */ +class StoreFactory extends Factory +{ + protected $model = Store::class; + + public function definition(): array + { + $name = fake()->company().' Store'; + + return [ + 'organization_id' => Organization::factory(), + 'name' => $name, + 'handle' => Str::slug($name).'-'.fake()->unique()->numberBetween(100, 999), + 'status' => StoreStatus::Active, + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ]; + } + + public function suspended(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => StoreStatus::Suspended, + ]); + } +} diff --git a/database/factories/StoreSettingsFactory.php b/database/factories/StoreSettingsFactory.php new file mode 100644 index 0000000..4391ac1 --- /dev/null +++ b/database/factories/StoreSettingsFactory.php @@ -0,0 +1,28 @@ + + */ +class StoreSettingsFactory extends Factory +{ + protected $model = StoreSettings::class; + + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'settings_json' => [ + 'storefront' => [ + 'contact_email' => fake()->safeEmail(), + ], + ], + 'updated_at' => now(), + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 80da5ac..ea621a4 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Enums\UserStatus; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; @@ -27,7 +28,9 @@ public function definition(): array 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), - 'password' => static::$password ??= Hash::make('password'), + 'password_hash' => static::$password ??= Hash::make('password'), + 'status' => UserStatus::Active, + 'last_login_at' => fake()->dateTimeBetween('-30 days'), 'remember_token' => Str::random(10), 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, @@ -56,4 +59,14 @@ public function withTwoFactor(): static 'two_factor_confirmed_at' => now(), ]); } + + /** + * Indicate that the user is disabled. + */ + public function disabled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => UserStatus::Disabled, + ]); + } } diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 05fb5d9..044fdbe 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -14,11 +14,19 @@ public function up(): void Schema::create('users', function (Blueprint $table) { $table->id(); $table->string('name'); - $table->string('email')->unique(); + $table->string('email'); $table->timestamp('email_verified_at')->nullable(); - $table->string('password'); + $table->string('password_hash'); + $table->enum('status', ['active', 'disabled'])->default('active'); + $table->timestamp('last_login_at')->nullable(); + $table->text('two_factor_secret')->nullable(); + $table->text('two_factor_recovery_codes')->nullable(); + $table->timestamp('two_factor_confirmed_at')->nullable(); $table->rememberToken(); $table->timestamps(); + + $table->unique('email', 'idx_users_email'); + $table->index('status', 'idx_users_status'); }); Schema::create('password_reset_tokens', function (Blueprint $table) { diff --git a/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php b/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php index 187d974..fe1c193 100644 --- a/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php +++ b/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php @@ -12,9 +12,17 @@ public function up(): void { Schema::table('users', function (Blueprint $table) { - $table->text('two_factor_secret')->after('password')->nullable(); - $table->text('two_factor_recovery_codes')->after('two_factor_secret')->nullable(); - $table->timestamp('two_factor_confirmed_at')->after('two_factor_recovery_codes')->nullable(); + if (! Schema::hasColumn('users', 'two_factor_secret')) { + $table->text('two_factor_secret')->nullable(); + } + + if (! Schema::hasColumn('users', 'two_factor_recovery_codes')) { + $table->text('two_factor_recovery_codes')->nullable(); + } + + if (! Schema::hasColumn('users', 'two_factor_confirmed_at')) { + $table->timestamp('two_factor_confirmed_at')->nullable(); + } }); } @@ -24,11 +32,17 @@ public function up(): void public function down(): void { Schema::table('users', function (Blueprint $table) { - $table->dropColumn([ - 'two_factor_secret', - 'two_factor_recovery_codes', - 'two_factor_confirmed_at', - ]); + $columns = []; + + foreach (['two_factor_secret', 'two_factor_recovery_codes', 'two_factor_confirmed_at'] as $column) { + if (Schema::hasColumn('users', $column)) { + $columns[] = $column; + } + } + + if ($columns !== []) { + $table->dropColumn($columns); + } }); } }; diff --git a/database/migrations/2026_02_14_100000_create_foundation_schema_tables.php b/database/migrations/2026_02_14_100000_create_foundation_schema_tables.php new file mode 100644 index 0000000..65a0ec9 --- /dev/null +++ b/database/migrations/2026_02_14_100000_create_foundation_schema_tables.php @@ -0,0 +1,82 @@ +id(); + $table->string('name'); + $table->string('billing_email'); + $table->timestamps(); + + $table->index('billing_email', 'idx_organizations_billing_email'); + }); + + Schema::create('stores', function (Blueprint $table) { + $table->id(); + $table->foreignId('organization_id')->constrained('organizations')->cascadeOnDelete(); + $table->string('name'); + $table->string('handle'); + $table->enum('status', ['active', 'suspended'])->default('active'); + $table->string('default_currency', 3)->default('USD'); + $table->string('default_locale', 10)->default('en'); + $table->string('timezone')->default('UTC'); + $table->timestamps(); + + $table->unique('handle', 'idx_stores_handle'); + $table->index('organization_id', 'idx_stores_organization_id'); + $table->index('status', 'idx_stores_status'); + }); + + Schema::create('store_domains', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('hostname'); + $table->enum('type', ['storefront', 'admin', 'api'])->default('storefront'); + $table->boolean('is_primary')->default(false); + $table->enum('tls_mode', ['managed', 'bring_your_own'])->default('managed'); + $table->timestamp('created_at')->nullable(); + + $table->unique('hostname', 'idx_store_domains_hostname'); + $table->index('store_id', 'idx_store_domains_store_id'); + $table->index(['store_id', 'is_primary'], 'idx_store_domains_store_primary'); + }); + + Schema::create('store_users', function (Blueprint $table) { + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->enum('role', ['owner', 'admin', 'staff', 'support'])->default('staff'); + $table->timestamp('created_at')->nullable(); + + $table->primary(['store_id', 'user_id']); + $table->index('user_id', 'idx_store_users_user_id'); + $table->index(['store_id', 'role'], 'idx_store_users_role'); + }); + + Schema::create('store_settings', function (Blueprint $table) { + $table->foreignId('store_id')->primary()->constrained('stores')->cascadeOnDelete(); + $table->text('settings_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('store_settings'); + Schema::dropIfExists('store_users'); + Schema::dropIfExists('store_domains'); + Schema::dropIfExists('stores'); + Schema::dropIfExists('organizations'); + } +}; diff --git a/database/migrations/2026_02_14_100100_create_catalog_schema_tables.php b/database/migrations/2026_02_14_100100_create_catalog_schema_tables.php new file mode 100644 index 0000000..4dc76e9 --- /dev/null +++ b/database/migrations/2026_02_14_100100_create_catalog_schema_tables.php @@ -0,0 +1,157 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->enum('status', ['draft', 'active', 'archived'])->default('draft'); + $table->text('description_html')->nullable(); + $table->string('vendor')->nullable(); + $table->string('product_type')->nullable(); + $table->text('tags')->default('[]'); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'handle'], 'idx_products_store_handle'); + $table->index('store_id', 'idx_products_store_id'); + $table->index(['store_id', 'status'], 'idx_products_store_status'); + $table->index(['store_id', 'published_at'], 'idx_products_published_at'); + $table->index(['store_id', 'vendor'], 'idx_products_vendor'); + $table->index(['store_id', 'product_type'], 'idx_products_product_type'); + }); + + Schema::create('product_options', function (Blueprint $table) { + $table->id(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->string('name'); + $table->integer('position')->default(0); + + $table->index('product_id', 'idx_product_options_product_id'); + $table->unique(['product_id', 'position'], 'idx_product_options_product_position'); + }); + + Schema::create('product_option_values', function (Blueprint $table) { + $table->id(); + $table->foreignId('product_option_id')->constrained('product_options')->cascadeOnDelete(); + $table->string('value'); + $table->integer('position')->default(0); + + $table->index('product_option_id', 'idx_product_option_values_option_id'); + $table->unique(['product_option_id', 'position'], 'idx_product_option_values_option_position'); + }); + + Schema::create('product_variants', function (Blueprint $table) { + $table->id(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->string('sku')->nullable(); + $table->string('barcode')->nullable(); + $table->integer('price_amount')->default(0); + $table->integer('compare_at_amount')->nullable(); + $table->string('currency', 3)->default('USD'); + $table->integer('weight_g')->nullable(); + $table->boolean('requires_shipping')->default(true); + $table->boolean('is_default')->default(false); + $table->integer('position')->default(0); + $table->enum('status', ['active', 'archived'])->default('active'); + $table->timestamps(); + + $table->index('product_id', 'idx_product_variants_product_id'); + $table->index('sku', 'idx_product_variants_sku'); + $table->index('barcode', 'idx_product_variants_barcode'); + $table->index(['product_id', 'position'], 'idx_product_variants_product_position'); + $table->index(['product_id', 'is_default'], 'idx_product_variants_product_default'); + }); + + Schema::create('variant_option_values', function (Blueprint $table) { + $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->foreignId('product_option_value_id')->constrained('product_option_values')->cascadeOnDelete(); + + $table->primary(['variant_id', 'product_option_value_id']); + $table->index('product_option_value_id', 'idx_variant_option_values_value_id'); + }); + + Schema::create('inventory_items', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->integer('quantity_on_hand')->default(0); + $table->integer('quantity_reserved')->default(0); + $table->enum('policy', ['deny', 'continue'])->default('deny'); + + $table->unique('variant_id', 'idx_inventory_items_variant_id'); + $table->index('store_id', 'idx_inventory_items_store_id'); + }); + + Schema::create('collections', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->text('description_html')->nullable(); + $table->enum('type', ['manual', 'automated'])->default('manual'); + $table->enum('status', ['draft', 'active', 'archived'])->default('active'); + $table->timestamps(); + + $table->unique(['store_id', 'handle'], 'idx_collections_store_handle'); + $table->index('store_id', 'idx_collections_store_id'); + $table->index(['store_id', 'status'], 'idx_collections_store_status'); + }); + + Schema::create('collection_products', function (Blueprint $table) { + $table->foreignId('collection_id')->constrained('collections')->cascadeOnDelete(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->integer('position')->default(0); + + $table->primary(['collection_id', 'product_id']); + $table->index('product_id', 'idx_collection_products_product_id'); + $table->index(['collection_id', 'position'], 'idx_collection_products_position'); + }); + + Schema::create('product_media', function (Blueprint $table) { + $table->id(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->enum('type', ['image', 'video'])->default('image'); + $table->string('storage_key'); + $table->text('alt_text')->nullable(); + $table->integer('width')->nullable(); + $table->integer('height')->nullable(); + $table->string('mime_type')->nullable(); + $table->integer('byte_size')->nullable(); + $table->integer('position')->default(0); + $table->enum('status', ['processing', 'ready', 'failed'])->default('processing'); + $table->timestamp('created_at')->nullable(); + + $table->index('product_id', 'idx_product_media_product_id'); + $table->index(['product_id', 'position'], 'idx_product_media_product_position'); + $table->index('status', 'idx_product_media_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_media'); + Schema::dropIfExists('collection_products'); + Schema::dropIfExists('collections'); + Schema::dropIfExists('inventory_items'); + Schema::dropIfExists('variant_option_values'); + Schema::dropIfExists('product_variants'); + Schema::dropIfExists('product_option_values'); + Schema::dropIfExists('product_options'); + Schema::dropIfExists('products'); + } +}; diff --git a/database/migrations/2026_02_14_100200_create_theme_and_content_schema_tables.php b/database/migrations/2026_02_14_100200_create_theme_and_content_schema_tables.php new file mode 100644 index 0000000..3d2bb2a --- /dev/null +++ b/database/migrations/2026_02_14_100200_create_theme_and_content_schema_tables.php @@ -0,0 +1,97 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('name'); + $table->string('version')->nullable(); + $table->enum('status', ['draft', 'published'])->default('draft'); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->index('store_id', 'idx_themes_store_id'); + $table->index(['store_id', 'status'], 'idx_themes_store_status'); + }); + + Schema::create('theme_files', function (Blueprint $table) { + $table->id(); + $table->foreignId('theme_id')->constrained('themes')->cascadeOnDelete(); + $table->string('path'); + $table->string('storage_key'); + $table->string('sha256'); + $table->integer('byte_size')->default(0); + + $table->unique(['theme_id', 'path'], 'idx_theme_files_theme_path'); + $table->index('theme_id', 'idx_theme_files_theme_id'); + }); + + Schema::create('theme_settings', function (Blueprint $table) { + $table->foreignId('theme_id')->primary()->constrained('themes')->cascadeOnDelete(); + $table->text('settings_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + }); + + Schema::create('pages', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->text('body_html')->nullable(); + $table->enum('status', ['draft', 'published', 'archived'])->default('draft'); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'handle'], 'idx_pages_store_handle'); + $table->index('store_id', 'idx_pages_store_id'); + $table->index(['store_id', 'status'], 'idx_pages_store_status'); + }); + + Schema::create('navigation_menus', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('handle'); + $table->string('title'); + $table->timestamps(); + + $table->unique(['store_id', 'handle'], 'idx_navigation_menus_store_handle'); + $table->index('store_id', 'idx_navigation_menus_store_id'); + }); + + Schema::create('navigation_items', function (Blueprint $table) { + $table->id(); + $table->foreignId('menu_id')->constrained('navigation_menus')->cascadeOnDelete(); + $table->enum('type', ['link', 'page', 'collection', 'product'])->default('link'); + $table->string('label'); + $table->string('url')->nullable(); + $table->foreignId('resource_id')->nullable(); + $table->integer('position')->default(0); + + $table->index('menu_id', 'idx_navigation_items_menu_id'); + $table->index(['menu_id', 'position'], 'idx_navigation_items_menu_position'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('navigation_items'); + Schema::dropIfExists('navigation_menus'); + Schema::dropIfExists('pages'); + Schema::dropIfExists('theme_settings'); + Schema::dropIfExists('theme_files'); + Schema::dropIfExists('themes'); + } +}; diff --git a/database/migrations/2026_02_14_100300_create_search_schema_tables.php b/database/migrations/2026_02_14_100300_create_search_schema_tables.php new file mode 100644 index 0000000..2e5c18b --- /dev/null +++ b/database/migrations/2026_02_14_100300_create_search_schema_tables.php @@ -0,0 +1,43 @@ +foreignId('store_id')->primary()->constrained('stores')->cascadeOnDelete(); + $table->text('synonyms_json')->default('[]'); + $table->text('stop_words_json')->default('[]'); + $table->timestamp('updated_at')->nullable(); + }); + + Schema::create('search_queries', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->text('query'); + $table->text('filters_json')->nullable(); + $table->integer('results_count')->default(0); + $table->timestamp('created_at')->nullable(); + + $table->index('store_id', 'idx_search_queries_store_id'); + $table->index(['store_id', 'created_at'], 'idx_search_queries_store_created'); + $table->index(['store_id', 'query'], 'idx_search_queries_store_query'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('search_queries'); + Schema::dropIfExists('search_settings'); + } +}; diff --git a/database/migrations/2026_02_14_100350_create_customer_schema_tables.php b/database/migrations/2026_02_14_100350_create_customer_schema_tables.php new file mode 100644 index 0000000..f384001 --- /dev/null +++ b/database/migrations/2026_02_14_100350_create_customer_schema_tables.php @@ -0,0 +1,57 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('email'); + $table->string('password_hash')->nullable(); + $table->string('name')->nullable(); + $table->boolean('marketing_opt_in')->default(false); + $table->timestamps(); + + $table->unique(['store_id', 'email'], 'idx_customers_store_email'); + $table->index('store_id', 'idx_customers_store_id'); + }); + + Schema::create('customer_addresses', function (Blueprint $table) { + $table->id(); + $table->foreignId('customer_id')->constrained('customers')->cascadeOnDelete(); + $table->string('label')->nullable(); + $table->text('address_json')->default('{}'); + $table->boolean('is_default')->default(false); + + $table->index('customer_id', 'idx_customer_addresses_customer_id'); + $table->index(['customer_id', 'is_default'], 'idx_customer_addresses_default'); + }); + + Schema::create('customer_password_reset_tokens', function (Blueprint $table) { + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('email'); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + + $table->primary(['store_id', 'email']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('customer_password_reset_tokens'); + Schema::dropIfExists('customer_addresses'); + Schema::dropIfExists('customers'); + } +}; diff --git a/database/migrations/2026_02_14_100400_create_cart_checkout_discount_schema_tables.php b/database/migrations/2026_02_14_100400_create_cart_checkout_discount_schema_tables.php new file mode 100644 index 0000000..b695ce6 --- /dev/null +++ b/database/migrations/2026_02_14_100400_create_cart_checkout_discount_schema_tables.php @@ -0,0 +1,131 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained('customers')->nullOnDelete(); + $table->string('currency', 3)->default('USD'); + $table->integer('cart_version')->default(1); + $table->enum('status', ['active', 'converted', 'abandoned'])->default('active'); + $table->timestamps(); + + $table->index('store_id', 'idx_carts_store_id'); + $table->index('customer_id', 'idx_carts_customer_id'); + $table->index(['store_id', 'status'], 'idx_carts_store_status'); + }); + + Schema::create('cart_lines', function (Blueprint $table) { + $table->id(); + $table->foreignId('cart_id')->constrained('carts')->cascadeOnDelete(); + $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->integer('quantity')->default(1); + $table->integer('unit_price_amount')->default(0); + $table->integer('line_subtotal_amount')->default(0); + $table->integer('line_discount_amount')->default(0); + $table->integer('line_total_amount')->default(0); + + $table->index('cart_id', 'idx_cart_lines_cart_id'); + $table->unique(['cart_id', 'variant_id'], 'idx_cart_lines_cart_variant'); + }); + + Schema::create('checkouts', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('cart_id')->constrained('carts')->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained('customers')->nullOnDelete(); + $table->enum('status', ['started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired'])->default('started'); + $table->enum('payment_method', ['credit_card', 'paypal', 'bank_transfer'])->nullable(); + $table->string('email')->nullable(); + $table->text('shipping_address_json')->nullable(); + $table->text('billing_address_json')->nullable(); + $table->foreignId('shipping_method_id')->nullable(); + $table->string('discount_code')->nullable(); + $table->text('tax_provider_snapshot_json')->nullable(); + $table->text('totals_json')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + + $table->index('store_id', 'idx_checkouts_store_id'); + $table->index('cart_id', 'idx_checkouts_cart_id'); + $table->index('customer_id', 'idx_checkouts_customer_id'); + $table->index(['store_id', 'status'], 'idx_checkouts_status'); + $table->index('expires_at', 'idx_checkouts_expires_at'); + }); + + Schema::create('shipping_zones', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('name'); + $table->text('countries_json')->default('[]'); + $table->text('regions_json')->default('[]'); + + $table->index('store_id', 'idx_shipping_zones_store_id'); + }); + + Schema::create('shipping_rates', function (Blueprint $table) { + $table->id(); + $table->foreignId('zone_id')->constrained('shipping_zones')->cascadeOnDelete(); + $table->string('name'); + $table->enum('type', ['flat', 'weight', 'price', 'carrier'])->default('flat'); + $table->text('config_json')->default('{}'); + $table->boolean('is_active')->default(true); + + $table->index('zone_id', 'idx_shipping_rates_zone_id'); + $table->index(['zone_id', 'is_active'], 'idx_shipping_rates_zone_active'); + }); + + Schema::create('tax_settings', function (Blueprint $table) { + $table->foreignId('store_id')->primary()->constrained('stores')->cascadeOnDelete(); + $table->enum('mode', ['manual', 'provider'])->default('manual'); + $table->enum('provider', ['stripe_tax', 'none'])->default('none'); + $table->boolean('prices_include_tax')->default(false); + $table->text('config_json')->default('{}'); + }); + + Schema::create('discounts', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->enum('type', ['code', 'automatic'])->default('code'); + $table->string('code')->nullable(); + $table->enum('value_type', ['fixed', 'percent', 'free_shipping']); + $table->integer('value_amount')->default(0); + $table->timestamp('starts_at'); + $table->timestamp('ends_at')->nullable(); + $table->integer('usage_limit')->nullable(); + $table->integer('usage_count')->default(0); + $table->text('rules_json')->default('{}'); + $table->enum('status', ['draft', 'active', 'expired', 'disabled'])->default('active'); + $table->timestamps(); + + $table->unique(['store_id', 'code'], 'idx_discounts_store_code'); + $table->index('store_id', 'idx_discounts_store_id'); + $table->index(['store_id', 'status'], 'idx_discounts_store_status'); + $table->index(['store_id', 'type'], 'idx_discounts_store_type'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('discounts'); + Schema::dropIfExists('tax_settings'); + Schema::dropIfExists('shipping_rates'); + Schema::dropIfExists('shipping_zones'); + Schema::dropIfExists('checkouts'); + Schema::dropIfExists('cart_lines'); + Schema::dropIfExists('carts'); + } +}; diff --git a/database/migrations/2026_02_14_100500_create_order_payment_fulfillment_schema_tables.php b/database/migrations/2026_02_14_100500_create_order_payment_fulfillment_schema_tables.php new file mode 100644 index 0000000..f17ce15 --- /dev/null +++ b/database/migrations/2026_02_14_100500_create_order_payment_fulfillment_schema_tables.php @@ -0,0 +1,133 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained('customers')->nullOnDelete(); + $table->string('order_number'); + $table->enum('payment_method', ['credit_card', 'paypal', 'bank_transfer']); + $table->enum('status', ['pending', 'paid', 'fulfilled', 'cancelled', 'refunded'])->default('pending'); + $table->enum('financial_status', ['pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided'])->default('pending'); + $table->enum('fulfillment_status', ['unfulfilled', 'partial', 'fulfilled'])->default('unfulfilled'); + $table->string('currency', 3)->default('USD'); + $table->integer('subtotal_amount')->default(0); + $table->integer('discount_amount')->default(0); + $table->integer('shipping_amount')->default(0); + $table->integer('tax_amount')->default(0); + $table->integer('total_amount')->default(0); + $table->string('email')->nullable(); + $table->text('billing_address_json')->nullable(); + $table->text('shipping_address_json')->nullable(); + $table->timestamp('placed_at')->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'order_number'], 'idx_orders_store_order_number'); + $table->index('store_id', 'idx_orders_store_id'); + $table->index('customer_id', 'idx_orders_customer_id'); + $table->index(['store_id', 'status'], 'idx_orders_store_status'); + $table->index(['store_id', 'financial_status'], 'idx_orders_store_financial'); + $table->index(['store_id', 'fulfillment_status'], 'idx_orders_store_fulfillment'); + $table->index(['store_id', 'placed_at'], 'idx_orders_placed_at'); + }); + + Schema::create('order_lines', function (Blueprint $table) { + $table->id(); + $table->foreignId('order_id')->constrained('orders')->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->constrained('products')->nullOnDelete(); + $table->foreignId('variant_id')->nullable()->constrained('product_variants')->nullOnDelete(); + $table->string('title_snapshot'); + $table->string('sku_snapshot')->nullable(); + $table->integer('quantity')->default(1); + $table->integer('unit_price_amount')->default(0); + $table->integer('total_amount')->default(0); + $table->text('tax_lines_json')->default('[]'); + $table->text('discount_allocations_json')->default('[]'); + + $table->index('order_id', 'idx_order_lines_order_id'); + $table->index('product_id', 'idx_order_lines_product_id'); + $table->index('variant_id', 'idx_order_lines_variant_id'); + }); + + Schema::create('payments', function (Blueprint $table) { + $table->id(); + $table->foreignId('order_id')->constrained('orders')->cascadeOnDelete(); + $table->enum('provider', ['mock'])->default('mock'); + $table->enum('method', ['credit_card', 'paypal', 'bank_transfer']); + $table->string('provider_payment_id')->nullable(); + $table->enum('status', ['pending', 'captured', 'failed', 'refunded'])->default('pending'); + $table->integer('amount')->default(0); + $table->string('currency', 3)->default('USD'); + $table->text('raw_json_encrypted')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index('order_id', 'idx_payments_order_id'); + $table->index(['provider', 'provider_payment_id'], 'idx_payments_provider_id'); + $table->index('method', 'idx_payments_method'); + $table->index('status', 'idx_payments_status'); + }); + + Schema::create('refunds', function (Blueprint $table) { + $table->id(); + $table->foreignId('order_id')->constrained('orders')->cascadeOnDelete(); + $table->foreignId('payment_id')->constrained('payments')->cascadeOnDelete(); + $table->integer('amount')->default(0); + $table->text('reason')->nullable(); + $table->enum('status', ['pending', 'processed', 'failed'])->default('pending'); + $table->string('provider_refund_id')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index('order_id', 'idx_refunds_order_id'); + $table->index('payment_id', 'idx_refunds_payment_id'); + $table->index('status', 'idx_refunds_status'); + }); + + Schema::create('fulfillments', function (Blueprint $table) { + $table->id(); + $table->foreignId('order_id')->constrained('orders')->cascadeOnDelete(); + $table->enum('status', ['pending', 'shipped', 'delivered'])->default('pending'); + $table->string('tracking_company')->nullable(); + $table->string('tracking_number')->nullable(); + $table->string('tracking_url')->nullable(); + $table->timestamp('shipped_at')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index('order_id', 'idx_fulfillments_order_id'); + $table->index('status', 'idx_fulfillments_status'); + $table->index(['tracking_company', 'tracking_number'], 'idx_fulfillments_tracking'); + }); + + Schema::create('fulfillment_lines', function (Blueprint $table) { + $table->id(); + $table->foreignId('fulfillment_id')->constrained('fulfillments')->cascadeOnDelete(); + $table->foreignId('order_line_id')->constrained('order_lines')->cascadeOnDelete(); + $table->integer('quantity')->default(1); + + $table->index('fulfillment_id', 'idx_fulfillment_lines_fulfillment_id'); + $table->unique(['fulfillment_id', 'order_line_id'], 'idx_fulfillment_lines_fulfillment_order_line'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('fulfillment_lines'); + Schema::dropIfExists('fulfillments'); + Schema::dropIfExists('refunds'); + Schema::dropIfExists('payments'); + Schema::dropIfExists('order_lines'); + Schema::dropIfExists('orders'); + } +}; diff --git a/database/migrations/2026_02_14_100550_create_analytics_schema_tables.php b/database/migrations/2026_02_14_100550_create_analytics_schema_tables.php new file mode 100644 index 0000000..dc50a00 --- /dev/null +++ b/database/migrations/2026_02_14_100550_create_analytics_schema_tables.php @@ -0,0 +1,57 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('type'); + $table->string('session_id')->nullable(); + $table->foreignId('customer_id')->nullable()->constrained('customers')->nullOnDelete(); + $table->text('properties_json')->default('{}'); + $table->string('client_event_id')->nullable(); + $table->timestamp('occurred_at')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index('store_id', 'idx_analytics_events_store_id'); + $table->index(['store_id', 'type'], 'idx_analytics_events_store_type'); + $table->index(['store_id', 'created_at'], 'idx_analytics_events_store_created'); + $table->index('session_id', 'idx_analytics_events_session'); + $table->index('customer_id', 'idx_analytics_events_customer'); + $table->unique(['store_id', 'client_event_id'], 'idx_analytics_events_client_event'); + }); + + Schema::create('analytics_daily', function (Blueprint $table) { + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->date('date'); + $table->integer('orders_count')->default(0); + $table->integer('revenue_amount')->default(0); + $table->integer('aov_amount')->default(0); + $table->integer('visits_count')->default(0); + $table->integer('add_to_cart_count')->default(0); + $table->integer('checkout_started_count')->default(0); + $table->integer('checkout_completed_count')->default(0); + + $table->primary(['store_id', 'date']); + $table->index(['store_id', 'date'], 'idx_analytics_daily_store_date'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('analytics_daily'); + Schema::dropIfExists('analytics_events'); + } +}; diff --git a/database/migrations/2026_02_14_100600_create_apps_and_webhooks_schema_tables.php b/database/migrations/2026_02_14_100600_create_apps_and_webhooks_schema_tables.php new file mode 100644 index 0000000..0272f8d --- /dev/null +++ b/database/migrations/2026_02_14_100600_create_apps_and_webhooks_schema_tables.php @@ -0,0 +1,102 @@ +id(); + $table->string('name'); + $table->enum('status', ['active', 'disabled'])->default('active'); + $table->timestamp('created_at')->nullable(); + + $table->index('status', 'idx_apps_status'); + }); + + Schema::create('app_installations', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('app_id')->constrained('apps')->cascadeOnDelete(); + $table->text('scopes_json')->default('[]'); + $table->enum('status', ['active', 'suspended', 'uninstalled'])->default('active'); + $table->timestamp('installed_at')->nullable(); + + $table->unique(['store_id', 'app_id'], 'idx_app_installations_store_app'); + $table->index('store_id', 'idx_app_installations_store_id'); + $table->index('app_id', 'idx_app_installations_app_id'); + }); + + Schema::create('oauth_clients', function (Blueprint $table) { + $table->id(); + $table->foreignId('app_id')->constrained('apps')->cascadeOnDelete(); + $table->string('client_id'); + $table->text('client_secret_encrypted'); + $table->text('redirect_uris_json')->default('[]'); + + $table->unique('client_id', 'idx_oauth_clients_client_id'); + $table->index('app_id', 'idx_oauth_clients_app_id'); + }); + + Schema::create('oauth_tokens', function (Blueprint $table) { + $table->id(); + $table->foreignId('installation_id')->constrained('app_installations')->cascadeOnDelete(); + $table->string('access_token_hash'); + $table->string('refresh_token_hash')->nullable(); + $table->timestamp('expires_at'); + + $table->index('installation_id', 'idx_oauth_tokens_installation_id'); + $table->unique('access_token_hash', 'idx_oauth_tokens_access_hash'); + $table->index('expires_at', 'idx_oauth_tokens_expires_at'); + }); + + Schema::create('webhook_subscriptions', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('app_installation_id')->nullable()->constrained('app_installations')->cascadeOnDelete(); + $table->string('event_type'); + $table->string('target_url'); + $table->text('signing_secret_encrypted'); + $table->enum('status', ['active', 'paused', 'disabled'])->default('active'); + + $table->index('store_id', 'idx_webhook_subscriptions_store_id'); + $table->index(['store_id', 'event_type'], 'idx_webhook_subscriptions_store_event'); + $table->index('app_installation_id', 'idx_webhook_subscriptions_installation'); + }); + + Schema::create('webhook_deliveries', function (Blueprint $table) { + $table->id(); + $table->foreignId('subscription_id')->constrained('webhook_subscriptions')->cascadeOnDelete(); + $table->string('event_id'); + $table->integer('attempt_count')->default(1); + $table->enum('status', ['pending', 'success', 'failed'])->default('pending'); + $table->timestamp('last_attempt_at')->nullable(); + $table->integer('response_code')->nullable(); + $table->text('response_body_snippet')->nullable(); + + $table->index('subscription_id', 'idx_webhook_deliveries_subscription_id'); + $table->index('event_id', 'idx_webhook_deliveries_event_id'); + $table->index('status', 'idx_webhook_deliveries_status'); + $table->index('last_attempt_at', 'idx_webhook_deliveries_last_attempt'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_deliveries'); + Schema::dropIfExists('webhook_subscriptions'); + Schema::dropIfExists('oauth_tokens'); + Schema::dropIfExists('oauth_clients'); + Schema::dropIfExists('app_installations'); + Schema::dropIfExists('apps'); + } +}; diff --git a/database/migrations/2026_02_14_100700_add_remember_token_to_customers_table.php b/database/migrations/2026_02_14_100700_add_remember_token_to_customers_table.php new file mode 100644 index 0000000..1c2c555 --- /dev/null +++ b/database/migrations/2026_02_14_100700_add_remember_token_to_customers_table.php @@ -0,0 +1,28 @@ +rememberToken()->after('password_hash'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('customers', function (Blueprint $table) { + $table->dropColumn('remember_token'); + }); + } +}; diff --git a/database/migrations/2026_02_14_100710_add_checkout_id_to_orders_table.php b/database/migrations/2026_02_14_100710_add_checkout_id_to_orders_table.php new file mode 100644 index 0000000..22ce423 --- /dev/null +++ b/database/migrations/2026_02_14_100710_add_checkout_id_to_orders_table.php @@ -0,0 +1,36 @@ +foreignId('checkout_id') + ->nullable() + ->after('customer_id') + ->constrained('checkouts') + ->nullOnDelete(); + + $table->unique('checkout_id', 'uq_orders_checkout_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('orders', function (Blueprint $table): void { + $table->dropForeign(['checkout_id']); + $table->dropUnique('uq_orders_checkout_id'); + $table->dropColumn('checkout_id'); + }); + } +}; diff --git a/database/seeders/AppWebhookSeeder.php b/database/seeders/AppWebhookSeeder.php new file mode 100644 index 0000000..af0d12b --- /dev/null +++ b/database/seeders/AppWebhookSeeder.php @@ -0,0 +1,83 @@ +where('handle', 'demo-store')->firstOrFail(); + + $app = App::query()->updateOrCreate( + ['name' => 'Demo Insights App'], + [ + 'status' => AppStatus::Active, + 'created_at' => now(), + ], + ); + + $installation = AppInstallation::query()->updateOrCreate( + ['store_id' => $store->id, 'app_id' => $app->id], + [ + 'scopes_json' => ['read-products', 'read-orders', 'read-analytics'], + 'status' => AppInstallationStatus::Active, + 'installed_at' => now()->subDay(), + ], + ); + + OauthClient::query()->updateOrCreate( + ['client_id' => 'demo-client-id'], + [ + 'app_id' => $app->id, + 'client_secret_encrypted' => encrypt('demo-client-secret'), + 'redirect_uris_json' => ['https://app.example.test/callback'], + ], + ); + + OauthToken::query()->updateOrCreate( + ['access_token_hash' => hash('sha256', 'demo-access-token')], + [ + 'installation_id' => $installation->id, + 'refresh_token_hash' => hash('sha256', 'demo-refresh-token'), + 'expires_at' => now()->addMonth(), + ], + ); + + $subscription = WebhookSubscription::query()->updateOrCreate( + [ + 'store_id' => $store->id, + 'event_type' => 'order.created', + 'target_url' => 'https://app.example.test/webhooks/order-created', + ], + [ + 'app_installation_id' => $installation->id, + 'signing_secret_encrypted' => encrypt('demo-signing-secret'), + 'status' => WebhookSubscriptionStatus::Active, + ], + ); + + WebhookDelivery::query()->updateOrCreate( + ['subscription_id' => $subscription->id, 'event_id' => 'order-1001-created'], + [ + 'attempt_count' => 1, + 'status' => WebhookDeliveryStatus::Success, + 'last_attempt_at' => now()->subMinutes(5), + 'response_code' => 200, + 'response_body_snippet' => 'ok', + ], + ); + } +} diff --git a/database/seeders/CatalogSeeder.php b/database/seeders/CatalogSeeder.php new file mode 100644 index 0000000..6e16285 --- /dev/null +++ b/database/seeders/CatalogSeeder.php @@ -0,0 +1,113 @@ +where('handle', 'demo-store')->firstOrFail(); + + $collection = Collection::query()->updateOrCreate( + ['store_id' => $store->id, 'handle' => 'featured'], + [ + 'title' => 'Featured Products', + 'description_html' => '

Top picks from the catalog.

', + 'type' => CollectionType::Manual, + 'status' => CollectionStatus::Active, + ], + ); + + $product = Product::query()->updateOrCreate( + ['store_id' => $store->id, 'handle' => 'demo-t-shirt'], + [ + 'title' => 'Demo T-Shirt', + 'status' => ProductStatus::Active, + 'description_html' => '

Comfortable cotton t-shirt.

', + 'vendor' => 'Acme Apparel', + 'product_type' => 'Shirts', + 'tags' => ['new', 'popular'], + 'published_at' => now()->subDay(), + ], + ); + + $sizeOption = ProductOption::query()->updateOrCreate( + ['product_id' => $product->id, 'position' => 1], + ['name' => 'Size'], + ); + + $sizeM = ProductOptionValue::query()->updateOrCreate( + ['product_option_id' => $sizeOption->id, 'position' => 1], + ['value' => 'M'], + ); + + $variant = ProductVariant::query()->updateOrCreate( + ['product_id' => $product->id, 'sku' => 'DEMO-TSHIRT-M'], + [ + 'barcode' => '4006381333931', + 'price_amount' => 2499, + 'compare_at_amount' => 2999, + 'currency' => 'EUR', + 'weight_g' => 220, + 'requires_shipping' => true, + 'is_default' => true, + 'position' => 1, + 'status' => ProductVariantStatus::Active, + ], + ); + + VariantOptionValue::query()->updateOrCreate( + ['variant_id' => $variant->id, 'product_option_value_id' => $sizeM->id], + [], + ); + + InventoryItem::query()->updateOrCreate( + ['variant_id' => $variant->id], + [ + 'store_id' => $store->id, + 'quantity_on_hand' => 25, + 'quantity_reserved' => 2, + 'policy' => InventoryPolicy::Deny, + ], + ); + + CollectionProduct::query()->updateOrCreate( + ['collection_id' => $collection->id, 'product_id' => $product->id], + ['position' => 1], + ); + + ProductMedia::query()->updateOrCreate( + ['product_id' => $product->id, 'position' => 1], + [ + 'type' => ProductMediaType::Image, + 'storage_key' => 'products/demo-tshirt/main.jpg', + 'alt_text' => 'Demo T-Shirt front view', + 'width' => 1600, + 'height' => 1600, + 'mime_type' => 'image/jpeg', + 'byte_size' => 240_000, + 'status' => ProductMediaStatus::Ready, + 'created_at' => now(), + ], + ); + } +} diff --git a/database/seeders/CommerceSeeder.php b/database/seeders/CommerceSeeder.php new file mode 100644 index 0000000..1bf6b0c --- /dev/null +++ b/database/seeders/CommerceSeeder.php @@ -0,0 +1,236 @@ +where('handle', 'demo-store')->firstOrFail(); + $variant = ProductVariant::query()->where('sku', 'DEMO-TSHIRT-M')->firstOrFail(); + + $customer = Customer::query()->updateOrCreate( + ['store_id' => $store->id, 'email' => 'customer@demo-store.test'], + [ + 'name' => 'Demo Customer', + 'password_hash' => Hash::make('password'), + 'marketing_opt_in' => true, + ], + ); + + CustomerAddress::query()->updateOrCreate( + ['customer_id' => $customer->id, 'label' => 'Home'], + [ + 'address_json' => [ + 'first_name' => 'Demo', + 'last_name' => 'Customer', + 'address1' => 'Main Street 1', + 'city' => 'Berlin', + 'country_code' => 'DE', + 'zip' => '10115', + ], + 'is_default' => true, + ], + ); + + $cart = Cart::query()->updateOrCreate( + ['store_id' => $store->id, 'customer_id' => $customer->id, 'status' => CartStatus::Active], + [ + 'currency' => 'EUR', + 'cart_version' => 1, + ], + ); + + CartLine::query()->updateOrCreate( + ['cart_id' => $cart->id, 'variant_id' => $variant->id], + [ + 'quantity' => 2, + 'unit_price_amount' => 2499, + 'line_subtotal_amount' => 4998, + 'line_discount_amount' => 500, + 'line_total_amount' => 4498, + ], + ); + + $shippingZone = ShippingZone::query()->updateOrCreate( + ['store_id' => $store->id, 'name' => 'Germany'], + [ + 'countries_json' => ['DE'], + 'regions_json' => [], + ], + ); + + $shippingRate = ShippingRate::query()->updateOrCreate( + ['zone_id' => $shippingZone->id, 'name' => 'Standard Shipping'], + [ + 'type' => ShippingRateType::Flat, + 'config_json' => ['amount' => 499], + 'is_active' => true, + ], + ); + + TaxSetting::query()->updateOrCreate( + ['store_id' => $store->id], + [ + 'mode' => TaxMode::Manual, + 'provider' => TaxProvider::None, + 'prices_include_tax' => false, + 'config_json' => ['default_rate_bps' => 1900], + ], + ); + + $discount = Discount::query()->updateOrCreate( + ['store_id' => $store->id, 'code' => 'WELCOME10'], + [ + 'type' => DiscountType::Code, + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + 'starts_at' => now()->subWeek(), + 'ends_at' => now()->addMonth(), + 'usage_limit' => null, + 'usage_count' => 1, + 'rules_json' => ['min_subtotal_amount' => 2000], + 'status' => DiscountStatus::Active, + ], + ); + + Checkout::query()->updateOrCreate( + ['store_id' => $store->id, 'cart_id' => $cart->id], + [ + 'customer_id' => $customer->id, + 'status' => CheckoutStatus::Completed, + 'payment_method' => PaymentMethod::CreditCard, + 'email' => $customer->email, + 'shipping_address_json' => [ + 'address1' => 'Main Street 1', + 'city' => 'Berlin', + 'country_code' => 'DE', + 'zip' => '10115', + ], + 'billing_address_json' => [ + 'address1' => 'Main Street 1', + 'city' => 'Berlin', + 'country_code' => 'DE', + 'zip' => '10115', + ], + 'shipping_method_id' => $shippingRate->id, + 'discount_code' => $discount->code, + 'tax_provider_snapshot_json' => ['provider' => 'manual'], + 'totals_json' => [ + 'subtotal' => 4998, + 'discount' => 500, + 'shipping' => 499, + 'tax' => 855, + 'total' => 5852, + ], + 'expires_at' => now()->addHours(2), + ], + ); + + $order = Order::query()->updateOrCreate( + ['store_id' => $store->id, 'order_number' => '1001'], + [ + 'customer_id' => $customer->id, + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Paid, + 'financial_status' => OrderFinancialStatus::Paid, + 'fulfillment_status' => OrderFulfillmentStatus::Partial, + 'currency' => 'EUR', + 'subtotal_amount' => 4998, + 'discount_amount' => 500, + 'shipping_amount' => 499, + 'tax_amount' => 855, + 'total_amount' => 5852, + 'email' => $customer->email, + 'billing_address_json' => [ + 'address1' => 'Main Street 1', + 'city' => 'Berlin', + 'country_code' => 'DE', + ], + 'shipping_address_json' => [ + 'address1' => 'Main Street 1', + 'city' => 'Berlin', + 'country_code' => 'DE', + ], + 'placed_at' => now()->subHour(), + ], + ); + + $orderLine = OrderLine::query()->updateOrCreate( + ['order_id' => $order->id, 'variant_id' => $variant->id], + [ + 'product_id' => $variant->product_id, + 'title_snapshot' => 'Demo T-Shirt', + 'sku_snapshot' => $variant->sku, + 'quantity' => 2, + 'unit_price_amount' => 2499, + 'total_amount' => 4498, + 'tax_lines_json' => [['title' => 'VAT', 'rate' => 1900, 'amount' => 855]], + 'discount_allocations_json' => [['discount_code' => 'WELCOME10', 'amount' => 500]], + ], + ); + + $payment = Payment::query()->updateOrCreate( + ['order_id' => $order->id, 'provider_payment_id' => 'mock_payment_1001'], + [ + 'provider' => PaymentProvider::Mock, + 'method' => PaymentMethod::CreditCard, + 'status' => PaymentStatus::Captured, + 'amount' => 5852, + 'currency' => 'EUR', + 'raw_json_encrypted' => encrypt(json_encode(['result' => 'captured'])), + 'created_at' => now()->subHour(), + ], + ); + + $fulfillment = Fulfillment::query()->updateOrCreate( + ['order_id' => $order->id, 'tracking_number' => 'DHL123456789'], + [ + 'status' => FulfillmentStatus::Shipped, + 'tracking_company' => 'DHL', + 'tracking_url' => 'https://tracking.example.test/DHL123456789', + 'shipped_at' => now()->subMinutes(30), + 'created_at' => now()->subMinutes(35), + ], + ); + + FulfillmentLine::query()->updateOrCreate( + ['fulfillment_id' => $fulfillment->id, 'order_line_id' => $orderLine->id], + ['quantity' => 1], + ); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef..be81519 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,8 +2,6 @@ namespace Database\Seeders; -use App\Models\User; -// use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder @@ -13,11 +11,12 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', + $this->call([ + FoundationSeeder::class, + CatalogSeeder::class, + CommerceSeeder::class, + SearchAnalyticsSeeder::class, + AppWebhookSeeder::class, ]); } } diff --git a/database/seeders/FoundationSeeder.php b/database/seeders/FoundationSeeder.php new file mode 100644 index 0000000..f635864 --- /dev/null +++ b/database/seeders/FoundationSeeder.php @@ -0,0 +1,121 @@ +updateOrCreate( + ['billing_email' => 'billing@acme.test'], + ['name' => 'Acme Commerce GmbH'], + ); + + $store = Store::query()->updateOrCreate( + ['handle' => 'demo-store'], + [ + 'organization_id' => $organization->id, + 'name' => 'Demo Store', + 'status' => StoreStatus::Active, + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ], + ); + + StoreDomain::query()->updateOrCreate( + ['hostname' => 'demo-store.test'], + [ + 'store_id' => $store->id, + 'type' => StoreDomainType::Storefront, + 'is_primary' => true, + 'tls_mode' => StoreDomainTlsMode::Managed, + 'created_at' => now(), + ], + ); + + StoreDomain::query()->updateOrCreate( + ['hostname' => 'admin.demo-store.test'], + [ + 'store_id' => $store->id, + 'type' => StoreDomainType::Admin, + 'is_primary' => false, + 'tls_mode' => StoreDomainTlsMode::Managed, + 'created_at' => now(), + ], + ); + + StoreDomain::query()->updateOrCreate( + ['hostname' => 'api.demo-store.test'], + [ + 'store_id' => $store->id, + 'type' => StoreDomainType::Api, + 'is_primary' => false, + 'tls_mode' => StoreDomainTlsMode::BringYourOwn, + 'created_at' => now(), + ], + ); + + $owner = User::query()->updateOrCreate( + ['email' => 'owner@demo-store.test'], + [ + 'name' => 'Store Owner', + 'password_hash' => Hash::make('password'), + 'status' => UserStatus::Active, + 'email_verified_at' => now(), + 'last_login_at' => now()->subDay(), + ], + ); + + $staff = User::query()->updateOrCreate( + ['email' => 'staff@demo-store.test'], + [ + 'name' => 'Store Staff', + 'password_hash' => Hash::make('password'), + 'status' => UserStatus::Active, + 'email_verified_at' => now(), + 'last_login_at' => now()->subHours(4), + ], + ); + + StoreUser::query()->updateOrCreate( + ['store_id' => $store->id, 'user_id' => $owner->id], + ['role' => StoreUserRole::Owner, 'created_at' => now()], + ); + + StoreUser::query()->updateOrCreate( + ['store_id' => $store->id, 'user_id' => $staff->id], + ['role' => StoreUserRole::Staff, 'created_at' => now()], + ); + + StoreSettings::query()->updateOrCreate( + ['store_id' => $store->id], + [ + 'settings_json' => [ + 'branding' => [ + 'store_name' => $store->name, + 'currency' => $store->default_currency, + ], + 'contact' => [ + 'email' => 'hello@demo-store.test', + ], + ], + 'updated_at' => now(), + ], + ); + } +} diff --git a/database/seeders/SearchAnalyticsSeeder.php b/database/seeders/SearchAnalyticsSeeder.php new file mode 100644 index 0000000..f664768 --- /dev/null +++ b/database/seeders/SearchAnalyticsSeeder.php @@ -0,0 +1,64 @@ +where('handle', 'demo-store')->firstOrFail(); + + SearchSetting::query()->updateOrCreate( + ['store_id' => $store->id], + [ + 'synonyms_json' => [ + ['tee', 't-shirt'], + ['sneaker', 'shoe'], + ], + 'stop_words_json' => ['the', 'and', 'for'], + 'updated_at' => now(), + ], + ); + + SearchQuery::query()->updateOrCreate( + ['store_id' => $store->id, 'query' => 'demo t-shirt'], + [ + 'filters_json' => ['in_stock' => true], + 'results_count' => 1, + 'created_at' => now()->subMinutes(10), + ], + ); + + AnalyticsEvent::query()->updateOrCreate( + ['store_id' => $store->id, 'client_event_id' => 'evt-demo-001'], + [ + 'type' => 'product_view', + 'session_id' => 'session-demo-001', + 'customer_id' => null, + 'properties_json' => ['handle' => 'demo-t-shirt'], + 'occurred_at' => now()->subMinutes(15), + 'created_at' => now()->subMinutes(15), + ], + ); + + DB::table('analytics_daily')->updateOrInsert( + ['store_id' => $store->id, 'date' => now()->toDateString()], + [ + 'orders_count' => 1, + 'revenue_amount' => 5852, + 'aov_amount' => 5852, + 'visits_count' => 12, + 'add_to_cart_count' => 3, + 'checkout_started_count' => 2, + 'checkout_completed_count' => 1, + ], + ); + } +} diff --git a/deptrac.yaml b/deptrac.yaml new file mode 100644 index 0000000..d31a423 --- /dev/null +++ b/deptrac.yaml @@ -0,0 +1,60 @@ +deptrac: + paths: + - ./app + exclude_files: + - '#.*tests?.*#' + - '#.*vendor.*#' + - '#.*storage.*#' + layers: + - name: Domain + collectors: + - type: classLike + value: ^App\\Models\\.* + - type: classLike + value: ^App\\Enums\\.* + - type: classLike + value: ^App\\ValueObjects\\.* + - type: classLike + value: ^App\\Support\\Tenant\\.* + - type: classLike + value: ^App\\Concerns\\.* + - name: Application + collectors: + - type: classLike + value: ^App\\Services\\.* + - type: classLike + value: ^App\\Actions\\.* + - type: classLike + value: ^App\\Policies\\.* + - type: classLike + value: ^App\\Jobs\\.* + - type: classLike + value: ^App\\Listeners\\.* + - type: classLike + value: ^App\\Exceptions\\.* + - name: Infrastructure + collectors: + - type: classLike + value: ^App\\Providers\\.* + - type: classLike + value: ^App\\Http\\Middleware\\.* + - type: classLike + value: ^App\\Console\\.* + - name: Presentation + collectors: + - type: classLike + value: ^App\\Http\\Controllers\\.* + - type: classLike + value: ^App\\Livewire\\.* + - type: classLike + value: ^App\\View\\Components\\.* + ruleset: + Domain: ~ + Application: + - Domain + Infrastructure: + - Application + - Domain + Presentation: + - Application + - Domain diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..74b5151 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,21 @@ +includes: + - vendor/larastan/larastan/extension.neon + +parameters: + level: max + paths: + - app/Auth + - app/Enums + - app/Http/Middleware + - app/Support + tmpDir: var/phpstan + + reportUnmatchedIgnoredErrors: true + checkExplicitMixed: true + checkImplicitMixed: true + treatPhpDocTypesAsCertain: false + checkMissingCallableSignature: true + checkUninitializedProperties: true + + parallel: + maximumNumberOfProcesses: 8 diff --git a/pint.json b/pint.json index 93061b6..9baf1ab 100644 --- a/pint.json +++ b/pint.json @@ -1,3 +1,6 @@ { - "preset": "laravel" + "preset": "laravel", + "exclude": [ + "var" + ] } diff --git a/resources/views/admin/analytics/index.blade.php b/resources/views/admin/analytics/index.blade.php new file mode 100644 index 0000000..7f915ac --- /dev/null +++ b/resources/views/admin/analytics/index.blade.php @@ -0,0 +1,25 @@ +@extends('admin.layout') + +@section('title', 'Analytics') + +@section('content') +
+ + + + @forelse ($daily as $row) + @php($conversion = ((int) $row->visits_count) > 0 ? (((int) $row->checkout_completed_count) / ((int) $row->visits_count)) * 100 : 0) + + + + + + + + @empty + + @endforelse + +
DateOrdersRevenueVisitsConversion
{{ $row->date }}{{ (int) $row->orders_count }}{{ number_format(((int) $row->revenue_amount) / 100, 2, '.', ',') }}{{ (int) $row->visits_count }}{{ number_format($conversion, 2) }}%
No analytics data available.
+
+@endsection diff --git a/resources/views/admin/apps/index.blade.php b/resources/views/admin/apps/index.blade.php new file mode 100644 index 0000000..7f87894 --- /dev/null +++ b/resources/views/admin/apps/index.blade.php @@ -0,0 +1,32 @@ +@extends('admin.layout') + +@section('title', 'Apps') + +@section('content') +
+
+

Installed Apps

+ +
+ +
+

Available Apps

+
    + @forelse ($availableApps as $app) +
  • {{ $app->name }} ({{ is_object($app->status) ? $app->status->value : $app->status }})
  • + @empty +
  • No apps registered.
  • + @endforelse +
+
+
+@endsection diff --git a/resources/views/admin/apps/show.blade.php b/resources/views/admin/apps/show.blade.php new file mode 100644 index 0000000..308a83f --- /dev/null +++ b/resources/views/admin/apps/show.blade.php @@ -0,0 +1,25 @@ +@extends('admin.layout') + +@section('title', 'App Installation') + +@section('content') +
+
+

{{ $installation->app?->name ?? ('App #'.$installation->app_id) }}

+

Status: {{ is_object($installation->status) ? $installation->status->value : $installation->status }}

+

Granted Scopes

+
{{ json_encode($installation->scopes_json ?? [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES) }}
+
+ +
+
+

OAuth Tokens

+

{{ $installation->oauthTokens->count() }}

+
+
+

Webhook Subscriptions

+

{{ $installation->webhookSubscriptions->count() }}

+
+
+
+@endsection diff --git a/resources/views/admin/auth/forgot-password.blade.php b/resources/views/admin/auth/forgot-password.blade.php new file mode 100644 index 0000000..1fadcc3 --- /dev/null +++ b/resources/views/admin/auth/forgot-password.blade.php @@ -0,0 +1,29 @@ + + + + + + Admin Forgot Password + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+

Forgot password

+

Enter your email and we will send a reset link.

+ +
+ @csrf +
+ + + @error('email')

{{ $message }}

@enderror +
+ +
+ + +
+ + diff --git a/resources/views/admin/auth/login.blade.php b/resources/views/admin/auth/login.blade.php new file mode 100644 index 0000000..cdac517 --- /dev/null +++ b/resources/views/admin/auth/login.blade.php @@ -0,0 +1,37 @@ + + + + + + Admin Login + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+

Sign in

+

Access your admin workspace.

+ +
+ @csrf +
+ + + @error('email')

{{ $message }}

@enderror +
+
+ + + @error('password')

{{ $message }}

@enderror +
+ + +
+ + +
+ + diff --git a/resources/views/admin/auth/reset-password.blade.php b/resources/views/admin/auth/reset-password.blade.php new file mode 100644 index 0000000..181cb6d --- /dev/null +++ b/resources/views/admin/auth/reset-password.blade.php @@ -0,0 +1,34 @@ + + + + + + Admin Reset Password + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+

Reset password

+ +
+ @csrf + +
+ + + @error('email')

{{ $message }}

@enderror +
+
+ + + @error('password')

{{ $message }}

@enderror +
+
+ + +
+ +
+
+ + diff --git a/resources/views/admin/collections/form.blade.php b/resources/views/admin/collections/form.blade.php new file mode 100644 index 0000000..83f564e --- /dev/null +++ b/resources/views/admin/collections/form.blade.php @@ -0,0 +1,122 @@ +@extends('admin.layout') + +@section('title', $mode === 'create' ? 'Create Collection' : 'Edit Collection') + +@section('content') +@php + $isEdit = $mode === 'edit' && $collection !== null; + $formAction = '#'; + + if ($isEdit && \Illuminate\Support\Facades\Route::has('admin.collections.update')) { + $formAction = route('admin.collections.update', ['collection' => $collection->id]); + } + + if (! $isEdit && \Illuminate\Support\Facades\Route::has('admin.collections.store')) { + $formAction = route('admin.collections.store'); + } + + $linkedProductIds = old('product_ids'); + + if ($linkedProductIds === null && $collection !== null) { + $linkedProductIds = $collection->products->pluck('id')->implode(', '); + } +@endphp + +
+

{{ $isEdit ? 'Edit Collection' : 'Create Collection' }}

+

Manage collection metadata and sync product membership by comma-separated IDs.

+ + @if ($errors->any()) +
+

Please fix the following errors:

+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + + @if ($formAction === '#') +
+ Collection submission route is not wired yet. +
+ @endif + +
+ @csrf + @if ($isEdit) + @method('PUT') + @endif + +
+
+ + + @error('title')

{{ $message }}

@enderror +
+ +
+ + + @error('handle')

{{ $message }}

@enderror +
+ +
+ + + @error('type')

{{ $message }}

@enderror +
+ +
+ + + @error('status')

{{ $message }}

@enderror +
+
+ +
+ + +

Only products from this store are synced.

+ @error('product_ids')

{{ $message }}

@enderror +
+ +
+ + + @error('description_html')

{{ $message }}

@enderror +
+ +
+ Back + + +
+
+ + @if ($isEdit && \Illuminate\Support\Facades\Route::has('admin.collections.destroy')) +
+ @csrf + @method('DELETE') + +
+ @endif +
+@endsection diff --git a/resources/views/admin/collections/index.blade.php b/resources/views/admin/collections/index.blade.php new file mode 100644 index 0000000..6b51a70 --- /dev/null +++ b/resources/views/admin/collections/index.blade.php @@ -0,0 +1,50 @@ +@extends('admin.layout') + +@section('title', 'Collections') + +@section('content') + + +
+ + + + + + + + + + + + @forelse ($collections as $collection) + + + + + + + + @empty + + @endforelse + +
TitleHandleStatusProductsActions
{{ $collection->title }}{{ $collection->handle }}{{ is_object($collection->status) ? $collection->status->value : $collection->status }}{{ (int) $collection->products_count }} +
+ Edit + + @if (\Illuminate\Support\Facades\Route::has('admin.collections.destroy')) +
+ @csrf + @method('DELETE') + +
+ @endif +
+
No collections found.
+
+ +
{{ $collections->links() }}
+@endsection diff --git a/resources/views/admin/customers/index.blade.php b/resources/views/admin/customers/index.blade.php new file mode 100644 index 0000000..46a4117 --- /dev/null +++ b/resources/views/admin/customers/index.blade.php @@ -0,0 +1,26 @@ +@extends('admin.layout') + +@section('title', 'Customers') + +@section('content') +
+ + + + + + @forelse ($customers as $customer) + + + + + + + @empty + + @endforelse + +
NameEmailOrdersActions
{{ $customer->name }}{{ $customer->email }}{{ (int) $customer->orders_count }}View
No customers found.
+
+
{{ $customers->links() }}
+@endsection diff --git a/resources/views/admin/customers/show.blade.php b/resources/views/admin/customers/show.blade.php new file mode 100644 index 0000000..01f9f60 --- /dev/null +++ b/resources/views/admin/customers/show.blade.php @@ -0,0 +1,38 @@ +@extends('admin.layout') + +@section('title', 'Customer '.$customer->name) + +@section('content') +
+
+

Order History

+
    + @forelse ($customer->orders as $order) +
  • + {{ $order->order_number }} + {{ number_format(((int) $order->total_amount) / 100, 2, '.', ',') }} {{ strtoupper($order->currency) }} +
  • + @empty +
  • No orders yet.
  • + @endforelse +
+
+
+
+

Profile

+

{{ $customer->name }}

+

{{ $customer->email }}

+
+
+

Addresses

+
    + @forelse ($customer->addresses as $address) +
  • {{ $address->label }} @if($address->is_default) (Default)@endif
  • + @empty +
  • No addresses.
  • + @endforelse +
+
+
+
+@endsection diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php new file mode 100644 index 0000000..e83f775 --- /dev/null +++ b/resources/views/admin/dashboard.blade.php @@ -0,0 +1,56 @@ +@extends('admin.layout') + +@section('title', 'Dashboard') + +@section('content') +
+
+

Orders

+

{{ number_format($ordersCount) }}

+
+
+

Revenue

+

{{ number_format($revenue / 100, 2, '.', ',') }} {{ strtoupper($store->default_currency) }}

+
+
+

Customers

+

{{ number_format($customersCount) }}

+
+
+

Products

+

{{ number_format($productsCount) }}

+
+
+ +
+
+

Recent Orders

+
+
+ + + + + + + + + + + @forelse ($recentOrders as $order) + + + + + + + @empty + + + + @endforelse + +
OrderCustomerStatusTotal
{{ $order->order_number }}{{ $order->email ?? 'Guest' }}{{ is_object($order->status) ? $order->status->value : $order->status }}{{ number_format(((int) $order->total_amount) / 100, 2, '.', ',') }}
No orders yet.
+
+
+@endsection diff --git a/resources/views/admin/developers/index.blade.php b/resources/views/admin/developers/index.blade.php new file mode 100644 index 0000000..d609db2 --- /dev/null +++ b/resources/views/admin/developers/index.blade.php @@ -0,0 +1,23 @@ +@extends('admin.layout') + +@section('title', 'Developers') + +@section('content') +
+

Developer Integrations

+

Manage API tokens and webhook subscriptions for installed apps.

+ +
+ + + + @forelse ($installations as $installation) + + @empty + + @endforelse + +
InstallationTokensWebhooks
{{ $installation->app?->name ?? ('App #'.$installation->app_id) }}{{ $installation->oauthTokens->count() }}{{ $installation->webhookSubscriptions->count() }}
No app installations.
+
+
+@endsection diff --git a/resources/views/admin/discounts/form.blade.php b/resources/views/admin/discounts/form.blade.php new file mode 100644 index 0000000..b124fe4 --- /dev/null +++ b/resources/views/admin/discounts/form.blade.php @@ -0,0 +1,168 @@ +@extends('admin.layout') + +@section('title', $mode === 'create' ? 'Create Discount' : 'Edit Discount') + +@section('content') +@php + $isEdit = $mode === 'edit' && $discount !== null; + $formAction = '#'; + + if ($isEdit && \Illuminate\Support\Facades\Route::has('admin.discounts.update')) { + $formAction = route('admin.discounts.update', ['discount' => $discount->id]); + } + + if (! $isEdit && \Illuminate\Support\Facades\Route::has('admin.discounts.store')) { + $formAction = route('admin.discounts.store'); + } + + $startsAt = old('starts_at'); + + if ($startsAt === null && $discount?->starts_at !== null) { + $startsAt = $discount->starts_at->format('Y-m-d\\TH:i'); + } + + $endsAt = old('ends_at'); + + if ($endsAt === null && $discount?->ends_at !== null) { + $endsAt = $discount->ends_at->format('Y-m-d\\TH:i'); + } + + $rulesJson = old('rules_json'); + + if ($rulesJson === null) { + if ($discount !== null) { + $rulesJson = json_encode($discount->rules_json ?? [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } else { + $rulesJson = '{}'; + } + } +@endphp + +
+

{{ $isEdit ? 'Edit Discount' : 'Create Discount' }}

+

Configure discount behavior and targeting rules.

+ + @if ($errors->any()) +
+

Please fix the following errors:

+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + + @if ($formAction === '#') +
+ Discount submission route is not wired yet. +
+ @endif + +
+ @csrf + @if ($isEdit) + @method('PUT') + @endif + +
+
+ + + @error('type')

{{ $message }}

@enderror +
+ +
+ + +

Leave empty for automatic discounts.

+ @error('code')

{{ $message }}

@enderror +
+ +
+ + + @error('value_type')

{{ $message }}

@enderror +
+ +
+ + + @error('value_amount')

{{ $message }}

@enderror +
+ +
+ + + @error('starts_at')

{{ $message }}

@enderror +
+ +
+ + + @error('ends_at')

{{ $message }}

@enderror +
+ +
+ + + @error('usage_limit')

{{ $message }}

@enderror +
+ +
+ + + @error('usage_count')

{{ $message }}

@enderror +
+ +
+ + + @error('status')

{{ $message }}

@enderror +
+
+ +
+ + + @error('rules_json')

{{ $message }}

@enderror +
+ +
+ Back + + +
+
+ + @if ($isEdit && \Illuminate\Support\Facades\Route::has('admin.discounts.destroy')) +
+ @csrf + @method('DELETE') + +
+ @endif +
+@endsection diff --git a/resources/views/admin/discounts/index.blade.php b/resources/views/admin/discounts/index.blade.php new file mode 100644 index 0000000..ecf86b7 --- /dev/null +++ b/resources/views/admin/discounts/index.blade.php @@ -0,0 +1,43 @@ +@extends('admin.layout') + +@section('title', 'Discounts') + +@section('content') + + +
+ + + + + + @forelse ($discounts as $discount) + + + + + + + + @empty + + @endforelse + +
CodeTypeStatusUsageActions
{{ $discount->code ?? 'Automatic' }}{{ is_object($discount->value_type) ? $discount->value_type->value : $discount->value_type }}{{ is_object($discount->status) ? $discount->status->value : $discount->status }}{{ (int) $discount->usage_count }} / {{ $discount->usage_limit ?? '∞' }} +
+ Edit + + @if (\Illuminate\Support\Facades\Route::has('admin.discounts.destroy')) +
+ @csrf + @method('DELETE') + +
+ @endif +
+
No discounts found.
+
+
{{ $discounts->links() }}
+@endsection diff --git a/resources/views/admin/inventory/index.blade.php b/resources/views/admin/inventory/index.blade.php new file mode 100644 index 0000000..190271a --- /dev/null +++ b/resources/views/admin/inventory/index.blade.php @@ -0,0 +1,37 @@ +@extends('admin.layout') + +@section('title', 'Inventory') + +@section('content') +
+ + + + + + + + + + + + + @forelse ($items as $item) + @php($available = (int) $item->quantity_on_hand - (int) $item->quantity_reserved) + + + + + + + + + @empty + + @endforelse + +
ProductVariantOn HandReservedAvailablePolicy
{{ $item->variant?->product?->title ?? 'Unknown' }}{{ $item->variant?->sku ?? ('Variant #'.$item->variant_id) }}{{ (int) $item->quantity_on_hand }}{{ (int) $item->quantity_reserved }}{{ $available }}{{ is_object($item->policy) ? $item->policy->value : $item->policy }}
No inventory records found.
+
+ +
{{ $items->links() }}
+@endsection diff --git a/resources/views/admin/layout.blade.php b/resources/views/admin/layout.blade.php new file mode 100644 index 0000000..ce00a15 --- /dev/null +++ b/resources/views/admin/layout.blade.php @@ -0,0 +1,55 @@ + + + + + + @yield('title', 'Admin') | {{ $store->name ?? config('app.name') }} + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+ + +
+
+
+

@yield('title', 'Admin')

+
+ @csrf + +
+
+
+ +
+ @if (session('status')) +
{{ session('status') }}
+ @endif + + @yield('content') +
+
+
+ + diff --git a/resources/views/admin/navigation/index.blade.php b/resources/views/admin/navigation/index.blade.php new file mode 100644 index 0000000..5bb2cab --- /dev/null +++ b/resources/views/admin/navigation/index.blade.php @@ -0,0 +1,23 @@ +@extends('admin.layout') + +@section('title', 'Navigation') + +@section('content') +
+ @forelse ($menus as $menu) +
+

{{ $menu->title }}

+

{{ $menu->handle }}

+
    + @forelse ($menu->items as $item) +
  • {{ $item->title }} ({{ $item->type }}: {{ $item->target }})
  • + @empty +
  • No menu items.
  • + @endforelse +
+
+ @empty +
No navigation menus configured.
+ @endforelse +
+@endsection diff --git a/resources/views/admin/orders/index.blade.php b/resources/views/admin/orders/index.blade.php new file mode 100644 index 0000000..ed61f2e --- /dev/null +++ b/resources/views/admin/orders/index.blade.php @@ -0,0 +1,33 @@ +@extends('admin.layout') + +@section('title', 'Orders') + +@section('content') +
+ + + + + + + + + + + + @forelse ($orders as $order) + + + + + + + + @empty + + @endforelse + +
OrderEmailFinancialFulfillmentTotal
{{ $order->order_number }}{{ $order->email ?? 'Guest' }}{{ is_object($order->financial_status) ? $order->financial_status->value : $order->financial_status }}{{ is_object($order->fulfillment_status) ? $order->fulfillment_status->value : $order->fulfillment_status }}{{ number_format(((int) $order->total_amount) / 100, 2, '.', ',') }}
No orders found.
+
+
{{ $orders->links() }}
+@endsection diff --git a/resources/views/admin/orders/show.blade.php b/resources/views/admin/orders/show.blade.php new file mode 100644 index 0000000..f5de0fe --- /dev/null +++ b/resources/views/admin/orders/show.blade.php @@ -0,0 +1,56 @@ +@extends('admin.layout') + +@section('title', 'Order '.$order->order_number) + +@section('content') +
+
+
+

Order Summary

+
+
Order Number
{{ $order->order_number }}
+
Placed At
{{ optional($order->placed_at)->toDateTimeString() ?? '—' }}
+
Status
{{ is_object($order->status) ? $order->status->value : $order->status }}
+
Financial
{{ is_object($order->financial_status) ? $order->financial_status->value : $order->financial_status }}
+
+
+ +
+ + + + + + @foreach ($order->lines as $line) + + + + + + + @endforeach + +
ProductSKUQtyTotal
{{ $line->title_snapshot }}{{ $line->sku_snapshot ?? '—' }}{{ (int) $line->quantity }}{{ number_format(((int) $line->total_amount) / 100, 2, '.', ',') }}
+
+
+ +
+
+

Customer

+

{{ $order->customer?->name ?? 'Guest' }}

+

{{ $order->email ?? '—' }}

+
+ +
+

Totals

+
+
Subtotal
{{ number_format(((int) $order->subtotal_amount) / 100, 2, '.', ',') }}
+
Discount
-{{ number_format(((int) $order->discount_amount) / 100, 2, '.', ',') }}
+
Shipping
{{ number_format(((int) $order->shipping_amount) / 100, 2, '.', ',') }}
+
Tax
{{ number_format(((int) $order->tax_amount) / 100, 2, '.', ',') }}
+
Total
{{ number_format(((int) $order->total_amount) / 100, 2, '.', ',') }} {{ strtoupper($order->currency) }}
+
+
+
+
+@endsection diff --git a/resources/views/admin/pages/form.blade.php b/resources/views/admin/pages/form.blade.php new file mode 100644 index 0000000..cf13a3e --- /dev/null +++ b/resources/views/admin/pages/form.blade.php @@ -0,0 +1,109 @@ +@extends('admin.layout') + +@section('title', $mode === 'create' ? 'Create Page' : 'Edit Page') + +@section('content') +@php + $isEdit = $mode === 'edit' && $page !== null; + $formAction = '#'; + + if ($isEdit && \Illuminate\Support\Facades\Route::has('admin.pages.update')) { + $formAction = route('admin.pages.update', ['page' => $page->id]); + } + + if (! $isEdit && \Illuminate\Support\Facades\Route::has('admin.pages.store')) { + $formAction = route('admin.pages.store'); + } + + $publishedAt = old('published_at'); + + if ($publishedAt === null && $page?->published_at !== null) { + $publishedAt = $page->published_at->format('Y-m-d\\TH:i'); + } +@endphp + +
+

{{ $isEdit ? 'Edit Page' : 'Create Page' }}

+

Manage static page content and publication status.

+ + @if ($errors->any()) +
+

Please fix the following errors:

+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + + @if ($formAction === '#') +
+ Page submission route is not wired yet. +
+ @endif + +
+ @csrf + @if ($isEdit) + @method('PUT') + @endif + +
+
+ + + @error('title')

{{ $message }}

@enderror +
+ +
+ + + @error('handle')

{{ $message }}

@enderror +
+ +
+ + + @error('status')

{{ $message }}

@enderror +
+ +
+ + + @error('published_at')

{{ $message }}

@enderror +
+
+ +
+ + + @error('body_html')

{{ $message }}

@enderror +
+ +
+ Back + + +
+
+ + @if ($isEdit && \Illuminate\Support\Facades\Route::has('admin.pages.destroy')) +
+ @csrf + @method('DELETE') + +
+ @endif +
+@endsection diff --git a/resources/views/admin/pages/index.blade.php b/resources/views/admin/pages/index.blade.php new file mode 100644 index 0000000..ed4c507 --- /dev/null +++ b/resources/views/admin/pages/index.blade.php @@ -0,0 +1,39 @@ +@extends('admin.layout') + +@section('title', 'Pages') + +@section('content') + +
+ + + + @forelse ($pages as $page) + + + + + + + @empty + + @endforelse + +
TitleHandleStatusActions
{{ $page->title }}{{ $page->handle }}{{ is_object($page->status) ? $page->status->value : $page->status }} +
+ Edit + + @if (\Illuminate\Support\Facades\Route::has('admin.pages.destroy')) +
+ @csrf + @method('DELETE') + +
+ @endif +
+
No pages found.
+
+
{{ $pages->links() }}
+@endsection diff --git a/resources/views/admin/products/form.blade.php b/resources/views/admin/products/form.blade.php new file mode 100644 index 0000000..bf9c2b0 --- /dev/null +++ b/resources/views/admin/products/form.blade.php @@ -0,0 +1,185 @@ +@extends('admin.layout') + +@section('title', $mode === 'create' ? 'Create Product' : 'Edit Product') + +@section('content') +@php + $isEdit = $mode === 'edit' && $product !== null; + $defaultVariant = null; + + if ($product !== null) { + $defaultVariant = $product->variants->firstWhere('is_default', true) ?? $product->variants->first(); + } + + $formAction = '#'; + + if ($isEdit && \Illuminate\Support\Facades\Route::has('admin.products.update')) { + $formAction = route('admin.products.update', ['product' => $product->id]); + } + + if (! $isEdit && \Illuminate\Support\Facades\Route::has('admin.products.store')) { + $formAction = route('admin.products.store'); + } + + $publishedAt = old('published_at'); + + if ($publishedAt === null && $product?->published_at !== null) { + $publishedAt = $product->published_at->format('Y-m-d\\TH:i'); + } + + $tags = old('tags'); + + if ($tags === null && $product !== null) { + $tags = implode(', ', is_array($product->tags) ? $product->tags : []); + } + + $currency = old('currency'); + + if ($currency === null) { + $currency = $defaultVariant?->currency ?? $store->default_currency ?? 'USD'; + } + + $requiresShipping = old('requires_shipping'); + + if ($requiresShipping === null) { + $requiresShipping = $defaultVariant?->requires_shipping ?? true; + } +@endphp + +
+

{{ $isEdit ? 'Edit Product' : 'Create Product' }}

+

Create and update product data with default variant pricing and inventory.

+ + @if ($errors->any()) +
+

Please fix the following errors:

+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + + @if ($formAction === '#') +
+ Product submission route is not wired yet. +
+ @endif + +
+ @csrf + @if ($isEdit) + @method('PUT') + @endif + +
+
+ + + @error('title')

{{ $message }}

@enderror +
+ +
+ + + @error('handle')

{{ $message }}

@enderror +
+ +
+ + + @error('status')

{{ $message }}

@enderror +
+ +
+ + + @error('published_at')

{{ $message }}

@enderror +
+ +
+ + + @error('vendor')

{{ $message }}

@enderror +
+ +
+ + + @error('product_type')

{{ $message }}

@enderror +
+ +
+ + + @error('tags')

{{ $message }}

@enderror +
+
+ +
+

Default Variant

+

Price and fulfillment values are maintained on the default variant.

+ +
+
+ + + @error('price_amount')

{{ $message }}

@enderror +
+ +
+ + + @error('compare_at_amount')

{{ $message }}

@enderror +
+ +
+ + + @error('currency')

{{ $message }}

@enderror +
+ +
+ + + @error('requires_shipping')

{{ $message }}

@enderror +
+
+
+ +
+ + + @error('description_html')

{{ $message }}

@enderror +
+ +
+ Back + + +
+
+ + @if ($isEdit && \Illuminate\Support\Facades\Route::has('admin.products.destroy')) +
+ @csrf + @method('DELETE') + +
+ @endif +
+@endsection diff --git a/resources/views/admin/products/index.blade.php b/resources/views/admin/products/index.blade.php new file mode 100644 index 0000000..cce1635 --- /dev/null +++ b/resources/views/admin/products/index.blade.php @@ -0,0 +1,50 @@ +@extends('admin.layout') + +@section('title', 'Products') + +@section('content') + + +
+ + + + + + + + + + + + @forelse ($products as $product) + + + + + + + + @empty + + @endforelse + +
TitleHandleStatusVariantsActions
{{ $product->title }}{{ $product->handle }}{{ is_object($product->status) ? $product->status->value : $product->status }}{{ (int) $product->variants_count }} +
+ Edit + + @if (\Illuminate\Support\Facades\Route::has('admin.products.destroy')) +
+ @csrf + @method('DELETE') + +
+ @endif +
+
No products found.
+
+ +
{{ $products->links() }}
+@endsection diff --git a/resources/views/admin/search/settings.blade.php b/resources/views/admin/search/settings.blade.php new file mode 100644 index 0000000..a9b1e5d --- /dev/null +++ b/resources/views/admin/search/settings.blade.php @@ -0,0 +1,23 @@ +@extends('admin.layout') + +@section('title', 'Search Settings') + +@section('content') +
+

Search Index Configuration

+ @if ($settings) +
+
+

Synonyms

+
{{ json_encode($settings->synonyms_json, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES) }}
+
+
+

Stop Words

+
{{ json_encode($settings->stop_words_json, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES) }}
+
+
+ @else +

No search settings configured for this store.

+ @endif +
+@endsection diff --git a/resources/views/admin/settings/index.blade.php b/resources/views/admin/settings/index.blade.php new file mode 100644 index 0000000..682ba3b --- /dev/null +++ b/resources/views/admin/settings/index.blade.php @@ -0,0 +1,27 @@ +@extends('admin.layout') + +@section('title', 'Settings') + +@section('content') +
+
+

Store Settings

+
+
Store Name
{{ $store->name }}
+
Handle
{{ $store->handle }}
+
Status
{{ is_object($store->status) ? $store->status->value : $store->status }}
+
Currency
{{ strtoupper($store->default_currency) }}
+
+ + @if ($settings) +

JSON Settings

+
{{ json_encode($settings->settings_json, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES) }}
+ @endif +
+ + +
+@endsection diff --git a/resources/views/admin/settings/shipping.blade.php b/resources/views/admin/settings/shipping.blade.php new file mode 100644 index 0000000..6b1d9c1 --- /dev/null +++ b/resources/views/admin/settings/shipping.blade.php @@ -0,0 +1,28 @@ +@extends('admin.layout') + +@section('title', 'Shipping Settings') + +@section('content') +
+ @forelse ($zones as $zone) +
+

{{ $zone->name }}

+

Countries: {{ implode(', ', is_array($zone->countries_json) ? $zone->countries_json : []) ?: '—' }}

+
+ + + + @forelse ($zone->rates as $rate) + + @empty + + @endforelse + +
RateTypeActive
{{ $rate->name }}{{ is_object($rate->type) ? $rate->type->value : $rate->type }}{{ $rate->is_active ? 'Yes' : 'No' }}
No rates configured.
+
+
+ @empty +
No shipping zones configured.
+ @endforelse +
+@endsection diff --git a/resources/views/admin/settings/taxes.blade.php b/resources/views/admin/settings/taxes.blade.php new file mode 100644 index 0000000..62a1e29 --- /dev/null +++ b/resources/views/admin/settings/taxes.blade.php @@ -0,0 +1,19 @@ +@extends('admin.layout') + +@section('title', 'Tax Settings') + +@section('content') +
+ @if ($tax) +
+
Mode
{{ is_object($tax->mode) ? $tax->mode->value : $tax->mode }}
+
Provider
{{ is_object($tax->provider) ? $tax->provider->value : $tax->provider }}
+
Prices Include Tax
{{ $tax->prices_include_tax ? 'Yes' : 'No' }}
+
Updated
{{ optional($tax->updated_at)->toDateTimeString() ?? '—' }}
+
+
{{ json_encode($tax->config_json, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES) }}
+ @else +

No tax settings configured for this store.

+ @endif +
+@endsection diff --git a/resources/views/admin/themes/editor.blade.php b/resources/views/admin/themes/editor.blade.php new file mode 100644 index 0000000..1a25e29 --- /dev/null +++ b/resources/views/admin/themes/editor.blade.php @@ -0,0 +1,23 @@ +@extends('admin.layout') + +@section('title', 'Theme Editor') + +@section('content') +
+
+

{{ $theme->name }}

+

Theme file inventory and settings snapshot.

+
    + @forelse ($theme->files as $file) +
  • {{ $file->path }} ({{ is_object($file->type) ? $file->type->value : $file->type }})
  • + @empty +
  • No theme files stored.
  • + @endforelse +
+
+
+

Settings

+
{{ json_encode($theme->settings?->settings_json ?? [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES) }}
+
+
+@endsection diff --git a/resources/views/admin/themes/index.blade.php b/resources/views/admin/themes/index.blade.php new file mode 100644 index 0000000..4b5b1eb --- /dev/null +++ b/resources/views/admin/themes/index.blade.php @@ -0,0 +1,18 @@ +@extends('admin.layout') + +@section('title', 'Themes') + +@section('content') +
+ @forelse ($themes as $theme) +
+

{{ $theme->name }}

+

Version {{ $theme->version }}

+

Status: {{ is_object($theme->status) ? $theme->status->value : $theme->status }}

+ Open editor +
+ @empty +

No themes available.

+ @endforelse +
+@endsection diff --git a/resources/views/storefront/account/addresses/index.blade.php b/resources/views/storefront/account/addresses/index.blade.php new file mode 100644 index 0000000..e0f3c76 --- /dev/null +++ b/resources/views/storefront/account/addresses/index.blade.php @@ -0,0 +1,143 @@ +@extends('storefront.layout') + +@section('title', 'Address Book') + +@section('content') +

Address Book

+ +@if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+@endif + +
+

Add Address

+ +
+ @csrf + + + + + + + + + + + + + +
+ +
+
+
+ +
+ @forelse ($addresses as $address) + @php($json = is_array($address->address_json) ? $address->address_json : []) +
+

+ {{ $address->label ?: 'Address' }} + @if($address->is_default) + (Default) + @endif +

+ +
+

{{ trim(($json['first_name'] ?? '').' '.($json['last_name'] ?? '')) }}

+

{{ $json['address1'] ?? '' }}

+ @if (! empty($json['address2'])) +

{{ $json['address2'] }}

+ @endif +

{{ $json['city'] ?? '' }} {{ $json['postal_code'] ?? '' }}

+

{{ $json['country_code'] ?? ($json['country'] ?? '') }}

+
+ +
+ Edit +
+ @csrf + @method('PUT') + + + + + + + + + + + + + + +
+
+ +
+ @csrf + @method('DELETE') + +
+
+ @empty +

No addresses saved yet.

+ @endforelse +
+@endsection diff --git a/resources/views/storefront/account/dashboard.blade.php b/resources/views/storefront/account/dashboard.blade.php new file mode 100644 index 0000000..1f45777 --- /dev/null +++ b/resources/views/storefront/account/dashboard.blade.php @@ -0,0 +1,31 @@ +@extends('storefront.layout') + +@section('title', 'Account Dashboard') + +@section('content') +
+
+

Welcome, {{ $customer->name }}

+

Manage your profile, orders, and addresses.

+
+
+ @csrf + +
+
+ + +@endsection diff --git a/resources/views/storefront/account/forgot-password.blade.php b/resources/views/storefront/account/forgot-password.blade.php new file mode 100644 index 0000000..bdd021f --- /dev/null +++ b/resources/views/storefront/account/forgot-password.blade.php @@ -0,0 +1,20 @@ +@extends('storefront.layout') + +@section('title', 'Forgot Password') + +@section('content') +
+

Forgot password

+

Enter your account email and we will send you a password reset link.

+ +
+ @csrf +
+ + + @error('email')

{{ $message }}

@enderror +
+ +
+
+@endsection diff --git a/resources/views/storefront/account/login.blade.php b/resources/views/storefront/account/login.blade.php new file mode 100644 index 0000000..b743f81 --- /dev/null +++ b/resources/views/storefront/account/login.blade.php @@ -0,0 +1,32 @@ +@extends('storefront.layout') + +@section('title', 'Account Login') + +@section('content') +
+

Log in

+ +
+ @csrf +
+ + + @error('email')

{{ $message }}

@enderror +
+
+ + + @error('password')

{{ $message }}

@enderror +
+ + +
+ + +
+@endsection diff --git a/resources/views/storefront/account/orders/index.blade.php b/resources/views/storefront/account/orders/index.blade.php new file mode 100644 index 0000000..6cc1d59 --- /dev/null +++ b/resources/views/storefront/account/orders/index.blade.php @@ -0,0 +1,26 @@ +@extends('storefront.layout') + +@section('title', 'Order History') + +@section('content') +

Your Orders

+ +
+ + + + @forelse ($orders as $order) + + + + + + + @empty + + @endforelse + +
OrderPlacedStatusTotal
{{ $order->order_number }}{{ optional($order->placed_at)->toDateString() ?? '—' }}{{ is_object($order->status) ? $order->status->value : $order->status }}{{ number_format(((int) $order->total_amount) / 100, 2, '.', ',') }} {{ strtoupper($order->currency) }}
No orders yet.
+
+
{{ $orders->links() }}
+@endsection diff --git a/resources/views/storefront/account/orders/show.blade.php b/resources/views/storefront/account/orders/show.blade.php new file mode 100644 index 0000000..9535286 --- /dev/null +++ b/resources/views/storefront/account/orders/show.blade.php @@ -0,0 +1,35 @@ +@extends('storefront.layout') + +@section('title', 'Order '.$order->order_number) + +@section('content') +Back to orders + +

Order {{ $order->order_number }}

+ +
+
+

Items

+
    + @foreach ($order->lines as $line) +
  • + {{ $line->title_snapshot }} + x{{ (int) $line->quantity }} + {{ number_format(((int) $line->total_amount) / 100, 2, '.', ',') }} +
  • + @endforeach +
+
+ +
+

Summary

+
+
Subtotal
{{ number_format(((int) $order->subtotal_amount) / 100, 2, '.', ',') }}
+
Discount
-{{ number_format(((int) $order->discount_amount) / 100, 2, '.', ',') }}
+
Shipping
{{ number_format(((int) $order->shipping_amount) / 100, 2, '.', ',') }}
+
Tax
{{ number_format(((int) $order->tax_amount) / 100, 2, '.', ',') }}
+
Total
{{ number_format(((int) $order->total_amount) / 100, 2, '.', ',') }} {{ strtoupper($order->currency) }}
+
+
+
+@endsection diff --git a/resources/views/storefront/account/register.blade.php b/resources/views/storefront/account/register.blade.php new file mode 100644 index 0000000..ba1736d --- /dev/null +++ b/resources/views/storefront/account/register.blade.php @@ -0,0 +1,41 @@ +@extends('storefront.layout') + +@section('title', 'Create Account') + +@section('content') +
+

Create account

+ +
+ @csrf +
+ + + @error('name')

{{ $message }}

@enderror +
+
+ + + @error('email')

{{ $message }}

@enderror +
+
+ + + @error('password')

{{ $message }}

@enderror +
+
+ + +
+ + +
+ +

+ Already have an account? + Log in +

+
+@endsection diff --git a/resources/views/storefront/account/reset-password.blade.php b/resources/views/storefront/account/reset-password.blade.php new file mode 100644 index 0000000..5ead60b --- /dev/null +++ b/resources/views/storefront/account/reset-password.blade.php @@ -0,0 +1,29 @@ +@extends('storefront.layout') + +@section('title', 'Reset Password') + +@section('content') +
+

Reset password

+ +
+ @csrf + +
+ + + @error('email')

{{ $message }}

@enderror +
+
+ + + @error('password')

{{ $message }}

@enderror +
+
+ + +
+ +
+
+@endsection diff --git a/resources/views/storefront/cart/show.blade.php b/resources/views/storefront/cart/show.blade.php new file mode 100644 index 0000000..539f64e --- /dev/null +++ b/resources/views/storefront/cart/show.blade.php @@ -0,0 +1,120 @@ +@extends('storefront.layout') + +@section('title', 'Cart | '.($currentStore->name ?? config('app.name'))) + +@section('content') +
+

Shopping Cart

+ + @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + + @if (! $cart) +

+ Your cart is currently empty. +

+ @elseif ($cart->lines->isEmpty()) +

+ Your cart has no line items yet. +

+ @else +
+
+ @foreach ($cart->lines as $line) + @php($variant = $line->variant) + @php($product = $variant?->product) +
+
+
+ @if ($product) + + {{ $product->title }} + + @else +

Unavailable product

+ @endif + +

Quantity: {{ (int) $line->quantity }}

+ @if (is_string($variant?->sku) && $variant->sku !== '') +

SKU: {{ $variant->sku }}

+ @endif +
+
+

{{ number_format(((int) $line->line_total_amount) / 100, 2, '.', ',') }} {{ strtoupper((string) $cart->currency) }}

+
+
+ +
+
+ @csrf + @method('PATCH') + +
+ + +
+ +
+ +
+ @csrf + @method('DELETE') + + +
+
+
+ @endforeach +
+ + +
+ @endif +
+@endsection diff --git a/resources/views/storefront/checkout/confirmation.blade.php b/resources/views/storefront/checkout/confirmation.blade.php new file mode 100644 index 0000000..c92c28e --- /dev/null +++ b/resources/views/storefront/checkout/confirmation.blade.php @@ -0,0 +1,39 @@ +@extends('storefront.layout') + +@section('title', 'Checkout Confirmation #'.$checkout->id.' | '.($currentStore->name ?? config('app.name'))) + +@section('content') +
+

Checkout Confirmation

+

Checkout #{{ $checkout->id }} is currently {{ str_replace('_', ' ', $status) }}.

+ + @if ($order) +
+ Order #{{ $order->order_number }} was matched to this completed checkout. +
+ @endif + +
+
+
Subtotal
+
{{ number_format(((int) $totals['subtotal_amount']) / 100, 2, '.', ',') }} {{ strtoupper((string) $totals['currency']) }}
+
+
+
Discounts
+
-{{ number_format(((int) $totals['discount_amount']) / 100, 2, '.', ',') }} {{ strtoupper((string) $totals['currency']) }}
+
+
+
Shipping
+
{{ number_format(((int) $totals['shipping_amount']) / 100, 2, '.', ',') }} {{ strtoupper((string) $totals['currency']) }}
+
+
+
Tax
+
{{ number_format(((int) $totals['tax_amount']) / 100, 2, '.', ',') }} {{ strtoupper((string) $totals['currency']) }}
+
+
+
Total
+
{{ number_format(((int) $totals['total_amount']) / 100, 2, '.', ',') }} {{ strtoupper((string) $totals['currency']) }}
+
+
+
+@endsection diff --git a/resources/views/storefront/checkout/show.blade.php b/resources/views/storefront/checkout/show.blade.php new file mode 100644 index 0000000..49c5647 --- /dev/null +++ b/resources/views/storefront/checkout/show.blade.php @@ -0,0 +1,301 @@ +@extends('storefront.layout') + +@section('title', 'Checkout #'.$checkout->id.' | '.($currentStore->name ?? config('app.name'))) + +@section('content') +
+
+ @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + +
+

Checkout #{{ $checkout->id }}

+

Status: {{ str_replace('_', ' ', $status) }}

+ @if (is_string($checkout->email) && $checkout->email !== '') +

Email: {{ $checkout->email }}

+ @endif +
+ + @php($shippingAddress = is_array(old('shipping_address')) ? old('shipping_address') : (is_array($checkout->shipping_address_json) ? $checkout->shipping_address_json : [])) + @php($billingAddress = is_array(old('billing_address')) ? old('billing_address') : (is_array($checkout->billing_address_json) ? $checkout->billing_address_json : [])) + @php($useShippingAsBilling = old('use_shipping_as_billing', ! is_array($checkout->billing_address_json) || $checkout->billing_address_json === $checkout->shipping_address_json)) + +
+

1. Contact & Address

+ +
+ @csrf + @method('PUT') + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + + + + @if (! (bool) $useShippingAsBilling) +
+

Billing address

+
+ + + + + + + +
+
+ @endif + + +
+
+ + @php($selectedShippingMethod = (int) old('shipping_method_id', (int) ($checkout->shipping_method_id ?? 0))) +
+

2. Shipping Method

+ +
+ @csrf + @method('PUT') + + @if (! $requiresShipping) +

No shipping is required for this cart.

+ @elseif ($shippingMethods === []) +

Add a valid shipping address to see available shipping methods.

+ @else + @foreach ($shippingMethods as $method) + + @endforeach + @endif + + +
+
+ + @php($selectedPaymentMethod = (string) old('payment_method', (string) ($checkout->payment_method?->value ?? ''))) +
+

3. Payment Method

+ +
+ @csrf + @method('PUT') + + @foreach ($paymentMethods as $value => $label) + + @endforeach + + +
+
+ +
+

4. Discount

+ +
+ @csrf + + +
+ + @if (is_string($checkout->discount_code) && $checkout->discount_code !== '') +
+

+ Applied: {{ $checkout->discount_code }} + @if ($appliedDiscount) + ({{ str_replace('_', ' ', $appliedDiscount->value_type->value) }}) + @endif +

+
+ @csrf + @method('DELETE') + +
+
+ @endif +
+ +
+

5. Complete Payment

+ +
+ @csrf + +
+ + +
+ +
+ + + + +
+ + +
+
+ +
+

Items

+ + @php($lines = $checkout->cart?->lines ?? collect()) + + @if ($lines->isEmpty()) +

This checkout currently has no cart lines.

+ @else +
    + @foreach ($lines as $line) + @php($product = $line->variant?->product) +
  • +
    +

    {{ $product?->title ?? 'Unavailable product' }}

    +

    Quantity: {{ (int) $line->quantity }}

    +
    +

    + {{ number_format(((int) $line->line_total_amount) / 100, 2, '.', ',') }} {{ strtoupper((string) $totals['currency']) }} +

    +
  • + @endforeach +
+ @endif +
+
+ + +
+@endsection diff --git a/resources/views/storefront/collections/index.blade.php b/resources/views/storefront/collections/index.blade.php new file mode 100644 index 0000000..0cd9eeb --- /dev/null +++ b/resources/views/storefront/collections/index.blade.php @@ -0,0 +1,31 @@ +@extends('storefront.layout') + +@section('title', ($currentStore->name ?? config('app.name')).' | Collections') + +@section('content') +
+

Collections

+

Explore all active collections available in this store.

+ + @if ($collections->isEmpty()) +

+ There are currently no active collections. +

+ @else + + +
+ {{ $collections->links() }} +
+ @endif +
+@endsection diff --git a/resources/views/storefront/collections/show.blade.php b/resources/views/storefront/collections/show.blade.php new file mode 100644 index 0000000..faa5ca5 --- /dev/null +++ b/resources/views/storefront/collections/show.blade.php @@ -0,0 +1,48 @@ +@extends('storefront.layout') + +@section('title', $collection->title.' | '.($currentStore->name ?? config('app.name'))) + +@section('content') +
+ Back to collections + +

{{ $collection->title }}

+ + @if (is_string($collection->description_html) && $collection->description_html !== '') +
+ {!! $collection->description_html !!} +
+ @endif +
+ +
+

Products

+ + @if ($collection->products->isEmpty()) +

+ No active products are assigned to this collection yet. +

+ @else + + @endif +
+@endsection diff --git a/resources/views/storefront/home.blade.php b/resources/views/storefront/home.blade.php new file mode 100644 index 0000000..ae2a8c6 --- /dev/null +++ b/resources/views/storefront/home.blade.php @@ -0,0 +1,70 @@ +@extends('storefront.layout') + +@section('title', ($currentStore->name ?? config('app.name')).' | Home') + +@section('content') +
+

Storefront

+

+ {{ $currentStore->name ?? config('app.name') }} +

+

+ Browse current collections and recently published products. +

+
+ +
+
+

Featured Collections

+ + View all + +
+ + @if ($collections->isEmpty()) +

+ No active collections are available yet. +

+ @else +
+ @foreach ($collections as $collection) + +

{{ $collection->title }}

+

+ {{ (int) $collection->products_count }} products +

+
+ @endforeach +
+ @endif +
+ +
+

New Products

+ + @if ($products->isEmpty()) +

+ No products have been published yet. +

+ @else + + @endif +
+@endsection diff --git a/resources/views/storefront/layout.blade.php b/resources/views/storefront/layout.blade.php new file mode 100644 index 0000000..326ba32 --- /dev/null +++ b/resources/views/storefront/layout.blade.php @@ -0,0 +1,42 @@ + + + + + + @yield('title', ($currentStore->name ?? config('app.name')).' Storefront') + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + + Skip to main content + + +
+ +
+ +
+ @if (session('status')) +
+ {{ session('status') }} +
+ @endif + + @yield('content') +
+ + diff --git a/resources/views/storefront/pages/show.blade.php b/resources/views/storefront/pages/show.blade.php new file mode 100644 index 0000000..580ee8e --- /dev/null +++ b/resources/views/storefront/pages/show.blade.php @@ -0,0 +1,19 @@ +@extends('storefront.layout') + +@section('title', $page->title.' | '.($currentStore->name ?? config('app.name'))) + +@section('content') +
+

{{ $page->title }}

+ + @if ($page->published_at) +

+ Published {{ $page->published_at->format('F j, Y') }} +

+ @endif + +
+ {!! $page->body_html !!} +
+
+@endsection diff --git a/resources/views/storefront/products/show.blade.php b/resources/views/storefront/products/show.blade.php new file mode 100644 index 0000000..38801e8 --- /dev/null +++ b/resources/views/storefront/products/show.blade.php @@ -0,0 +1,124 @@ +@extends('storefront.layout') + +@section('title', $product->title.' | '.($currentStore->name ?? config('app.name'))) + +@section('content') +
+
+

{{ $product->title }}

+ + @if (is_string($product->vendor) && $product->vendor !== '') +

{{ $product->vendor }}

+ @endif + + @if (is_string($product->description_html) && $product->description_html !== '') +
+ {!! $product->description_html !!} +
+ @endif + +
+

Available Variants

+ + @if ($product->variants->isEmpty()) +

No active variants are available for this product.

+ @else +
    + @foreach ($product->variants as $variant) +
  • + {{ $variant->sku ?: 'Variant '.$loop->iteration }} + + {{ number_format(((int) $variant->price_amount) / 100, 2, '.', ',') }} {{ strtoupper((string) $variant->currency) }} + +
  • + @endforeach +
+ @endif +
+ +
+

Add to Cart

+ + @if ($errors->has('cart') || $errors->has('variant_id') || $errors->has('quantity')) +
+
    + @foreach ($errors->only(['cart', 'variant_id', 'quantity']) as $messages) + @foreach ((array) $messages as $message) +
  • {{ $message }}
  • + @endforeach + @endforeach +
+
+ @endif + + @if ($product->variants->isEmpty()) +

This product cannot be added to the cart right now.

+ @else +
+ @csrf + +
+ + + @error('variant_id') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('quantity') +

{{ $message }}

+ @enderror +
+ +
+ + View cart +
+
+ @endif +
+
+ +
+

Collections

+ + @if ($product->collections->isEmpty()) +

This product is not currently assigned to an active collection.

+ @else + + @endif + +
+

Handle

+

{{ $product->handle }}

+
+
+
+@endsection diff --git a/resources/views/storefront/search/index.blade.php b/resources/views/storefront/search/index.blade.php new file mode 100644 index 0000000..feb552f --- /dev/null +++ b/resources/views/storefront/search/index.blade.php @@ -0,0 +1,61 @@ +@extends('storefront.layout') + +@section('title', 'Search | '.($currentStore->name ?? config('app.name'))) + +@section('content') +
+

Search Products

+ +
+ + + +
+ + @if ($query !== '') +

+ {{ $products->total() }} result{{ $products->total() === 1 ? '' : 's' }} for "{{ $query }}" +

+ @endif +
+ +
+ @if ($products->isEmpty()) +

+ No products matched your search. +

+ @else + + +
+ {{ $products->links() }} +
+ @endif +
+@endsection diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..1f5074a --- /dev/null +++ b/routes/api.php @@ -0,0 +1,102 @@ +middleware('store.resolve') + ->name('api.storefront.') + ->group(function (): void { + Route::get('/health', fn (): array => ['ok' => true]) + ->middleware('throttle:api.storefront') + ->name('health'); + + Route::prefix('carts') + ->middleware('throttle:api.storefront') + ->name('carts.') + ->group(function (): void { + Route::post('/', [CartController::class, 'store'])->name('store'); + Route::get('/{cart}', [CartController::class, 'show'])->name('show'); + Route::post('/{cart}/lines', [CartController::class, 'addLine'])->name('lines.store'); + Route::put('/{cart}/lines/{line}', [CartController::class, 'updateLine'])->name('lines.update'); + Route::delete('/{cart}/lines/{line}', [CartController::class, 'removeLine'])->name('lines.destroy'); + }); + + Route::prefix('checkouts') + ->middleware('throttle:checkout') + ->name('checkouts.') + ->group(function (): void { + Route::post('/', [CheckoutController::class, 'store'])->name('store'); + Route::get('/{checkout}', [CheckoutController::class, 'show'])->name('show'); + Route::put('/{checkout}/address', [CheckoutController::class, 'updateAddress'])->name('address'); + Route::put('/{checkout}/shipping-method', [CheckoutController::class, 'selectShippingMethod'])->name('shipping-method'); + Route::put('/{checkout}/payment-method', [CheckoutController::class, 'selectPaymentMethod'])->name('payment-method'); + Route::post('/{checkout}/apply-discount', [CheckoutController::class, 'applyDiscount'])->name('apply-discount'); + Route::delete('/{checkout}/discount', [CheckoutController::class, 'removeDiscount'])->name('remove-discount'); + Route::post('/{checkout}/pay', [CheckoutController::class, 'pay'])->name('pay'); + }); + + Route::get('/orders/{orderNumber}', [OrderController::class, 'show']) + ->middleware('throttle:api.storefront') + ->name('orders.show'); + + Route::prefix('search') + ->middleware('throttle:search') + ->name('search.') + ->group(function (): void { + Route::get('/', [SearchController::class, 'search'])->name('index'); + Route::get('/suggest', [SearchController::class, 'suggest'])->name('suggest'); + }); + + Route::prefix('analytics') + ->middleware('throttle:analytics') + ->name('analytics.') + ->group(function (): void { + Route::post('/events', [AnalyticsEventController::class, 'store'])->name('events.store'); + }); + }); + +Route::prefix('admin/v1') + ->middleware(['auth:sanctum', 'store.resolve', 'throttle:api.admin']) + ->name('api.admin.') + ->group(function (): void { + Route::get('/me', [AdminMeController::class, 'show'])->name('me'); + + Route::prefix('stores/{store}') + ->group(function (): void { + Route::apiResource('products', AdminProductController::class) + ->names([ + 'index' => 'products.index', + 'store' => 'products.store', + 'show' => 'products.show', + 'update' => 'products.update', + 'destroy' => 'products.destroy', + ]); + + Route::apiResource('collections', AdminCollectionController::class) + ->names([ + 'index' => 'collections.index', + 'store' => 'collections.store', + 'show' => 'collections.show', + 'update' => 'collections.update', + 'destroy' => 'collections.destroy', + ]); + + Route::apiResource('discounts', AdminDiscountController::class) + ->names([ + 'index' => 'discounts.index', + 'store' => 'discounts.store', + 'show' => 'discounts.show', + 'update' => 'discounts.update', + 'destroy' => 'discounts.destroy', + ]); + }); + }); diff --git a/routes/console.php b/routes/console.php index 3c9adf1..9035d77 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,15 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::call(static function (): void { + app(ExpireAbandonedCheckouts::class)->handle(app(CheckoutService::class)); +})->name('expire-abandoned-checkouts')->everyFifteenMinutes(); diff --git a/routes/web.php b/routes/web.php index f755f11..7b5e6ae 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,10 +1,194 @@ name('home'); +Route::prefix('admin') + ->name('admin.auth.') + ->middleware('store.resolve') + ->group(function (): void { + Route::middleware('guest')->group(function (): void { + Route::get('login', [AdminLoginController::class, 'create'])->name('login'); + Route::post('login', [AdminLoginController::class, 'store']) + ->middleware('throttle:login') + ->name('login.store'); + + Route::get('forgot-password', [AdminForgotPasswordController::class, 'create'])->name('forgot-password'); + Route::post('forgot-password', [AdminForgotPasswordController::class, 'store'])->name('forgot-password.store'); + + Route::get('reset-password/{token}', [AdminResetPasswordController::class, 'create'])->name('reset-password'); + Route::post('reset-password', [AdminResetPasswordController::class, 'store'])->name('reset-password.store'); + }); + + Route::post('logout', [AdminLoginController::class, 'destroy']) + ->middleware('auth') + ->name('logout'); + }); + +Route::middleware(['auth', 'verified', 'store.resolve', 'role.check']) + ->prefix('admin') + ->name('admin.') + ->group(function (): void { + Route::get('/', [AdminPageController::class, 'dashboard'])->name('dashboard'); + + Route::prefix('products')->name('products.')->group(function (): void { + Route::get('/', [AdminPageController::class, 'productsIndex'])->name('index'); + Route::get('create', [AdminPageController::class, 'productsCreate'])->name('create'); + Route::post('/', [AdminPageController::class, 'productsStore'])->name('store'); + Route::get('{product}/edit', [AdminPageController::class, 'productsEdit'])->name('edit'); + Route::put('{product}', [AdminPageController::class, 'productsUpdate'])->name('update'); + Route::delete('{product}', [AdminPageController::class, 'productsDestroy'])->name('destroy'); + }); + + Route::get('inventory', [AdminPageController::class, 'inventoryIndex'])->name('inventory.index'); + + Route::prefix('collections')->name('collections.')->group(function (): void { + Route::get('/', [AdminPageController::class, 'collectionsIndex'])->name('index'); + Route::get('create', [AdminPageController::class, 'collectionsCreate'])->name('create'); + Route::post('/', [AdminPageController::class, 'collectionsStore'])->name('store'); + Route::get('{collection}/edit', [AdminPageController::class, 'collectionsEdit'])->name('edit'); + Route::put('{collection}', [AdminPageController::class, 'collectionsUpdate'])->name('update'); + Route::delete('{collection}', [AdminPageController::class, 'collectionsDestroy'])->name('destroy'); + }); + + Route::prefix('orders')->name('orders.')->group(function (): void { + Route::get('/', [AdminPageController::class, 'ordersIndex'])->name('index'); + Route::get('{order}', [AdminPageController::class, 'ordersShow'])->name('show'); + }); + + Route::prefix('customers')->name('customers.')->group(function (): void { + Route::get('/', [AdminPageController::class, 'customersIndex'])->name('index'); + Route::get('{customer}', [AdminPageController::class, 'customersShow'])->name('show'); + }); + + Route::prefix('discounts')->name('discounts.')->group(function (): void { + Route::get('/', [AdminPageController::class, 'discountsIndex'])->name('index'); + Route::get('create', [AdminPageController::class, 'discountsCreate'])->name('create'); + Route::post('/', [AdminPageController::class, 'discountsStore'])->name('store'); + Route::get('{discount}/edit', [AdminPageController::class, 'discountsEdit'])->name('edit'); + Route::put('{discount}', [AdminPageController::class, 'discountsUpdate'])->name('update'); + Route::delete('{discount}', [AdminPageController::class, 'discountsDestroy'])->name('destroy'); + }); + + Route::prefix('settings')->name('settings.')->group(function (): void { + Route::get('/', [AdminPageController::class, 'settingsIndex'])->name('index'); + Route::get('shipping', [AdminPageController::class, 'settingsShipping'])->name('shipping'); + Route::get('taxes', [AdminPageController::class, 'settingsTaxes'])->name('taxes'); + }); + + Route::prefix('themes')->name('themes.')->group(function (): void { + Route::get('/', [AdminPageController::class, 'themesIndex'])->name('index'); + Route::get('{theme}/editor', [AdminPageController::class, 'themesEditor'])->name('editor'); + }); + + Route::prefix('pages')->name('pages.')->group(function (): void { + Route::get('/', [AdminPageController::class, 'pagesIndex'])->name('index'); + Route::get('create', [AdminPageController::class, 'pagesCreate'])->name('create'); + Route::post('/', [AdminPageController::class, 'pagesStore'])->name('store'); + Route::get('{page}/edit', [AdminPageController::class, 'pagesEdit'])->name('edit'); + Route::put('{page}', [AdminPageController::class, 'pagesUpdate'])->name('update'); + Route::delete('{page}', [AdminPageController::class, 'pagesDestroy'])->name('destroy'); + }); + + Route::get('navigation', [AdminPageController::class, 'navigationIndex'])->name('navigation.index'); + + Route::prefix('apps')->name('apps.')->group(function (): void { + Route::get('/', [AdminPageController::class, 'appsIndex'])->name('index'); + Route::get('{installation}', [AdminPageController::class, 'appsShow'])->name('show'); + }); + + Route::get('developers', [AdminPageController::class, 'developersIndex'])->name('developers.index'); + Route::get('analytics', [AdminPageController::class, 'analyticsIndex'])->name('analytics.index'); + Route::get('search/settings', [AdminPageController::class, 'searchSettings'])->name('search.settings'); + }); + +Route::middleware('store.resolve')->group(function (): void { + Route::get('/', [StorefrontHomeController::class, 'index'])->name('home'); + Route::get('/collections', [StorefrontCollectionController::class, 'index'])->name('storefront.collections.index'); + Route::get('/collections/{handle}', [StorefrontCollectionController::class, 'show'])->name('storefront.collections.show'); + Route::get('/products/{handle}', [StorefrontProductController::class, 'show'])->name('storefront.products.show'); + Route::get('/cart', [StorefrontCartController::class, 'show'])->name('storefront.cart.show'); + Route::post('/cart/lines', [StorefrontCartController::class, 'addLine'])->name('storefront.cart.lines.store'); + Route::patch('/cart/lines/{lineId}', [StorefrontCartController::class, 'updateLine']) + ->whereNumber('lineId') + ->name('storefront.cart.lines.update'); + Route::delete('/cart/lines/{lineId}', [StorefrontCartController::class, 'removeLine']) + ->whereNumber('lineId') + ->name('storefront.cart.lines.destroy'); + Route::post('/cart/checkout', [StorefrontCartController::class, 'startCheckout'])->name('storefront.cart.checkout.start'); + Route::get('/search', [StorefrontSearchController::class, 'index'])->name('storefront.search.index'); + Route::get('/pages/{handle}', [StorefrontPageController::class, 'show'])->name('storefront.pages.show'); + Route::get('/checkout/{checkoutId}', [StorefrontCheckoutController::class, 'show']) + ->whereNumber('checkoutId') + ->name('storefront.checkout.show'); + Route::put('/checkout/{checkoutId}/address', [StorefrontCheckoutController::class, 'updateAddress']) + ->whereNumber('checkoutId') + ->name('storefront.checkout.address.update'); + Route::put('/checkout/{checkoutId}/shipping-method', [StorefrontCheckoutController::class, 'selectShippingMethod']) + ->whereNumber('checkoutId') + ->name('storefront.checkout.shipping-method.update'); + Route::put('/checkout/{checkoutId}/payment-method', [StorefrontCheckoutController::class, 'selectPaymentMethod']) + ->whereNumber('checkoutId') + ->name('storefront.checkout.payment-method.update'); + Route::post('/checkout/{checkoutId}/discount', [StorefrontCheckoutController::class, 'applyDiscount']) + ->whereNumber('checkoutId') + ->name('storefront.checkout.discount.apply'); + Route::delete('/checkout/{checkoutId}/discount', [StorefrontCheckoutController::class, 'removeDiscount']) + ->whereNumber('checkoutId') + ->name('storefront.checkout.discount.remove'); + Route::post('/checkout/{checkoutId}/pay', [StorefrontCheckoutController::class, 'pay']) + ->whereNumber('checkoutId') + ->name('storefront.checkout.pay'); + Route::get('/checkout/{checkoutId}/confirmation', [StorefrontCheckoutController::class, 'confirmation']) + ->whereNumber('checkoutId') + ->name('storefront.checkout.confirmation'); + + Route::prefix('account') + ->name('account.') + ->group(function (): void { + Route::get('login', [AccountAuthController::class, 'showLogin'])->name('login'); + Route::post('login', [AccountAuthController::class, 'login']) + ->middleware('throttle:login') + ->name('login.store'); + Route::get('register', [AccountAuthController::class, 'showRegister'])->name('register'); + Route::post('register', [AccountAuthController::class, 'register']) + ->middleware('throttle:login') + ->name('register.store'); + Route::get('forgot-password', [AccountAuthController::class, 'showForgotPassword'])->name('forgot-password'); + Route::post('forgot-password', [AccountAuthController::class, 'sendResetLink'])->name('forgot-password.store'); + Route::get('reset-password/{token}', [AccountAuthController::class, 'showResetPassword'])->name('reset-password'); + Route::post('reset-password', [AccountAuthController::class, 'resetPassword'])->name('reset-password.store'); + + Route::middleware('auth:customer')->group(function (): void { + Route::get('/', [AccountDashboardController::class, 'index'])->name('dashboard'); + Route::get('/orders', [AccountOrderController::class, 'index'])->name('orders.index'); + Route::get('/orders/{orderNumber}', [AccountOrderController::class, 'show'])->name('orders.show'); + Route::get('/addresses', [AccountAddressController::class, 'index'])->name('addresses.index'); + Route::post('/addresses', [AccountAddressController::class, 'store'])->name('addresses.store'); + Route::put('/addresses/{address}', [AccountAddressController::class, 'update']) + ->whereNumber('address') + ->name('addresses.update'); + Route::delete('/addresses/{address}', [AccountAddressController::class, 'destroy']) + ->whereNumber('address') + ->name('addresses.destroy'); + Route::post('/logout', [AccountAuthController::class, 'logout'])->name('logout'); + }); + }); +}); Route::view('dashboard', 'dashboard') ->middleware(['auth', 'verified']) diff --git a/specs/progress.md b/specs/progress.md new file mode 100644 index 0000000..bd5620b --- /dev/null +++ b/specs/progress.md @@ -0,0 +1,115 @@ +# Shop Progress + +Last updated: 2026-02-14 + +## Iteration 1 - Foundation (Completed) + +| Checkpoint | Status | Notes | +|---|---|---| +| Tooling baseline (`phpstan.neon`, `deptrac.yaml`, `pint.json`) | Completed | PHPStan and Deptrac configs added, Pint excludes `var/` cache output. | +| Core schema (Phase 1-5,7,8,10 migrations) | Completed | Full multi-tenant commerce schema created for foundation, catalog, checkout, orders, analytics, and apps/webhooks. | +| Enums | Completed | Domain enum set added under `app/Enums/*` for statuses, policies, and types. | +| Models + relationships | Completed | Store, catalog, checkout, order, analytics, and app models scaffolded with casts/relations. | +| Tenant middleware (`ResolveStore`) | Completed | Hostname -> store resolution, container binding, unknown host `404`, suspended store `503`. | +| Role middleware (`CheckStoreRole`) | Completed | Store membership + role gate enforced via `store_users`. | +| Store scoping primitives (`StoreScope`, `BelongsToStore`) | Completed | Global scope and automatic `store_id` assignment implemented. | +| Customer auth provider + guard wiring | Completed | Custom `customer` provider/guard/password broker integrated. | +| Foundation seeders/factories | Completed | Idempotent baseline seed data for org/store/domain/users and core commerce placeholders. | +| Foundation tests | Completed | Tenancy, store-scoping, admin/customer auth, and existing starter auth/settings tests all pass. | + +## Iteration 1 Verification + +- [x] `composer test` +- [x] `./vendor/bin/phpstan analyse --configuration=phpstan.neon --memory-limit=1G` +- [x] `./vendor/bin/deptrac analyse --config-file=deptrac.yaml` + +## Next Iteration + +- Iteration 2: catalog services + storefront/admin APIs + cart/checkout core workflows + corresponding unit/feature tests. + +## Iteration 2 - Catalog, API, and Checkout Workflow (Completed) + +| Checkpoint | Status | Notes | +|---|---|---| +| Service layer (`CartService`, `CheckoutService`, `PricingEngine`, `DiscountService`, `ShippingCalculator`, `TaxCalculator`, `InventoryService`) | Completed | Deterministic cart/checkout totals, discount application, shipping quotes, and stock reservation/commit primitives implemented. | +| Domain exceptions + value objects | Completed | Added typed domain exceptions and immutable value objects for pricing/tax/shipping outcomes. | +| Storefront API (`/api/storefront/v1`) | Completed | Cart, checkout, order lookup, search, and analytics ingestion endpoints are implemented and covered by feature tests. | +| Admin API (`/api/admin/v1/stores/{store}/...`) | Completed | Product, collection, and discount CRUD endpoints implemented with tenant-scoped route context. | +| Request validation + JSON resources | Completed | Added dedicated FormRequest classes and admin API resources for consistent payload shape and validation behavior. | +| API and service unit/feature tests | Completed | Added service unit tests and storefront/admin API feature tests for success and failure flows. | +| Static analysis + architecture gates | Completed | Deptrac layer violations resolved; PHPStan config updated and Larastan integrated for Laravel-aware analysis baseline. | + +## Iteration 2 Verification + +- [x] `composer test` +- [x] `./vendor/bin/phpstan analyse --configuration=phpstan.neon --memory-limit=1G` +- [x] `./vendor/bin/deptrac analyse --config-file=deptrac.yaml` + +## Next Iteration + +- Iteration 3: replace placeholder web routes with implemented storefront/admin pages (server-rendered), wire customer account pages, and expand functional coverage. + +## Iteration 3 - Web Flows, Admin CRUD, and Account Enhancements (Completed) + +| Checkpoint | Status | Notes | +|---|---|---| +| Storefront web route wiring + controller-backed pages | Completed | Replaced placeholder route layer with controller-driven storefront and account routes. | +| Storefront cart + checkout interactive actions | Completed | Implemented add/update/remove cart lines, start checkout, address/shipping/payment selection, discount apply/remove, and checkout payment completion with order creation. | +| Admin web CRUD forms (products, collections, discounts, pages) | Completed | Replaced placeholder form scaffolds with production form submissions, tenant-scoped validation, and create/update/delete/archival handlers. | +| Account address CRUD | Completed | Added customer address create/update/delete flows and default-address behavior enforcement. | +| Customer auth hardening for remember/reset flows | Completed | Added customer `remember_token` migration and store-scoped customer password reset token repository/manager integration. | +| Web feature coverage expansion | Completed | Added/expanded feature tests for storefront interaction flows and admin CRUD success/failure paths. | +| Tooling gates for this iteration | Completed | Pint, Pest, PHPStan max-level, and Deptrac all green after implementation. | + +## Iteration 3 Verification + +- [x] `composer test` +- [x] `./vendor/bin/phpstan analyse --configuration=phpstan.neon --memory-limit=1G` +- [x] `./vendor/bin/deptrac analyse --config-file=deptrac.yaml` + +## Next Iteration + +- Iteration 4: independent senior review cycle and Playwright-based acceptance walkthrough (storefront + admin) with bug-fix loop. + +## Iteration 4 - Senior Review Hardening and Reliability Fixes (Completed) + +| Checkpoint | Status | Notes | +|---|---|---| +| Order-to-checkout linkage for deterministic confirmation/idempotency | Completed | Added `orders.checkout_id` relation + unique constraint and updated completion flows to persist/query by checkout id first. | +| API payment idempotency hardening | Completed | Added row locks + existing-order return path in API pay flow to prevent duplicate orders on repeated requests. | +| Discount finalization correctness | Completed | Checkout completion now increments `discounts.usage_count` once per created order and writes `order_lines.discount_allocations_json` with `{discount_id, code, amount}` entries. | +| Scoped discount allocation correctness | Completed | Replaced naive all-line allocation with line allocations derived from validated pricing/discount engine outputs (`cart_lines.line_discount_amount`), including scoped product rules. | +| Invalidated discount recovery at payment time | Completed | Pay flows now gracefully recover when a previously-applied discount becomes invalid before capture by clearing code and finalizing without discount instead of failing. | +| Inventory lifecycle completion | Completed | Added commit-on-order-completion and release-on-expiry for reserved stock; added scheduled expiration job for abandoned checkouts (`ExpireAbandonedCheckouts`) every 15 minutes. | +| Review-loop regression tests | Completed | Added API/storefront regressions for usage_count, allocation structure/sums, scoped eligibility, invalidated-discount payment recovery, idempotency, inventory commit, and expiry release; added job/schedule tests. | +| Fresh independent reviewer loop | Completed | Multiple fresh-agent review passes executed; all critical/high findings fixed; final pass reports no critical/high blockers. | + +## Iteration 4 Verification + +- [x] `composer test` +- [x] `./vendor/bin/phpstan analyse --configuration=phpstan.neon --memory-limit=1G` +- [x] `./vendor/bin/deptrac analyse --config-file=deptrac.yaml` + +## Next Iteration + +- Iteration 5: Playwright end-to-end acceptance walkthrough (customer + admin) against `http://shop.test/`, with fix-and-retest loop for any discovered defects. + +## Iteration 5 - Playwright Acceptance and Runtime Defect Fixes (Completed) + +| Checkpoint | Status | Notes | +|---|---|---| +| Customer account registration redirect-loop fix | Completed | Enforced middleware priority so `ResolveStore` runs before auth checks (`bootstrap/app.php`), preventing `/account` ↔ `/account/login` redirect loops on tenant hosts. | +| Admin document title rendering fix | Completed | Corrected Blade title rendering to evaluate store suffix instead of outputting literal concatenation text (`resources/views/admin/layout.blade.php`). | +| Storefront order detail route fix for `#` prefixed order numbers | Completed | Fixed account order links to use path-safe order numbers and centralized normalization in `Order` model for route input matching. | +| Regression test hardening for discovered defects | Completed | Added storefront tests for redirect-loop, order-link normalization (including `%23` encoded route), order empty state, and cross-customer order access denial; added admin title-rendering regression test. | +| Full Playwright acceptance walkthrough (storefront + admin) | Completed | Restarted and completed browser review after fixes: customer checkout/payment/account/orders/addresses/logout and admin login/dashboard/orders/products/discounts/pages/logout all verified on `http://shop.test/`. | +| Fresh independent reviewer loop | Completed | Ran fresh-agent review passes; addressed maintainability and missing-edge test feedback; final reviewed state has no critical/high findings. | +| Full quality gate rerun (`composer test`, PHPStan, Deptrac, Pint`) | Completed | Re-ran full suite and static/architecture/style checks on final code. | + +## Iteration 5 Verification + +- [x] `composer test` +- [x] `./vendor/bin/phpstan analyse --configuration=phpstan.neon --memory-limit=1G` +- [x] `./vendor/bin/deptrac analyse --config-file=deptrac.yaml` +- [x] `./vendor/bin/pint --dirty --test` +- [x] Playwright MCP acceptance walkthrough on `http://shop.test/` (customer + admin, post-fix restart completed) diff --git a/tests/Feature/AdminWeb/AdminWebPagesTest.php b/tests/Feature/AdminWeb/AdminWebPagesTest.php new file mode 100644 index 0000000..b673d79 --- /dev/null +++ b/tests/Feature/AdminWeb/AdminWebPagesTest.php @@ -0,0 +1,479 @@ +create([ + 'name' => 'Acme Fashion', + 'handle' => 'acme-fashion', + 'default_currency' => 'EUR', + ]); + + StoreDomain::factory()->create([ + 'store_id' => $store->id, + 'hostname' => 'shop.test', + ]); + + $user = User::factory()->create([ + 'name' => 'Admin User', + 'email' => 'admin@acme.test', + 'password_hash' => Hash::make('password'), + 'email_verified_at' => now(), + ]); + + StoreUser::query()->create([ + 'store_id' => $store->id, + 'user_id' => $user->id, + 'role' => StoreUserRole::Owner, + 'created_at' => now(), + ]); + + $product = Product::factory()->create([ + 'store_id' => $store->id, + 'title' => 'Classic Cotton T-Shirt', + 'handle' => 'classic-cotton-t-shirt', + ]); + + $collection = Collection::factory()->create([ + 'store_id' => $store->id, + 'title' => 'T-Shirts', + 'handle' => 't-shirts', + ]); + + $customer = Customer::factory()->create([ + 'store_id' => $store->id, + 'email' => 'customer@acme.test', + ]); + + $order = Order::factory()->create([ + 'store_id' => $store->id, + 'customer_id' => $customer->id, + 'order_number' => '#1001', + ]); + + $discount = Discount::factory()->create([ + 'store_id' => $store->id, + 'code' => 'WELCOME10', + ]); + + $theme = Theme::query()->create([ + 'store_id' => $store->id, + 'name' => 'Default', + 'version' => '1.0.0', + 'status' => 'draft', + 'published_at' => null, + ]); + + $page = Page::query()->create([ + 'store_id' => $store->id, + 'title' => 'About', + 'handle' => 'about', + 'body_html' => '

About

', + 'status' => 'published', + 'published_at' => now(), + ]); + + $app = PlatformApp::query()->create([ + 'name' => 'Inventory Sync', + 'status' => 'active', + 'created_at' => now(), + ]); + + $installation = AppInstallation::query()->create([ + 'store_id' => $store->id, + 'app_id' => $app->id, + 'scopes_json' => ['orders:read'], + 'status' => 'active', + 'installed_at' => now(), + ]); + + return [ + 'store' => $store, + 'user' => $user, + 'product' => $product, + 'collection' => $collection, + 'order' => $order, + 'customer' => $customer, + 'discount' => $discount, + 'theme' => $theme, + 'page' => $page, + 'installation' => $installation, + ]; +} + +test('admin login page renders and authenticated admin can login', function (): void { + $fixture = createAdminFixture(); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->get('/admin/login') + ->assertOk() + ->assertSee('Sign in'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->post('/admin/login', [ + 'email' => $fixture['user']->email, + 'password' => 'password', + ]) + ->assertRedirect('/admin'); + + $this->assertAuthenticated(); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->get('/admin') + ->assertOk() + ->assertSee('Dashboard'); +}); + +test('admin dashboard renders evaluated title tag', function (): void { + $fixture = createAdminFixture(); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($fixture['user']) + ->get('/admin') + ->assertOk() + ->assertSee('Dashboard | Acme Fashion', false); +}); + +test('authenticated admin can open core admin pages', function (): void { + $fixture = createAdminFixture(); + + $user = $fixture['user']; + + $paths = [ + '/admin/products', + '/admin/products/create', + '/admin/products/'.$fixture['product']->id.'/edit', + '/admin/inventory', + '/admin/collections', + '/admin/collections/create', + '/admin/collections/'.$fixture['collection']->id.'/edit', + '/admin/orders', + '/admin/orders/'.$fixture['order']->id, + '/admin/customers', + '/admin/customers/'.$fixture['customer']->id, + '/admin/discounts', + '/admin/discounts/create', + '/admin/discounts/'.$fixture['discount']->id.'/edit', + '/admin/settings', + '/admin/settings/shipping', + '/admin/settings/taxes', + '/admin/themes', + '/admin/themes/'.$fixture['theme']->id.'/editor', + '/admin/pages', + '/admin/pages/create', + '/admin/pages/'.$fixture['page']->id.'/edit', + '/admin/navigation', + '/admin/apps', + '/admin/apps/'.$fixture['installation']->id, + '/admin/developers', + '/admin/analytics', + '/admin/search/settings', + ]; + + foreach ($paths as $path) { + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($user) + ->get($path) + ->assertOk(); + } +}); + +test('authenticated admin can create update and archive a product via admin web forms', function (): void { + $fixture = createAdminFixture(); + $store = $fixture['store']; + $user = $fixture['user']; + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($user) + ->post('/admin/products', [ + 'title' => 'Web Hoodie', + 'handle' => '', + 'description_html' => '

Comfortable hoodie.

', + 'vendor' => 'Acme', + 'product_type' => 'Hoodies', + 'status' => 'active', + 'tags' => 'hoodie, winter', + 'published_at' => now()->format('Y-m-d H:i:s'), + 'price_amount' => 4599, + 'compare_at_amount' => 5599, + 'currency' => 'eur', + 'requires_shipping' => 1, + ]) + ->assertRedirect(); + + $product = Product::query() + ->where('store_id', $store->id) + ->where('title', 'Web Hoodie') + ->first(); + + expect($product)->toBeInstanceOf(Product::class); + expect($product?->handle)->toBe('web-hoodie'); + + $variant = ProductVariant::query() + ->where('product_id', $product?->id) + ->where('is_default', true) + ->first(); + + expect($variant)->toBeInstanceOf(ProductVariant::class); + expect($variant?->price_amount)->toBe(4599); + expect($variant?->currency)->toBe('EUR'); + + $inventoryItem = InventoryItem::query() + ->where('store_id', $store->id) + ->where('variant_id', $variant?->id) + ->first(); + + expect($inventoryItem)->toBeInstanceOf(InventoryItem::class); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($user) + ->put('/admin/products/'.$product?->id, [ + 'title' => 'Web Hoodie Updated', + 'handle' => 'web-hoodie-updated', + 'description_html' => '

Updated details.

', + 'vendor' => 'Acme Updated', + 'product_type' => 'Outerwear', + 'status' => 'draft', + 'tags' => 'updated, featured', + 'published_at' => '', + 'price_amount' => 3999, + 'compare_at_amount' => '', + 'currency' => 'USD', + 'requires_shipping' => 0, + ]) + ->assertRedirect('/admin/products/'.$product?->id.'/edit'); + + $product?->refresh(); + $variant?->refresh(); + + expect($product?->title)->toBe('Web Hoodie Updated'); + expect($product?->status->value ?? $product?->status)->toBe('draft'); + expect($variant?->price_amount)->toBe(3999); + expect($variant?->currency)->toBe('USD'); + expect($variant?->requires_shipping)->toBeFalse(); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($user) + ->delete('/admin/products/'.$product?->id) + ->assertRedirect('/admin/products'); + + $product?->refresh(); + + expect($product?->status->value ?? $product?->status)->toBe('archived'); +}); + +test('product web form validates required fields', function (): void { + $fixture = createAdminFixture(); + $user = $fixture['user']; + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($user) + ->post('/admin/products', [ + 'title' => '', + 'status' => 'active', + 'price_amount' => -1, + 'currency' => 'EU', + ]) + ->assertSessionHasErrors(['title', 'price_amount', 'currency']); +}); + +test('authenticated admin can create update and delete a collection via admin web forms', function (): void { + $fixture = createAdminFixture(); + $store = $fixture['store']; + $user = $fixture['user']; + + $firstProduct = Product::factory()->create(['store_id' => $store->id]); + $secondProduct = Product::factory()->create(['store_id' => $store->id]); + $thirdProduct = Product::factory()->create(['store_id' => $store->id]); + $outsideStoreProduct = Product::factory()->create(); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($user) + ->post('/admin/collections', [ + 'title' => 'Seasonal Picks', + 'handle' => '', + 'description_html' => '

Top picks.

', + 'type' => 'manual', + 'status' => 'active', + 'product_ids' => $firstProduct->id.', '.$secondProduct->id.', '.$outsideStoreProduct->id, + ]) + ->assertRedirect(); + + $collection = Collection::query() + ->where('store_id', $store->id) + ->where('title', 'Seasonal Picks') + ->first(); + + expect($collection)->toBeInstanceOf(Collection::class); + expect($collection?->handle)->toBe('seasonal-picks'); + + $linkedProductIds = $collection?->products()->pluck('products.id')->map(static fn (mixed $id): int => (int) $id)->all() ?? []; + expect($linkedProductIds)->toEqualCanonicalizing([$firstProduct->id, $secondProduct->id]); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($user) + ->put('/admin/collections/'.$collection?->id, [ + 'title' => 'Seasonal Picks Updated', + 'handle' => 'seasonal-picks-updated', + 'description_html' => '

Updated picks.

', + 'type' => 'automated', + 'status' => 'draft', + 'product_ids' => $secondProduct->id.', '.$thirdProduct->id, + ]) + ->assertRedirect('/admin/collections/'.$collection?->id.'/edit'); + + $collection?->refresh(); + + expect($collection?->title)->toBe('Seasonal Picks Updated'); + expect($collection?->type->value ?? $collection?->type)->toBe('automated'); + expect($collection?->status->value ?? $collection?->status)->toBe('draft'); + + $linkedProductIds = $collection?->products()->pluck('products.id')->map(static fn (mixed $id): int => (int) $id)->all() ?? []; + expect($linkedProductIds)->toEqualCanonicalizing([$secondProduct->id, $thirdProduct->id]); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($user) + ->delete('/admin/collections/'.$collection?->id) + ->assertRedirect('/admin/collections'); + + $this->assertDatabaseMissing('collections', ['id' => $collection?->id]); +}); + +test('authenticated admin can create update and delete a discount via admin web forms', function (): void { + $fixture = createAdminFixture(); + $store = $fixture['store']; + $user = $fixture['user']; + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($user) + ->post('/admin/discounts', [ + 'type' => 'code', + 'code' => 'save15', + 'value_type' => 'percent', + 'value_amount' => 15, + 'starts_at' => now()->subHour()->format('Y-m-d H:i:s'), + 'ends_at' => '', + 'usage_limit' => 100, + 'usage_count' => 0, + 'rules_json' => '{"minimum_subtotal":1000}', + 'status' => 'active', + ]) + ->assertRedirect(); + + $discount = Discount::query() + ->where('store_id', $store->id) + ->where('code', 'SAVE15') + ->first(); + + expect($discount)->toBeInstanceOf(Discount::class); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($user) + ->put('/admin/discounts/'.$discount?->id, [ + 'type' => 'automatic', + 'code' => '', + 'value_type' => 'fixed', + 'value_amount' => 700, + 'starts_at' => now()->format('Y-m-d H:i:s'), + 'ends_at' => now()->addWeek()->format('Y-m-d H:i:s'), + 'usage_limit' => '', + 'usage_count' => 5, + 'rules_json' => '{"minimum_subtotal":5000}', + 'status' => 'disabled', + ]) + ->assertRedirect('/admin/discounts/'.$discount?->id.'/edit'); + + $discount?->refresh(); + + expect($discount?->type->value ?? $discount?->type)->toBe('automatic'); + expect($discount?->code)->toBeNull(); + expect($discount?->value_amount)->toBe(700); + expect($discount?->status->value ?? $discount?->status)->toBe('disabled'); + expect($discount?->rules_json)->toBe(['minimum_subtotal' => 5000]); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($user) + ->delete('/admin/discounts/'.$discount?->id) + ->assertRedirect('/admin/discounts'); + + $this->assertDatabaseMissing('discounts', ['id' => $discount?->id]); +}); + +test('authenticated admin can create update and delete a page via admin web forms', function (): void { + $fixture = createAdminFixture(); + $store = $fixture['store']; + $user = $fixture['user']; + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($user) + ->post('/admin/pages', [ + 'title' => 'Shipping Policy', + 'handle' => '', + 'body_html' => '

Ships in 2 days.

', + 'status' => 'published', + 'published_at' => now()->format('Y-m-d H:i:s'), + ]) + ->assertRedirect(); + + $page = Page::query() + ->where('store_id', $store->id) + ->where('title', 'Shipping Policy') + ->first(); + + expect($page)->toBeInstanceOf(Page::class); + expect($page?->handle)->toBe('shipping-policy'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($user) + ->put('/admin/pages/'.$page?->id, [ + 'title' => 'Shipping Policy Updated', + 'handle' => 'shipping-policy-updated', + 'body_html' => '

Ships in 1 day.

', + 'status' => 'draft', + 'published_at' => '', + ]) + ->assertRedirect('/admin/pages/'.$page?->id.'/edit'); + + $page?->refresh(); + + expect($page?->title)->toBe('Shipping Policy Updated'); + expect($page?->handle)->toBe('shipping-policy-updated'); + expect($page?->status->value ?? $page?->status)->toBe('draft'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($user) + ->delete('/admin/pages/'.$page?->id) + ->assertRedirect('/admin/pages'); + + $this->assertDatabaseMissing('pages', ['id' => $page?->id]); +}); diff --git a/tests/Feature/Api/AdminApiAuthTest.php b/tests/Feature/Api/AdminApiAuthTest.php new file mode 100644 index 0000000..3835916 --- /dev/null +++ b/tests/Feature/Api/AdminApiAuthTest.php @@ -0,0 +1,130 @@ +create([ + 'name' => 'Admin API Org', + 'billing_email' => 'billing+admin-api@example.test', + ]); + + $store = Store::query()->create([ + 'organization_id' => $organization->id, + 'name' => 'Admin API Store', + 'handle' => 'admin-api-store', + 'status' => 'active', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'UTC', + ]); + + StoreDomain::query()->create([ + 'store_id' => $store->id, + 'hostname' => $hostname, + 'type' => 'api', + 'is_primary' => true, + 'tls_mode' => 'managed', + 'created_at' => now(), + ]); + + return $store; +} + +function adminApiCreateAdminMember(Store $store): User +{ + /** @var User $user */ + $user = User::factory()->create([ + 'name' => 'Admin API User', + 'email' => 'admin-api@example.test', + ]); + + DB::table('store_users')->insert([ + 'store_id' => $store->id, + 'user_id' => $user->id, + 'role' => 'admin', + 'created_at' => now(), + ]); + + return $user; +} + +test('admin me endpoint requires sanctum authentication', function (): void { + $hostname = 'admin-me-auth.test'; + adminApiCreateStore($hostname); + + $this->getJson( + adminApiUrl($hostname, '/api/admin/v1/me'), + )->assertUnauthorized(); +}); + +test('authenticated admin can access admin me endpoint', function (): void { + $hostname = 'admin-me-ok.test'; + $store = adminApiCreateStore($hostname); + $user = adminApiCreateAdminMember($store); + $this->actingAs($user, 'web'); + + $this->getJson( + adminApiUrl($hostname, '/api/admin/v1/me'), + )->assertOk() + ->assertJsonPath('user_id', $user->id); +}); + +test('admin products collections and discounts endpoints require auth', function (): void { + $hostname = 'admin-auth-required.test'; + $store = adminApiCreateStore($hostname); + + $this->getJson( + adminApiUrl($hostname, "/api/admin/v1/stores/{$store->id}/products"), + )->assertUnauthorized(); + + $this->getJson( + adminApiUrl($hostname, "/api/admin/v1/stores/{$store->id}/collections"), + )->assertUnauthorized(); + + $this->getJson( + adminApiUrl($hostname, "/api/admin/v1/stores/{$store->id}/discounts"), + )->assertUnauthorized(); +}); + +test('authenticated admin can access products collections and discounts endpoints', function (): void { + $hostname = 'admin-authenticated-lists.test'; + $store = adminApiCreateStore($hostname); + $user = adminApiCreateAdminMember($store); + $this->actingAs($user, 'web'); + + $this->getJson( + adminApiUrl($hostname, "/api/admin/v1/stores/{$store->id}/products"), + )->assertOk(); + + $this->getJson( + adminApiUrl($hostname, "/api/admin/v1/stores/{$store->id}/collections"), + )->assertOk(); + + $this->getJson( + adminApiUrl($hostname, "/api/admin/v1/stores/{$store->id}/discounts"), + )->assertOk(); +}); diff --git a/tests/Feature/Api/StorefrontCartApiTest.php b/tests/Feature/Api/StorefrontCartApiTest.php new file mode 100644 index 0000000..216a215 --- /dev/null +++ b/tests/Feature/Api/StorefrontCartApiTest.php @@ -0,0 +1,226 @@ +create([ + 'name' => 'Cart API Org', + 'billing_email' => 'billing+cart-api@example.test', + ]); + + $store = Store::query()->create([ + 'organization_id' => $organization->id, + 'name' => 'Cart API Store', + 'handle' => 'cart-api-store', + 'status' => 'active', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'UTC', + ]); + + StoreDomain::query()->create([ + 'store_id' => $store->id, + 'hostname' => $hostname, + 'type' => 'storefront', + 'is_primary' => true, + 'tls_mode' => 'managed', + 'created_at' => now(), + ]); + + return $store; +} + +function storefrontCartApiCreateVariant( + Store $store, + string $suffix, + int $price = 2500, + int $quantityOnHand = 10, + InventoryPolicy $policy = InventoryPolicy::Deny, +): ProductVariant { + $product = Product::query()->create([ + 'store_id' => $store->id, + 'title' => 'Cart API Product '.$suffix, + 'handle' => 'cart-api-product-'.$suffix, + 'status' => 'active', + 'description_html' => null, + 'vendor' => null, + 'product_type' => null, + 'tags' => [], + 'published_at' => now(), + ]); + + $variant = ProductVariant::query()->create([ + 'product_id' => $product->id, + 'sku' => 'SKU-CART-API-'.$suffix, + 'barcode' => null, + 'price_amount' => $price, + 'compare_at_amount' => null, + 'currency' => 'EUR', + 'weight_g' => 150, + 'requires_shipping' => true, + 'is_default' => true, + 'position' => 1, + 'status' => 'active', + ]); + + InventoryItem::query()->create([ + 'store_id' => $store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => $quantityOnHand, + 'quantity_reserved' => 0, + 'policy' => $policy, + ]); + + return $variant; +} + +test('supports the storefront cart flow create add update and remove', function (): void { + $hostname = 'cart-flow.test'; + $store = storefrontCartApiCreateStore($hostname); + $variant = storefrontCartApiCreateVariant($store, 'flow', price: 2500); + + $createResponse = $this->postJson( + storefrontCartApiUrl($hostname, '/api/storefront/v1/carts'), + ['currency' => 'EUR'], + ); + + $createResponse->assertCreated() + ->assertJsonPath('currency', 'EUR') + ->assertJsonPath('cart_version', 1) + ->assertJsonPath('status', 'active'); + + $cartId = (int) $createResponse->json('id'); + + $addResponse = $this->postJson( + storefrontCartApiUrl($hostname, "/api/storefront/v1/carts/{$cartId}/lines"), + [ + 'variant_id' => $variant->id, + 'quantity' => 2, + 'cart_version' => 1, + ], + ); + + $addResponse->assertCreated() + ->assertJsonPath('lines.0.variant_id', $variant->id) + ->assertJsonPath('lines.0.quantity', 2); + + $lineId = (int) $addResponse->json('lines.0.id'); + $cartVersion = (int) $addResponse->json('cart_version'); + + $updateResponse = $this->putJson( + storefrontCartApiUrl($hostname, "/api/storefront/v1/carts/{$cartId}/lines/{$lineId}"), + [ + 'quantity' => 3, + 'cart_version' => $cartVersion, + ], + ); + + $updateResponse->assertOk() + ->assertJsonPath('lines.0.quantity', 3); + + $updatedVersion = (int) $updateResponse->json('cart_version'); + + $this->deleteJson( + storefrontCartApiUrl($hostname, "/api/storefront/v1/carts/{$cartId}/lines/{$lineId}"), + ['cart_version' => $updatedVersion], + )->assertOk() + ->assertJsonCount(0, 'lines'); +}); + +test('returns 404 for unknown cart ids', function (): void { + $hostname = 'cart-unknown.test'; + storefrontCartApiCreateStore($hostname); + + $this->getJson( + storefrontCartApiUrl($hostname, '/api/storefront/v1/carts/999999'), + )->assertNotFound(); +}); + +test('returns 409 when cart version does not match current version', function (): void { + $hostname = 'cart-version-mismatch.test'; + $store = storefrontCartApiCreateStore($hostname); + $variant = storefrontCartApiCreateVariant($store, 'version', price: 1200); + + $cart = Cart::query()->create([ + 'store_id' => $store->id, + 'customer_id' => null, + 'currency' => 'EUR', + 'cart_version' => 3, + 'status' => 'active', + ]); + + $line = CartLine::query()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'quantity' => 1, + 'unit_price_amount' => 1200, + 'line_subtotal_amount' => 1200, + 'line_discount_amount' => 0, + 'line_total_amount' => 1200, + ]); + + $this->putJson( + storefrontCartApiUrl($hostname, "/api/storefront/v1/carts/{$cart->id}/lines/{$line->id}"), + [ + 'quantity' => 2, + 'cart_version' => 2, + ], + )->assertStatus(409); +}); + +test('returns 422 when requested quantity exceeds deny policy inventory', function (): void { + $hostname = 'cart-inventory.test'; + $store = storefrontCartApiCreateStore($hostname); + $variant = storefrontCartApiCreateVariant( + store: $store, + suffix: 'inventory', + price: 990, + quantityOnHand: 1, + policy: InventoryPolicy::Deny, + ); + + $cart = Cart::query()->create([ + 'store_id' => $store->id, + 'customer_id' => null, + 'currency' => 'EUR', + 'cart_version' => 1, + 'status' => 'active', + ]); + + $this->postJson( + storefrontCartApiUrl($hostname, "/api/storefront/v1/carts/{$cart->id}/lines"), + [ + 'variant_id' => $variant->id, + 'quantity' => 2, + 'cart_version' => 1, + ], + )->assertUnprocessable(); +}); diff --git a/tests/Feature/Api/StorefrontCatalogApiTest.php b/tests/Feature/Api/StorefrontCatalogApiTest.php new file mode 100644 index 0000000..0bd1f65 --- /dev/null +++ b/tests/Feature/Api/StorefrontCatalogApiTest.php @@ -0,0 +1,204 @@ +create([ + 'name' => 'Catalog API Org', + 'billing_email' => 'billing+catalog-api@example.test', + ]); + + $store = Store::query()->create([ + 'organization_id' => $organization->id, + 'name' => 'Catalog API Store', + 'handle' => 'catalog-api-store', + 'status' => 'active', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'UTC', + ]); + + StoreDomain::query()->create([ + 'store_id' => $store->id, + 'hostname' => $hostname, + 'type' => 'storefront', + 'is_primary' => true, + 'tls_mode' => 'managed', + 'created_at' => now(), + ]); + + return $store; +} + +function storefrontCatalogApiCreateProduct( + Store $store, + string $title, + string $handle, + string $status = 'active', +): Product { + $product = Product::query()->create([ + 'store_id' => $store->id, + 'title' => $title, + 'handle' => $handle, + 'status' => $status, + 'description_html' => null, + 'vendor' => 'Fixture', + 'product_type' => 'Fixture', + 'tags' => ['fixture'], + 'published_at' => $status === 'active' ? now() : null, + ]); + + ProductVariant::query()->create([ + 'product_id' => $product->id, + 'sku' => 'SKU-CAT-'.$handle, + 'barcode' => null, + 'price_amount' => 2499, + 'compare_at_amount' => null, + 'currency' => 'EUR', + 'weight_g' => 250, + 'requires_shipping' => true, + 'is_default' => true, + 'position' => 1, + 'status' => 'active', + ]); + + return $product; +} + +test('search returns matching active products and logs search query', function (): void { + $hostname = 'catalog-search.test'; + $store = storefrontCatalogApiCreateStore($hostname); + + $activeProduct = storefrontCatalogApiCreateProduct( + store: $store, + title: 'Blue Cotton T-Shirt', + handle: 'blue-cotton-t-shirt', + status: 'active', + ); + + storefrontCatalogApiCreateProduct( + store: $store, + title: 'Draft Cotton Prototype', + handle: 'draft-cotton-prototype', + status: 'draft', + ); + + $response = $this->getJson( + storefrontCatalogApiUrl($hostname, '/api/storefront/v1/search?query=cotton'), + ); + + $response->assertOk() + ->assertJsonFragment(['id' => $activeProduct->id, 'handle' => $activeProduct->handle]) + ->assertJsonMissing(['handle' => 'draft-cotton-prototype']); + + $this->assertDatabaseHas('search_queries', [ + 'store_id' => $store->id, + 'query' => 'cotton', + ]); +}); + +test('search returns empty data for unknown terms', function (): void { + $hostname = 'catalog-search-empty.test'; + $store = storefrontCatalogApiCreateStore($hostname); + storefrontCatalogApiCreateProduct($store, 'Blue Cotton T-Shirt', 'blue-cotton', 'active'); + + $response = $this->getJson( + storefrontCatalogApiUrl($hostname, '/api/storefront/v1/search?query=zznonexistentzz'), + ); + + $response->assertOk() + ->assertJsonPath('data', []); + + $this->assertDatabaseHas('search_queries', [ + 'store_id' => $store->id, + 'query' => 'zznonexistentzz', + 'results_count' => 0, + ]); +}); + +test('analytics ingestion accepts valid event batches and stores events', function (): void { + $hostname = 'catalog-analytics-valid.test'; + $store = storefrontCatalogApiCreateStore($hostname); + + $response = $this->postJson( + storefrontCatalogApiUrl($hostname, '/api/storefront/v1/analytics/events'), + [ + 'events' => [ + [ + 'type' => 'page_view', + 'session_id' => 'sess_abc123', + 'client_event_id' => 'evt_001', + 'properties' => [ + 'url' => '/products/blue-cotton-t-shirt', + ], + 'occurred_at' => now()->toIso8601String(), + ], + [ + 'type' => 'add_to_cart', + 'session_id' => 'sess_abc123', + 'client_event_id' => 'evt_002', + 'properties' => [ + 'product_id' => 10, + 'variant_id' => 11, + 'quantity' => 1, + ], + 'occurred_at' => now()->toIso8601String(), + ], + ], + ], + ); + + $response->assertStatus(202) + ->assertJsonPath('accepted', 2) + ->assertJsonPath('rejected', 0); + + $this->assertDatabaseHas('analytics_events', [ + 'store_id' => $store->id, + 'type' => 'page_view', + 'client_event_id' => 'evt_001', + ]); +}); + +test('analytics ingestion rejects invalid event payloads', function (): void { + $hostname = 'catalog-analytics-invalid.test'; + storefrontCatalogApiCreateStore($hostname); + + $this->postJson( + storefrontCatalogApiUrl($hostname, '/api/storefront/v1/analytics/events'), + [ + 'events' => [ + [ + 'type' => 'unknown_type', + 'session_id' => 'sess_abc123', + 'client_event_id' => 'evt_invalid', + 'properties' => [], + 'occurred_at' => now()->toIso8601String(), + ], + ], + ], + )->assertUnprocessable(); +}); diff --git a/tests/Feature/Api/StorefrontCheckoutApiTest.php b/tests/Feature/Api/StorefrontCheckoutApiTest.php new file mode 100644 index 0000000..825dd9f --- /dev/null +++ b/tests/Feature/Api/StorefrontCheckoutApiTest.php @@ -0,0 +1,676 @@ +create([ + 'name' => 'Checkout API Org', + 'billing_email' => 'billing+checkout-api@example.test', + ]); + + $store = Store::query()->create([ + 'organization_id' => $organization->id, + 'name' => 'Checkout API Store', + 'handle' => 'checkout-api-store', + 'status' => 'active', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'UTC', + ]); + + StoreDomain::query()->create([ + 'store_id' => $store->id, + 'hostname' => $hostname, + 'type' => 'storefront', + 'is_primary' => true, + 'tls_mode' => 'managed', + 'created_at' => now(), + ]); + + return $store; +} + +function storefrontCheckoutApiCreateVariant( + Store $store, + string $suffix, + int $price = 2500, + bool $requiresShipping = true, +): ProductVariant { + $product = Product::query()->create([ + 'store_id' => $store->id, + 'title' => 'Checkout Product '.$suffix, + 'handle' => 'checkout-product-'.$suffix, + 'status' => 'active', + 'description_html' => null, + 'vendor' => null, + 'product_type' => null, + 'tags' => [], + 'published_at' => now(), + ]); + + $variant = ProductVariant::query()->create([ + 'product_id' => $product->id, + 'sku' => 'SKU-CHECKOUT-'.$suffix, + 'barcode' => null, + 'price_amount' => $price, + 'compare_at_amount' => null, + 'currency' => 'EUR', + 'weight_g' => 250, + 'requires_shipping' => $requiresShipping, + 'is_default' => true, + 'position' => 1, + 'status' => 'active', + ]); + + InventoryItem::query()->create([ + 'store_id' => $store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 50, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]); + + return $variant; +} + +function storefrontCheckoutApiCreateCart(Store $store, ProductVariant $variant, int $quantity = 2): Cart +{ + $cart = Cart::query()->create([ + 'store_id' => $store->id, + 'customer_id' => null, + 'currency' => 'EUR', + 'cart_version' => 1, + 'status' => 'active', + ]); + + $lineSubtotal = $variant->price_amount * $quantity; + + CartLine::query()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'quantity' => $quantity, + 'unit_price_amount' => $variant->price_amount, + 'line_subtotal_amount' => $lineSubtotal, + 'line_discount_amount' => 0, + 'line_total_amount' => $lineSubtotal, + ]); + + return $cart; +} + +function storefrontCheckoutApiCreateShippingRate(Store $store): ShippingRate +{ + $zone = ShippingZone::query()->create([ + 'store_id' => $store->id, + 'name' => 'Germany', + 'countries_json' => ['DE'], + 'regions_json' => [], + ]); + + return ShippingRate::query()->create([ + 'zone_id' => $zone->id, + 'name' => 'Standard Shipping', + 'type' => 'flat', + 'config_json' => ['price_amount' => 500, 'currency' => 'EUR'], + 'is_active' => true, + ]); +} + +function storefrontCheckoutApiCreateDiscount(Store $store, string $code = 'WELCOME10'): Discount +{ + return Discount::query()->create([ + 'store_id' => $store->id, + 'type' => DiscountType::Code, + 'code' => $code, + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + 'starts_at' => now()->subDay(), + 'ends_at' => now()->addDay(), + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => [], + 'status' => DiscountStatus::Active, + ]); +} + +function storefrontCheckoutApiAddressPayload(): array +{ + return [ + 'shipping_address' => [ + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'address1' => '123 Main St', + 'address2' => 'Apt 4B', + 'city' => 'Berlin', + 'province' => 'Berlin', + 'province_code' => 'BE', + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => '10115', + 'phone' => '+49301234567', + ], + 'billing_address' => null, + 'use_shipping_as_billing' => true, + ]; +} + +function storefrontCheckoutApiPayPayload(): array +{ + return [ + 'payment_method' => 'credit_card', + 'card_number' => '4242424242424242', + 'card_expiry' => '12/28', + 'card_cvc' => '123', + 'card_holder' => 'Jane Doe', + ]; +} + +function storefrontCheckoutApiAssertOrderDiscountAllocations(Order $order, Discount $discount): void +{ + $order->loadMissing('lines'); + expect($order->lines->count())->toBeGreaterThan(0); + + $allocatedDiscountAmount = 0; + $linesWithAllocations = 0; + + foreach ($order->lines as $line) { + $allocations = $line->discount_allocations_json; + + expect($allocations)->toBeArray(); + + if ($allocations === []) { + continue; + } + + $linesWithAllocations++; + + foreach ($allocations as $allocation) { + expect(is_array($allocation))->toBeTrue(); + expect(array_key_exists('discount_id', $allocation))->toBeTrue(); + expect(array_key_exists('code', $allocation))->toBeTrue(); + expect(array_key_exists('amount', $allocation))->toBeTrue(); + expect($allocation['discount_id'])->toBe((int) $discount->id); + expect($allocation['code'])->toBe((string) $discount->code); + expect(is_int($allocation['amount']))->toBeTrue(); + expect($allocation['amount'])->toBeGreaterThan(0); + + $allocatedDiscountAmount += $allocation['amount']; + } + } + + expect($linesWithAllocations)->toBeGreaterThan(0); + expect($allocatedDiscountAmount)->toBe((int) $order->discount_amount); +} + +test('supports checkout transition flow from started to completed', function (): void { + $hostname = 'checkout-flow.test'; + $store = storefrontCheckoutApiCreateStore($hostname); + $variant = storefrontCheckoutApiCreateVariant($store, 'flow'); + $cart = storefrontCheckoutApiCreateCart($store, $variant); + $shippingRate = storefrontCheckoutApiCreateShippingRate($store); + + $createCheckoutResponse = $this->postJson( + storefrontCheckoutApiUrl($hostname, '/api/storefront/v1/checkouts'), + [ + 'cart_id' => $cart->id, + 'email' => 'buyer@example.test', + ], + ); + + $createCheckoutResponse->assertCreated() + ->assertJsonPath('status', 'started'); + + $checkoutId = (int) $createCheckoutResponse->json('id'); + + $this->putJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}/address"), + storefrontCheckoutApiAddressPayload(), + )->assertOk() + ->assertJsonPath('status', 'addressed'); + + $this->putJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}/shipping-method"), + ['shipping_method_id' => $shippingRate->id], + )->assertOk() + ->assertJsonPath('status', 'shipping_selected'); + + $this->putJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}/payment-method"), + ['payment_method' => 'credit_card'], + )->assertOk() + ->assertJsonPath('status', 'payment_selected'); + + $this->postJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}/pay"), + [ + 'payment_method' => 'credit_card', + 'card_number' => '4242424242424242', + 'card_expiry' => '12/28', + 'card_cvc' => '123', + 'card_holder' => 'Jane Doe', + ], + )->assertOk() + ->assertJsonPath('status', 'completed'); + + $this->assertDatabaseHas('orders', [ + 'store_id' => $store->id, + 'checkout_id' => $checkoutId, + ]); + + $inventoryItem = InventoryItem::query() + ->where('variant_id', $variant->id) + ->firstOrFail(); + + expect((int) $inventoryItem->quantity_on_hand)->toBe(48); + expect((int) $inventoryItem->quantity_reserved)->toBe(0); +}); + +test('completing checkout with a valid discount increments usage count and stores line allocations', function (): void { + $hostname = 'checkout-discount-completion.test'; + $store = storefrontCheckoutApiCreateStore($hostname); + $primaryVariant = storefrontCheckoutApiCreateVariant($store, 'discount-completion-a', 2500); + $secondaryVariant = storefrontCheckoutApiCreateVariant($store, 'discount-completion-b', 1500); + $cart = storefrontCheckoutApiCreateCart($store, $primaryVariant, 1); + $shippingRate = storefrontCheckoutApiCreateShippingRate($store); + $discount = storefrontCheckoutApiCreateDiscount($store, 'WELCOME10'); + $discount->rules_json = [ + 'applicable_product_ids' => [$primaryVariant->product_id], + ]; + $discount->save(); + + CartLine::query()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $secondaryVariant->id, + 'quantity' => 2, + 'unit_price_amount' => $secondaryVariant->price_amount, + 'line_subtotal_amount' => $secondaryVariant->price_amount * 2, + 'line_discount_amount' => 0, + 'line_total_amount' => $secondaryVariant->price_amount * 2, + ]); + + $checkoutId = (int) $this->postJson( + storefrontCheckoutApiUrl($hostname, '/api/storefront/v1/checkouts'), + [ + 'cart_id' => $cart->id, + 'email' => 'buyer@example.test', + ], + )->assertCreated()->json('id'); + + $this->putJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}/address"), + storefrontCheckoutApiAddressPayload(), + )->assertOk(); + + $this->putJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}/shipping-method"), + ['shipping_method_id' => $shippingRate->id], + )->assertOk(); + + $this->putJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}/payment-method"), + ['payment_method' => 'credit_card'], + )->assertOk(); + + $this->postJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}/apply-discount"), + ['code' => 'WELCOME10'], + )->assertOk() + ->assertJsonPath('discount_code', 'WELCOME10'); + + $paymentResponse = $this->postJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}/pay"), + storefrontCheckoutApiPayPayload(), + )->assertOk() + ->assertJsonPath('status', 'completed'); + + $orderId = (int) $paymentResponse->json('order.id'); + + $discount->refresh(); + expect((int) $discount->usage_count)->toBe(1); + + $order = Order::query() + ->where('id', $orderId) + ->where('checkout_id', $checkoutId) + ->firstOrFail(); + + expect((int) $order->discount_amount)->toBeGreaterThan(0); + + storefrontCheckoutApiAssertOrderDiscountAllocations($order, $discount); + + $order->loadMissing('lines'); + $primaryLine = $order->lines->firstWhere('variant_id', $primaryVariant->id); + $secondaryLine = $order->lines->firstWhere('variant_id', $secondaryVariant->id); + + expect($primaryLine)->not->toBeNull(); + expect($secondaryLine)->not->toBeNull(); + expect(is_array($primaryLine?->discount_allocations_json))->toBeTrue(); + expect($primaryLine?->discount_allocations_json)->not->toBeEmpty(); + expect($secondaryLine?->discount_allocations_json)->toBe([]); +}); + +test('pay succeeds when an applied discount becomes invalid before payment', function (): void { + $hostname = 'checkout-discount-invalidated-on-pay.test'; + $store = storefrontCheckoutApiCreateStore($hostname); + $variant = storefrontCheckoutApiCreateVariant($store, 'discount-invalidated', 2500); + $cart = storefrontCheckoutApiCreateCart($store, $variant, 1); + $shippingRate = storefrontCheckoutApiCreateShippingRate($store); + $discount = storefrontCheckoutApiCreateDiscount($store, 'WELCOME10'); + + $checkoutId = (int) $this->postJson( + storefrontCheckoutApiUrl($hostname, '/api/storefront/v1/checkouts'), + [ + 'cart_id' => $cart->id, + 'email' => 'buyer@example.test', + ], + )->assertCreated()->json('id'); + + $this->putJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}/address"), + storefrontCheckoutApiAddressPayload(), + )->assertOk(); + + $this->putJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}/shipping-method"), + ['shipping_method_id' => $shippingRate->id], + )->assertOk(); + + $this->putJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}/payment-method"), + ['payment_method' => 'credit_card'], + )->assertOk(); + + $this->postJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}/apply-discount"), + ['code' => 'WELCOME10'], + )->assertOk() + ->assertJsonPath('discount_code', 'WELCOME10'); + + $discount->rules_json = [ + 'applicable_product_ids' => [999999], + ]; + $discount->save(); + + $paymentResponse = $this->postJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}/pay"), + storefrontCheckoutApiPayPayload(), + )->assertOk() + ->assertJsonPath('status', 'completed'); + + $orderId = (int) $paymentResponse->json('order.id'); + + $discount->refresh(); + expect((int) $discount->usage_count)->toBe(0); + + $checkout = Checkout::query()->findOrFail($checkoutId); + expect($checkout->discount_code)->toBeNull(); + + $order = Order::query() + ->whereKey($orderId) + ->where('checkout_id', $checkoutId) + ->firstOrFail(); + + expect((int) $order->discount_amount)->toBe(0); + + $order->loadMissing('lines'); + + foreach ($order->lines as $line) { + expect($line->discount_allocations_json)->toBe([]); + } +}); + +test('pay endpoint is idempotent for repeated requests on the same checkout', function (): void { + $hostname = 'checkout-idempotent-pay.test'; + $store = storefrontCheckoutApiCreateStore($hostname); + $variant = storefrontCheckoutApiCreateVariant($store, 'idempotent'); + $cart = storefrontCheckoutApiCreateCart($store, $variant); + $shippingRate = storefrontCheckoutApiCreateShippingRate($store); + $discount = storefrontCheckoutApiCreateDiscount($store, 'WELCOME10'); + + $checkoutId = (int) $this->postJson( + storefrontCheckoutApiUrl($hostname, '/api/storefront/v1/checkouts'), + [ + 'cart_id' => $cart->id, + 'email' => 'buyer@example.test', + ], + )->assertCreated()->json('id'); + + $this->putJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}/address"), + storefrontCheckoutApiAddressPayload(), + )->assertOk(); + + $this->putJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}/shipping-method"), + ['shipping_method_id' => $shippingRate->id], + )->assertOk(); + + $this->putJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}/payment-method"), + ['payment_method' => 'credit_card'], + )->assertOk(); + + $this->postJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}/apply-discount"), + ['code' => 'WELCOME10'], + )->assertOk() + ->assertJsonPath('discount_code', 'WELCOME10'); + + $firstPayment = $this->postJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}/pay"), + storefrontCheckoutApiPayPayload(), + )->assertOk(); + + $firstOrderId = (int) $firstPayment->json('order.id'); + + $discount->refresh(); + expect((int) $discount->usage_count)->toBe(1); + + $this->postJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}/pay"), + storefrontCheckoutApiPayPayload(), + )->assertOk() + ->assertJsonPath('status', 'completed') + ->assertJsonPath('order.id', $firstOrderId); + + $discount->refresh(); + expect((int) $discount->usage_count)->toBe(1); + + $order = Order::query() + ->where('id', $firstOrderId) + ->where('checkout_id', $checkoutId) + ->firstOrFail(); + + expect((int) $order->discount_amount)->toBeGreaterThan(0); + + storefrontCheckoutApiAssertOrderDiscountAllocations($order, $discount); + + expect(Order::query() + ->where('store_id', $store->id) + ->where('checkout_id', $checkoutId) + ->count())->toBe(1); +}); + +test('returns 409 for invalid checkout state transition', function (): void { + $hostname = 'checkout-invalid-transition.test'; + $store = storefrontCheckoutApiCreateStore($hostname); + $variant = storefrontCheckoutApiCreateVariant($store, 'invalid-transition'); + $cart = storefrontCheckoutApiCreateCart($store, $variant); + + $checkout = Checkout::query()->create([ + 'store_id' => $store->id, + 'cart_id' => $cart->id, + 'customer_id' => null, + 'status' => 'started', + 'payment_method' => null, + 'email' => 'buyer@example.test', + 'shipping_address_json' => null, + 'billing_address_json' => null, + 'shipping_method_id' => null, + 'discount_code' => null, + 'tax_provider_snapshot_json' => null, + 'totals_json' => null, + 'expires_at' => null, + ]); + + $this->postJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkout->id}/pay"), + ['payment_method' => 'credit_card'], + )->assertStatus(409); +}); + +test('expiring a payment selected checkout releases reserved inventory', function (): void { + $hostname = 'checkout-expiry-releases-inventory.test'; + $store = storefrontCheckoutApiCreateStore($hostname); + $variant = storefrontCheckoutApiCreateVariant($store, 'expiry-release', 2500); + $cart = storefrontCheckoutApiCreateCart($store, $variant, 1); + $shippingRate = storefrontCheckoutApiCreateShippingRate($store); + + $checkoutId = (int) $this->postJson( + storefrontCheckoutApiUrl($hostname, '/api/storefront/v1/checkouts'), + [ + 'cart_id' => $cart->id, + 'email' => 'buyer@example.test', + ], + )->assertCreated()->json('id'); + + $this->putJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}/address"), + storefrontCheckoutApiAddressPayload(), + )->assertOk(); + + $this->putJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}/shipping-method"), + ['shipping_method_id' => $shippingRate->id], + )->assertOk(); + + $this->putJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}/payment-method"), + ['payment_method' => 'credit_card'], + )->assertOk() + ->assertJsonPath('status', 'payment_selected'); + + $inventoryItem = InventoryItem::query() + ->where('variant_id', $variant->id) + ->firstOrFail(); + + expect((int) $inventoryItem->quantity_reserved)->toBe(1); + + Checkout::query()->whereKey($checkoutId)->update([ + 'expires_at' => now()->subMinute(), + ]); + + $this->getJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkoutId}"), + )->assertStatus(422) + ->assertJsonPath('error_code', 'checkout_expired'); + + $inventoryItem->refresh(); + + expect((int) $inventoryItem->quantity_reserved)->toBe(0); + + $checkout = Checkout::query()->findOrFail($checkoutId); + expect((string) $checkout->status->value)->toBe('expired'); +}); + +test('applies a valid discount code to checkout', function (): void { + $hostname = 'checkout-discount-valid.test'; + $store = storefrontCheckoutApiCreateStore($hostname); + $variant = storefrontCheckoutApiCreateVariant($store, 'discount-valid'); + $cart = storefrontCheckoutApiCreateCart($store, $variant); + $shippingRate = storefrontCheckoutApiCreateShippingRate($store); + storefrontCheckoutApiCreateDiscount($store, 'WELCOME10'); + + $checkout = Checkout::query()->create([ + 'store_id' => $store->id, + 'cart_id' => $cart->id, + 'customer_id' => null, + 'status' => 'shipping_selected', + 'payment_method' => null, + 'email' => 'buyer@example.test', + 'shipping_address_json' => storefrontCheckoutApiAddressPayload()['shipping_address'], + 'billing_address_json' => storefrontCheckoutApiAddressPayload()['shipping_address'], + 'shipping_method_id' => $shippingRate->id, + 'discount_code' => null, + 'tax_provider_snapshot_json' => null, + 'totals_json' => null, + 'expires_at' => now()->addDay(), + ]); + + $this->postJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkout->id}/apply-discount"), + ['code' => 'WELCOME10'], + )->assertOk() + ->assertJsonPath('discount_code', 'WELCOME10'); +}); + +test('returns 422 for invalid discount code', function (): void { + $hostname = 'checkout-discount-invalid.test'; + $store = storefrontCheckoutApiCreateStore($hostname); + $variant = storefrontCheckoutApiCreateVariant($store, 'discount-invalid'); + $cart = storefrontCheckoutApiCreateCart($store, $variant); + + $checkout = Checkout::query()->create([ + 'store_id' => $store->id, + 'cart_id' => $cart->id, + 'customer_id' => null, + 'status' => 'shipping_selected', + 'payment_method' => null, + 'email' => 'buyer@example.test', + 'shipping_address_json' => storefrontCheckoutApiAddressPayload()['shipping_address'], + 'billing_address_json' => storefrontCheckoutApiAddressPayload()['shipping_address'], + 'shipping_method_id' => 1, + 'discount_code' => null, + 'tax_provider_snapshot_json' => null, + 'totals_json' => null, + 'expires_at' => now()->addDay(), + ]); + + $this->postJson( + storefrontCheckoutApiUrl($hostname, "/api/storefront/v1/checkouts/{$checkout->id}/apply-discount"), + ['code' => 'INVALID'], + )->assertUnprocessable(); +}); + +test('returns 404 for unknown checkout ids', function (): void { + $hostname = 'checkout-unknown.test'; + storefrontCheckoutApiCreateStore($hostname); + + $this->getJson( + storefrontCheckoutApiUrl($hostname, '/api/storefront/v1/checkouts/999999'), + )->assertNotFound(); +}); diff --git a/tests/Feature/Auth/AdminAuthTest.php b/tests/Feature/Auth/AdminAuthTest.php new file mode 100644 index 0000000..be08c8f --- /dev/null +++ b/tests/Feature/Auth/AdminAuthTest.php @@ -0,0 +1,61 @@ +group(function (): void { + Route::get('/__admin/auth-smoke', static fn () => response()->noContent()) + ->middleware('auth:web'); + + Route::post('/__admin/login-attempt', static fn () => response()->noContent()) + ->middleware('throttle:login'); + }); +}); + +function adminAuthCreateUser(): User +{ + /** @var User $user */ + $user = User::query()->forceCreate([ + 'name' => 'Admin User', + 'email' => 'admin-'.Str::lower(Str::random(8)).'@example.test', + 'password_hash' => Hash::make('password'), + 'status' => 'active', + 'email_verified_at' => now(), + ]); + + return $user; +} + +test('admin protected endpoint requires authentication', function (): void { + $this->get('/__admin/auth-smoke') + ->assertRedirect(route('login', absolute: false)); +}); + +test('authenticated admin user can access admin protected endpoint', function (): void { + $admin = adminAuthCreateUser(); + + $this->actingAs($admin, 'web') + ->get('/__admin/auth-smoke') + ->assertNoContent(); +}); + +test('rate limits repeated admin login attempts to five per minute', function (): void { + for ($attempt = 1; $attempt <= 5; $attempt++) { + $this->post('/__admin/login-attempt', [ + 'email' => 'admin@example.test', + ])->assertNoContent(); + } + + $this->post('/__admin/login-attempt', [ + 'email' => 'admin@example.test', + ])->assertStatus(429); +}); diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index fff11fd..6863270 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -66,4 +66,4 @@ $response->assertRedirect(route('home')); $this->assertGuest(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/CustomerAuthTest.php b/tests/Feature/Auth/CustomerAuthTest.php new file mode 100644 index 0000000..4e5dcb4 --- /dev/null +++ b/tests/Feature/Auth/CustomerAuthTest.php @@ -0,0 +1,43 @@ +group(function (): void { + Route::get('/__account/auth-smoke', static fn () => response()->noContent()) + ->middleware('auth:customer'); + + Route::post('/__account/login-attempt', static fn () => response()->noContent()) + ->middleware('throttle:login'); + }); +}); + +test('customer auth guard is configured', function (): void { + expect(config('auth.guards.customer'))->toBeArray(); + expect(config('auth.guards.customer.driver'))->toBe('session'); + expect(config('auth.guards.customer.provider'))->toBe('customers'); + expect(config('auth.providers.customers'))->toBeArray(); +}); + +test('customer protected endpoint redirects guests to storefront login', function (): void { + $this->get('/__account/auth-smoke') + ->assertRedirect('/account/login'); +}); + +test('rate limits repeated customer login attempts to five per minute', function (): void { + for ($attempt = 1; $attempt <= 5; $attempt++) { + $this->post('/__account/login-attempt', [ + 'email' => 'customer@example.test', + ])->assertNoContent(); + } + + $this->post('/__account/login-attempt', [ + 'email' => 'customer@example.test', + ])->assertStatus(429); +}); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php index 66f58e3..c8ea4ec 100644 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -66,4 +66,4 @@ expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); Event::assertNotDispatched(Verified::class); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php index f42a259..997196f 100644 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ b/tests/Feature/Auth/PasswordConfirmationTest.php @@ -10,4 +10,4 @@ $response = $this->actingAs($user)->get(route('password.confirm')); $response->assertOk(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php index bea7825..9972118 100644 --- a/tests/Feature/Auth/PasswordResetTest.php +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -58,4 +58,4 @@ return true; }); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index c22ea5e..144036c 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -20,4 +20,4 @@ ->assertRedirect(route('dashboard', absolute: false)); $this->assertAuthenticated(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/TwoFactorChallengeTest.php b/tests/Feature/Auth/TwoFactorChallengeTest.php index cda794f..a2ce0cd 100644 --- a/tests/Feature/Auth/TwoFactorChallengeTest.php +++ b/tests/Feature/Auth/TwoFactorChallengeTest.php @@ -31,4 +31,4 @@ 'email' => $user->email, 'password' => 'password', ])->assertRedirect(route('two-factor.login')); -}); \ No newline at end of file +}); diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php index fcd0258..412a103 100644 --- a/tests/Feature/DashboardTest.php +++ b/tests/Feature/DashboardTest.php @@ -15,4 +15,4 @@ $response = $this->get(route('dashboard')); $response->assertOk(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 8b5843f..5a208d8 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -1,7 +1,21 @@ get('/'); + $store = Store::factory()->create(); + + StoreDomain::factory()->create([ + 'store_id' => $store->id, + 'hostname' => 'shop.test', + ]); + + $response = $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->get('/'); $response->assertStatus(200); }); diff --git a/tests/Feature/Jobs/ExpireAbandonedCheckoutsTest.php b/tests/Feature/Jobs/ExpireAbandonedCheckoutsTest.php new file mode 100644 index 0000000..dce4160 --- /dev/null +++ b/tests/Feature/Jobs/ExpireAbandonedCheckoutsTest.php @@ -0,0 +1,135 @@ +create([ + 'default_currency' => 'EUR', + ]); + + $product = Product::factory()->create([ + 'store_id' => $store->id, + ]); + + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'price_amount' => 2500, + 'currency' => 'EUR', + 'requires_shipping' => true, + ]); + + $inventoryItem = InventoryItem::query()->create([ + 'store_id' => $store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 10, + 'quantity_reserved' => 1, + 'policy' => 'deny', + ]); + + $cart = Cart::factory()->create([ + 'store_id' => $store->id, + 'customer_id' => null, + 'currency' => 'EUR', + 'status' => 'active', + ]); + + CartLine::query()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'quantity' => 1, + 'unit_price_amount' => 2500, + 'line_subtotal_amount' => 2500, + 'line_discount_amount' => 0, + 'line_total_amount' => 2500, + ]); + + $checkout = Checkout::query()->create([ + 'store_id' => $store->id, + 'cart_id' => $cart->id, + 'customer_id' => null, + 'status' => CheckoutStatus::PaymentSelected, + 'payment_method' => 'credit_card', + 'email' => 'buyer@example.test', + 'shipping_address_json' => [ + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'address1' => 'Main Street 1', + 'city' => 'Berlin', + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => '10115', + ], + 'billing_address_json' => [ + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'address1' => 'Main Street 1', + 'city' => 'Berlin', + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => '10115', + ], + 'shipping_method_id' => null, + 'discount_code' => null, + 'tax_provider_snapshot_json' => null, + 'totals_json' => null, + 'expires_at' => $expired ? now()->subMinute() : now()->addHour(), + ]); + + return [ + 'checkout' => $checkout, + 'inventory_item' => $inventoryItem, + ]; +} + +test('job expires overdue payment selected checkouts and releases reserved inventory', function (): void { + $fixture = createExpirableCheckoutFixture(); + + app(ExpireAbandonedCheckouts::class)->handle(app(CheckoutService::class)); + + $fixture['checkout']->refresh(); + $fixture['inventory_item']->refresh(); + + expect($fixture['checkout']->status)->toBe(CheckoutStatus::Expired) + ->and((int) $fixture['inventory_item']->quantity_reserved)->toBe(0) + ->and((int) $fixture['inventory_item']->quantity_on_hand)->toBe(10); +}); + +test('job does not touch active checkouts that are not expired', function (): void { + $fixture = createExpirableCheckoutFixture(expired: false); + + app(ExpireAbandonedCheckouts::class)->handle(app(CheckoutService::class)); + + $fixture['checkout']->refresh(); + $fixture['inventory_item']->refresh(); + + expect($fixture['checkout']->status)->toBe(CheckoutStatus::PaymentSelected) + ->and((int) $fixture['inventory_item']->quantity_reserved)->toBe(1) + ->and((int) $fixture['inventory_item']->quantity_on_hand)->toBe(10); +}); + +test('scheduler registers the abandoned checkout expiration task', function (): void { + Artisan::call('schedule:list'); + + $output = Artisan::output(); + + expect($output)->toContain('expire-abandoned-checkouts'); +}); diff --git a/tests/Feature/Settings/PasswordUpdateTest.php b/tests/Feature/Settings/PasswordUpdateTest.php index a6379b2..759e3b2 100644 --- a/tests/Feature/Settings/PasswordUpdateTest.php +++ b/tests/Feature/Settings/PasswordUpdateTest.php @@ -39,4 +39,4 @@ ->call('updatePassword'); $response->assertHasErrors(['current_password']); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php index 276e9fe..fa5f185 100644 --- a/tests/Feature/Settings/ProfileUpdateTest.php +++ b/tests/Feature/Settings/ProfileUpdateTest.php @@ -75,4 +75,4 @@ $response->assertHasErrors(['password']); expect($user->fresh())->not->toBeNull(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Settings/TwoFactorAuthenticationTest.php b/tests/Feature/Settings/TwoFactorAuthenticationTest.php index e2d530f..b57a320 100644 --- a/tests/Feature/Settings/TwoFactorAuthenticationTest.php +++ b/tests/Feature/Settings/TwoFactorAuthenticationTest.php @@ -69,4 +69,4 @@ 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, ]); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Storefront/StorefrontWebPagesTest.php b/tests/Feature/Storefront/StorefrontWebPagesTest.php new file mode 100644 index 0000000..e783c88 --- /dev/null +++ b/tests/Feature/Storefront/StorefrontWebPagesTest.php @@ -0,0 +1,1055 @@ +create([ + 'name' => 'Acme Fashion', + 'handle' => 'acme-fashion', + 'default_currency' => 'EUR', + ]); + + StoreDomain::factory()->create([ + 'store_id' => $store->id, + 'hostname' => 'shop.test', + ]); + + $collection = Collection::factory()->create([ + 'store_id' => $store->id, + 'title' => 'T-Shirts', + 'handle' => 't-shirts', + 'status' => CollectionStatus::Active, + ]); + + $product = Product::factory()->create([ + 'store_id' => $store->id, + 'title' => 'Classic Cotton T-Shirt', + 'handle' => 'classic-cotton-t-shirt', + 'status' => ProductStatus::Active, + 'published_at' => now(), + ]); + + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->id, + 'is_default' => true, + 'status' => ProductVariantStatus::Active, + 'price_amount' => 2499, + 'currency' => 'EUR', + 'sku' => 'TSHIRT-001', + ]); + + $collection->products()->attach($product->id, ['position' => 1]); + + $page = Page::query()->create([ + 'store_id' => $store->id, + 'title' => 'About', + 'handle' => 'about', + 'body_html' => '

About Acme Fashion

', + 'status' => PageStatus::Published, + 'published_at' => now(), + ]); + + $customer = Customer::factory()->create([ + 'store_id' => $store->id, + 'name' => 'John Doe', + 'email' => 'customer@acme.test', + 'password_hash' => Hash::make('password'), + ]); + + CustomerAddress::query()->create([ + 'customer_id' => $customer->id, + 'label' => 'Home', + 'address_json' => [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'address1' => 'Main Street 1', + 'city' => 'Berlin', + 'country_code' => 'DE', + 'postal_code' => '10115', + ], + 'is_default' => true, + ]); + + $cart = Cart::factory()->create([ + 'store_id' => $store->id, + 'customer_id' => $customer->id, + 'currency' => 'EUR', + ]); + + CartLine::query()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'quantity' => 1, + 'unit_price_amount' => 2499, + 'line_subtotal_amount' => 2499, + 'line_discount_amount' => 0, + 'line_total_amount' => 2499, + ]); + + $checkout = Checkout::query()->create([ + 'store_id' => $store->id, + 'cart_id' => $cart->id, + 'customer_id' => $customer->id, + 'status' => 'completed', + 'payment_method' => 'credit_card', + 'email' => $customer->email, + 'totals_json' => [ + 'subtotal' => 2499, + 'discount' => 0, + 'shipping' => 499, + 'tax' => 380, + 'total' => 3378, + 'currency' => 'EUR', + ], + 'expires_at' => now()->addHour(), + ]); + + $order = Order::factory()->create([ + 'store_id' => $store->id, + 'customer_id' => $customer->id, + 'order_number' => '#1001', + 'email' => $customer->email, + 'currency' => 'EUR', + 'total_amount' => 3378, + ]); + + OrderLine::query()->create([ + 'order_id' => $order->id, + 'product_id' => $product->id, + 'variant_id' => $variant->id, + 'title_snapshot' => $product->title, + 'sku_snapshot' => $variant->sku, + 'quantity' => 1, + 'unit_price_amount' => 2499, + 'total_amount' => 2499, + 'tax_lines_json' => [], + 'discount_allocations_json' => [], + ]); + + return [ + 'store' => $store, + 'product' => $product, + 'variant' => $variant, + 'collection' => $collection, + 'page' => $page, + 'customer' => $customer, + 'cart' => $cart, + 'checkout' => $checkout, + 'order' => $order, + ]; +} + +function createStorefrontShippingRate(Store $store): ShippingRate +{ + $zone = ShippingZone::query()->create([ + 'store_id' => $store->id, + 'name' => 'Germany', + 'countries_json' => ['DE'], + 'regions_json' => [], + ]); + + return ShippingRate::query()->create([ + 'zone_id' => $zone->id, + 'name' => 'Standard Shipping', + 'type' => 'flat', + 'config_json' => ['amount' => 500, 'currency' => 'EUR'], + 'is_active' => true, + ]); +} + +function createStorefrontDiscount(Store $store, string $code = 'WELCOME10'): Discount +{ + return Discount::query()->create([ + 'store_id' => $store->id, + 'type' => DiscountType::Code, + 'code' => $code, + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + 'starts_at' => now()->subDay(), + 'ends_at' => now()->addDay(), + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => [], + 'status' => DiscountStatus::Active, + ]); +} + +function storefrontEnumValue(mixed $value): string +{ + if ($value instanceof BackedEnum) { + return (string) $value->value; + } + + return (string) $value; +} + +function storefrontAssertOrderDiscountAllocations(Order $order, Discount $discount): void +{ + $order->loadMissing('lines'); + expect($order->lines->count())->toBeGreaterThan(0); + + $allocatedDiscountAmount = 0; + $linesWithAllocations = 0; + + foreach ($order->lines as $line) { + $allocations = $line->discount_allocations_json; + + expect($allocations)->toBeArray(); + + if ($allocations === []) { + continue; + } + + $linesWithAllocations++; + + foreach ($allocations as $allocation) { + expect(is_array($allocation))->toBeTrue(); + expect(array_key_exists('discount_id', $allocation))->toBeTrue(); + expect(array_key_exists('code', $allocation))->toBeTrue(); + expect(array_key_exists('amount', $allocation))->toBeTrue(); + expect($allocation['discount_id'])->toBe((int) $discount->id); + expect($allocation['code'])->toBe((string) $discount->code); + expect(is_int($allocation['amount']))->toBeTrue(); + expect($allocation['amount'])->toBeGreaterThan(0); + + $allocatedDiscountAmount += $allocation['amount']; + } + } + + expect($linesWithAllocations)->toBeGreaterThan(0); + expect($allocatedDiscountAmount)->toBe((int) $order->discount_amount); +} + +test('storefront core pages render with tenant host', function (): void { + $fixture = createStorefrontFixture(); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->get('/') + ->assertOk() + ->assertSee('Acme Fashion'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->get('/collections') + ->assertOk() + ->assertSee('Collections'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->get('/collections/'.$fixture['collection']->handle) + ->assertOk() + ->assertSee('T-Shirts'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->get('/products/'.$fixture['product']->handle) + ->assertOk() + ->assertSee('Classic Cotton T-Shirt'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->get('/pages/'.$fixture['page']->handle) + ->assertOk() + ->assertSee('About'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->get('/search?q=shirt') + ->assertOk() + ->assertSee('Search Products'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->get('/checkout/'.$fixture['checkout']->id) + ->assertOk() + ->assertSee('Checkout #'.$fixture['checkout']->id); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->get('/checkout/'.$fixture['checkout']->id.'/confirmation') + ->assertOk() + ->assertSee('Checkout Confirmation'); +}); + +test('customer can register and access account pages', function (): void { + createStorefrontFixture(); + + $registerResponse = $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->post('/account/register', [ + 'name' => 'New Customer', + 'email' => 'new-customer@acme.test', + 'password' => 'password123', + 'password_confirmation' => 'password123', + 'marketing_opt_in' => '1', + ]); + + $registerResponse->assertRedirect('/account'); + + $customer = Customer::query()->where('email', 'new-customer@acme.test')->first(); + expect($customer)->not->toBeNull(); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($customer, 'customer') + ->get('/account') + ->assertOk() + ->assertSee('Welcome, New Customer'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($customer, 'customer') + ->get('/account/orders') + ->assertOk() + ->assertSee('Your Orders'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($customer, 'customer') + ->get('/account/addresses') + ->assertOk() + ->assertSee('Address Book'); +}); + +test('customer can open order detail page from orders listing when order numbers contain hash prefix', function (): void { + $fixture = createStorefrontFixture(); + + $ordersResponse = $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($fixture['customer'], 'customer') + ->get('/account/orders'); + + $ordersResponse + ->assertOk() + ->assertSee('href="http://shop.test/account/orders/1001"', false) + ->assertDontSee('/account/orders/#1001'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($fixture['customer'], 'customer') + ->get('/account/orders/1001') + ->assertOk() + ->assertSee('Order #1001'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($fixture['customer'], 'customer') + ->get('/account/orders/'.urlencode((string) $fixture['order']->order_number)) + ->assertOk() + ->assertSee('Order #1001'); +}); + +test('orders page shows empty state for customers without orders', function (): void { + $fixture = createStorefrontFixture(); + + $customerWithoutOrders = Customer::factory()->create([ + 'store_id' => $fixture['store']->id, + 'name' => 'No Orders Customer', + 'email' => 'no-orders-customer@acme.test', + 'password_hash' => Hash::make('password'), + ]); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($customerWithoutOrders, 'customer') + ->get('/account/orders') + ->assertOk() + ->assertSee('No orders yet.'); +}); + +test('customer cannot view another customer order detail', function (): void { + $fixture = createStorefrontFixture(); + + $otherCustomer = Customer::factory()->create([ + 'store_id' => $fixture['store']->id, + 'name' => 'Other Customer', + 'email' => 'other-customer@acme.test', + 'password_hash' => Hash::make('password'), + ]); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($otherCustomer, 'customer') + ->get('/account/orders/1001') + ->assertNotFound(); +}); + +test('registration redirect resolves to account dashboard without redirect loops', function (): void { + createStorefrontFixture(); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->followingRedirects() + ->post('/account/register', [ + 'name' => 'Loop Check Customer', + 'email' => 'loop-check-customer@acme.test', + 'password' => 'password123', + 'password_confirmation' => 'password123', + 'marketing_opt_in' => '0', + ]) + ->assertOk() + ->assertSee('Welcome, Loop Check Customer'); +}); + +test('customer login and logout flow works', function (): void { + $fixture = createStorefrontFixture(); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->post('/account/login', [ + 'email' => $fixture['customer']->email, + 'password' => 'password', + ]) + ->assertRedirect('/account'); + + $this->assertAuthenticated('customer'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->post('/account/logout') + ->assertRedirect('/account/login'); + + $this->assertGuest('customer'); +}); + +test('product add to cart form creates a guest cart line', function (): void { + $fixture = createStorefrontFixture(); + $productPath = '/products/'.$fixture['product']->handle; + + $response = $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->from($productPath) + ->post('/cart/lines', [ + 'variant_id' => $fixture['variant']->id, + 'quantity' => 2, + ]); + + $response->assertRedirect($productPath) + ->assertSessionHas('status') + ->assertSessionHas('cart_id'); + + $cartId = (int) $response->getSession()->get('cart_id'); + + $this->assertDatabaseHas('cart_lines', [ + 'cart_id' => $cartId, + 'variant_id' => $fixture['variant']->id, + 'quantity' => 2, + ]); +}); + +test('product add to cart form returns validation errors for unknown variants', function (): void { + $fixture = createStorefrontFixture(); + $productPath = '/products/'.$fixture['product']->handle; + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->from($productPath) + ->post('/cart/lines', [ + 'variant_id' => 999999, + 'quantity' => 1, + ]) + ->assertRedirect($productPath) + ->assertSessionHasErrors('variant_id'); +}); + +test('cart line quantity update and remove forms mutate lines', function (): void { + $fixture = createStorefrontFixture(); + $line = CartLine::query()->where('cart_id', $fixture['cart']->id)->firstOrFail(); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->withSession(['cart_id' => $fixture['cart']->id]) + ->patch('/cart/lines/'.$line->id, [ + 'quantity' => 3, + 'cart_version' => (int) $fixture['cart']->cart_version, + ]) + ->assertRedirect('/cart') + ->assertSessionHas('status'); + + $this->assertDatabaseHas('cart_lines', [ + 'id' => $line->id, + 'quantity' => 3, + ]); + + $fixture['cart']->refresh(); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->withSession(['cart_id' => $fixture['cart']->id]) + ->delete('/cart/lines/'.$line->id, [ + 'cart_version' => (int) $fixture['cart']->cart_version, + ]) + ->assertRedirect('/cart') + ->assertSessionHas('status'); + + $this->assertDatabaseMissing('cart_lines', [ + 'id' => $line->id, + ]); +}); + +test('cart line remove form shows errors for unknown lines', function (): void { + $fixture = createStorefrontFixture(); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->withSession(['cart_id' => $fixture['cart']->id]) + ->delete('/cart/lines/999999', [ + 'cart_version' => (int) $fixture['cart']->cart_version, + ]) + ->assertRedirect('/cart') + ->assertSessionHasErrors('line'); +}); + +test('cart can start checkout from cart page', function (): void { + $fixture = createStorefrontFixture(); + + $response = $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->withSession(['cart_id' => $fixture['cart']->id]) + ->post('/cart/checkout'); + + $newCheckout = Checkout::query() + ->where('store_id', $fixture['store']->id) + ->where('cart_id', $fixture['cart']->id) + ->where('status', 'started') + ->latest('id') + ->first(); + + expect($newCheckout)->not->toBeNull(); + + $response->assertRedirect('/checkout/'.$newCheckout->id) + ->assertSessionHas('status'); +}); + +test('cart checkout start fails for empty carts', function (): void { + $fixture = createStorefrontFixture(); + CartLine::query()->where('cart_id', $fixture['cart']->id)->delete(); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->withSession(['cart_id' => $fixture['cart']->id]) + ->post('/cart/checkout') + ->assertRedirect('/cart') + ->assertSessionHasErrors('cart'); +}); + +test('checkout step forms can set address shipping payment discount and complete payment', function (): void { + $fixture = createStorefrontFixture(); + $shippingRate = createStorefrontShippingRate($fixture['store']); + createStorefrontDiscount($fixture['store'], 'WELCOME10'); + + $checkout = Checkout::query()->create([ + 'store_id' => $fixture['store']->id, + 'cart_id' => $fixture['cart']->id, + 'customer_id' => $fixture['customer']->id, + 'status' => 'started', + 'payment_method' => null, + 'email' => null, + 'shipping_address_json' => null, + 'billing_address_json' => null, + 'shipping_method_id' => null, + 'discount_code' => null, + 'tax_provider_snapshot_json' => null, + 'totals_json' => null, + 'expires_at' => now()->addDay(), + ]); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->put('/checkout/'.$checkout->id.'/address', [ + 'email' => 'buyer@example.test', + 'shipping_address' => [ + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'address1' => 'Main Street 1', + 'city' => 'Berlin', + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => '10115', + ], + 'use_shipping_as_billing' => '1', + ]) + ->assertRedirect('/checkout/'.$checkout->id) + ->assertSessionHas('status'); + + $checkout->refresh(); + expect(storefrontEnumValue($checkout->status))->toBe('addressed'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->put('/checkout/'.$checkout->id.'/shipping-method', [ + 'shipping_method_id' => $shippingRate->id, + ]) + ->assertRedirect('/checkout/'.$checkout->id) + ->assertSessionHas('status'); + + $checkout->refresh(); + expect(storefrontEnumValue($checkout->status))->toBe('shipping_selected'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->put('/checkout/'.$checkout->id.'/payment-method', [ + 'payment_method' => 'credit_card', + ]) + ->assertRedirect('/checkout/'.$checkout->id) + ->assertSessionHas('status'); + + $checkout->refresh(); + expect(storefrontEnumValue($checkout->status))->toBe('payment_selected'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->post('/checkout/'.$checkout->id.'/discount', [ + 'code' => 'WELCOME10', + ]) + ->assertRedirect('/checkout/'.$checkout->id) + ->assertSessionHas('status'); + + $checkout->refresh(); + expect($checkout->discount_code)->toBe('WELCOME10'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->delete('/checkout/'.$checkout->id.'/discount') + ->assertRedirect('/checkout/'.$checkout->id) + ->assertSessionHas('status'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->post('/checkout/'.$checkout->id.'/pay', [ + 'payment_method' => 'credit_card', + 'card_number' => '4242424242424242', + 'card_expiry' => '12/28', + 'card_cvc' => '123', + 'card_holder' => 'Jane Doe', + ]) + ->assertRedirect('/checkout/'.$checkout->id.'/confirmation') + ->assertSessionHas('status'); + + $checkout->refresh(); + $fixture['cart']->refresh(); + $createdOrder = Order::query()->where('checkout_id', $checkout->id)->first(); + + expect(storefrontEnumValue($checkout->status))->toBe('completed') + ->and(storefrontEnumValue($fixture['cart']->status))->toBe('converted'); + + $this->assertDatabaseHas('orders', [ + 'store_id' => $fixture['store']->id, + 'email' => 'buyer@example.test', + 'checkout_id' => $checkout->id, + ]); + + expect($createdOrder)->toBeInstanceOf(Order::class); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->get('/checkout/'.$checkout->id.'/confirmation') + ->assertOk() + ->assertSee((string) $createdOrder?->order_number); +}); + +test('checkout completion with a valid discount increments usage count and persists line allocations', function (): void { + $fixture = createStorefrontFixture(); + $shippingRate = createStorefrontShippingRate($fixture['store']); + $discount = createStorefrontDiscount($fixture['store'], 'WELCOME10'); + $discount->rules_json = [ + 'applicable_product_ids' => [$fixture['product']->id], + ]; + $discount->save(); + + $secondProduct = Product::factory()->create([ + 'store_id' => $fixture['store']->id, + 'title' => 'Structured Allocation Product', + 'handle' => 'structured-allocation-product', + 'status' => ProductStatus::Active, + 'published_at' => now(), + ]); + + $secondVariant = ProductVariant::factory()->create([ + 'product_id' => $secondProduct->id, + 'is_default' => true, + 'status' => ProductVariantStatus::Active, + 'price_amount' => 1501, + 'currency' => 'EUR', + 'sku' => 'STRUCT-ALLOC-001', + ]); + + CartLine::query()->create([ + 'cart_id' => $fixture['cart']->id, + 'variant_id' => $secondVariant->id, + 'quantity' => 1, + 'unit_price_amount' => 1501, + 'line_subtotal_amount' => 1501, + 'line_discount_amount' => 0, + 'line_total_amount' => 1501, + ]); + + $checkout = Checkout::query()->create([ + 'store_id' => $fixture['store']->id, + 'cart_id' => $fixture['cart']->id, + 'customer_id' => $fixture['customer']->id, + 'status' => 'started', + 'payment_method' => null, + 'email' => null, + 'shipping_address_json' => null, + 'billing_address_json' => null, + 'shipping_method_id' => null, + 'discount_code' => null, + 'tax_provider_snapshot_json' => null, + 'totals_json' => null, + 'expires_at' => now()->addDay(), + ]); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->put('/checkout/'.$checkout->id.'/address', [ + 'email' => 'buyer@example.test', + 'shipping_address' => [ + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'address1' => 'Main Street 1', + 'city' => 'Berlin', + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => '10115', + ], + 'use_shipping_as_billing' => '1', + ]) + ->assertRedirect('/checkout/'.$checkout->id) + ->assertSessionHas('status'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->put('/checkout/'.$checkout->id.'/shipping-method', [ + 'shipping_method_id' => $shippingRate->id, + ]) + ->assertRedirect('/checkout/'.$checkout->id) + ->assertSessionHas('status'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->put('/checkout/'.$checkout->id.'/payment-method', [ + 'payment_method' => 'credit_card', + ]) + ->assertRedirect('/checkout/'.$checkout->id) + ->assertSessionHas('status'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->post('/checkout/'.$checkout->id.'/discount', [ + 'code' => 'WELCOME10', + ]) + ->assertRedirect('/checkout/'.$checkout->id) + ->assertSessionHas('status'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->post('/checkout/'.$checkout->id.'/pay', [ + 'payment_method' => 'credit_card', + 'card_number' => '4242424242424242', + 'card_expiry' => '12/28', + 'card_cvc' => '123', + 'card_holder' => 'Jane Doe', + ]) + ->assertRedirect('/checkout/'.$checkout->id.'/confirmation') + ->assertSessionHas('status'); + + $checkout->refresh(); + $discount->refresh(); + + expect(storefrontEnumValue($checkout->status))->toBe('completed'); + expect((int) $discount->usage_count)->toBe(1); + + $order = Order::query() + ->where('checkout_id', $checkout->id) + ->firstOrFail(); + + expect((int) $order->discount_amount)->toBeGreaterThan(0); + expect((int) $order->lines()->count())->toBe(2); + + storefrontAssertOrderDiscountAllocations($order, $discount); + + $order->loadMissing('lines'); + $primaryLine = $order->lines->firstWhere('variant_id', $fixture['variant']->id); + $secondaryLine = $order->lines->firstWhere('variant_id', $secondVariant->id); + + expect($primaryLine)->not->toBeNull(); + expect($secondaryLine)->not->toBeNull(); + expect(is_array($primaryLine?->discount_allocations_json))->toBeTrue(); + expect($primaryLine?->discount_allocations_json)->not->toBeEmpty(); + expect($secondaryLine?->discount_allocations_json)->toBe([]); +}); + +test('checkout pay succeeds when applied discount becomes invalid before payment', function (): void { + $fixture = createStorefrontFixture(); + $shippingRate = createStorefrontShippingRate($fixture['store']); + $discount = createStorefrontDiscount($fixture['store'], 'WELCOME10'); + + $checkout = Checkout::query()->create([ + 'store_id' => $fixture['store']->id, + 'cart_id' => $fixture['cart']->id, + 'customer_id' => $fixture['customer']->id, + 'status' => 'started', + 'payment_method' => null, + 'email' => null, + 'shipping_address_json' => null, + 'billing_address_json' => null, + 'shipping_method_id' => null, + 'discount_code' => null, + 'tax_provider_snapshot_json' => null, + 'totals_json' => null, + 'expires_at' => now()->addDay(), + ]); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->put('/checkout/'.$checkout->id.'/address', [ + 'email' => 'buyer@example.test', + 'shipping_address' => [ + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'address1' => 'Main Street 1', + 'city' => 'Berlin', + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => '10115', + ], + 'use_shipping_as_billing' => '1', + ]) + ->assertRedirect('/checkout/'.$checkout->id) + ->assertSessionHas('status'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->put('/checkout/'.$checkout->id.'/shipping-method', [ + 'shipping_method_id' => $shippingRate->id, + ]) + ->assertRedirect('/checkout/'.$checkout->id) + ->assertSessionHas('status'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->put('/checkout/'.$checkout->id.'/payment-method', [ + 'payment_method' => 'credit_card', + ]) + ->assertRedirect('/checkout/'.$checkout->id) + ->assertSessionHas('status'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->post('/checkout/'.$checkout->id.'/discount', [ + 'code' => 'WELCOME10', + ]) + ->assertRedirect('/checkout/'.$checkout->id) + ->assertSessionHas('status'); + + $discount->rules_json = [ + 'applicable_product_ids' => [999999], + ]; + $discount->save(); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->post('/checkout/'.$checkout->id.'/pay', [ + 'payment_method' => 'credit_card', + 'card_number' => '4242424242424242', + 'card_expiry' => '12/28', + 'card_cvc' => '123', + 'card_holder' => 'Jane Doe', + ]) + ->assertRedirect('/checkout/'.$checkout->id.'/confirmation') + ->assertSessionHas('status'); + + $checkout->refresh(); + $discount->refresh(); + + expect(storefrontEnumValue($checkout->status))->toBe('completed'); + expect($checkout->discount_code)->toBeNull(); + expect((int) $discount->usage_count)->toBe(0); + + $order = Order::query() + ->where('checkout_id', $checkout->id) + ->firstOrFail(); + + expect((int) $order->discount_amount)->toBe(0); + + $order->loadMissing('lines'); + + foreach ($order->lines as $line) { + expect($line->discount_allocations_json)->toBe([]); + } +}); + +test('checkout forms return errors for invalid discount and invalid payment state', function (): void { + $fixture = createStorefrontFixture(); + + $checkout = Checkout::query()->create([ + 'store_id' => $fixture['store']->id, + 'cart_id' => $fixture['cart']->id, + 'customer_id' => $fixture['customer']->id, + 'status' => 'started', + 'payment_method' => null, + 'email' => 'buyer@example.test', + 'shipping_address_json' => null, + 'billing_address_json' => null, + 'shipping_method_id' => null, + 'discount_code' => null, + 'tax_provider_snapshot_json' => null, + 'totals_json' => null, + 'expires_at' => now()->addDay(), + ]); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->post('/checkout/'.$checkout->id.'/discount', [ + 'code' => 'INVALID', + ]) + ->assertRedirect('/checkout/'.$checkout->id) + ->assertSessionHasErrors('code'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->post('/checkout/'.$checkout->id.'/pay', [ + 'payment_method' => 'credit_card', + ]) + ->assertRedirect('/checkout/'.$checkout->id) + ->assertSessionHasErrors('checkout'); +}); + +test('visiting an expired payment selected checkout releases reserved inventory', function (): void { + $fixture = createStorefrontFixture(); + + $inventoryItem = InventoryItem::query()->create([ + 'store_id' => $fixture['store']->id, + 'variant_id' => $fixture['variant']->id, + 'quantity_on_hand' => 10, + 'quantity_reserved' => 1, + 'policy' => 'deny', + ]); + + $checkout = Checkout::query()->create([ + 'store_id' => $fixture['store']->id, + 'cart_id' => $fixture['cart']->id, + 'customer_id' => $fixture['customer']->id, + 'status' => 'payment_selected', + 'payment_method' => 'credit_card', + 'email' => 'buyer@example.test', + 'shipping_address_json' => [ + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'address1' => 'Main Street 1', + 'city' => 'Berlin', + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => '10115', + ], + 'billing_address_json' => [ + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'address1' => 'Main Street 1', + 'city' => 'Berlin', + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => '10115', + ], + 'shipping_method_id' => null, + 'discount_code' => null, + 'tax_provider_snapshot_json' => null, + 'totals_json' => null, + 'expires_at' => now()->subMinute(), + ]); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->get('/checkout/'.$checkout->id) + ->assertOk() + ->assertSee('Checkout #'.$checkout->id); + + $inventoryItem->refresh(); + $checkout->refresh(); + + expect((int) $inventoryItem->quantity_reserved)->toBe(0); + expect(storefrontEnumValue($checkout->status))->toBe('expired'); +}); + +test('customer can create update and delete addresses from account', function (): void { + $fixture = createStorefrontFixture(); + $customer = $fixture['customer']; + + $createResponse = $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($customer, 'customer') + ->post('/account/addresses', [ + 'label' => 'Office', + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'address1' => 'Second Street 5', + 'address2' => 'Floor 3', + 'city' => 'Berlin', + 'province' => 'Berlin', + 'province_code' => 'BE', + 'postal_code' => '10117', + 'country' => 'Germany', + 'country_code' => 'de', + 'phone' => '+4930123000', + 'is_default' => '1', + ]); + + $createResponse->assertRedirect('/account/addresses') + ->assertSessionHas('status'); + + $createdAddress = CustomerAddress::query() + ->where('customer_id', $customer->id) + ->where('label', 'Office') + ->first(); + + expect($createdAddress)->toBeInstanceOf(CustomerAddress::class); + expect($createdAddress?->is_default)->toBeTrue(); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($customer, 'customer') + ->put('/account/addresses/'.$createdAddress?->id, [ + 'label' => 'Office HQ', + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'address1' => 'Third Street 9', + 'city' => 'Berlin', + 'postal_code' => '10999', + 'country' => 'Germany', + 'country_code' => 'de', + 'is_default' => '0', + ]) + ->assertRedirect('/account/addresses') + ->assertSessionHas('status'); + + $createdAddress?->refresh(); + + expect($createdAddress?->label)->toBe('Office HQ'); + expect($createdAddress?->address_json['address1'] ?? null)->toBe('Third Street 9'); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($customer, 'customer') + ->delete('/account/addresses/'.$createdAddress?->id) + ->assertRedirect('/account/addresses') + ->assertSessionHas('status'); + + $this->assertDatabaseMissing('customer_addresses', [ + 'id' => $createdAddress?->id, + ]); +}); + +test('address form validates required fields', function (): void { + $fixture = createStorefrontFixture(); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->actingAs($fixture['customer'], 'customer') + ->post('/account/addresses', [ + 'label' => 'Invalid', + 'first_name' => '', + 'last_name' => '', + 'address1' => '', + 'city' => '', + 'postal_code' => '', + 'country_code' => '', + ]) + ->assertRedirect() + ->assertSessionHasErrors([ + 'first_name', + 'last_name', + 'address1', + 'city', + 'postal_code', + 'country_code', + ]); +}); diff --git a/tests/Feature/Tenancy/StoreIsolationTest.php b/tests/Feature/Tenancy/StoreIsolationTest.php new file mode 100644 index 0000000..edb9850 --- /dev/null +++ b/tests/Feature/Tenancy/StoreIsolationTest.php @@ -0,0 +1,248 @@ +forgetInstance('current_store'); +}); + +function storeIsolationEnsureSchema(): void +{ + if (! Schema::hasTable('organizations')) { + Schema::create('organizations', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->string('billing_email'); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('stores')) { + Schema::create('stores', function (Blueprint $table): void { + $table->id(); + $table->foreignId('organization_id')->constrained('organizations')->cascadeOnDelete(); + $table->string('name'); + $table->string('handle'); + $table->string('status')->default('active'); + $table->string('default_currency', 3)->default('USD'); + $table->string('default_locale', 10)->default('en'); + $table->string('timezone')->default('UTC'); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('scoped_products')) { + Schema::create('scoped_products', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('name'); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('scoped_orders')) { + Schema::create('scoped_orders', function (Blueprint $table): void { + $table->id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('number'); + $table->timestamps(); + }); + } +} + +function storeIsolationSeedStore(string $status = 'active'): int +{ + $now = now(); + $suffix = Str::lower(Str::random(6)); + + $organizationId = DB::table('organizations')->insertGetId([ + 'name' => 'Org '.$suffix, + 'billing_email' => "billing-{$suffix}@example.test", + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return DB::table('stores')->insertGetId([ + 'organization_id' => $organizationId, + 'name' => 'Store '.$suffix, + 'handle' => 'store-'.$suffix, + 'status' => $status, + 'default_currency' => 'USD', + 'default_locale' => 'en', + 'timezone' => 'UTC', + 'created_at' => $now, + 'updated_at' => $now, + ]); +} + +function storeIsolationScopedModel(string $table): Model +{ + expect(class_exists(\App\Models\Scopes\StoreScope::class)) + ->toBeTrue('StoreScope is missing.'); + + $model = new class extends Model + { + protected $guarded = []; + + protected static function booted(): void + { + self::addGlobalScope(app(\App\Models\Scopes\StoreScope::class)); + } + }; + + $model->setTable($table); + + return $model; +} + +function storeIsolationBelongsToStoreModel(string $table): Model +{ + expect(trait_exists(\App\Models\Concerns\BelongsToStore::class)) + ->toBeTrue('BelongsToStore trait is missing.'); + + $model = new class extends Model + { + use \App\Models\Concerns\BelongsToStore; + + protected $guarded = []; + }; + + $model->setTable($table); + + return $model; +} + +test('scopes product-like queries to the current store', function (): void { + $storeAId = storeIsolationSeedStore(); + $storeBId = storeIsolationSeedStore(); + + $now = now(); + + for ($index = 0; $index < 3; $index++) { + DB::table('scoped_products')->insert([ + 'store_id' => $storeAId, + 'name' => "A Product {$index}", + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + for ($index = 0; $index < 5; $index++) { + DB::table('scoped_products')->insert([ + 'store_id' => $storeBId, + 'name' => "B Product {$index}", + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + app()->instance('current_store', (object) ['id' => $storeAId]); + + $productModel = storeIsolationScopedModel('scoped_products'); + + expect($productModel->newQuery()->count())->toBe(3); +}); + +test('scopes order-like queries to the current store', function (): void { + $storeAId = storeIsolationSeedStore(); + $storeBId = storeIsolationSeedStore(); + + $now = now(); + + for ($index = 0; $index < 2; $index++) { + DB::table('scoped_orders')->insert([ + 'store_id' => $storeAId, + 'number' => "A-{$index}", + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + for ($index = 0; $index < 7; $index++) { + DB::table('scoped_orders')->insert([ + 'store_id' => $storeBId, + 'number' => "B-{$index}", + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + app()->instance('current_store', (object) ['id' => $storeAId]); + + $orderModel = storeIsolationScopedModel('scoped_orders'); + + expect($orderModel->newQuery()->count())->toBe(2); +}); + +test('automatically sets store_id on model creation', function (): void { + $storeAId = storeIsolationSeedStore(); + + app()->instance('current_store', (object) ['id' => $storeAId]); + + $productModel = storeIsolationBelongsToStoreModel('scoped_products'); + + $created = $productModel->newQuery()->create([ + 'name' => 'Scoped Product', + ]); + + expect((int) $created->getAttribute('store_id'))->toBe($storeAId); +}); + +test('prevents accessing another store records via direct id', function (): void { + $storeAId = storeIsolationSeedStore(); + $storeBId = storeIsolationSeedStore(); + + $productId = DB::table('scoped_products')->insertGetId([ + 'store_id' => $storeAId, + 'name' => 'A Product', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + app()->instance('current_store', (object) ['id' => $storeBId]); + + $productModel = storeIsolationScopedModel('scoped_products'); + + expect($productModel->newQuery()->find($productId))->toBeNull(); +}); + +test('allows cross-store access when store scope is removed', function (): void { + $storeAId = storeIsolationSeedStore(); + $storeBId = storeIsolationSeedStore(); + + $now = now(); + + DB::table('scoped_products')->insert([ + [ + 'store_id' => $storeAId, + 'name' => 'A Product', + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'store_id' => $storeBId, + 'name' => 'B Product', + 'created_at' => $now, + 'updated_at' => $now, + ], + ]); + + app()->instance('current_store', (object) ['id' => $storeAId]); + + $productModel = storeIsolationScopedModel('scoped_products'); + + expect( + $productModel + ->newQuery() + ->withoutGlobalScope(\App\Models\Scopes\StoreScope::class) + ->count() + )->toBe(2); +}); diff --git a/tests/Feature/Tenancy/TenantResolutionTest.php b/tests/Feature/Tenancy/TenantResolutionTest.php new file mode 100644 index 0000000..6aa96e3 --- /dev/null +++ b/tests/Feature/Tenancy/TenantResolutionTest.php @@ -0,0 +1,83 @@ +toBeTrue('Missing organizations table for tenant resolution tests.'); + expect(Schema::hasTable('stores'))->toBeTrue('Missing stores table for tenant resolution tests.'); + expect(Schema::hasTable('store_domains'))->toBeTrue('Missing store_domains table for tenant resolution tests.'); + + $now = now(); + $random = Str::lower(Str::random(6)); + + $organizationId = DB::table('organizations')->insertGetId([ + 'name' => 'Org '.$random, + 'billing_email' => "billing-{$random}@example.test", + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $storeId = DB::table('stores')->insertGetId([ + 'organization_id' => $organizationId, + 'name' => 'Store '.$random, + 'handle' => 'store-'.$random, + 'status' => $status, + 'default_currency' => 'USD', + 'default_locale' => 'en', + 'timezone' => 'UTC', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + DB::table('store_domains')->insert([ + 'store_id' => $storeId, + 'hostname' => $hostname, + 'type' => 'storefront', + 'is_primary' => true, + 'tls_mode' => 'managed', + 'created_at' => $now, + ]); + + return $storeId; +} + +function tenantResolutionRegisterProbe(string $uri): void +{ + Route::middleware([ResolveStore::class])->get($uri, static fn () => response()->noContent()); +} + +test('returns 404 for unknown hostname', function (): void { + expect(class_exists(ResolveStore::class))->toBeTrue('ResolveStore middleware is missing.'); + + $uri = '/__tenant-resolution-unknown'; + tenantResolutionRegisterProbe($uri); + + $this->get('http://nonexistent.test'.$uri) + ->assertNotFound(); +}); + +test('returns 503 for suspended store on storefront requests', function (): void { + expect(class_exists(ResolveStore::class))->toBeTrue('ResolveStore middleware is missing.'); + + $hostname = 'suspended-store.test'; + tenantResolutionSeedStore($hostname, 'suspended'); + + $uri = '/__tenant-resolution-suspended'; + tenantResolutionRegisterProbe($uri); + + $this->get("http://{$hostname}{$uri}") + ->assertStatus(503); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 60f04a4..0164e8f 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -12,36 +12,4 @@ */ pest()->extend(Tests\TestCase::class) - // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) - ->in('Feature'); - -/* -|-------------------------------------------------------------------------- -| Expectations -|-------------------------------------------------------------------------- -| -| When you're writing tests, you often need to check that values meet certain conditions. The -| "expect()" function gives you access to a set of "expectations" methods that you can use -| to assert different things. Of course, you may extend the Expectation API at any time. -| -*/ - -expect()->extend('toBeOne', function () { - return $this->toBe(1); -}); - -/* -|-------------------------------------------------------------------------- -| Functions -|-------------------------------------------------------------------------- -| -| While Pest is very powerful out-of-the-box, you may have some testing code specific to your -| project that you don't want to repeat in every file. Here you can also expose helpers as -| global functions to help you to reduce the number of lines of code in your test files. -| -*/ - -function something() -{ - // .. -} + ->in('Feature', 'Unit'); diff --git a/tests/Unit/Services/CartServiceTest.php b/tests/Unit/Services/CartServiceTest.php new file mode 100644 index 0000000..ab3b7e2 --- /dev/null +++ b/tests/Unit/Services/CartServiceTest.php @@ -0,0 +1,184 @@ +toBeTrue('App\\Services\\CartService is expected to exist.'); + + /** @var CartService $service */ + $service = app(CartService::class); + + foreach (['create', 'addLine', 'updateLineQuantity', 'removeLine'] as $method) { + expect(method_exists($service, $method)) + ->toBeTrue(sprintf('CartService must expose %s(...).', $method)); + } + + return $service; +} + +function cartServiceCreateStore(string $suffix): Store +{ + $organization = Organization::query()->create([ + 'name' => 'Org '.$suffix, + 'billing_email' => 'billing+'.$suffix.'@example.test', + ]); + + return Store::query()->create([ + 'organization_id' => $organization->id, + 'name' => 'Store '.$suffix, + 'handle' => 'store-'.$suffix, + 'status' => 'active', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'UTC', + ]); +} + +function cartServiceCreateVariant( + Store $store, + string $suffix, + int $price = 2500, + int $quantityOnHand = 10, + InventoryPolicy $policy = InventoryPolicy::Deny, + string $productStatus = 'active', + string $variantStatus = 'active', +): ProductVariant { + $product = Product::query()->create([ + 'store_id' => $store->id, + 'title' => 'Cart Product '.$suffix, + 'handle' => 'cart-product-'.$suffix, + 'status' => $productStatus, + 'description_html' => null, + 'vendor' => null, + 'product_type' => null, + 'tags' => [], + 'published_at' => $productStatus === 'active' ? now() : null, + ]); + + $variant = ProductVariant::query()->create([ + 'product_id' => $product->id, + 'sku' => 'SKU-CART-'.$suffix, + 'barcode' => null, + 'price_amount' => $price, + 'compare_at_amount' => null, + 'currency' => 'EUR', + 'weight_g' => 200, + 'requires_shipping' => true, + 'is_default' => true, + 'position' => 1, + 'status' => $variantStatus, + ]); + + InventoryItem::query()->create([ + 'store_id' => $store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => $quantityOnHand, + 'quantity_reserved' => 0, + 'policy' => $policy, + ]); + + return $variant; +} + +test('creates a cart with store currency and version one', function (): void { + $store = cartServiceCreateStore('create'); + $service = cartService(); + + $cart = $service->create($store, null); + + expect($cart)->toBeInstanceOf(Cart::class) + ->and($cart->store_id)->toBe($store->id) + ->and($cart->currency)->toBe('EUR') + ->and($cart->cart_version)->toBe(1) + ->and($cart->status->value)->toBe('active'); +}); + +test('adds and merges lines for the same variant', function (): void { + $store = cartServiceCreateStore('merge-lines'); + $variant = cartServiceCreateVariant($store, 'merge'); + $service = cartService(); + $cart = $service->create($store, null); + + $service->addLine($cart, $variant->id, 1); + $service->addLine($cart, $variant->id, 2); + $cart->refresh(); + + /** @var CartLine|null $line */ + $line = $cart->lines()->first(); + + expect($cart->lines()->count())->toBe(1) + ->and($line)->not->toBeNull() + ->and($line?->quantity)->toBe(3) + ->and($line?->line_subtotal_amount)->toBe(7500) + ->and($line?->line_total_amount)->toBe(7500) + ->and($cart->cart_version)->toBe(3); +}); + +test('increments version on add update and remove mutations', function (): void { + $store = cartServiceCreateStore('versioning'); + $variant = cartServiceCreateVariant($store, 'version'); + $service = cartService(); + $cart = $service->create($store, null); // v1 + + $line = $service->addLine($cart, $variant->id, 1); // v2 + $cart->refresh(); + + $service->updateLineQuantity($cart, $line->id, 4); // v3 + $cart->refresh(); + + $service->removeLine($cart, $line->id); // v4 + $cart->refresh(); + + expect($cart->cart_version)->toBe(4); +}); + +test('removes a line when updated quantity is zero', function (): void { + $store = cartServiceCreateStore('remove-zero'); + $variant = cartServiceCreateVariant($store, 'remove-zero'); + $service = cartService(); + $cart = $service->create($store, null); + $line = $service->addLine($cart, $variant->id, 2); + + $service->updateLineQuantity($cart, $line->id, 0); + + expect($cart->lines()->whereKey($line->id)->exists())->toBeFalse(); +}); + +test('rejects adding variants from inactive products', function (): void { + $store = cartServiceCreateStore('inactive-product'); + $variant = cartServiceCreateVariant($store, 'inactive', productStatus: 'draft'); + $service = cartService(); + $cart = $service->create($store, null); + + expect(fn () => $service->addLine($cart, $variant->id, 1)) + ->toThrow(ModelNotFoundException::class); +}); + +test('rejects add line when inventory is insufficient for deny policy', function (): void { + $store = cartServiceCreateStore('insufficient'); + $variant = cartServiceCreateVariant( + store: $store, + suffix: 'insufficient', + quantityOnHand: 1, + policy: InventoryPolicy::Deny, + ); + $service = cartService(); + $cart = $service->create($store, null); + + expect(fn () => $service->addLine($cart, $variant->id, 2)) + ->toThrow(\App\Exceptions\InsufficientInventoryException::class); +}); diff --git a/tests/Unit/Services/DiscountServiceTest.php b/tests/Unit/Services/DiscountServiceTest.php new file mode 100644 index 0000000..3a37942 --- /dev/null +++ b/tests/Unit/Services/DiscountServiceTest.php @@ -0,0 +1,304 @@ +toBeTrue('App\\Services\\DiscountService is expected to exist.'); + + /** @var DiscountService $service */ + $service = app(DiscountService::class); + + foreach (['validate', 'calculate'] as $method) { + expect(method_exists($service, $method)) + ->toBeTrue(sprintf('DiscountService must expose %s(...).', $method)); + } + + return $service; +} + +function discountServiceCreateStore(string $suffix = 'discount'): Store +{ + $organization = Organization::query()->create([ + 'name' => 'Org '.$suffix, + 'billing_email' => 'billing+'.$suffix.'@example.test', + ]); + + return Store::query()->create([ + 'organization_id' => $organization->id, + 'name' => 'Store '.$suffix, + 'handle' => 'store-'.$suffix, + 'status' => 'active', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'UTC', + ]); +} + +function discountServiceCreateCart(Store $store, array $lineSubtotals): Cart +{ + $cart = Cart::query()->create([ + 'store_id' => $store->id, + 'customer_id' => null, + 'currency' => 'EUR', + 'cart_version' => 1, + 'status' => 'active', + ]); + + foreach ($lineSubtotals as $index => $lineSubtotal) { + $product = Product::query()->create([ + 'store_id' => $store->id, + 'title' => 'Discount Product '.$index, + 'handle' => 'discount-product-'.$index, + 'status' => 'active', + 'description_html' => null, + 'vendor' => null, + 'product_type' => null, + 'tags' => [], + 'published_at' => now(), + ]); + + $quantity = 1; + $unitPrice = (int) $lineSubtotal; + + $variant = ProductVariant::query()->create([ + 'product_id' => $product->id, + 'sku' => 'SKU-DISC-'.$index, + 'barcode' => null, + 'price_amount' => $unitPrice, + 'compare_at_amount' => null, + 'currency' => 'EUR', + 'weight_g' => 200, + 'requires_shipping' => true, + 'is_default' => true, + 'position' => 1, + 'status' => 'active', + ]); + + CartLine::query()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'quantity' => $quantity, + 'unit_price_amount' => $unitPrice, + 'line_subtotal_amount' => $lineSubtotal, + 'line_discount_amount' => 0, + 'line_total_amount' => $lineSubtotal, + ]); + } + + return $cart->fresh(['lines']) ?? $cart; +} + +function discountServiceCreateDiscount(Store $store, array $overrides = []): Discount +{ + $defaults = [ + 'store_id' => $store->id, + 'type' => DiscountType::Code, + 'code' => 'SAVE10', + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + 'starts_at' => now()->subDay(), + 'ends_at' => now()->addDay(), + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => [], + 'status' => DiscountStatus::Active, + ]; + + return Discount::query()->create(array_merge($defaults, $overrides)); +} + +function discountResultAmount(mixed $result): ?int +{ + $keys = ['amount', 'discount', 'discount_amount', 'total_discount', 'applied_amount']; + + if (is_array($result)) { + foreach ($keys as $key) { + if (array_key_exists($key, $result) && is_numeric($result[$key])) { + return (int) $result[$key]; + } + } + } + + if (is_object($result)) { + foreach ($keys as $key) { + if (isset($result->{$key}) && is_numeric($result->{$key})) { + return (int) $result->{$key}; + } + } + + if (method_exists($result, 'toArray')) { + /** @var array $asArray */ + $asArray = $result->toArray(); + + return discountResultAmount($asArray); + } + } + + return null; +} + +/** + * @return array + */ +function discountResultLineAllocations(mixed $result): array +{ + if (is_array($result)) { + $allocations = $result['line_allocations'] ?? $result['lineAllocations'] ?? []; + + if (is_array($allocations)) { + /** @var array $normalized */ + $normalized = []; + + foreach ($allocations as $lineId => $amount) { + $normalized[(int) $lineId] = (int) $amount; + } + + return $normalized; + } + } + + if (is_object($result)) { + if (isset($result->lineAllocations) && is_array($result->lineAllocations)) { + /** @var array $allocations */ + $allocations = array_map(static fn (mixed $value): int => (int) $value, $result->lineAllocations); + + return $allocations; + } + + if (method_exists($result, 'toArray')) { + /** @var array $asArray */ + $asArray = $result->toArray(); + + return discountResultLineAllocations($asArray); + } + } + + return []; +} + +test('validates an active discount code case insensitively', function (): void { + $store = discountServiceCreateStore('validate-active'); + $cart = discountServiceCreateCart($store, [5000]); + $discount = discountServiceCreateDiscount($store, [ + 'code' => 'SUMMER20', + 'value_amount' => 20, + ]); + + $service = discountService(); + $validated = $service->validate('summer20', $store, $cart); + + expect($validated)->toBeInstanceOf(Discount::class) + ->and($validated->id)->toBe($discount->id); +}); + +test('rejects unknown discount codes', function (): void { + $store = discountServiceCreateStore('unknown-code'); + $cart = discountServiceCreateCart($store, [2500]); + $service = discountService(); + + expect(fn () => $service->validate('DOESNOTEXIST', $store, $cart)) + ->toThrow(\App\Exceptions\InvalidDiscountException::class); +}); + +test('rejects expired discounts', function (): void { + $store = discountServiceCreateStore('expired'); + $cart = discountServiceCreateCart($store, [2500]); + discountServiceCreateDiscount($store, [ + 'code' => 'OLD10', + 'ends_at' => now()->subMinute(), + ]); + + $service = discountService(); + + expect(fn () => $service->validate('OLD10', $store, $cart)) + ->toThrow(\App\Exceptions\InvalidDiscountException::class); +}); + +test('rejects discounts that reached usage limit', function (): void { + $store = discountServiceCreateStore('usage-limit'); + $cart = discountServiceCreateCart($store, [2500]); + discountServiceCreateDiscount($store, [ + 'code' => 'MAXED', + 'usage_limit' => 10, + 'usage_count' => 10, + ]); + + $service = discountService(); + + expect(fn () => $service->validate('MAXED', $store, $cart)) + ->toThrow(\App\Exceptions\InvalidDiscountException::class); +}); + +test('rejects discounts when minimum purchase is not met', function (): void { + $store = discountServiceCreateStore('minimum'); + $cart = discountServiceCreateCart($store, [3000]); + discountServiceCreateDiscount($store, [ + 'code' => 'MIN5000', + 'rules_json' => [ + 'min_purchase_amount' => 5000, + 'minimum_purchase_amount' => 5000, + ], + ]); + + $service = discountService(); + + expect(fn () => $service->validate('MIN5000', $store, $cart)) + ->toThrow(\App\Exceptions\InvalidDiscountException::class); +}); + +test('calculates percent discount amounts deterministically', function (): void { + $store = discountServiceCreateStore('percent'); + $cart = discountServiceCreateCart($store, [7500, 2500]); + $discount = discountServiceCreateDiscount($store, [ + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + ]); + $service = discountService(); + + $result = $service->calculate($discount, $cart); + $allocations = discountResultLineAllocations($result); + + expect(discountResultAmount($result)) + ->toBe(1000, 'Expected 10% discount on 10,000 cents to equal 1,000 cents.') + ->and(array_sum($allocations))->toBe(1000) + ->and(count($allocations))->toBe(2); +}); + +test('caps fixed discount amounts at subtotal', function (): void { + $store = discountServiceCreateStore('fixed-cap'); + $cart = discountServiceCreateCart($store, [300]); + $discount = discountServiceCreateDiscount($store, [ + 'value_type' => DiscountValueType::Fixed, + 'value_amount' => 500, + ]); + $service = discountService(); + + $result = $service->calculate($discount, $cart); + + expect(discountResultAmount($result)) + ->toBe(300, 'Fixed discounts must never produce a negative discounted subtotal.'); +}); diff --git a/tests/Unit/Services/HandleGeneratorTest.php b/tests/Unit/Services/HandleGeneratorTest.php new file mode 100644 index 0000000..ab7e97d --- /dev/null +++ b/tests/Unit/Services/HandleGeneratorTest.php @@ -0,0 +1,122 @@ +fail('HandleGenerator is expected in App\\Support or App\\Services.'); + + return ServicesHandleGenerator::class; +} + +function handleGeneratorService(): object +{ + $class = handleGeneratorClass(); + + $service = app($class); + + expect(method_exists($service, 'generate')) + ->toBeTrue('HandleGenerator must expose a generate(...) method.'); + + return $service; +} + +function handleGeneratorCreateStore(string $suffix): Store +{ + $organization = Organization::query()->create([ + 'name' => 'Org '.$suffix, + 'billing_email' => 'billing+'.$suffix.'@example.test', + ]); + + return Store::query()->create([ + 'organization_id' => $organization->id, + 'name' => 'Store '.$suffix, + 'handle' => 'store-'.$suffix, + 'status' => 'active', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'UTC', + ]); +} + +function handleGeneratorCreateProduct(Store $store, string $handle, string $title = 'Sample Product'): Product +{ + return Product::query()->create([ + 'store_id' => $store->id, + 'title' => $title, + 'handle' => $handle, + 'status' => 'active', + 'description_html' => '

Fixture

', + 'vendor' => 'Fixture Vendor', + 'product_type' => 'Fixture Type', + 'tags' => [], + 'published_at' => now(), + ]); +} + +test('generates a slug from title when no collision exists', function (): void { + $store = handleGeneratorCreateStore('slug-source'); + $service = handleGeneratorService(); + + $handle = $service->generate('My Amazing Product', 'products', $store->id); + + expect($handle)->toBe('my-amazing-product'); +}); + +test('appends incrementing numeric suffixes on collisions', function (): void { + $store = handleGeneratorCreateStore('collision'); + handleGeneratorCreateProduct($store, 't-shirt', 'T-Shirt'); + handleGeneratorCreateProduct($store, 't-shirt-1', 'T-Shirt'); + + $service = handleGeneratorService(); + $handle = $service->generate('T Shirt', 'products', $store->id); + + expect($handle)->toBe('t-shirt-2'); +}); + +test('scopes uniqueness checks per store', function (): void { + $storeA = handleGeneratorCreateStore('store-a'); + $storeB = handleGeneratorCreateStore('store-b'); + handleGeneratorCreateProduct($storeA, 'classic-hoodie', 'Classic Hoodie'); + + $service = handleGeneratorService(); + $handleForStoreB = $service->generate('Classic Hoodie', 'products', $storeB->id); + + expect($handleForStoreB)->toBe('classic-hoodie'); +}); + +test('ignores the current record when exclude id is provided', function (): void { + $store = handleGeneratorCreateStore('exclude-id'); + $product = handleGeneratorCreateProduct($store, 'utility-pants', 'Utility Pants'); + + $service = handleGeneratorService(); + $handle = $service->generate('Utility Pants', 'products', $store->id, $product->id); + + expect($handle)->toBe('utility-pants'); +}); + +test('normalizes special characters into url safe handles', function (): void { + $store = handleGeneratorCreateStore('special'); + $service = handleGeneratorService(); + + $handle = $service->generate("Loewe's Fall/Winter 2026", 'products', $store->id); + + expect($handle) + ->toMatch('/^[a-z0-9]+(?:-[a-z0-9]+)*$/') + ->and($handle)->toContain('2026'); +}); diff --git a/tests/Unit/Services/InventoryServiceTest.php b/tests/Unit/Services/InventoryServiceTest.php new file mode 100644 index 0000000..38da185 --- /dev/null +++ b/tests/Unit/Services/InventoryServiceTest.php @@ -0,0 +1,147 @@ +toBeTrue('App\\Services\\InventoryService is expected to exist.'); + + /** @var InventoryService $service */ + $service = app(InventoryService::class); + + foreach (['checkAvailability', 'reserve', 'release', 'commit', 'restock'] as $method) { + expect(method_exists($service, $method)) + ->toBeTrue(sprintf('InventoryService must expose %s(...).', $method)); + } + + return $service; +} + +function inventoryFixture( + InventoryPolicy $policy = InventoryPolicy::Deny, + int $onHand = 10, + int $reserved = 0, +): InventoryItem { + $organization = Organization::query()->create([ + 'name' => 'Inventory Org', + 'billing_email' => 'billing+inventory@example.test', + ]); + + $store = Store::query()->create([ + 'organization_id' => $organization->id, + 'name' => 'Inventory Store', + 'handle' => 'inventory-store', + 'status' => 'active', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'UTC', + ]); + + $product = Product::query()->create([ + 'store_id' => $store->id, + 'title' => 'Inventory Product', + 'handle' => 'inventory-product', + 'status' => 'active', + 'description_html' => null, + 'vendor' => null, + 'product_type' => null, + 'tags' => [], + 'published_at' => now(), + ]); + + $variant = ProductVariant::query()->create([ + 'product_id' => $product->id, + 'sku' => 'SKU-INV-001', + 'barcode' => null, + 'price_amount' => 2500, + 'compare_at_amount' => null, + 'currency' => 'EUR', + 'weight_g' => 250, + 'requires_shipping' => true, + 'is_default' => true, + 'position' => 1, + 'status' => 'active', + ]); + + return InventoryItem::query()->create([ + 'store_id' => $store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => $onHand, + 'quantity_reserved' => $reserved, + 'policy' => $policy, + ]); +} + +test('checks availability for deny policy using available quantity', function (): void { + $service = inventoryService(); + $item = inventoryFixture(InventoryPolicy::Deny, onHand: 7, reserved: 2); // available = 5 + + expect($service->checkAvailability($item, 5))->toBeTrue() + ->and($service->checkAvailability($item, 6))->toBeFalse(); +}); + +test('allows availability check for continue policy even when stock is insufficient', function (): void { + $service = inventoryService(); + $item = inventoryFixture(InventoryPolicy::Continue, onHand: 0, reserved: 0); + + expect($service->checkAvailability($item, 99))->toBeTrue(); +}); + +test('reserve increments quantity reserved', function (): void { + $service = inventoryService(); + $item = inventoryFixture(InventoryPolicy::Deny, onHand: 10, reserved: 1); + + $service->reserve($item, 3); + $item->refresh(); + + expect($item->quantity_reserved)->toBe(4); +}); + +test('reserve throws when deny policy does not have enough available inventory', function (): void { + $service = inventoryService(); + $item = inventoryFixture(InventoryPolicy::Deny, onHand: 2, reserved: 1); // available = 1 + + expect(fn () => $service->reserve($item, 2)) + ->toThrow(\App\Exceptions\InsufficientInventoryException::class); +}); + +test('release decrements reserved quantity', function (): void { + $service = inventoryService(); + $item = inventoryFixture(InventoryPolicy::Deny, onHand: 12, reserved: 5); + + $service->release($item, 2); + $item->refresh(); + + expect($item->quantity_reserved)->toBe(3); +}); + +test('commit decrements on hand and reserved quantities', function (): void { + $service = inventoryService(); + $item = inventoryFixture(InventoryPolicy::Deny, onHand: 20, reserved: 6); + + $service->commit($item, 4); + $item->refresh(); + + expect($item->quantity_on_hand)->toBe(16) + ->and($item->quantity_reserved)->toBe(2); +}); + +test('restock increments on hand quantity', function (): void { + $service = inventoryService(); + $item = inventoryFixture(InventoryPolicy::Deny, onHand: 3, reserved: 0); + + $service->restock($item, 7); + $item->refresh(); + + expect($item->quantity_on_hand)->toBe(10); +}); diff --git a/tests/Unit/Services/PricingEngineTest.php b/tests/Unit/Services/PricingEngineTest.php new file mode 100644 index 0000000..008786a --- /dev/null +++ b/tests/Unit/Services/PricingEngineTest.php @@ -0,0 +1,245 @@ +toBeTrue('App\\Services\\PricingEngine is expected to exist.'); + + /** @var PricingEngine $engine */ + $engine = app(PricingEngine::class); + + expect(method_exists($engine, 'calculate')) + ->toBeTrue('PricingEngine must expose calculate(...).'); + + return $engine; +} + +function pricingEngineCreateStore(string $suffix): Store +{ + $organization = Organization::query()->create([ + 'name' => 'Org '.$suffix, + 'billing_email' => 'billing+'.$suffix.'@example.test', + ]); + + return Store::query()->create([ + 'organization_id' => $organization->id, + 'name' => 'Store '.$suffix, + 'handle' => 'store-'.$suffix, + 'status' => 'active', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'UTC', + ]); +} + +/** + * @param array $lineDefinitions + */ +function pricingEngineCreateCheckout(Store $store, array $lineDefinitions, ?string $discountCode = null): Checkout +{ + $cart = Cart::query()->create([ + 'store_id' => $store->id, + 'customer_id' => null, + 'currency' => 'EUR', + 'cart_version' => 1, + 'status' => 'active', + ]); + + foreach ($lineDefinitions as $index => $lineDefinition) { + $product = Product::query()->create([ + 'store_id' => $store->id, + 'title' => 'Pricing Product '.$index, + 'handle' => 'pricing-product-'.$index, + 'status' => 'active', + 'description_html' => null, + 'vendor' => null, + 'product_type' => null, + 'tags' => [], + 'published_at' => now(), + ]); + + $variant = ProductVariant::query()->create([ + 'product_id' => $product->id, + 'sku' => 'SKU-PRC-'.$index, + 'barcode' => null, + 'price_amount' => $lineDefinition['unit_price'], + 'compare_at_amount' => null, + 'currency' => 'EUR', + 'weight_g' => 250, + 'requires_shipping' => true, + 'is_default' => true, + 'position' => 1, + 'status' => 'active', + ]); + + $lineSubtotal = $lineDefinition['unit_price'] * $lineDefinition['quantity']; + + CartLine::query()->create([ + 'cart_id' => $cart->id, + 'variant_id' => $variant->id, + 'quantity' => $lineDefinition['quantity'], + 'unit_price_amount' => $lineDefinition['unit_price'], + 'line_subtotal_amount' => $lineSubtotal, + 'line_discount_amount' => 0, + 'line_total_amount' => $lineSubtotal, + ]); + } + + return Checkout::query()->create([ + 'store_id' => $store->id, + 'cart_id' => $cart->id, + 'customer_id' => null, + 'status' => 'started', + 'payment_method' => null, + 'email' => 'checkout@example.test', + 'shipping_address_json' => null, + 'billing_address_json' => null, + 'shipping_method_id' => null, + 'discount_code' => $discountCode, + 'tax_provider_snapshot_json' => null, + 'totals_json' => null, + 'expires_at' => null, + ]); +} + +function pricingEngineCreateDiscount(Store $store, string $code, DiscountValueType $valueType, int $valueAmount): Discount +{ + return Discount::query()->create([ + 'store_id' => $store->id, + 'type' => DiscountType::Code, + 'code' => $code, + 'value_type' => $valueType, + 'value_amount' => $valueAmount, + 'starts_at' => now()->subDay(), + 'ends_at' => now()->addDay(), + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => [], + 'status' => DiscountStatus::Active, + ]); +} + +function pricingResultValue(mixed $result, array $keys): mixed +{ + if (is_array($result)) { + foreach ($keys as $key) { + if (array_key_exists($key, $result)) { + return $result[$key]; + } + } + } + + if (is_object($result)) { + foreach ($keys as $key) { + if (isset($result->{$key})) { + return $result->{$key}; + } + } + + foreach ($keys as $key) { + $getter = 'get'.str_replace(' ', '', ucwords(str_replace('_', ' ', $key))); + + if (method_exists($result, $getter)) { + return $result->{$getter}(); + } + } + + if (method_exists($result, 'toArray')) { + /** @var array $asArray */ + $asArray = $result->toArray(); + + return pricingResultValue($asArray, $keys); + } + } + + return null; +} + +function pricingResultSummary(mixed $result): array +{ + return [ + 'subtotal' => (int) pricingResultValue($result, ['subtotal']), + 'discount' => (int) pricingResultValue($result, ['discount', 'discount_amount', 'total_discount']), + 'shipping' => (int) pricingResultValue($result, ['shipping', 'shipping_amount']), + 'tax' => (int) pricingResultValue($result, ['tax', 'tax_total', 'taxTotal']), + 'total' => (int) pricingResultValue($result, ['total']), + 'currency' => (string) pricingResultValue($result, ['currency']), + ]; +} + +test('calculates subtotal and total for a checkout without shipping or discount', function (): void { + $store = pricingEngineCreateStore('subtotal'); + $checkout = pricingEngineCreateCheckout($store, [ + ['unit_price' => 1000, 'quantity' => 2], + ['unit_price' => 2500, 'quantity' => 1], + ]); + $engine = pricingEngineService(); + + $result = $engine->calculate($checkout); + + $summary = pricingResultSummary($result); + + expect($summary['subtotal'])->toBe(4500) + ->and($summary['discount'])->toBe(0) + ->and($summary['shipping'])->toBe(0) + ->and($summary['tax'])->toBe(0) + ->and($summary['total'])->toBe(4500) + ->and($summary['currency'])->toBe('EUR'); +}); + +test('caps fixed discount so totals never go negative', function (): void { + $store = pricingEngineCreateStore('fixed-cap'); + pricingEngineCreateDiscount($store, 'BIGSAVE', DiscountValueType::Fixed, 500); + $checkout = pricingEngineCreateCheckout($store, [ + ['unit_price' => 300, 'quantity' => 1], + ], discountCode: 'BIGSAVE'); + + $engine = pricingEngineService(); + $result = $engine->calculate($checkout); + $summary = pricingResultSummary($result); + + expect($summary['subtotal'])->toBe(300) + ->and($summary['discount'])->toBe(300) + ->and($summary['total'])->toBe(0); +}); + +test('returns identical outputs for identical inputs', function (): void { + $store = pricingEngineCreateStore('deterministic'); + pricingEngineCreateDiscount($store, 'TENPERCENT', DiscountValueType::Percent, 10); + $checkout = pricingEngineCreateCheckout($store, [ + ['unit_price' => 2499, 'quantity' => 2], + ['unit_price' => 799, 'quantity' => 1], + ], discountCode: 'TENPERCENT'); + + $engine = pricingEngineService(); + + $first = pricingResultSummary($engine->calculate($checkout)); + $second = pricingResultSummary($engine->calculate($checkout)); + + expect($first)->toBe($second); +});