diff --git a/.blueprint b/.blueprint new file mode 100644 index 00000000..e4994e51 --- /dev/null +++ b/.blueprint @@ -0,0 +1,187 @@ +created: + - database/factories/OrganizationFactory.php + - database/factories/AppFactory.php + - database/factories/StoreFactory.php + - database/factories/StoreDomainFactory.php + - database/factories/StoreUserFactory.php + - database/factories/StoreSettingFactory.php + - database/factories/CustomerFactory.php + - database/factories/ThemeFactory.php + - database/factories/PageFactory.php + - database/factories/NavigationMenuFactory.php + - database/factories/SearchSettingFactory.php + - database/factories/ShippingZoneFactory.php + - database/factories/TaxSettingFactory.php + - database/factories/DiscountFactory.php + - database/factories/ProductFactory.php + - database/factories/CollectionFactory.php + - database/factories/AppInstallationFactory.php + - database/factories/OauthClientFactory.php + - database/factories/CustomerPasswordResetTokenFactory.php + - database/factories/ProductOptionFactory.php + - database/factories/ProductVariantFactory.php + - database/factories/ProductMediaFactory.php + - database/factories/CollectionProductFactory.php + - database/factories/CustomerAddressFactory.php + - database/factories/CartFactory.php + - database/factories/NavigationItemFactory.php + - database/factories/ThemeFileFactory.php + - database/factories/ThemeSettingFactory.php + - database/factories/SearchQueryFactory.php + - database/factories/ShippingRateFactory.php + - database/factories/OauthTokenFactory.php + - database/factories/WebhookSubscriptionFactory.php + - database/factories/ProductOptionValueFactory.php + - database/factories/InventoryItemFactory.php + - database/factories/CartLineFactory.php + - database/factories/CheckoutFactory.php + - database/factories/OrderFactory.php + - database/factories/AnalyticsEventFactory.php + - database/factories/AnalyticsDailyFactory.php + - database/factories/WebhookDeliveryFactory.php + - database/factories/VariantOptionValueFactory.php + - database/factories/OrderLineFactory.php + - database/factories/PaymentFactory.php + - database/factories/FulfillmentFactory.php + - database/factories/RefundFactory.php + - database/factories/FulfillmentLineFactory.php + - database/migrations/2026_09_13_065919_create_organizations_table.php + - database/migrations/2026_09_13_065920_create_apps_table.php + - database/migrations/2026_09_13_065921_create_stores_table.php + - database/migrations/2026_09_13_065922_create_store_domains_table.php + - database/migrations/2026_09_13_065923_create_store_users_table.php + - database/migrations/2026_09_13_065924_create_store_settings_table.php + - database/migrations/2026_09_13_065925_create_customers_table.php + - database/migrations/2026_09_13_065926_create_themes_table.php + - database/migrations/2026_09_13_065927_create_pages_table.php + - database/migrations/2026_09_13_065928_create_navigation_menus_table.php + - database/migrations/2026_09_13_065929_create_search_settings_table.php + - database/migrations/2026_09_13_065930_create_shipping_zones_table.php + - database/migrations/2026_09_13_065931_create_tax_settings_table.php + - database/migrations/2026_09_13_065932_create_discounts_table.php + - database/migrations/2026_09_13_065933_create_products_table.php + - database/migrations/2026_09_13_065934_create_collections_table.php + - database/migrations/2026_09_13_065935_create_app_installations_table.php + - database/migrations/2026_09_13_065936_create_oauth_clients_table.php + - database/migrations/2026_09_13_065937_create_customer_password_reset_tokens_table.php + - database/migrations/2026_09_13_065938_create_product_options_table.php + - database/migrations/2026_09_13_065939_create_product_variants_table.php + - database/migrations/2026_09_13_065940_create_product_media_table.php + - database/migrations/2026_09_13_065941_create_collection_products_table.php + - database/migrations/2026_09_13_065942_create_customer_addresses_table.php + - database/migrations/2026_09_13_065943_create_carts_table.php + - database/migrations/2026_09_13_065944_create_navigation_items_table.php + - database/migrations/2026_09_13_065945_create_theme_files_table.php + - database/migrations/2026_09_13_065946_create_theme_settings_table.php + - database/migrations/2026_09_13_065947_create_search_queries_table.php + - database/migrations/2026_09_13_065948_create_shipping_rates_table.php + - database/migrations/2026_09_13_065949_create_oauth_tokens_table.php + - database/migrations/2026_09_13_065950_create_webhook_subscriptions_table.php + - database/migrations/2026_09_13_065951_create_product_option_values_table.php + - database/migrations/2026_09_13_065952_create_inventory_items_table.php + - database/migrations/2026_09_13_065953_create_cart_lines_table.php + - database/migrations/2026_09_13_065954_create_checkouts_table.php + - database/migrations/2026_09_13_065955_create_orders_table.php + - database/migrations/2026_09_13_065956_create_analytics_events_table.php + - database/migrations/2026_09_13_065957_create_analytics_daily_table.php + - database/migrations/2026_09_13_065958_create_webhook_deliveries_table.php + - database/migrations/2026_09_13_065959_create_variant_option_values_table.php + - database/migrations/2026_09_13_070000_create_order_lines_table.php + - database/migrations/2026_09_13_070001_create_payments_table.php + - database/migrations/2026_09_13_070002_create_fulfillments_table.php + - database/migrations/2026_09_13_070003_create_refunds_table.php + - database/migrations/2026_09_13_070004_create_fulfillment_lines_table.php + - app/Models/Organization.php + - app/Models/App.php + - app/Models/Store.php + - app/Models/StoreDomain.php + - app/Models/StoreUser.php + - app/Models/StoreSetting.php + - app/Models/Customer.php + - app/Models/Theme.php + - app/Models/Page.php + - app/Models/NavigationMenu.php + - app/Models/SearchSetting.php + - app/Models/ShippingZone.php + - app/Models/TaxSetting.php + - app/Models/Discount.php + - app/Models/Product.php + - app/Models/Collection.php + - app/Models/AppInstallation.php + - app/Models/OauthClient.php + - app/Models/CustomerPasswordResetToken.php + - app/Models/ProductOption.php + - app/Models/ProductVariant.php + - app/Models/ProductMedia.php + - app/Models/CollectionProduct.php + - app/Models/CustomerAddress.php + - app/Models/Cart.php + - app/Models/NavigationItem.php + - app/Models/ThemeFile.php + - app/Models/ThemeSetting.php + - app/Models/SearchQuery.php + - app/Models/ShippingRate.php + - app/Models/OauthToken.php + - app/Models/WebhookSubscription.php + - app/Models/ProductOptionValue.php + - app/Models/InventoryItem.php + - app/Models/CartLine.php + - app/Models/Checkout.php + - app/Models/Order.php + - app/Models/AnalyticsEvent.php + - app/Models/AnalyticsDaily.php + - app/Models/WebhookDelivery.php + - app/Models/VariantOptionValue.php + - app/Models/OrderLine.php + - app/Models/Payment.php + - app/Models/Fulfillment.php + - app/Models/Refund.php + - app/Models/FulfillmentLine.php +models: + User: { name: string, email: string, email_verified_at: 'datetime nullable', password: string, remember_token: 'string nullable', two_factor_secret: 'text nullable', two_factor_recovery_codes: 'text nullable', two_factor_confirmed_at: 'datetime nullable', status: "string default:'active'", last_login_at: 'datetime nullable' } + Organization: { name: string, billing_email: string, relationships: { hasMany: Store }, indexes: [{ index: billing_email }] } + App: { timestamps: false, name: string, status: 'string default:active', created_at: 'timestamp nullable', relationships: { hasMany: 'AppInstallation, OauthClient' }, indexes: [{ index: status }] } + Store: { organization_id: 'id foreign:organizations onDelete:cascade', name: string, handle: string, status: 'string default:active', default_currency: 'string default:USD', default_locale: 'string default:en', timezone: 'string default:UTC', relationships: { hasOne: 'StoreSetting, SearchSetting, TaxSetting', hasMany: 'StoreDomain, Customer, Theme, Page, NavigationMenu, Cart, Checkout, Order, Discount, ShippingZone, Product, Collection, SearchQuery, AnalyticsEvent, WebhookSubscription, InventoryItem, AppInstallation' }, indexes: [{ unique: handle }, { index: organization_id }, { index: status }] } + StoreDomain: { timestamps: false, store_id: 'id foreign:stores onDelete:cascade', hostname: string, type: 'string default:storefront', is_primary: 'integer default:0', tls_mode: 'string default:managed', created_at: 'timestamp nullable', indexes: [{ unique: hostname }, { index: store_id }, { index: 'store_id, is_primary' }] } + StoreUser: { meta: { pivot: true }, id: false, timestamps: false, store_id: 'id foreign:stores onDelete:cascade', user_id: 'id foreign:users onDelete:cascade', role: 'string default:staff', created_at: 'timestamp nullable', indexes: [{ unique: 'store_id, user_id' }, { index: user_id }, { index: 'store_id, role' }] } + StoreSetting: { id: false, timestamps: false, store_id: 'id foreign:stores onDelete:cascade primary', settings_json: "json default:'{}'", updated_at: 'timestamp nullable' } + Customer: { store_id: 'id foreign:stores onDelete:cascade', email: string, password_hash: 'string nullable', name: 'string nullable', marketing_opt_in: 'integer default:0', relationships: { hasMany: 'CustomerAddress, Order, Cart, Checkout, AnalyticsEvent' }, indexes: [{ unique: 'store_id, email' }, { index: store_id }] } + Theme: { store_id: 'id foreign:stores onDelete:cascade', name: string, version: 'string nullable', status: 'string default:draft', published_at: 'timestamp nullable', relationships: { hasOne: ThemeSetting, hasMany: ThemeFile }, indexes: [{ index: store_id }, { index: 'store_id, status' }] } + Page: { store_id: 'id foreign:stores onDelete:cascade', title: string, handle: string, body_html: 'text nullable', status: 'string default:draft', published_at: 'timestamp nullable', indexes: [{ unique: 'store_id, handle' }, { index: store_id }, { index: 'store_id, status' }] } + NavigationMenu: { store_id: 'id foreign:stores onDelete:cascade', handle: string, title: string, relationships: { hasMany: NavigationItem }, indexes: [{ unique: 'store_id, handle' }, { index: store_id }] } + SearchSetting: { id: false, timestamps: false, store_id: 'id foreign:stores onDelete:cascade primary', synonyms_json: "json default:'[]'", stop_words_json: "json default:'[]'", updated_at: 'timestamp nullable' } + ShippingZone: { timestamps: false, store_id: 'id foreign:stores onDelete:cascade', name: string, countries_json: "json default:'[]'", regions_json: "json default:'[]'", relationships: { hasMany: ShippingRate }, indexes: [{ index: store_id }] } + TaxSetting: { id: false, timestamps: false, store_id: 'id foreign:stores onDelete:cascade primary', mode: 'string default:manual', provider: 'string default:none', prices_include_tax: 'integer default:0', config_json: "json default:'{}'" } + Discount: { store_id: 'id foreign:stores onDelete:cascade', type: 'string default:code', code: 'string nullable', value_type: string, value_amount: 'integer default:0', starts_at: timestamp, ends_at: 'timestamp nullable', usage_limit: 'integer nullable', usage_count: 'integer default:0', rules_json: "json default:'{}'", status: 'string default:active', indexes: [{ unique: 'store_id, code' }, { index: store_id }, { index: 'store_id, status' }, { index: 'store_id, type' }] } + Product: { store_id: 'id foreign:stores onDelete:cascade', title: string, handle: string, status: 'string default:draft', description_html: 'text nullable', vendor: 'string nullable', product_type: 'string nullable', tags: "json default:'[]'", published_at: 'timestamp nullable', relationships: { hasMany: 'ProductOption, ProductVariant, ProductMedia' }, indexes: [{ unique: 'store_id, handle' }, { index: store_id }, { index: 'store_id, status' }, { index: 'store_id, published_at' }, { index: 'store_id, vendor' }, { index: 'store_id, product_type' }] } + Collection: { store_id: 'id foreign:stores onDelete:cascade', title: string, handle: string, description_html: 'text nullable', type: 'string default:manual', status: 'string default:active', indexes: [{ unique: 'store_id, handle' }, { index: store_id }, { index: 'store_id, status' }] } + AppInstallation: { timestamps: false, store_id: 'id foreign:stores onDelete:cascade', app_id: 'id foreign:apps onDelete:cascade', scopes_json: "json default:'[]'", status: 'string default:active', installed_at: 'timestamp nullable', relationships: { hasMany: 'OauthToken, WebhookSubscription' }, indexes: [{ unique: 'store_id, app_id' }, { index: store_id }, { index: app_id }] } + OauthClient: { timestamps: false, app_id: 'id foreign:apps onDelete:cascade', client_id: string, client_secret_encrypted: text, redirect_uris_json: "json default:'[]'", indexes: [{ unique: client_id }, { index: app_id }] } + CustomerPasswordResetToken: { id: false, timestamps: false, store_id: 'id foreign:stores onDelete:cascade', email: string, token: string, created_at: 'timestamp nullable', indexes: [{ unique: 'store_id, email' }] } + ProductOption: { timestamps: false, product_id: 'id foreign:products onDelete:cascade', name: string, position: 'integer default:0', relationships: { hasMany: ProductOptionValue }, indexes: [{ index: product_id }, { unique: 'product_id, position' }] } + ProductVariant: { product_id: 'id foreign:products onDelete:cascade', sku: 'string nullable', barcode: 'string nullable', price_amount: 'integer default:0', compare_at_amount: 'integer nullable', currency: 'string default:USD', weight_g: 'integer nullable', requires_shipping: 'integer default:1', is_default: 'integer default:0', position: 'integer default:0', status: 'string default:active', relationships: { hasOne: InventoryItem }, indexes: [{ index: product_id }, { index: sku }, { index: barcode }, { index: 'product_id, position' }, { index: 'product_id, is_default' }] } + ProductMedia: { timestamps: false, product_id: 'id foreign:products onDelete:cascade', type: 'string default:image', storage_key: string, alt_text: 'string nullable', width: 'integer nullable', height: 'integer nullable', mime_type: 'string nullable', byte_size: 'integer nullable', position: 'integer default:0', status: 'string default:processing', created_at: 'timestamp nullable', indexes: [{ index: product_id }, { index: 'product_id, position' }, { index: status }] } + CollectionProduct: { id: false, timestamps: false, collection_id: 'id foreign:collections onDelete:cascade', product_id: 'id foreign:products onDelete:cascade', position: 'integer default:0', indexes: [{ unique: 'collection_id, product_id' }, { index: product_id }, { index: 'collection_id, position' }] } + CustomerAddress: { timestamps: false, customer_id: 'id foreign:customers onDelete:cascade', label: 'string nullable', address_json: "json default:'{}'", is_default: 'integer default:0', indexes: [{ index: customer_id }, { index: 'customer_id, is_default' }] } + Cart: { store_id: 'id foreign:stores onDelete:cascade', customer_id: 'id foreign:customers nullable onDelete:null', currency: 'string default:USD', cart_version: 'integer default:1', status: 'string default:active', relationships: { hasMany: 'CartLine, Checkout' }, indexes: [{ index: store_id }, { index: customer_id }, { index: 'store_id, status' }] } + NavigationItem: { timestamps: false, menu_id: 'id foreign:navigation_menus onDelete:cascade', type: 'string default:link', label: string, url: 'string nullable', resource_id: 'integer nullable', position: 'integer default:0', indexes: [{ index: menu_id }, { index: 'menu_id, position' }] } + ThemeFile: { timestamps: false, theme_id: 'id foreign:themes onDelete:cascade', path: string, storage_key: string, sha256: string, byte_size: 'integer default:0', indexes: [{ unique: 'theme_id, path' }, { index: theme_id }] } + ThemeSetting: { id: false, timestamps: false, theme_id: 'id foreign:themes onDelete:cascade primary', settings_json: "json default:'{}'", updated_at: 'timestamp nullable' } + SearchQuery: { timestamps: false, store_id: 'id foreign:stores onDelete:cascade', query: string, filters_json: 'json nullable', results_count: 'integer default:0', created_at: 'timestamp nullable', indexes: [{ index: store_id }, { index: 'store_id, created_at' }, { index: 'store_id, query' }] } + ShippingRate: { timestamps: false, zone_id: 'id foreign:shipping_zones onDelete:cascade', name: string, type: 'string default:flat', config_json: "json default:'{}'", is_active: 'integer default:1', indexes: [{ index: zone_id }, { index: 'zone_id, is_active' }] } + OauthToken: { timestamps: false, installation_id: 'id foreign:app_installations onDelete:cascade', access_token_hash: string, refresh_token_hash: 'string nullable', expires_at: timestamp, indexes: [{ index: installation_id }, { unique: access_token_hash }, { index: expires_at }] } + WebhookSubscription: { timestamps: false, store_id: 'id foreign:stores onDelete:cascade', app_installation_id: 'id foreign:app_installations nullable onDelete:cascade', event_type: string, target_url: string, signing_secret_encrypted: text, status: 'string default:active', relationships: { hasMany: WebhookDelivery }, indexes: [{ index: store_id }, { index: 'store_id, event_type' }, { index: app_installation_id }] } + ProductOptionValue: { timestamps: false, product_option_id: 'id foreign:product_options onDelete:cascade', value: string, position: 'integer default:0', indexes: [{ index: product_option_id }, { unique: 'product_option_id, position' }] } + InventoryItem: { timestamps: false, store_id: 'id foreign:stores onDelete:cascade', variant_id: 'id foreign:product_variants onDelete:cascade', quantity_on_hand: 'integer default:0', quantity_reserved: 'integer default:0', policy: 'string default:deny', indexes: [{ unique: variant_id }, { index: store_id }] } + CartLine: { timestamps: false, cart_id: 'id foreign:carts onDelete:cascade', variant_id: 'id foreign:product_variants onDelete:cascade', quantity: 'integer default:1', unit_price_amount: 'integer default:0', line_subtotal_amount: 'integer default:0', line_discount_amount: 'integer default:0', line_total_amount: 'integer default:0', indexes: [{ index: cart_id }, { unique: 'cart_id, variant_id' }] } + Checkout: { store_id: 'id foreign:stores onDelete:cascade', cart_id: 'id foreign:carts onDelete:cascade', customer_id: 'id foreign:customers nullable onDelete:null', status: 'string default:started', payment_method: 'string nullable', email: 'string nullable', shipping_address_json: 'json nullable', billing_address_json: 'json nullable', shipping_method_id: 'integer nullable', discount_code: 'string nullable', tax_provider_snapshot_json: 'json nullable', totals_json: 'json nullable', expires_at: 'timestamp nullable', indexes: [{ index: store_id }, { index: cart_id }, { index: customer_id }, { index: 'store_id, status' }, { index: expires_at }] } + Order: { store_id: 'id foreign:stores onDelete:cascade', customer_id: 'id foreign:customers nullable onDelete:null', order_number: string, payment_method: string, status: 'string default:pending', financial_status: 'string default:pending', fulfillment_status: 'string default:unfulfilled', currency: 'string default:USD', subtotal_amount: 'integer default:0', discount_amount: 'integer default:0', shipping_amount: 'integer default:0', tax_amount: 'integer default:0', total_amount: 'integer default:0', email: 'string nullable', billing_address_json: 'json nullable', shipping_address_json: 'json nullable', placed_at: 'timestamp nullable', relationships: { hasMany: 'OrderLine, Payment, Refund, Fulfillment' }, indexes: [{ unique: 'store_id, order_number' }, { index: store_id }, { index: customer_id }, { index: 'store_id, status' }, { index: 'store_id, financial_status' }, { index: 'store_id, fulfillment_status' }, { index: 'store_id, placed_at' }] } + AnalyticsEvent: { timestamps: false, store_id: 'id foreign:stores onDelete:cascade', type: string, session_id: 'string nullable', customer_id: 'id foreign:customers nullable onDelete:null', properties_json: "json default:'{}'", client_event_id: 'string nullable', occurred_at: 'timestamp nullable', created_at: 'timestamp nullable', indexes: [{ index: store_id }, { index: 'store_id, type' }, { index: 'store_id, created_at' }, { index: session_id }, { index: customer_id }, { unique: 'store_id, client_event_id' }] } + AnalyticsDaily: { meta: { table: analytics_daily }, id: false, timestamps: false, store_id: 'id foreign:stores onDelete:cascade', date: string, orders_count: 'integer default:0', revenue_amount: 'integer default:0', aov_amount: 'integer default:0', visits_count: 'integer default:0', add_to_cart_count: 'integer default:0', checkout_started_count: 'integer default:0', checkout_completed_count: 'integer default:0', indexes: [{ unique: 'store_id, date' }] } + WebhookDelivery: { timestamps: false, subscription_id: 'id foreign:webhook_subscriptions onDelete:cascade', event_id: string, attempt_count: 'integer default:1', status: 'string default:pending', last_attempt_at: 'timestamp nullable', response_code: 'integer nullable', response_body_snippet: 'text nullable', indexes: [{ index: subscription_id }, { index: event_id }, { index: status }, { index: last_attempt_at }] } + VariantOptionValue: { id: false, timestamps: false, variant_id: 'id foreign:product_variants onDelete:cascade', product_option_value_id: 'id foreign:product_option_values onDelete:cascade', indexes: [{ unique: 'variant_id, product_option_value_id' }, { index: product_option_value_id }] } + OrderLine: { timestamps: false, order_id: 'id foreign:orders onDelete:cascade', product_id: 'id foreign:products nullable onDelete:null', variant_id: 'id foreign:product_variants nullable onDelete:null', title_snapshot: string, sku_snapshot: 'string nullable', quantity: 'integer default:1', unit_price_amount: 'integer default:0', total_amount: 'integer default:0', tax_lines_json: "json default:'[]'", discount_allocations_json: "json default:'[]'", relationships: { hasMany: FulfillmentLine }, indexes: [{ index: order_id }, { index: product_id }, { index: variant_id }] } + Payment: { timestamps: false, order_id: 'id foreign:orders onDelete:cascade', provider: 'string default:mock', method: string, provider_payment_id: 'string nullable', status: 'string default:pending', amount: 'integer default:0', currency: 'string default:USD', raw_json_encrypted: 'text nullable', created_at: 'timestamp nullable', relationships: { hasMany: Refund }, indexes: [{ index: order_id }, { index: 'provider, provider_payment_id' }, { index: method }, { index: status }] } + Fulfillment: { timestamps: false, order_id: 'id foreign:orders onDelete:cascade', status: 'string default:pending', tracking_company: 'string nullable', tracking_number: 'string nullable', tracking_url: 'string nullable', shipped_at: 'timestamp nullable', created_at: 'timestamp nullable', relationships: { hasMany: FulfillmentLine }, indexes: [{ index: order_id }, { index: status }, { index: 'tracking_company, tracking_number' }] } + Refund: { timestamps: false, order_id: 'id foreign:orders onDelete:cascade', payment_id: 'id foreign:payments onDelete:cascade', amount: 'integer default:0', reason: 'text nullable', status: 'string default:pending', provider_refund_id: 'string nullable', created_at: 'timestamp nullable', indexes: [{ index: order_id }, { index: payment_id }, { index: status }] } + FulfillmentLine: { timestamps: false, fulfillment_id: 'id foreign:fulfillments onDelete:cascade', order_line_id: 'id foreign:order_lines onDelete:cascade', quantity: 'integer default:1', indexes: [{ index: fulfillment_id }, { unique: 'fulfillment_id, order_line_id' }] } diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 0ad95248..e323bf0d 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -3,7 +3,7 @@ "laravel-boost": { "command": "php", "args": [ - "./artisan", + "artisan", "boost:mcp" ] }, @@ -17,4 +17,4 @@ } } } -} \ No newline at end of file +} diff --git a/.cursor/skills/developing-with-fortify/SKILL.md b/.cursor/skills/developing-with-fortify/SKILL.md new file mode 100644 index 00000000..db3558bc --- /dev/null +++ b/.cursor/skills/developing-with-fortify/SKILL.md @@ -0,0 +1,116 @@ +--- +name: developing-with-fortify +description: Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications. +--- + +# Laravel Fortify Development + +Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications. + +## Documentation + +Use `search-docs` for detailed Laravel Fortify patterns and documentation. + +## Usage + +- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints +- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.) +- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field +- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.) +- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc. + +## Available Features + +Enable in `config/fortify.php` features array: + +- `Features::registration()` - User registration +- `Features::resetPasswords()` - Password reset via email +- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail` +- `Features::updateProfileInformation()` - Profile updates +- `Features::updatePasswords()` - Password changes +- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes + +> Use `search-docs` for feature configuration options and customization patterns. + +## Setup Workflows + +### Two-Factor Authentication Setup + +``` +- [ ] Add TwoFactorAuthenticatable trait to User model +- [ ] Enable feature in config/fortify.php +- [ ] Run migrations for 2FA columns +- [ ] Set up view callbacks in FortifyServiceProvider +- [ ] Create 2FA management UI +- [ ] Test QR code and recovery codes +``` + +> Use `search-docs` for TOTP implementation and recovery code handling patterns. + +### Email Verification Setup + +``` +- [ ] Enable emailVerification feature in config +- [ ] Implement MustVerifyEmail interface on User model +- [ ] Set up verifyEmailView callback +- [ ] Add verified middleware to protected routes +- [ ] Test verification email flow +``` + +> Use `search-docs` for MustVerifyEmail implementation patterns. + +### Password Reset Setup + +``` +- [ ] Enable resetPasswords feature in config +- [ ] Set up requestPasswordResetLinkView callback +- [ ] Set up resetPasswordView callback +- [ ] Define password.reset named route (if views disabled) +- [ ] Test reset email and link flow +``` + +> Use `search-docs` for custom password reset flow patterns. + +### SPA Authentication Setup + +``` +- [ ] Set 'views' => false in config/fortify.php +- [ ] Install and configure Laravel Sanctum +- [ ] Use 'web' guard in fortify config +- [ ] Set up CSRF token handling +- [ ] Test XHR authentication flows +``` + +> Use `search-docs` for integration and SPA authentication patterns. + +## Best Practices + +### Custom Authentication Logic + +Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects. + +### Registration Customization + +Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields. + +### Rate Limiting + +Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination. + +## Key Endpoints + +| Feature | Method | Endpoint | +|------------------------|----------|---------------------------------------------| +| Login | POST | `/login` | +| Logout | POST | `/logout` | +| Register | POST | `/register` | +| Password Reset Request | POST | `/forgot-password` | +| Password Reset | POST | `/reset-password` | +| Email Verify Notice | GET | `/email/verify` | +| Resend Verification | POST | `/email/verification-notification` | +| Password Confirm | POST | `/user/confirm-password` | +| Enable 2FA | POST | `/user/two-factor-authentication` | +| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` | +| 2FA Challenge | POST | `/two-factor-challenge` | +| Get QR Code | GET | `/user/two-factor-qr-code` | +| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` | diff --git a/.cursor/skills/fluxui-development/SKILL.md b/.cursor/skills/fluxui-development/SKILL.md new file mode 100644 index 00000000..d4fb5a03 --- /dev/null +++ b/.cursor/skills/fluxui-development/SKILL.md @@ -0,0 +1,81 @@ +--- +name: fluxui-development +description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling." +license: MIT +metadata: + author: laravel +--- + +# Flux UI Development + +## Documentation + +Use `search-docs` for detailed Flux UI patterns and documentation. + +## Basic Usage + +This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components. + +Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize. + +Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs. + + +```blade +Click me +``` + +## Available Components (Free Edition) + +Available: avatar, badge, brand, breadcrumbs, button, callout, card, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, pagination, profile, progress, radio, select, separator, skeleton, switch, table, text, textarea, toast, tooltip + +## Icons + +Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names. + + +```blade +Export +``` + +For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command: + +```bash +php artisan flux:icon crown grip-vertical github +``` + +## Common Patterns + +### Form Fields + + +```blade + + Email + + + +``` + +### Modals + + +```blade + + Title +

Content

+
+``` + +## Verification + +1. Check component renders correctly +2. Test interactive states +3. Verify mobile responsiveness + +## Common Pitfalls + +- Trying to use Pro-only components in the free edition +- Not checking if a Flux component exists before creating custom implementations +- Forgetting to use the `search-docs` tool for component-specific documentation +- Not following existing project patterns for Flux usage diff --git a/.cursor/skills/infer-conventions/SKILL.md b/.cursor/skills/infer-conventions/SKILL.md new file mode 100644 index 00000000..c9b549fe --- /dev/null +++ b/.cursor/skills/infer-conventions/SKILL.md @@ -0,0 +1,105 @@ +--- +name: infer-conventions +description: "Use this skill to analyze how a Laravel application is actually written and record its conventions as shared rules. Trigger when the user wants to detect, infer, document, or standardize project conventions or coding style, set up or grow `.ai/rules`, resolve mixed or conflicting patterns (e.g. \"are we using Form Requests or inline validation?\"), or onboard agents and teammates to \"how we do things here\". Covers: a systematic sweep of ~49 Laravel convention dimensions (validation, models, architecture, testing, frontend, database, console), open-ended house-pattern discovery, conflict reporting, and recording rules scoped to the right paths via the Boost `record-rule` MCP tool. Only run this skill when the user explicitly asks for it; never start a sweep as part of another task. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand." +disable-model-invocation: true +license: MIT +metadata: + author: laravel +--- + +# Infer Conventions + +Learn how this application writes Laravel, then record what you learn as durable, path-scoped rules other agents will read. You are documenting reality, not improving it. + +## Ground Rules (read before you start) + +- Consistency first. The codebase's majority style is the convention. Never judge it, never propose a "better" pattern, never record what the code should do. If the app validates inline everywhere, that is the rule, even if Form Requests would be nicer. +- Skip what an active tool produces, keep what a tool would fight. Inspect the project's Pint and Rector configuration first; a Rector transformation is tooling-owned only when its package and relevant rule or set are installed and enabled. Active tools may rewrite code toward one canonical form: `$casts` to `casts()`, `$fillable` to attributes, magic accessors to the `Attribute` class, pipe-string rules to arrays, `$signature` to `#[Signature]`, named migrations to anonymous, and many more. When the app already sits at an active tool's target form, the tool owns it, so record nothing. But when the app deliberately holds a form an active tool would refactor away, such as legacy `getXxxAttribute()` accessors the `Attribute` class would replace, no tool can reproduce that choice and an agent defaults the other way. That against-the-grain hold is exactly what to record. +- Record decisions, not defaults. A consistent pattern earns a rule only when it reflects a choice: the app took one valid option where the framework or common practice offered others, or the pattern would surprise a competent agent. Framework defaults steer nothing, so skip them: anonymous migrations, `$signature` commands, `ShouldQueue` jobs, `casts()` on Laravel 11+, named routes, Rule objects in `app/Rules`, and `Mail::fake()` or `Bus::fake()` to isolate framework services. A real fork is not enough on its own. Weigh the side the app took, and record only the side an agent would not reach for by itself: inline closures everywhere, legacy accessors, a bespoke query layer. Watch for the false fork too. "No Mockery" next to facade fakes is not a choice against Mockery, because they double different things. The test for every candidate: without this rule, would the next agent plausibly write it differently? Only "yes" earns a rule. +- Architecture choices are the gold. Record presence and deliberate absence. The structural pattern the app commits to is the highest-signal convention and the one no tool can decide: Action classes and how they are invoked (`handle` / `execute` / `__invoke`), service objects, dedicated query objects exposing `builder()`, DTOs (spatie/laravel-data vs readonly classes), Form Request validation vs inline, an events and listeners spine vs direct calls, and domain or module folders. Also record a consistent non-pattern, such as "query Eloquent directly in controllers, no repository layer", so the next agent matches the app's altitude instead of over-engineering. +- Never duplicate `.ai/rules`. Read `.ai/rules/index.md` and the area files before the sweep. A dimension already covered there is marked done and skipped. +- Evidence or silence. A convention needs at least 3 consistent examples and no meaningful rival to become a candidate. Every Step 1 verdict applies this bar. +- The recorded rule states the convention, nothing else. One or two imperative lines: this project does X, so do X here. Keep detection evidence out. No counts, ratios, current usage, file lists, or example paths, because that is proof for the confirm step, not part of the rule. One short syntax fragment at most, and point to `search-docs` for API details. + +## Process + +Each step ends on a checkable completion criterion. Do not advance until it holds. + +Fan out when you can. The sweep is embarrassingly parallel. If your environment can spawn subagents (a Task, dispatch, or equivalent tool), do Step 0 yourself, then hand each checklist group (A to J) and the architecture map to its own subagent. Each subagent runs the greps, reads a few representative files, and returns structured verdicts (dimension, verdict, evidence, proposed glob / title / note). You aggregate, dedupe, then run Steps 3 to 5. It is far faster on a real app. No subagents available? Run the steps in sequence, with the same bar and the same output. + +### Step 0: Orient + +Read `composer.json` (installed packages tell you which checklist groups apply), the `pint.json` / PHPStan / Rector config, `.ai/rules/index.md` if present, and most important, map the `app/` tree. List every directory under `app/` (and any `Modules/`, `src/`, `packages/`, or domain root). Every folder beyond Laravel's default skeleton (`Http`, `Models`, `Providers`, `Console`, `Exceptions`) is a structural pattern the app committed to and a high-value rule waiting to be written: `Actions`, `Services`, `Data` or DTOs, `Queries`, `Repositories`, `ViewModels`, `Pipelines`, `Support`, `Enums`, `Contracts`, `Observers`, or `Domain` and module roots. Note each one. You will confirm how it is used in Step 2. + +This app ships a frontend stack, so the frontend checklist group applies. Sweep it. + +Done when: you have the applicable checklist groups, the dimensions already recorded in `.ai/rules`, and a list of every non-default `app/` directory mapped to the pattern it represents. + +### Step 1: Predefined sweep + +Open `references/checklist.md` and work every applicable dimension using its search hints. Give each exactly one verdict: + +- Pattern. Clears the bar, rival under ~20% of sites, and reflects a real choice (passes the decisions-not-defaults test). A recording candidate. Cite 2 to 3 example files. +- Conflict. Both styles present in meaningful numbers. Report the split with counts and example files. Never record a preferred winner while the code remains mixed, even in yolo, because that would describe an aspiration rather than reality. Record only if the user identifies a stable path or context boundary that explains both styles; otherwise defer until the code is reconciled. +- Default. Consistent, but a framework or common-practice default the agent already writes unprompted. Skip it as a no-op, not a convention. +- No signal. Under the bar: feature unused, or too few examples. Skip silently (one summary line at most). +- Tooling-owned or Already-recorded. Skip per the ground rules. + +Done when: every applicable dimension carries exactly one of those verdicts. + +### Step 2: Open-ended pass + +First, close out the architecture map from Step 0. For every non-default `app/` directory you listed, confirm how the pattern is used and apply the same evidence and decisions-not-defaults tests as Step 1. Generator-standard or sparsely used directories such as `Rules`, `Observers`, `Mail`, and `Notifications` are signals to inspect, not automatic conventions. Make genuine structural patterns candidates: Action classes invoked via `handle` / `execute` / `__invoke`, Services constructor-injected, `Queries` objects exposing `builder(): Builder`, DTOs as readonly classes or spatie/laravel-data, module or domain folders as the unit of organization. Scope each qualifying pattern to its own directory glob. Also record a consistent deliberate absence, such as "no repository layer, controllers query Eloquent directly", so the next agent matches the app's altitude. + +Then find what else makes this codebase itself: base or abstract classes most code extends, traits used everywhere, tenancy or authorization scoping woven through queries, naming schemes, and custom helpers. Same evidence bar, cite files. Record every genuine structural pattern, and cap the other house findings at ~5 so the pass stays high-signal. + +Done when: every non-default `app/` directory from Step 0 has a verdict, and the pass has produced its cited house findings (or concluded there are none). + +### Step 3: Confirm + +Present every candidate in one batch. Per item: dimension, verdict, evidence (counts and files), and the exact proposed `glob` or `globs` / `title` / `note`. Conflicts are presented as questions about an existing context boundary or deferred cleanup, not as a choice of future style. + +Default mode is confirm: record only what the user approves. Switch to yolo only when the invocation said so ("yolo", "don't ask", "just record them"), then record all pattern candidates without asking. Conflicts still go to the user in yolo. + +Done when: every candidate is approved, rejected, or (conflicts) decided. + +### Step 4: Record + +Make one `record-rule` call for each glob an approved convention applies to. Choose the most specific globs that cover the cited evidence from the mapping table below; if a convention spans models and migrations, record it under both domains so agents discover it from either path. The `note` is the bare convention: strip every trace of detection (see the ground rule). If `record-rule` is unavailable (rules disabled), report the full rule text so the user can enable `BOOST_RULES_ENABLED` or add it by hand. + +Record this: + +> Accessors and mutators: use the legacy magic-method style (`getXxxAttribute()` / `setXxxAttribute()`), not the `Attribute` class. Match it in models. + +Not this: + +> Accessors/mutators use the legacy magic-method style; the `Attribute`-class style is not used anywhere (13 legacy, 0 Attribute-class), e.g. `app/Models/Post.php`. Match the legacy style in existing models. + +Done when: every approved item has a successful tool response, and any failure is reported with its rule text. + +### Step 5: Summarize + +List recorded rules (file and title), conflicts the user deferred, notable no-signals, and remind the user to commit `.ai/rules` so their team and agents share the conventions. + +## Glob mapping + +Attach each rule to the most specific path that covers its evidence. Never a lazy `app/**` when a subtree fits. Match the glob to where the code actually lives, which is not the same in a default skeleton and in a modular or DDD layout. Use the Step 0 `app/` map to pick the real path. + +Examples: + +- Models: `app/Models/**` in a default app, or `app/Modules/Blog/Models/**` / `src/Domain/Blog/**` in a modular one. +- Controllers, routing, validation, responses: `app/Http/**`, or `app/Modules/*/Http/**` when each module owns its HTTP layer. +- Actions, Services, DTOs: `app/Actions/**`, `app/Services/**`, `app/Data/**`, or the module path the app actually uses. +- Tests: `tests/**`. +- Migrations and database: `database/migrations/**`. +- Truly app-wide (rare, e.g. auth retrieval): `app/**`. + +`record-rule` takes one glob. When a convention genuinely spans two domains (e.g. UUID keys touch models and migrations), call it once per domain with the same title and note; mentioning another path in the note does not make the rule discoverable there. + +## Edge cases + +- Rules disabled or `record-rule` missing: detection is read-only, so Steps 0 to 3 still run, and recording falls back to the manual path in Step 4. +- Tiny or fresh app: most dimensions land on no-signal. Say so honestly ("not enough code to infer conventions yet") and record nothing. +- Huge app: each dimension is a bounded grep plus a handful of file reads. Sample representative files, do not read everything. +- Re-runs: reading `.ai/rules` in Step 0 makes re-runs incremental, so only new or undecided dimensions surface. +- Non-standard layout (modules, DDD): the open-ended pass catches the layout itself as convention #1. Adapt the globs in the mapping table to the observed paths. diff --git a/.cursor/skills/infer-conventions/references/checklist.md b/.cursor/skills/infer-conventions/references/checklist.md new file mode 100644 index 00000000..2b45cc25 --- /dev/null +++ b/.cursor/skills/infer-conventions/references/checklist.md @@ -0,0 +1,141 @@ +# Detection Checklist + +Every dimension here is a genuine fork: Laravel offers two or more valid approaches, the app's choice changes what the next agent writes, and no active project tool can pick for you. Left out on purpose: pure formatting (Pint owns it), any form an installed and enabled Rector rule rewrites to one canonical shape (`$casts` to `casts()`, `$fillable` to attributes, pipe-string rules to arrays, named to anonymous migrations, `$signature` to `#[Signature]`), and framework defaults any agent writes unprompted (`ShouldQueue` jobs, relation return types, `HasFactory`). + +Each item gives the fork, then a hint (a grep or dir to spot which side the app takes). Hints are only a start. Read the matched files, never record on a raw count. Apply the ground rules to every verdict: a consistent choice that is a default or a tool's target form is not a pattern. Rows tagged (architecture) are the highest-signal, so record presence and deliberate absence. + +--- + +## A. Validation & HTTP input + +1. Validation entry point: inline `$request->validate()` vs Form Request classes vs `Validator::make()`. + - Hint: `ls app/Http/Requests`; grep `->validate(` / `Validator::make(` in `app/Http/Controllers`. +2. Custom rule location: invokable rule objects in `app/Rules` vs inline closures vs `Validator::extend()` in a provider. Rule objects are the default `make:rule` path, so record only if the app leans on closures or `Validator::extend` instead. "No rule objects" alone is just no-signal. + - Hint: `ls app/Rules`; grep `Validator::extend` in `app/Providers`. +3. Typed input retrieval: typed getters (`$request->string()`, `->integer()`, `->enum()`, `->date()`) vs raw `$request->input()` / dynamic properties. + - Hint: grep `->string(` / `->integer(` / `->enum(` vs `->input(` in `app/Http`. +4. Custom messages/attributes: `lang/*/validation.php` vs Form Request `messages()` / `attributes()` methods. + - Hint: `ls lang`; grep `function messages`, `function attributes` in `app/Http/Requests`. + +## B. Controllers & routing + +5. Controller shape: invokable single-action (`__invoke`) vs resource controllers vs plain multi-method. + - Hint: grep `__invoke` in controllers; `Route::resource` / `apiResource` vs verb routes. +6. Business-logic location (architecture): fat controllers vs delegated to Actions / Services / Jobs. + - Hint: read a few controller methods; `ls app/Actions app/Services`. +7. Route handler style: closures in `routes/*.php` vs controller classes. + - Hint: count `function ()` vs `::class` in `routes/web.php`, `routes/api.php`. +8. Middleware assignment: route/group `->middleware()` vs controller `HasMiddleware::middleware()` vs `#[Middleware]` attribute. + - Hint: grep `implements HasMiddleware`, `#[Middleware(` in controllers vs `->middleware(` in routes. +9. Route model binding: implicit (type-hinted models) vs explicit `Route::bind` vs manual `findOrFail`. + - Hint: typed model params in signatures vs `findOrFail(` in controllers; grep `Route::bind`. +10. Rate limiting: named `RateLimiter::for()` + `throttle:name` vs inline `throttle:60,1`. + - Hint: grep `RateLimiter::for` in providers vs `throttle:` in route files. + +## C. Authorization + +11. Authorization home: Gates (`Gate::define`) vs Policy classes in `app/Policies`. + - Hint: `ls app/Policies`; grep `Gate::define` in `app/Providers`. +12. Authorization call site: `$this->authorize()` / `Gate::authorize()` vs `$user->can()` vs `can` middleware vs `#[Authorize]` vs `@can` in Blade. + - Hint: grep `authorize(`, `->can(`, `middleware('can:`, `#[Authorize(`, `@can(`. + +## D. Eloquent & models + +13. Mass assignment: `$fillable` allow-list vs `$guarded` block-list. + - Hint: grep `protected $fillable` / `protected $guarded` in `app/Models`. +14. Accessors/mutators: modern `Attribute` class vs legacy `getXxxAttribute()` / `setXxxAttribute()`. Record a legacy hold, it goes against the tool's grain. + - Hint: grep `: Attribute` / `Attribute::make` vs `function get[A-Z].*Attribute` in `app/Models`. +15. Primary keys: auto-increment vs `HasUuids` vs `HasUlids`. + - Hint: grep `HasUuids` / `HasUlids` in `app/Models`; migration `id()` vs `uuid('id')`. +16. Custom casts: dedicated `CastsAttributes` classes (`app/Casts`) vs inline `Attribute` vs built-in cast strings. + - Hint: `ls app/Casts`; grep `Cast::class`, `AsStringable::class` in models. +17. Data/query layer (architecture): Eloquent directly in controllers vs repositories vs dedicated query objects (e.g. classes exposing `builder(): Builder`). + - Hint: `ls app/Repositories app/Queries`; see where non-trivial queries are built. +18. Query scopes: local `scope`/`#[Scope]` methods vs dedicated builder classes. + - Hint: grep `function scope` / `#[Scope]` in models; `ls app/*/Builders`. +19. Model events: observers (`app/Observers`, `#[ObservedBy]`) vs `booted()` closures vs event classes. + - Hint: `ls app/Observers`; grep `booted`, `::observe`, `#[ObservedBy]`. +20. Eager-load posture: explicit per-query `->with()` vs model-level `$with` defaults. Treat `preventLazyLoading()` separately as a development guard because it can complement either posture. + - Hint: grep `protected $with`, `->with(`, and separately `preventLazyLoading` in `app/`. + +## E. Architecture & organization + +21. Action/Service structure (architecture): Action classes (invoked via `handle` / `execute` / `__invoke`) vs service objects vs neither. Cross-check the Step 0 `app/` map: any `Actions`/`Services`/`Pipelines`/`Jobs`-as-actions folder is this pattern, so record how it is invoked. + - Hint: `ls app/` (the whole tree, not just `Actions`/`Services`); grep the invocation method in the folder you find. +22. DTOs (architecture): spatie/laravel-data vs plain readonly classes vs arrays everywhere. + - Hint: `ls app/Data`; grep `extends Data`, `readonly class` in `app/`. +23. Dependency acquisition: constructor/method injection vs `app()` / `resolve()` / `App::make()` service location. + - Hint: grep `app(` / `resolve(` / `::make(` in `app/` vs promoted constructor deps. +24. Decoupling: events + listeners vs direct service calls. + - Hint: `ls app/Events app/Listeners`; grep `event(`, `::dispatch(`. +25. Helper vs facade idiom: global helpers (`config()`, `auth()`, `response()`) vs facades (`Config::`, `Auth::`, `Response::`). + - Hint: ratio of `config(` vs `Config::` (etc.) across `app/`. +26. Namespace layout (architecture): default `app/` skeleton vs domain/module folders (`app/Domain/**`, modules). + - Hint: `ls app/`, look for `Domain/`, `Modules/`, bounded-context folders. +27. Enums: backed vs pure; case naming; where they live. + - Hint: `ls app/Enums`; grep `enum .*: string`, `enum .*: int`. + +## F. Frontend & views + +This app ships a frontend stack, so the items below apply. + +28. Frontend stack: Blade+Livewire vs Inertia (Vue/React/Svelte) vs Blade-only / API + separate SPA. + - Hint: `composer.json` + `package.json`; `ls resources/js/pages`, `resources/views`. +29. Blade composition: class `` components vs anonymous components (`@props`) vs `@include` partials. + - Hint: `ls app/View/Components`; grep `constrained()` vs `foreignIdFor(Model::class)` vs manual `foreign()->references()->on()`. + - Hint: grep `foreignId(`, `foreignIdFor(`, `->foreign(` in `database/migrations`. +34. `down()` methods: real reverse logic vs omitted / one-way migrations. + - Hint: grep `function down` vs the migration count. +35. Enum storage: DB `enum()` column vs `string()` + PHP-enum cast on the model. + - Hint: grep `->enum(` in migrations vs string columns cast to enums. +36. Transactions: `DB::transaction(fn ...)` closure vs manual `beginTransaction` / `commit` / `rollBack`. + - Hint: grep `DB::transaction`, `beginTransaction` in `app/`. +37. Idempotent writes: `upsert` / `updateOrCreate` / `firstOrCreate` vs find-then-save. + - Hint: grep `upsert(`, `updateOrCreate(`, `firstOrCreate(` in `app/`. + +## H. Testing + +38. Framework: Pest (`it()` / `test()` / `expect()`) vs PHPUnit classes. + - Hint: `ls tests/Pest.php`; grep `it(` / `test(` vs `extends TestCase`. +39. DB reset: `RefreshDatabase` vs `DatabaseTruncation` vs `DatabaseMigrations`. + - Hint: grep those trait names in `tests/`. +40. Fixtures: compare how equivalent test-owned records are created, such as factories vs manual inserts. Track seeders separately for shared reference data because `$this->seed()` commonly and legitimately coexists with factories. + - Hint: grep `::factory(` and direct inserts in `tests/`; separately inspect `$this->seed(` calls and what those seeders provide. +41. Collaborator isolation: how the app doubles its own classes, Mockery `mock()` / `spy()` vs real integration. Ignore facade fakes like `Mail::fake()` here, they isolate framework services by default and are not a fork against Mockery. + - Hint: grep `->mock(`, `->spy(`, `Mockery::` in `tests/`. +42. Endpoint assertions: array `assertJson([...])` / `assertJsonFragment` vs fluent `AssertableJson`. + - Hint: grep `AssertableJson`, `assertJsonFragment` in `tests/`. + +## I. Responses & API resources + +43. Response shape: API Resource classes vs `response()->json()` vs returning models/arrays directly. + - Hint: `ls app/Http/Resources`; grep `JsonResource`, `->json(` in controllers. +44. Resource relationship inclusion: `whenLoaded()` guards vs unconditional relationship access. Do not count ordinary scalar attributes as rivals to conditional relationships, and evaluate general `when()` fields separately. + - Hint: compare relationship fields using `whenLoaded(` with unconditional relationship property access in `app/Http/Resources`. +45. Pagination contracts: within comparable endpoint categories, length-aware `paginate()` vs `simplePaginate()` vs `cursorPaginate()`. These have different totals, navigation, ordering, and performance contracts, so record only a stable path-scoped API policy, never a project-wide majority. + - Hint: grep those in `app/`, then group matches by endpoint type and client contract before comparing them. +46. Web redirects/URLs: `route('name')` vs `url('/path')` vs `action([...])`. + - Hint: grep `route('`, `url('/`, `action([` in `app/Http` and views. + +## J. Strings, collections & dates + +47. Iteration idiom: `collect()->map()->filter()` pipelines vs `array_map` / `foreach`. + - Hint: grep `collect(`, `->map(` vs `array_map`, `foreach` density in `app/`. +48. String API: fluent `Str::of()->...` (Stringable) vs static `Str::` vs native (`trim`, `strtoupper`). + - Hint: grep `Str::of(` vs `Str::` vs native string funcs. +49. Dates: compare equivalent construction call styles (`now()` / `today()` helpers vs `Carbon::`) separately from the application's mutable/immutable date policy. `Date::use(CarbonImmutable::class)` can make helpers return immutable dates, so those signals are complementary rather than conflicting. + - Hint: grep `now(` and `Carbon::` for call style; separately inspect `CarbonImmutable` and `Date::use` for mutability policy. + +--- + +Genuine forks only. Every row survived the "no tool can decide this, and it isn't the default" filter. Give each applicable dimension exactly one verdict: pattern, conflict, default, no-signal, tooling-owned, or already-recorded. The rows tagged (architecture) are where the highest-value rules come from. diff --git a/.cursor/skills/laravel-best-practices/SKILL.md b/.cursor/skills/laravel-best-practices/SKILL.md new file mode 100644 index 00000000..311ab844 --- /dev/null +++ b/.cursor/skills/laravel-best-practices/SKILL.md @@ -0,0 +1,59 @@ +--- +name: laravel-best-practices +description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns." +license: MIT +metadata: + author: laravel +--- + +# Laravel Best Practices + +Best practices for Laravel, organized as an index of rule files. Each rule file teaches what to do and why. For exact API syntax, verify with `search-docs`. + +## Consistency First + +Before applying any rule, check what the application already does. Laravel offers multiple valid approaches, and the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. + +Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it. Don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides. + +## How to Apply + +1. Check the changed files, nearby code, project configuration, and relevant tests for established patterns. Deviate only for a correctness or security defect, and call the deviation out. +2. Map every affected concern to the rule index below. Read each mapped rule file before editing. Skip unrelated rule files. +3. Make the smallest coherent change. Keep the application's architecture and naming instead of introducing a second pattern for the same job. +4. Verify version-sensitive Laravel APIs for the installed version with `search-docs`, or inspect the installed framework when it is unavailable. +5. Run the narrowest relevant tests first, then the project's formatting and static-analysis checks when the change warrants them. +6. Re-read the diff against every mapped rule before finishing. + +## Rule Index + +Cross-cutting changes often need more than one rule file. + +| Concern | Read | +| --- | --- | +| Query count, eager loading, indexes, large datasets | [`rules/db-performance.md`](rules/db-performance.md) | +| Subqueries, aggregates, complex ordering and query plans | [`rules/advanced-queries.md`](rules/advanced-queries.md) | +| Models, relationships, scopes, casts | [`rules/eloquent.md`](rules/eloquent.md) | +| Authentication, authorization, input safety, secrets, uploads | [`rules/security.md`](rules/security.md) | +| Form Requests and validation rules | [`rules/validation.md`](rules/validation.md) | +| Controllers, route binding, resources, middleware | [`rules/routing.md`](rules/routing.md) | +| Schema changes, columns, foreign keys, indexes | [`rules/migrations.md`](rules/migrations.md) | +| Jobs, retries, uniqueness, batches, Horizon | [`rules/queue-jobs.md`](rules/queue-jobs.md) | +| Cache lifetime, invalidation, locks, memoization | [`rules/caching.md`](rules/caching.md) | +| Outbound requests, retries, timeouts, fakes | [`rules/http-client.md`](rules/http-client.md) | +| Exceptions, reporting, rendering, log context | [`rules/error-handling.md`](rules/error-handling.md) | +| Events and notifications | [`rules/events-notifications.md`](rules/events-notifications.md) | +| Mailables and mail assertions | [`rules/mail.md`](rules/mail.md) | +| Scheduled tasks and overlap protection | [`rules/scheduling.md`](rules/scheduling.md) | +| Collections, lazy iteration, bulk operations | [`rules/collections.md`](rules/collections.md) | +| Blade components, attributes, composers | [`rules/blade-views.md`](rules/blade-views.md) | +| Environment values and application configuration | [`rules/config.md`](rules/config.md) | +| Tests: coverage, factories, fakes, and assertions | the `testing-best-practices` skill | +| Naming, helpers, file boundaries, PHP style | [`rules/style.md`](rules/style.md) | +| Actions, services, dependencies, application structure | [`rules/architecture.md`](rules/architecture.md) | + +## Decision Rules + +- Prefer framework features and existing application abstractions over new helpers or dependencies. +- Avoid speculative abstractions. Extract code when it creates a clear domain boundary, removes meaningful duplication, or makes behavior independently testable. +- Keep database access out of Blade views and prevent hidden N+1 queries across controllers, resources, jobs, and serialization. diff --git a/.cursor/skills/laravel-best-practices/rules/advanced-queries.md b/.cursor/skills/laravel-best-practices/rules/advanced-queries.md new file mode 100644 index 00000000..54dd783d --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/advanced-queries.md @@ -0,0 +1,106 @@ +# Advanced Query Best Practices + +## Select Single Relationship Values with Subqueries + +When only one value from a has-many relationship is needed, consider a correlated subquery with `addSelect()` instead of loading the entire relationship. This selects the value as part of the main query without an additional relationship query. + +```php +public function scopeWithLastLoginAt($query): void +{ + $query->addSelect([ + 'last_login_at' => Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->withCasts(['last_login_at' => 'datetime']); +} +``` + +## Create Dynamic Relationships with a Subquery Foreign Key + +The same pattern can select a foreign key and expose the selected model through a `belongsTo` relationship. Eager loading that relationship still executes a separate query, but it avoids loading the full has-many collection. + +```php +public function lastLogin(): BelongsTo +{ + return $this->belongsTo(Login::class, 'last_login_id'); +} + +public function scopeWithLastLogin($query): void +{ + $query->addSelect([ + 'last_login_id' => Login::select('id') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->with('lastLogin'); +} +``` + +## Combine Related Counts with Conditional Aggregates + +Combine several counts over the same filtered data set into one query by using conditional aggregates. Use `toBase()` when only scalar values are needed and model hydration provides no benefit. Confirm the expression syntax against the application's database engine. + +```php +$statuses = Feature::toBase() + ->selectRaw("count(case when status = 'Requested' then 1 end) as requested") + ->selectRaw("count(case when status = 'Planned' then 1 end) as planned") + ->selectRaw("count(case when status = 'Completed' then 1 end) as completed") + ->first(); +``` + +## Reuse Loaded Parent Models with `setRelation()` + +When a parent and its children are already loaded and code also accesses `$child->parent`, set the inverse relationship to the existing parent instance. This avoids an additional lazy-loading query for each child. + +```php +$feature->load('comments.user'); +$feature->comments->each->setRelation('feature', $feature); +``` + +## Compare `whereHas()` with an `IN` Subquery + +`whereHas()` typically produces an `EXISTS` subquery, while `whereIn()` can express the same filter with an `IN` subquery. Either form may be faster depending on the database engine, indexes, cardinality, and query plan. Measure both forms with representative data; neither subquery loads its result set into PHP memory. + +Option using `EXISTS`: + +```php +$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term)); +``` + +Option using `IN`: + +```php +$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id')); +``` + +## Measure Two Simple Queries Against One Complex Query + +Two targeted queries can outperform one complex correlated subquery or join when the first query is highly selective. They also add a database round trip, can transfer a large identifier list, and do not provide a single-query consistency snapshot. Decide from query plans and production-like measurements. + +## Design Composite Indexes for the Query + +For common multi-column sorts, consider a composite index whose column order supports the query's filters and ordering. Database engines may combine indexes or choose an explicit sort, so matching the `ORDER BY` list alone does not guarantee that an index will be used. Verify the query plan. + +```php +// Migration +$table->index(['last_name', 'first_name']); + +// Query that this index may support +User::query()->orderBy('last_name')->orderBy('first_name')->paginate(); +``` + +## Consider a Correlated Subquery for Has-Many Ordering + +When sorting by one value from a has-many relationship, a direct join can duplicate parent rows unless it first reduces the related table to one row per parent. A correlated subquery in `orderBy()` is often simpler, but its performance depends on the query plan and supporting indexes. + +```php +public function scopeOrderByLastLogin($query): void +{ + $query->orderByDesc(Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1) + ); +} +``` diff --git a/.cursor/skills/laravel-best-practices/rules/architecture.md b/.cursor/skills/laravel-best-practices/rules/architecture.md new file mode 100644 index 00000000..5e7af23b --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/architecture.md @@ -0,0 +1,219 @@ +# Architecture Best Practices + +## Extract Focused Business Operations + +Extract a discrete business operation into an action class when doing so makes the operation easier to reuse or test. An action class has no special meaning to Laravel; follow the project's naming and invocation conventions. + +```php +class CreateOrderAction +{ + public function __construct(private InventoryService $inventory) {} + + public function handle(array $data): Order + { + $order = Order::create($data); + $this->inventory->reserve($order); + + return $order; + } +} +``` + +## Inject Required Dependencies + +Prefer constructor injection for dependencies required throughout an object's lifetime. Method injection is appropriate for dependencies needed by one controller action, listener, job handler, or other container-invoked method. Avoid `app()` and `resolve()` when normal injection can make a dependency explicit. + +Hidden dependency: + +```php +class OrderController extends Controller +{ + public function store(StoreOrderRequest $request) + { + $service = app(OrderService::class); + + return $service->create($request->validated()); + } +} +``` + +Injected dependency: + +```php +class OrderController extends Controller +{ + public function store(StoreOrderRequest $request, OrderService $service) + { + return $service->create($request->validated()); + } +} +``` + +## Depend on Contracts at Boundaries + +Depend on contracts at system boundaries, such as payment gateways, notification channels, and external services, when testability or interchangeable implementations justify the abstraction. + +Concrete boundary dependency: + +```php +class OrderService +{ + public function __construct(private StripeGateway $gateway) {} +} +``` + +Contract boundary dependency: + +```php +interface PaymentGateway +{ + public function charge(int $amount, string $customerId): PaymentResult; +} + +class OrderService +{ + public function __construct(private PaymentGateway $gateway) {} +} +``` + +Bind in a service provider: + +```php +$this->app->bind(PaymentGateway::class, StripeGateway::class); +``` + +## Specify a Deterministic Sort Order + +Without an explicit `ORDER BY`, row order is undefined. Choose an order that matches the feature, and add a unique tie-breaker when stable pagination matters. + +Unspecified order: + +```php +$posts = Post::paginate(); +``` + +Newest first with a stable tie-breaker: + +```php +$posts = Post::query() + ->orderByDesc('created_at') + ->orderByDesc('id') + ->paginate(); +``` + +## Use Atomic Locks for Race Conditions + +Use a lock when concurrent execution must be serialized. `Cache::lock()` provides an atomic lock when the configured cache store supports locks. `lockForUpdate()` locks selected database rows and must run inside a database transaction. These mechanisms solve different coordination problems. + +```php +Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) { + $order->process(); +}); + +// Or at query level, inside a transaction +DB::transaction(function () use ($id) { + $product = Product::where('id', $id)->lockForUpdate()->first(); + + // Read and update the product while the database lock is held. +}); +``` + +## Use `mb_*` String Functions + +When no Laravel helper exists, prefer multibyte-aware functions such as `mb_strlen()` and `mb_strtolower()` for UTF-8 text. For example, `strlen()` counts bytes, while `strtolower()` is not multibyte-aware. + +Incorrect: + +```php +strlen('José'); // 5 bytes, not 4 characters +strtolower('MÜNCHEN'); // Does not lowercase Ü +``` + +Correct: + +```php +mb_strlen('José'); // 4 characters +mb_strtolower('MÜNCHEN'); // 'münchen' + +// Prefer Laravel's Str helpers when available +Str::length('José'); // 4 +Str::lower('MÜNCHEN'); // 'münchen' +``` + +## Use `defer()` for Post-Response Work + +For lightweight work that does not need retries or crash durability, consider `defer()` instead of dispatching a job. During an HTTP request, the callback normally runs after the response has been sent but remains in the same PHP process. + +Queued and durable: + +```php +dispatch(new LogPageView($page)); +``` + +Deferred in the current process: + +```php +defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()])); +``` + +Use a queued job when the work needs retries, queue controls, or durability across process failures. + +## Use `Context` for Request-Scoped Data + +The `Context` facade makes contextual data available across the current execution lifecycle without manually passing arguments through every layer. + +```php +// In middleware +Context::add('tenant_id', $request->header('X-Tenant-ID')); + +// Later in the same execution lifecycle +$tenantId = Context::get('tenant_id'); +``` + +Visible context is added to log context, and both visible and hidden context are captured and restored for queued jobs. Use `Context::addHidden()` for data that should propagate to queued jobs without appearing in logs. Do not place secrets in context unless that propagation is intended. + +## Use `Concurrency::run()` for Parallel Execution + +Run independent operations concurrently through Laravel's configured concurrency driver. + +```php +use Illuminate\Support\Facades\Concurrency; + +[$users, $orders] = Concurrency::run([ + fn () => User::count(), + fn () => Order::where('status', 'pending')->count(), +]); +``` + +With a process-based driver, each closure runs in a separate PHP process that boots the application. Use concurrency when independent database queries, HTTP client calls, or computations benefit enough to offset process and serialization overhead. The `sync` driver executes closures sequentially and is useful primarily during testing. + +## Follow Framework Conventions + +Follow Laravel conventions unless the domain or an existing schema requires an override. + +Customized schema: + +```php +class Customer extends Model +{ + protected $table = 'Customer'; + protected $primaryKey = 'customer_id'; + + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id'); + } +} +``` + +Conventional schema: + +```php +class Customer extends Model +{ + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class); + } +} +``` diff --git a/.cursor/skills/laravel-best-practices/rules/blade-views.md b/.cursor/skills/laravel-best-practices/rules/blade-views.md new file mode 100644 index 00000000..4ea2c6c3 --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/blade-views.md @@ -0,0 +1,36 @@ +# Blade and View Best Practices + +## Use `$attributes->merge()` in Component Templates + +Use the component attribute bag so callers can add attributes. `merge()` combines default attributes with caller-provided values; class values receive special merging behavior. + +```blade +
merge(['class' => 'alert alert-'.$type]) }}> + {{ $message }} +
+``` + +## Use `@pushOnce` for Per-Component Scripts + +If a component renders repeatedly, `@push` adds its script on every render. Use a consistently named `@pushOnce` block to add that content once per rendered response. + +## Prefer Components for Explicit Interfaces + +Use a Blade component when a reusable interface benefits from explicit props, an attribute bag, or slots. An include remains suitable for a small partial that intentionally uses the current view data; pass an explicit data array when implicit variable sharing would obscure its dependencies. + +## Share Compatible View Data with a View Composer + +Use a view composer to centralize data needed whenever one or more named Blade views are rendered. Keep the composer compatible with every view it targets, and avoid broad wildcards when views require different data shapes. A view composer runs when Laravel renders the matching view; it does not supply data to JSON, streamed, or other non-view responses. + +## Return Blade Fragments for Partial Rendering + +A route can return either a full view or a named fragment for clients such as htmx or Turbo. + +```php +return view('dashboard', compact('users')) + ->fragmentIf($request->hasHeader('HX-Request'), 'user-list'); +``` + +## Share Parent Component Props with `@aware` + +Use `@aware` when a nested component needs a prop explicitly passed to an ancestor component. It does not expose an ancestor's default prop value unless that value was passed through the attribute bag. diff --git a/.cursor/skills/laravel-best-practices/rules/caching.md b/.cursor/skills/laravel-best-practices/rules/caching.md new file mode 100644 index 00000000..cd2ffd60 --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/caching.md @@ -0,0 +1,100 @@ +# Caching Best Practices + +## Use `Cache::remember()` for Cache-Aside Reads + +`Cache::remember()` implements a cache-aside read without a separate truthiness check. It does not prevent concurrent requests from computing the same missing value; use an atomic lock when duplicate computation must be prevented. + +The manual version below incorrectly treats valid falsy values, such as `false` or `0`, as cache misses. + +Incorrect: + +```php +$val = Cache::get('stats'); +if (! $val) { + $val = $this->computeStats(); + Cache::put('stats', $val, 60); +} +``` + +Correct: + +```php +$val = Cache::remember('stats', 60, fn () => $this->computeStats()); +``` + +## Consider `Cache::flexible()` for Stale-While-Revalidate + +For frequently read keys, `Cache::flexible()` can serve stale data during a defined stale period and register a deferred refresh. During an HTTP request, that refresh normally runs after the response; it is not a durable background job. Once the stale period has elapsed, the request recomputes the value synchronously. + +Synchronous expiration: + +```php +Cache::remember('users', 300, fn () => User::all()); +``` + +Stale-while-revalidate tradeoff: + +```php +Cache::flexible('users', [300, 600], fn () => User::all()); +``` + +This value is fresh for five minutes and may be served stale until ten minutes after it was cached. + +## Use `Cache::memo()` to Avoid Redundant Hits Within an Execution + +If the same cache key is read repeatedly during one request or job, `memo()` decorates a cache store and retains resolved values in memory for that execution. + +```php +$settings = Cache::memo()->get('settings'); +``` + +Repeated reads through the same memoized store avoid additional store lookups. Writes through the memoized store update or invalidate its in-memory values as appropriate. + +## Use Cache Tags to Invalidate Related Groups + +Tags group related entries for invalidation without tracking each key. Cache tags are not supported by the `file`, `dynamodb`, or `database` drivers; confirm support before choosing a store. + +```php +Cache::tags(['user-1'])->flush(); +``` + +## Use `Cache::add()` for Atomic Conditional Writes + +`add()` atomically writes a value only when the key does not already exist. + +Incorrect: + +```php +if (! Cache::has('lock')) { + Cache::put('lock', true, 10); +} +``` + +Correct: + +```php +Cache::add('lock', true, 10); +``` + +Use `Cache::lock()` rather than an ordinary cache key when lock ownership and safe release are required. + +## Use `once()` for In-Process Memoization + +`once()` memoizes a callback's return value for the current request or job. Calls made from an object instance are scoped to that instance. Unlike `Cache::memo()`, `once()` does not read from an external cache store. + +```php +public function roles(): Collection +{ + return once(fn () => $this->loadRoles()); +} +``` + +Repeated calls return the memoized result without rerunning the callback. Use `once()` for repeated computation within one execution. Use `Cache::memo()` to memoize access to an underlying store that can also persist values across executions. + +## Configure Failover Cache Stores in Production + +The failover driver tries each configured store in order when a store operation throws an exception. It does not consult later stores for an ordinary cache miss, and data is not replicated between stores. + +```php +'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']], +``` diff --git a/.cursor/skills/laravel-best-practices/rules/collections.md b/.cursor/skills/laravel-best-practices/rules/collections.md new file mode 100644 index 00000000..211fec11 --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/collections.md @@ -0,0 +1,72 @@ +# Collection Best Practices + +## Use Higher-Order Messages for Simple Operations + +Explicit closure: + +```php +$users->each(function (User $user) { + $user->markAsVip(); +}); +``` + +Concise equivalent: + +```php +$users->each->markAsVip(); +``` + +Higher-order messages are available for supported collection methods such as `each`, `map`, `filter`, and `sum`. Use an explicit closure when arguments or nontrivial logic would be clearer. + +## Choose Between `cursor()` and `lazy()` + +`cursor()` executes one query and hydrates models individually, but it cannot eager load relationships. The database driver's result buffering can still consume substantial memory for very large results. Use it for low-memory, attribute-only iteration when one long-running query is acceptable. + +`lazy()` executes multiple chunked queries and returns a flat `LazyCollection`. It supports eager loading relationships for each chunk and avoids holding one database cursor open for the entire iteration. + +With relationships: + +```php +User::with('roles')->lazy()->each(function (User $user) { + // The roles for this chunk have been eager loaded. +}); +``` + +Without relationships: + +```php +User::cursor()->each(function (User $user) { + // Process model attributes. +}); +``` + +## Use `lazyById()` When Updating Records While Iterating + +`lazy()` uses offset pagination, so updates to columns that affect the query can shift rows and cause records to be skipped or processed twice. `lazyById()` paginates by a monotonic key and is safer when updating other columns during iteration. Do not change the pagination key itself while iterating. + +## Use `toQuery()` for Bulk Operations on Collections + +Use `toQuery()` to build a query from the models in an Eloquent collection instead of manually constructing a `whereIn` clause. + +Manual query: + +```php +User::whereIn('id', $users->modelKeys())->update(['active' => false]); +``` + +Collection query: + +```php +$users->toQuery()->update(['active' => false]); +``` + +`toQuery()` requires a non-empty Eloquent collection whose models are of the same type. Like other bulk Eloquent updates, it does not dispatch per-model update events, so use it only when those events are not required. + +## Use `#[CollectedBy]` for Custom Collection Classes + +The `#[CollectedBy]` attribute declares the custom collection class without requiring a `newCollection()` override. + +```php +#[CollectedBy(UserCollection::class)] +class User extends Model {} +``` diff --git a/.cursor/skills/laravel-best-practices/rules/config.md b/.cursor/skills/laravel-best-practices/rules/config.md new file mode 100644 index 00000000..e9811d29 --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/config.md @@ -0,0 +1,85 @@ +# Configuration Best Practices + +## Read Environment Variables in Configuration Files + +Call `env()` only from configuration files. After configuration is cached, Laravel does not load the application's `.env` file, so application code should read configuration values through `config()`. + +Incorrect: + +```php +$key = env('API_KEY'); +``` + +Correct: + +```php +// config/services.php +return [ + 'key' => env('API_KEY'), +]; + +// Application code +$key = config('services.key'); +``` + +## Protect Production Secrets + +Do not commit plaintext production secrets. Laravel can encrypt an environment file so its encrypted form can be stored safely, while deployment platforms can supply secrets through their native secret stores. + +Incorrect: + +```bash +# A plaintext .env file committed to the repository +STRIPE_SECRET=sk_live_abc123 +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI +``` + +Encrypted environment file: + +```bash +php artisan env:encrypt --env=production --readable +php artisan env:decrypt --env=production +``` + +For hosted deployments, consider the platform's native secret store, such as AWS Secrets Manager or Vault, and inject secrets at runtime. + +## Use `App::environment()` for Environment Checks + +Incorrect: + +```php +if (env('APP_ENV') === 'production') { + // ... +} +``` + +Correct: + +```php +if (app()->isProduction()) { + // ... +} + +if (App::environment('production')) { + // ... +} +``` + +## Name Repeated Domain Values + +Use an enum or class constant when a domain value is repeated or represents a constrained set. A one-off string literal does not always need a named constant. + +```php +// Repeated literal +return $this->type === 'normal'; + +// Named domain value +return $this->type === self::TYPE_NORMAL; +``` + +If the application supports localization, put user-facing strings in language files and retrieve them with `__()`. Simple literals are reasonable for applications that intentionally do not support multiple languages. + +```php +// In a localized application +return back()->with('message', __('app.article_added')); +``` diff --git a/.cursor/skills/laravel-best-practices/rules/db-performance.md b/.cursor/skills/laravel-best-practices/rules/db-performance.md new file mode 100644 index 00000000..3f339770 --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/db-performance.md @@ -0,0 +1,189 @@ +# Database Performance Best Practices + +## Eager Load Relationships Before Iterating + +When a relationship will be accessed for many models, eager load it with `with()` to avoid running one initial query plus one relationship query per model, commonly called an N+1 query pattern. Lazy loading is reasonable when the relationship may not be needed or only one model is involved. + +Lazy-loaded version: + +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Eager-loaded version: + +```php +$posts = Post::with('author')->get(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Constrain eager loads when large columns are unnecessary. Include the related model's primary key and every column Eloquent needs to match the relationship. In this example, `users.id` and `posts.user_id` match posts to users, while selecting `posts.id` preserves each related model's primary key: + +```php +$users = User::with(['posts' => function ($query) { + $query->select('id', 'user_id', 'title') + ->where('published', true) + ->latest() + ->limit(10); +}])->get(); +``` + +## Prevent Lazy Loading in Development + +Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development. + +```php +public function boot(): void +{ + Model::preventLazyLoading(! app()->isProduction()); +} +``` + +By default, accessing an unloaded relationship then throws a `LazyLoadingViolationException`. Applications can customize violation handling with `handleLazyLoadingViolationUsing()`. + +## Select Only Needed Columns + +Select only the columns the operation needs when omitting large text, binary, or JSON columns provides a meaningful benefit. + +All columns: + +```php +$posts = Post::with('author')->get(); +``` + +Selected columns: + +```php +$posts = Post::select('id', 'title', 'user_id', 'created_at') + ->with(['author:id,name,avatar']) + ->get(); +``` + +When limiting selected columns, retain every key Eloquent needs for matching. A `belongsTo` relationship needs its foreign key on the parent query and the owner's key on the related query. A `hasMany` relationship needs the parent's local key and the related model's foreign key. + +## Process Large Data Sets Incrementally + +Use chunking or lazy iteration when loading an entire result set would exceed the application's practical memory budget. + +Loads the complete result set: + +```php +$users = User::all(); +foreach ($users as $user) { + $user->notify(new WeeklyDigest); +} +``` + +Processes bounded chunks: + +```php +User::where('subscribed', true)->chunk(200, function ($users) { + foreach ($users as $user) { + $user->notify(new WeeklyDigest); + } +}); +``` + +Use `chunkById()` when updates can change which rows match the query. Standard `chunk()` uses offset pagination, whose result positions can shift as rows change: + +```php +User::where('active', false)->chunkById(200, function ($users) { + $users->each->delete(); +}); +``` + +For read-only, attribute-only iteration, `cursor()` hydrates models individually from one query, although some database drivers still buffer raw results. Use `lazy()` when relationships must be eager loaded in chunks, and use `lazyById()` or `chunkById()` when updates can affect query membership. See the collection rules for detailed tradeoffs. + +## Add Indexes for Measured Query Patterns + +Design indexes around frequent, performance-sensitive query patterns. A column's presence in `WHERE`, `ORDER BY`, `JOIN`, or `GROUP BY` does not by itself justify an index; selectivity, write cost, existing indexes, and the database query plan all matter. + +Schema without an application-specific query index: + +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Schema optimized for `WHERE status = ? ORDER BY created_at`: + +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); + $table->index(['status', 'created_at']); +}); +``` + +Confirm composite index column order and effectiveness with production-like data and the database's query-plan tools. Also check whether the database already created an index to support a foreign key before adding another one. + +## Count Relationships Without Loading Them + +Use `withCount()` when only relationship counts are needed; loading and hydrating every related model wastes memory. + +Loads related models: + +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->comments->count(); +} +``` + +Selects relationship counts: + +```php +$posts = Post::withCount('comments')->get(); +foreach ($posts as $post) { + echo $post->comments_count; +} +``` + +Conditional counting: + +```php +$posts = Post::withCount([ + 'comments', + 'comments as approved_comments_count' => function ($query) { + $query->where('approved', true); + }, +])->get(); +``` + +## Keep Queries Out of Blade Templates + +Prepare data before rendering a Blade template, such as in a controller, query service, or view composer. This keeps query behavior visible and testable. + +Query in the template: + +```blade +@foreach (User::all() as $user) + {{ $user->profile->name }} +@endforeach +``` + +Data prepared before rendering: + +```php +// Controller +$users = User::with('profile')->get(); + +return view('users.index', compact('users')); +``` + +```blade +@foreach ($users as $user) + {{ $user->profile->name }} +@endforeach +``` diff --git a/.cursor/skills/laravel-best-practices/rules/eloquent.md b/.cursor/skills/laravel-best-practices/rules/eloquent.md new file mode 100644 index 00000000..5a89510d --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/eloquent.md @@ -0,0 +1,158 @@ +# Eloquent Best Practices + +## Define Precise Relationship Types + +Define the relationship that matches the database association, and declare its concrete return type. + +```php +public function comments(): HasMany +{ + return $this->hasMany(Comment::class); +} + +public function author(): BelongsTo +{ + return $this->belongsTo(User::class, 'user_id'); +} +``` + +## Use Local Scopes for Reusable Queries + +Extract reusable query constraints into local scopes to avoid duplication. + +Duplicated constraints: + +```php +$active = User::where('verified', true)->whereNotNull('activated_at')->get(); +$articles = Article::whereHas('user', function ($q) { + $q->where('verified', true)->whereNotNull('activated_at'); +})->get(); +``` + +Reusable local scope: + +```php +#[Scope] +protected function active(Builder $query): Builder +{ + return $query->where('verified', true)->whereNotNull('activated_at'); +} + +// Usage +$active = User::active()->get(); +$articles = Article::whereHas('user', fn ($q) => $q->active())->get(); +``` + +## Apply Global Scopes Sparingly + +Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy. + +Global scope tradeoff: + +```php +class PublishedScope implements Scope +{ + public function apply(Builder $builder, Model $model): void + { + $builder->where('published', true); + } +} + +// Admin panels, reports, and jobs now omit drafts unless the scope is removed. +``` + +Explicit local scope: + +```php +#[Scope] +protected function published(Builder $query): Builder +{ + return $query->where('published', true); +} + +Post::published()->paginate(); // Explicit +Post::paginate(); // Admin sees all +``` + +## Define Attribute Casts + +Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion. + +```php +protected function casts(): array +{ + return [ + 'is_active' => 'boolean', + 'metadata' => 'array', + 'total' => 'decimal:2', + ]; +} +``` + +## Cast Date and Time Attributes + +Cast a date or timestamp attribute when application code should treat it as a Carbon instance. Eloquent already casts the conventional `created_at` and `updated_at` timestamps. + +Manual parsing in the template: + +```blade +{{ Carbon::parse($order->ordered_at)->toDateString() }} +``` + +Model cast: + +```php +protected function casts(): array +{ + return [ + 'ordered_at' => 'datetime', + ]; +} +``` + +```blade +{{ $order->ordered_at->toDateString() }} +{{ $order->ordered_at->format('m-d') }} +``` + +## Use `whereBelongsTo()` for Relationship Queries + +`whereBelongsTo()` expresses the relationship constraint without manually specifying its foreign key. + +Foreign key constraint: + +```php +Post::where('user_id', $user->id)->get(); +``` + +Relationship-aware constraint: + +```php +Post::whereBelongsTo($user)->get(); +Post::whereBelongsTo($user, 'author')->get(); +``` + +## Keep Application Queries Model-Aware + +Prefer Eloquent models and relationships for model-backed application queries. They preserve casts, scopes, and model table configuration. The query builder and raw SQL legitimately require table names, so use them when their lower-level behavior is intentional. + +Lower-level alternatives: + +```php +DB::table('users')->where('active', true)->get(); + +$query->join('companies', 'companies.id', '=', 'users.company_id'); + +DB::select('SELECT * FROM orders WHERE status = ?', ['pending']); +``` + +Model-aware queries: + +```php +User::where('active', true)->get(); +Order::where('status', 'pending')->get(); +``` + +When a query builder operation should follow a model's configured table name, use `(new User)->getTable()`. For complex joins or raw SQL, explicit table names may be clearer; keep those references covered by tests when schema changes are possible. + +In migrations, use explicit table names rather than application models. Migrations are historical snapshots, while models and their scopes can change after a migration is deployed. diff --git a/.cursor/skills/laravel-best-practices/rules/error-handling.md b/.cursor/skills/laravel-best-practices/rules/error-handling.md new file mode 100644 index 00000000..afcdf4ac --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/error-handling.md @@ -0,0 +1,77 @@ +# Error Handling Best Practices + +## Choose Where to Report and Render Exceptions + +Laravel supports exception-specific methods and centralized handler callbacks. Follow the pattern already established by the project. + +Exception methods keep behavior beside the exception definition: + +```php +class InvalidOrderException extends Exception +{ + public function report(): void + { + // Send the exception to a custom reporter. + } + + public function render(Request $request): Response + { + return response()->view('errors.invalid-order', status: 422); + } +} +``` + +Centralized callbacks in `bootstrap/app.php` keep the application's exception policy together: + +```php +->withExceptions(function (Exceptions $exceptions) { + $exceptions->report(function (InvalidOrderException $e) { + // Send the exception to a custom reporter. + }); + $exceptions->render(function (InvalidOrderException $e, Request $request) { + return response()->view('errors.invalid-order', status: 422); + }); +}) +``` + +An exception's `report()` method suppresses Laravel's default reporting unless it returns `false`. A report callback allows default reporting unless it returns `false` or is chained with `stop()`. Use `ShouldntReport` or `dontReport()` when the handler should not report an exception at all. By contrast, returning `false` from a `render()` method or render callback defers to Laravel's default rendering. + +## Mark Exceptions the Handler Should Not Report + +Implementing `ShouldntReport` prevents Laravel's exception handler from reporting that exception type and keeps the policy visible on the class. It does not prevent application code from logging the exception explicitly. + +```php +class PodcastProcessingException extends Exception implements ShouldntReport {} +``` + +## Throttle High-Volume Exception Reports + +A failing integration can flood logs or error tracking. Configure `throttle()` with a `Lottery` or `Limit` result to sample or rate-limit matching exception reports. Choose keys deliberately when separate exception classes, tenants, or integrations need independent limits. + +## Prevent Duplicate Reports of One Exception Instance + +Enable `dontReportDuplicates()` when the same exception object may pass through multiple `report($exception)` calls. It deduplicates by object identity, not by exception class or message. + +## Define JSON Rendering for API Routes + +Laravel normally uses request content negotiation to decide whether to render an exception as JSON. If the application's API contract requires JSON regardless of the `Accept` header, define that policy explicitly for the relevant routes. + +```php +$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { + return $request->is('api/*') || $request->expectsJson(); +}); +``` + +## Add Context to Exception Classes + +Attach structured data to an exception through `context()`. Laravel merges that data into the exception's log context when the handler reports it. + +```php +class InvalidOrderException extends Exception +{ + public function context(): array + { + return ['order_id' => $this->orderId]; + } +} +``` diff --git a/.cursor/skills/laravel-best-practices/rules/events-notifications.md b/.cursor/skills/laravel-best-practices/rules/events-notifications.md new file mode 100644 index 00000000..a8cce31a --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/events-notifications.md @@ -0,0 +1,52 @@ +# Events and Notifications Best Practices + +## Rely on Event Discovery + +Laravel discovers listeners in the configured listener directories by inspecting type-hinted event arguments on `handle()` or `__invoke()` methods. Register listeners manually only when discovery is disabled, the listener is outside those directories, or explicit registration is clearer. + +## Cache Event Discovery During Production Deployment + +Cache discovered listeners during production deployment with `php artisan optimize` or `php artisan event:cache`. Rebuild the cache whenever listener definitions change. + +## Use `ShouldDispatchAfterCommit` Inside Transactions + +When an event is dispatched inside a database transaction, `ShouldDispatchAfterCommit` delays dispatch until all open database transactions commit. If a transaction rolls back, Laravel discards the event. This affects synchronous and queued listeners; it is not limited to queue timing. + +```php +class OrderShipped implements ShouldDispatchAfterCommit {} +``` + +## Queue Slow Notifications + +Queue notifications that call external services, such as email, text messaging, or Slack, when they do not need to complete before the response. Keep a notification synchronous when immediate completion or failure feedback is part of the operation. + +```php +class InvoicePaid extends Notification implements ShouldQueue +{ + use Queueable; +} +``` + +## Dispatch Queued Notifications After Commit + +A queued notification sent inside a database transaction can run before the transaction commits. Call `afterCommit()` on the queued notification, or enable the queue connection's `after_commit` option, when its delivery depends on committed data. This setting has no scheduling effect on a synchronous notification. + +```php +$user->notify((new InvoicePaid($invoice))->afterCommit()); +``` + +## Route Notification Channels to Dedicated Queues + +Different notification channels can have different latency and priority requirements. Implement `viaQueues()` when channels should use separate queues. + +## Use On-Demand Notifications for Non-User Recipients + +Avoid creating dummy models to send notifications to arbitrary addresses. + +```php +Notification::route('mail', 'admin@example.com')->notify(new SystemAlert()); +``` + +## Implement `HasLocalePreference` on Notifiable Models + +Implement `HasLocalePreference::preferredLocale()` on a notifiable model when notifications and mailables should use the recipient's locale. Laravel also preserves that locale for queued delivery. An explicit `locale()` call can still override the preference for an individual notification. diff --git a/.cursor/skills/laravel-best-practices/rules/http-client.md b/.cursor/skills/laravel-best-practices/rules/http-client.md new file mode 100644 index 00000000..cc8fdd6a --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/http-client.md @@ -0,0 +1,157 @@ +# HTTP Client Best Practices + +## Set Explicit Timeouts + +Laravel's HTTP client has a 30-second response timeout by default. Choose response and connection timeouts that fit the service and the calling request or job. Remember that retries can multiply the total elapsed time. + +Less resilient: + +```php +$response = Http::get('https://api.example.com/users'); +``` + +Preferred: + +```php +$response = Http::connectTimeout(3) + ->timeout(5) + ->get('https://api.example.com/users'); +``` + +Define shared settings in a macro or a dedicated client: + +```php +Http::macro('github', function () { + return Http::baseUrl('https://api.github.com') + ->connectTimeout(3) + ->timeout(10) + ->withToken(config('services.github.token')); +}); + +$response = Http::github()->get('/repos/laravel/framework'); +``` + +## Retry Only Safe Operations + +Retry transient connection failures, rate-limit responses, and server errors with an appropriate delay. Retry idempotent requests such as `GET` when the operation can safely run more than once. Retry a state-changing request only when the remote API supports an idempotency key or provides equivalent duplicate protection. + +Unsafe without an idempotency guarantee: + +```php +$response = Http::retry([100, 500, 1000]) + ->post('https://api.example.com/v1/charges', $data); +``` + +Safe for an idempotent request: + +```php +$response = Http::connectTimeout(3) + ->timeout(10) + ->retry([100, 500, 1000], 0, function (Throwable $exception) { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException + && ($exception->response->serverError() || $exception->response->status() === 429)); + }) + ->get('https://api.example.com/data'); +``` + +For a supported state-changing API, send a stable idempotency key for every attempt: + +```php +$response = Http::withHeaders(['Idempotency-Key' => $paymentAttempt->uuid]) + ->connectTimeout(3) + ->timeout(10) + ->retry([100, 500, 1000], 0, function (Throwable $exception) { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException + && ($exception->response->serverError() || $exception->response->status() === 429)); + }) + ->post('https://api.example.com/v1/charges', $data); +``` + +## Handle Errors Explicitly + +The HTTP client returns responses for `4xx` and `5xx` status codes instead of throwing by default. Inspect the expected statuses or call `throw()` before consuming a success payload. + +Unsafe when a success payload is expected: + +```php +$user = Http::get('https://api.example.com/users/1')->json(); +``` + +Preferred: + +```php +$user = Http::connectTimeout(3) + ->timeout(5) + ->get('https://api.example.com/users/1') + ->throw() + ->json(); +``` + +Handle expected alternatives explicitly when graceful degradation is required: + +```php +$response = Http::connectTimeout(3) + ->timeout(5) + ->get('https://api.example.com/users/1'); + +if ($response->successful()) { + return $response->json(); +} + +if ($response->notFound()) { + return null; +} + +$response->throw(); +``` + +## Pool Independent Requests + +Use `Http::pool()` when several independent requests can run concurrently. Pooling changes execution time, not error handling; inspect or throw for each response as needed. + +```php +use Illuminate\Http\Client\Pool; + +$responses = Http::pool(fn (Pool $pool) => [ + $pool->as('users')->connectTimeout(3)->timeout(5) + ->get('https://api.example.com/users'), + $pool->as('posts')->connectTimeout(3)->timeout(5) + ->get('https://api.example.com/posts'), +]); + +$users = $responses['users']->throw()->json(); +$posts = $responses['posts']->throw()->json(); +``` + +## Fake HTTP Requests in Tests + +Use `Http::fake()` for external integrations, and use `Http::preventStrayRequests()` when an unexpected real request should fail the test. Also test timeouts, connection failures, and error responses that the application handles. + +```php +it('syncs a user from the API', function () { + Http::preventStrayRequests(); + + Http::fake([ + 'api.example.com/users/1' => Http::response([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]), + ]); + + (new UserSyncService)->sync(1); + + Http::assertSent(fn (Request $request) => + $request->url() === 'https://api.example.com/users/1' + ); +}); +``` + +For example, fake a connection failure when testing the integration's failure path: + +```php +Http::fake([ + 'api.example.com/*' => Http::failedConnection(), +]); +``` diff --git a/.cursor/skills/laravel-best-practices/rules/mail.md b/.cursor/skills/laravel-best-practices/rules/mail.md new file mode 100644 index 00000000..f6310437 --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/mail.md @@ -0,0 +1,54 @@ +# Mail Best Practices + +## Queue Slow Mail Delivery + +Implement `ShouldQueue` on a mailable when delivery should normally happen in the background. Laravel queues that mailable even when the call site uses `Mail::send()`. + +```php +class OrderShipped extends Mailable implements ShouldQueue +{ + use Queueable, SerializesModels; +} +``` + +Keep mail synchronous when the caller must know immediately whether delivery was accepted, or when no queue worker is available. + +## Dispatch Queued Mail After Commit + +A queued mailable dispatched during a database transaction can be processed before the transaction commits. Call `afterCommit()` on the mailable, or enable the queue connection's `after_commit` option, when the mail depends on committed records. + +```php +Mail::to($user)->send( + (new OrderShipped($order))->afterCommit() +); +``` + +If the transaction rolls back, an after-commit mailable is not dispatched. This setting affects queued mail only; it does not defer synchronous delivery. + +## Assert the Delivery Mode + +Use `Mail::assertQueued()` for queued mailables and `Mail::assertSent()` for synchronously sent mailables. + +Incorrect for a mailable that implements `ShouldQueue`: + +```php +Mail::assertSent(OrderShipped::class); +``` + +Correct: + +```php +Mail::assertQueued(OrderShipped::class); +``` + +## Use Markdown Mailables When They Fit + +Markdown mailables render HTML and plain-text versions from Laravel's mail components and support publishable themes. They are useful for conventional transactional messages, but a custom HTML and text pair may be more appropriate for a specialized design. + +```bash +php artisan make:mail OrderShipped --markdown=mail.orders.shipped +``` + +## Separate Content and Delivery Tests + +Test rendered content by instantiating the mailable and using assertions such as `assertSeeInHtml()` and `assertSeeInText()`. Test delivery separately with `Mail::fake()` and `assertSent()` or `assertQueued()` so failures identify the affected behavior. diff --git a/.cursor/skills/laravel-best-practices/rules/migrations.md b/.cursor/skills/laravel-best-practices/rules/migrations.md new file mode 100644 index 00000000..3345402e --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/migrations.md @@ -0,0 +1,67 @@ +# Migration Best Practices + +## Generate Migrations with Artisan + +Use `php artisan make:migration` to generate the timestamped filename and migration structure. + +```bash +php artisan make:migration create_posts_table +php artisan make:migration add_slug_to_posts_table +``` + +## Define Foreign-Key Constraints Deliberately + +Use `constrained()` when its naming conventions and default actions match the relationship. Specify the table or delete behavior when they do not. + +```php +$table->foreignId('user_id')->constrained()->cascadeOnDelete(); +$table->foreignId('author_id')->constrained('users'); +``` + +Do not add a duplicate single-column index without checking the database driver's treatment of foreign-key indexes and the indexes already created by the migration. + +## Treat Deployed Migrations as Immutable + +After a migration has run in a shared or production environment, create a new migration for subsequent changes. Editing the old file makes fresh installations differ from upgraded installations. + +For a local migration that has not been shared or deployed, editing and rerunning it may be simpler. + +## Design Indexes for Real Queries + +Add indexes based on query patterns, selectivity, write cost, and the database's ability to use composite indexes. A column appearing in `WHERE`, `ORDER BY`, or `JOIN` does not automatically need its own index. + +Declare each selected index in the schema migration that creates or changes the relevant table. Confirm important indexes with representative data and the database's query plan, and avoid redundant indexes whose leading columns duplicate an existing index without serving a distinct query. See the database performance and advanced query rules for index selection and column-order guidance. + +## Stage Changes That Affect Existing Rows + +Adding a required or unique column to a populated table often needs multiple deployment-safe steps. Add a nullable column, deploy code that can handle both states, backfill existing rows in bounded chunks, then add the required constraint or index after the data is valid. + +Do not assume this migration is safe on a populated table: + +```php +$table->string('slug')->unique(); +``` + +Large backfills are usually better implemented as an observable, restartable command or job than inside a schema migration. Small deterministic data changes may be reasonable in a migration when their locking, transaction, and deployment behavior is understood. + +## Mirror Defaults Only When Unsaved Models Need Them + +A database default is applied when a row is inserted, not when a model is instantiated. Mirror the value in the model's `$attributes` only when application code must observe that default before persistence, and keep both definitions synchronized. + +```php +// Migration +$table->string('status')->default('pending'); + +// Model +protected $attributes = [ + 'status' => 'pending', +]; +``` + +## Make Rollbacks Honest + +Implement `down()` when the change can be safely reversed. A rollback that drops populated columns or cannot restore transformed data is destructive even if it is syntactically reversible; document that limitation and prefer a forward-fix migration in production. + +## Keep Migrations Focused + +Keep each migration small enough to reason about, deploy, and reverse. Separate long-running backfills from schema changes when doing so reduces locks and supports phased deployment, but do not split related operations merely to enforce a blanket separation between data definition and data manipulation. diff --git a/.cursor/skills/laravel-best-practices/rules/queue-jobs.md b/.cursor/skills/laravel-best-practices/rules/queue-jobs.md new file mode 100644 index 00000000..011828a6 --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/queue-jobs.md @@ -0,0 +1,117 @@ +# Queue and Job Best Practices + +## Keep Reservation Time Longer Than Execution Time + +For queue drivers that use Laravel's `retry_after` setting, configure it to exceed the longest worker or job timeout by a safety margin. When a reservation expires, another worker can reserve the same job while the first process is still running. Keep the worker's `--timeout` several seconds shorter than `retry_after`. + +```php +// Job +public $timeout = 120; + +// config/queue.php for the connection +'retry_after' => 150, +``` + +Amazon Simple Queue Service uses its visibility timeout instead of Laravel's `retry_after`; configure that timeout at the queue level. Because workers can also stop after side effects but before acknowledging a job, make important jobs idempotent even with correct timeout settings. + +## Back Off Transient Failures + +Use progressively longer delays when a dependency needs time to recover. Do not retry permanent validation or business-rule failures. + +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 4; + + public $backoff = [1, 5, 10]; +} +``` + +Rate-limiting and exception-throttling middleware can release jobs back to the queue. Released attempts may still count toward the maximum attempt limit, so configure `$tries` or `retryUntil()` to allow the intended retry window. + +## Use Unique Jobs for Dispatch Deduplication + +Implement `ShouldBeUnique` when only one queued instance of a logical job should exist. Uniqueness uses a cache lock and is not a substitute for idempotent processing or a database constraint. + +```php +class GenerateInvoice implements ShouldQueue, ShouldBeUnique +{ + public $uniqueFor = 3600; + + public function uniqueId(): string + { + return (string) $this->order->id; + } +} +``` + +All dispatching processes must use a shared cache that supports locks. Unique-job constraints do not apply to jobs within batches. + +Use `ShouldBeUniqueUntilProcessing` only when the lock should be released immediately before processing begins, allowing another instance to be dispatched while the first is running: + +```php +class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing +{ + // ... +} +``` + +## Handle Terminal Failure When Needed + +Implement `failed()` when the application must update state, alert an operator, or record domain-specific context after all attempts are exhausted. Logging every failure in each job may duplicate the queue system's failure reporting. + +Laravel invokes `failed()` on a new job instance, so mutations made to the job during `handle()` are not available there. + +```php +public function failed(?Throwable $exception): void +{ + $this->podcast->update(['status' => 'failed']); + + Log::error('Podcast processing failed', [ + 'podcast_id' => $this->podcast->id, + 'exception' => $exception, + ]); +} +``` + +## Rate Limit External Calls + +Use queue middleware such as `RateLimited` when jobs share a third-party API quota. Define the named limiter and choose release delays and attempt limits together. + +```php +public function middleware(): array +{ + return [new RateLimited('external-api')]; +} +``` + +## Batch Jobs for Group Coordination + +Use `Bus::batch()` to monitor a group of jobs and run callbacks when the batch completes or encounters failures. A batch is not a database transaction: completed jobs are not rolled back when another job fails. By default, one failed job cancels the batch; call `allowFailures()` only when partial failure is acceptable. + +```php +Bus::batch([ + new ImportCsvChunk($chunk1), + new ImportCsvChunk($chunk2), +]) + ->then(fn (Batch $batch) => Notification::send($user, new ImportComplete)) + ->catch(fn (Batch $batch, Throwable $exception) => Log::error('Import batch failed', [ + 'exception' => $exception, + ])) + ->dispatch(); +``` + +## Configure Time-Based Retry Limits Deliberately + +Use `retryUntil()` as the time-based alternative to a maximum attempt count. Laravel may attempt the job any number of times until this deadline, subject to other failure conditions such as maximum exceptions. The method takes precedence over attempt-based limits, so setting `$tries = 0` is not required. + +```php +public function retryUntil(): DateTimeInterface +{ + return now()->addHours(4); +} +``` + +## Use Horizon for Redis Queue Operations + +Laravel Horizon provides monitoring, balancing, metrics, and supervisor configuration for Redis queues. It does not support non-Redis queue drivers. diff --git a/.cursor/skills/laravel-best-practices/rules/routing.md b/.cursor/skills/laravel-best-practices/rules/routing.md new file mode 100644 index 00000000..604a8d96 --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/routing.md @@ -0,0 +1,106 @@ +# Routing and Controller Best Practices + +## Use Implicit Route Model Binding + +Let Laravel resolve models from route parameters when the default lookup and missing-model behavior fit the endpoint. + +Instead of manual lookup: + +```php +public function show(int $id): View +{ + $post = Post::findOrFail($id); + + return view('posts.show', ['post' => $post]); +} +``` + +Use route model binding: + +```php +public function show(Post $post): View +{ + return view('posts.show', ['post' => $post]); +} +``` + +## Scope Nested Bindings + +Use scoped bindings when a nested resource must belong to its parent. This constrains model resolution; it does not replace authorization. + +```php +Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { + // The resolved post belongs to the resolved user. +})->scopeBindings(); +``` + +## Use Resource Routes for Resourceful Actions + +Use `Route::resource()` or `Route::apiResource()` when the endpoint follows Laravel's resource-controller actions. Define explicit routes when the behavior does not fit that vocabulary. + +```php +Route::resource('posts', PostController::class); + +// Alternatively, for an API-only resource: +Route::apiResource('posts', ApiPostController::class); +``` + +`apiResource()` omits the HTML-oriented `create` and `edit` routes. It does not itself add an `/api` prefix; that prefix comes from the application's API route configuration. + +## Organize Controllers Around Resources + +As a general default, organize each controller around one resource and use Laravel's standard resource actions: `index`, `show`, `create`, `store`, `edit`, `update`, and `destroy`. This keeps routes predictable and prevents controllers from accumulating unrelated behavior. + +When a controller needs a custom action such as `publish`, `approve`, or `archive`, first consider whether that behavior represents a separate resource. A focused resource controller gives the behavior its own authorization, validation, and middleware boundary. + +Custom action on the primary controller: + +```php +Route::post('/podcasts/{podcast}/publish', [PodcastController::class, 'publish']); +``` + +The published podcast modeled as a resource: + +```php +Route::post('/published-podcasts/{podcast}', [PublishedPodcastController::class, 'store']) + ->name('published-podcasts.store'); + +Route::delete('/published-podcasts/{podcast}', [PublishedPodcastController::class, 'destroy']) + ->name('published-podcasts.destroy'); +``` + +```php +class PublishedPodcastController extends Controller +{ + public function store(Podcast $podcast): RedirectResponse + { + $podcast->publish(); + + return back(); + } + + public function destroy(Podcast $podcast): RedirectResponse + { + $podcast->unpublish(); + + return back(); + } +} +``` + +Treat a custom verb as a design signal, not proof that another controller is required. Use query parameters for simple filtering, and keep an explicit action route when modeling the operation as a resource would obscure the domain or conflict with established project conventions. + +## Keep Controllers Focused on HTTP Concerns + +Controllers should coordinate HTTP input, authorization, validation, an application operation, and the response. Extract substantial or reusable business logic, but do not introduce an action or service merely to satisfy an arbitrary line limit. + +```php +public function store(StorePostRequest $request, CreatePostAction $create): RedirectResponse +{ + $post = $create->handle($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +A form request can perform validation and authorization before the controller runs. Do not repeat its rules in the controller. Keep simple, endpoint-specific validation inline when extraction would not improve reuse or clarity; see the validation rules for detailed guidance. diff --git a/.cursor/skills/laravel-best-practices/rules/scheduling.md b/.cursor/skills/laravel-best-practices/rules/scheduling.md new file mode 100644 index 00000000..4c25134a --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/scheduling.md @@ -0,0 +1,61 @@ +# Task Scheduling Best Practices + +## Prevent Unwanted Overlap + +Use `withoutOverlapping()` when a second run must not begin while the previous run holds the lock. This is appropriate for variable-duration tasks that are not safe to run concurrently. + +```php +Schedule::command('reports:generate') + ->everyFifteenMinutes() + ->withoutOverlapping(30); +``` + +The optional value is the lock expiration time in minutes, not the task timeout. Choose it carefully: the default is 24 hours, stale locks can be cleared with `php artisan schedule:clear-cache`, and an expiration that is too short can permit overlap while the first task still runs. The task itself should still tolerate retries and partial execution where practical. + +## Run a Task on One Server + +Use `onOneServer()` when only one scheduler node should run an eligible task. Scheduler nodes must use the same default cache store, and that store must support atomic locks. Supported stores include `database`, `memcached`, `dynamodb`, and `redis`. + +```php +Schedule::command('billing:charge')->daily()->onOneServer(); +``` + +Name scheduled closures before applying `onOneServer()`, especially when scheduling the same closure with different parameters, so each task has a distinct lock identity. + +## Run Eligible Commands in the Background + +Tasks due at the same time run sequentially by default. Use `runInBackground()` when an independent, long-running scheduled command should not delay later tasks. + +```php +Schedule::command('analytics:process')->hourly()->runInBackground(); +``` + +Laravel restricts `runInBackground()` to tasks scheduled with `command()` and `exec()`; it is not available for scheduled closures. Ensure background processes have appropriate logging and failure monitoring. + +## Restrict Tasks by Environment + +Use `environments()` when a task should run only in named application environments. Treat this as an operational safeguard, not an authorization control. + +```php +Schedule::command('billing:charge') + ->monthly() + ->environments(['production']); +``` + +## Group Shared Configuration + +Use schedule groups when several tasks genuinely share frequency or constraints. + +```php +Schedule::daily() + ->onOneServer() + ->timezone('America/New_York') + ->group(function () { + Schedule::command('emails:send --force'); + Schedule::command('emails:prune'); + }); +``` + +## Bound Work Inside the Task + +The scheduler does not provide a `takeUntilTimeout()` event method or terminate arbitrary tasks at a deadline. Bound work in the command or job itself by processing finite chunks, checking a deadline, or dispatching queue jobs with suitable timeouts. Use operating-system or process controls when hard termination is required. diff --git a/.cursor/skills/laravel-best-practices/rules/security.md b/.cursor/skills/laravel-best-practices/rules/security.md new file mode 100644 index 00000000..57b57f0f --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/security.md @@ -0,0 +1,156 @@ +# Security Best Practices + +## Control Mass Assignment + +Define `$fillable` when a model is populated from request-derived arrays, or deliberately guard attributes by another consistent model convention. Laravel models guard all attributes by default; `$guarded = []` opts out of that protection. + +```php +class User extends Model +{ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; +} +``` + +Do not pass untrusted request data to a model with `$guarded = []`. Mass-assignment protection controls which attributes `create()`, `fill()`, and `update()` may set; it does not validate values or authorize the operation. + +## Authorize Protected Actions + +Use policies, gates, or form request authorization for actions that depend on the current user's permissions. Authentication alone does not establish permission, and validation is not authorization. + +```php +public function update(UpdatePostRequest $request, Post $post): RedirectResponse +{ + Gate::authorize('update', $post); + + $post->update($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +Authorization may instead live in the form request: + +```php +public function authorize(): bool +{ + return $this->user()?->can('update', $this->route('post')) ?? false; +} +``` + +Public actions intentionally available to everyone do not need a redundant authorization check. + +## Bind Query Parameters + +Use Eloquent, the query builder, or explicit bindings instead of interpolating untrusted values into Structured Query Language (SQL). Bindings protect values, not identifiers such as column names or sort directions; map user-selected identifiers to an allow-list. + +Incorrect: + +```php +DB::select("SELECT * FROM users WHERE name = '{$request->name}'"); +``` + +Correct: + +```php +User::where('name', $request->name)->get(); +User::whereRaw('LOWER(name) = ?', [$request->string('name')->lower()->toString()])->get(); +``` + +## Escape Output in Its Context + +Blade's `{{ }}` syntax HTML-escapes output. Use `{!! !!}` only for content that has been sanitized for the exact HTML context in which it is rendered. Escaping rules differ for HTML, URLs, JavaScript, and Cascading Style Sheets. + +Incorrect for untrusted content: + +```blade +{!! $user->bio !!} +``` + +Correct: + +```blade +{{ $user->bio }} +``` + +## Apply Cross-Site Request Forgery Protection + +Include `@csrf` in state-changing Blade forms handled by Laravel's `web` middleware. Routes intentionally excluded from cross-site request forgery (CSRF) verification, such as validated third-party webhooks, need their own authenticity check. + +```blade +
+ @csrf + +
+``` + +Inertia applications commonly use Axios, which returns the encrypted `XSRF-TOKEN` cookie in the `X-XSRF-TOKEN` header. Confirm equivalent configuration when using another HTTP client. Do not disable CSRF protection merely to fix a token mismatch. + +## Rate Limit Sensitive Endpoints + +Apply suitable rate limits to login attempts, password recovery, verification messages, and expensive or abuse-prone application programming interface (API) routes. Choose the limiter key deliberately; an Internet Protocol (IP) address alone can unfairly group users behind a shared network, while an account identifier alone can enable targeted denial of service. + +```php +RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by(Str::transliterate( + Str::lower($request->string('email')).'|'.$request->ip() + )); +}); + +Route::post('/login', LoginController::class)->middleware('throttle:login'); +``` + +Rate limiting reduces abuse; it does not replace authentication, authorization, or upstream denial-of-service protection. + +## Validate and Store Uploads Safely + +Validate expected content type, dimensions where relevant, and size. Laravel's `mimes` rule reads the file contents and guesses a Multipurpose Internet Mail Extensions (MIME) type corresponding to the listed extensions; it does not validate the user-assigned filename extension. The `extensions` rule checks that extension and should not be used by itself. + +```php +public function rules(): array +{ + return [ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + ]; +} +``` + +Use Laravel's storage methods to generate a filename, and store untrusted files outside a publicly executable location. Public files can require additional controls, such as image re-encoding, content-disposition headers, and explicit blocking of active formats. + +```php +$path = $request->file('avatar')->store('avatars'); +``` + +## Keep Secrets Out of Application Code + +Do not commit populated environment files or hard-code credentials. Read environment variables in configuration files, then use `config()` in application code so configuration caching works correctly. See the configuration rules for encrypted environment files and external secret stores. + +## Audit Dependencies + +Run `composer audit` regularly and in continuous integration. Review findings for exploitability and update or mitigate affected packages promptly. + +```bash +composer audit +``` + +## Encrypt Sensitive Attributes When Appropriate + +Use an `encrypted` cast for sensitive values that must be recoverable, and use `$hidden` to omit them from array and JavaScript Object Notation (JSON) serialization. Hidden attributes remain accessible in PHP, and encryption does not replace access control. Encrypted values cannot be meaningfully queried and should use a `TEXT` or larger column because ciphertext length is variable. + +```php +class Integration extends Model +{ + protected $hidden = ['api_key', 'api_secret']; + + protected function casts(): array + { + return [ + 'api_key' => 'encrypted', + 'api_secret' => 'encrypted', + ]; + } +} +``` diff --git a/.cursor/skills/laravel-best-practices/rules/style.md b/.cursor/skills/laravel-best-practices/rules/style.md new file mode 100644 index 00000000..3f3f14fb --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/style.md @@ -0,0 +1,110 @@ +# Convention and Style Best Practices + +## Follow Project Naming Conventions + +Prefer Laravel's conventions in new code, but preserve an established project convention unless a coordinated rename is worthwhile. + +| Element | Convention | Example | +| --- | --- | --- | +| Controller | Singular resource name | `ArticleController` | +| Model | Singular StudlyCase | `User` | +| Table | Plural snake_case | `article_comments` | +| Pivot table | Singular model names in alphabetical order, in snake_case | `article_user` | +| Column | snake_case | `meta_title` | +| Conventional foreign key | Singular model name plus `_id`, in snake_case | `article_id` | +| Resource URI | Plural resource | `articles/1` | +| Route name | Dotted segments; snake_case within a segment when needed | `users.show_active` | +| Method | camelCase | `getAll` | +| Variable | camelCase | `$articlesWithAuthor` | +| Collection | Descriptive and plural | `$activeUsers` | +| Object | Descriptive and singular | `$activeUser` | +| View | kebab-case | `show-filtered.blade.php` | +| Configuration file | snake_case | `google_calendar.php` | +| Enumeration | Singular StudlyCase | `UserType` | + +## Prefer Clear, Idiomatic Syntax + +Use Laravel helpers and query methods when they communicate intent more directly. Do not shorten code when the result is ambiguous or loses useful type information. + +| More verbose | Idiomatic alternative | +| --- | --- | +| `Session::get('cart')` | `session('cart')` | +| `$request->session()->get('cart')` | `session('cart')` | +| `return Redirect::back()` | `return back()` | +| `Carbon::now()` | `now()` | +| `->where('column', '=', 1)` | `->where('column', 1)` | +| `->orderBy('created_at', 'desc')` | `->latest()` | +| `->orderBy('created_at', 'asc')` | `->oldest()` | +| `->first()?->name` | `->value('name')` when only that value is needed | + +Use typed request accessors such as `$request->string()`, `$request->integer()`, and `$request->boolean()` when their coercion matches the operation. + +## Use Utilities When They Clarify Intent + +Laravel's `Str`, `Arr`, `Number`, and `Uri` utilities provide expressive operations and framework-consistent behavior. Prefer them when they are clearer or safer than an equivalent PHP operation, not as an unconditional replacement for every built-in function. + +```php +$slug = Str::slug($title); +$short = Str::limit($text, 100); +$class = class_basename(User::class); +$result = Str::of($input)->trim()->replace('_', '-')->lower(); +``` + +Use `Arr` for dot notation and common transformations: + +```php +$name = Arr::get($array, 'user.name', 'default'); +$public = Arr::only($attributes, ['name', 'email']); +``` + +Use `Number` for localized display formatting rather than values that will be stored or calculated: + +```php +Number::format(1000000); +Number::currency(1500, 'USD'); +Number::fileSize(1024 * 1024); +``` + +Use `Uri` when constructing or transforming a uniform resource identifier (URI) benefits from a structured API: + +```php +$uri = Uri::of('https://example.com/search') + ->withQuery(['q' => 'laravel', 'page' => 1]); +``` + +Check the documentation for the Laravel version supported by the project before using newer utility classes or methods. + +## Keep Presentation Code Maintainable + +Prefer the project's asset pipeline, components, and existing conventions for substantial JavaScript and Cascading Style Sheets (CSS). Small page-specific scripts or styles can be reasonable in Blade layouts or stacks; avoid mixing large behavior and style blocks into templates. + +Pass server data with an encoding mechanism appropriate to its context. For example, Blade's `Js::from()` safely formats data for JavaScript: + +```blade + +``` + +Data attributes are useful for small scalar values, but serializing a large model into an attribute can expose unnecessary fields and complicate escaping. + +## Write Comments That Explain Why + +Prefer clear names and small units of code over comments that merely restate an operation. Add concise comments for non-obvious constraints, tradeoffs, workarounds, regular expressions, or external behavior that the code cannot express by itself. Keep comments accurate when behavior changes. + +Unhelpful: + +```php +// Check whether the query has joins. +if (count((array) $builder->getQuery()->joins) > 0) { + // ... +} +``` + +Clearer: + +```php +if ($this->hasJoins()) { + // ... +} +``` diff --git a/.cursor/skills/laravel-best-practices/rules/validation.md b/.cursor/skills/laravel-best-practices/rules/validation.md new file mode 100644 index 00000000..37ce6c5e --- /dev/null +++ b/.cursor/skills/laravel-best-practices/rules/validation.md @@ -0,0 +1,89 @@ +# Validation and Forms Best Practices + +## Extract Validation When It Improves the Boundary + +Use a form request when validation or authorization is substantial, reused, or clearer outside the controller. Inline `$request->validate()` remains appropriate for a small, endpoint-specific rule set. + +```php +public function store(StorePostRequest $request): RedirectResponse +{ + $post = Post::create($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +A form request's `authorize()` method can enforce access to the operation. Validation establishes the shape and values of input; it does not itself authorize the user. + +## Prefer Readable Rule Syntax + +Array syntax composes cleanly with rule objects and avoids delimiter issues. Prefer it in new code when it improves readability, while following a consistent local style. + +```php +'email' => ['required', 'email', Rule::unique('users')], +``` + +String syntax remains valid for simple rules: + +```php +'email' => 'required|email|unique:users', +``` + +## Use Only Intended Validated Data + +Use `validated()` or `safe()` instead of `$request->all()` when passing request data onward. Then select the fields intended for the operation when the validation rules also cover control fields or nested data. + +Unsafe: + +```php +Post::create($request->all()); +``` + +Preferred: + +```php +$post = Post::create($request->safe()->only(['title', 'body'])); +``` + +Validated data is not automatically safe for mass assignment. Keep model `$fillable` or `$guarded` rules aligned with the operation, and never add a sensitive attribute to validation merely to make mass assignment convenient. + +## Express Conditional Rules Clearly + +Use conditional rules such as `Rule::when()`, `required_if`, or `exclude_unless` when they make the condition explicit. Choose the simplest form that remains easy to test. + +```php +'company_name' => [ + 'string', + 'max:255', + Rule::when( + $this->input('account_type') === 'business', + ['required'], + ['nullable'], + ), +], +``` + +## Add Cross-Field Validation After Base Rules + +Use a form request's `after()` method for validation that depends on multiple fields or application state. Avoid expensive queries when prerequisite fields have already failed validation. + +```php +public function after(): array +{ + return [ + function (Validator $validator) { + if ($validator->errors()->hasAny(['product_id', 'quantity'])) { + return; + } + + $stock = Product::find($this->integer('product_id'))?->stock; + + if ($stock !== null && $this->integer('quantity') > $stock) { + $validator->errors()->add('quantity', 'Not enough stock.'); + } + }, + ]; +} +``` + +Validation against mutable state does not prevent a race between validation and persistence. Enforce inventory, uniqueness, and similar invariants with database constraints, atomic updates, or a database transaction as appropriate. diff --git a/.cursor/skills/livewire-development/SKILL.md b/.cursor/skills/livewire-development/SKILL.md new file mode 100644 index 00000000..f62fb274 --- /dev/null +++ b/.cursor/skills/livewire-development/SKILL.md @@ -0,0 +1,164 @@ +--- +name: livewire-development +description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, wire:sort, or islands, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, drag-and-drop, loading states, migrating from Livewire 3 to 4, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire." +license: MIT +metadata: + author: laravel +--- + +# Livewire Development + +## Documentation + +Use `search-docs` for detailed Livewire 4 patterns and documentation. + +## Basic Usage + +### Creating Components + +```bash +# Single-file component (SFC - default in v4) +# Creates: resources/views/components/⚡create-post.blade.php +php artisan make:livewire create-post + +# Page component (SFC - Full Page in v4) +# Creates: resources/views/pages/⚡create-post.blade.php +php artisan make:livewire pages::create-post + +# Multi-file component (MFC) +# Creates: resources/views/components/⚡create-post/create-post.php +# resources/views/components/⚡create-post/create-post.blade.php +php artisan make:livewire create-post --mfc + +# Class-based component (v3 style) +# Creates: app/Livewire/CreatePost.php AND resources/views/livewire/create-post.blade.php +php artisan make:livewire create-post --class + +# With namespace +php artisan make:livewire Posts/CreatePost +``` + +### Converting Between Formats + +Use `php artisan livewire:convert create-post` to convert between single-file, multi-file, and class-based formats. + +### Choosing a Component Format + +> **Always follow the project's existing conventions first.** Before creating any component, inspect the project's existing Livewire components to determine the established format (SFC, MFC, or class-based) and directory structure. Check `app/Livewire/`, `resources/views/components/`, and `resources/views/livewire/` for existing components. If the project already uses a consistent format, **use that same format** — even if it differs from the Livewire v4 defaults below. Only fall back to the v4 defaults (SFC in `resources/views/components/`) when no existing convention is established. + +Also check `config/livewire.php` for `make_command.type`, `make_command.emoji`, `component_locations`, and `component_namespaces` overrides, which change the default format and where files are stored. + +### Component Format Reference + +| Format | Flag | Class Path | View Path | +|--------|------|------------|-----------| +| Single-file (SFC) | default | — | `resources/views/components/⚡create-post.blade.php` (PHP + Blade in one file) | +| Full Page SFC | `pages::name` | — | `resources/views/pages/⚡create-post.blade.php` | +| Multi-file (MFC) | `--mfc` | `resources/views/components/⚡create-post/create-post.php` | `resources/views/components/⚡create-post/create-post.blade.php` | +| Class-based | `--class` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` | +| View-based | default (Blade-only) | — | `resources/views/components/⚡create-post.blade.php` (Blade-only with functional state) | + +> **Important:** The ⚡ prefix shown above is the **default** behavior in Livewire v4 — it is **configurable**. Check `config/livewire.php` for the `make_command.emoji` setting. When `true` (default), always include the ⚡ prefix in filenames you create. When `false`, omit the ⚡ prefix from all paths above. + +Namespaced components map to subdirectories: `make:livewire Posts/CreatePost` creates `resources/views/components/posts/⚡create-post.blade.php` (single-file by default). Use `make:livewire Posts/CreatePost --mfc` for multi-file output at `resources/views/components/posts/⚡create-post/create-post.php` and `resources/views/components/posts/⚡create-post/create-post.blade.php`. + +### Single-File Component Example + + +```php +count++; + } +}; +?> + +
+ +
+``` + +## Livewire 4 Specifics + +### Key Changes From Livewire 3 + +These things changed in Livewire 4, but may not have been updated in this application. Verify this application's setup to ensure you follow existing conventions. + +- Use `Route::livewire()` for full-page components (e.g., `Route::livewire('/posts/create', CreatePost::class)`); config keys renamed: `layout` → `component_layout`, `lazy_placeholder` → `component_placeholder`. +- `wire:model` now ignores child events by default (use `wire:model.deep` for old behavior); `wire:scroll` renamed to `wire:navigate:scroll`. +- Component tags must be properly closed; `wire:transition` now uses View Transitions API (modifiers removed). +- JavaScript: `$wire.$js('name', fn)` → `$wire.$js.name = fn`; `commit`/`request` hooks → `interceptMessage()`/`interceptRequest()`. + +### New Features + +- Component formats: single-file (SFC), multi-file (MFC), view-based components. +- Islands (`@island`) for isolated updates; async actions (`wire:click.async`, `#[Async]`) for parallel execution. +- Deferred/bundled loading: `defer`, `lazy.bundle` for optimized component loading. + +| Feature | Usage | Purpose | +|---------|-------|---------| +| Islands | `@island(name: 'stats')` | Isolated update regions | +| Async | `wire:click.async` or `#[Async]` | Non-blocking actions | +| Deferred | `defer` attribute | Load after page render | +| Bundled | `lazy.bundle` | Load multiple together | + +### New Directives + +- `wire:sort`, `wire:intersect`, `wire:ref`, `.renderless`, `.preserve-scroll` are available for use. +- `data-loading` attribute automatically added to elements triggering network requests. + +| Directive | Purpose | +|-----------|---------| +| `wire:sort` | Drag-and-drop sorting | +| `wire:intersect` | Viewport intersection detection | +| `wire:ref` | Element references for JS | +| `.renderless` | Component without rendering | +| `.preserve-scroll` | Preserve scroll position | + +## Best Practices + +- Always use `wire:key` in loops +- Use `wire:loading` for loading states +- Use `wire:model.live` for live updates; `wire:model` is deferred by default +- Validate and authorize in actions (treat like HTTP requests) + +## Configuration + +- `smart_wire_keys` defaults to `true`; new configs: `component_locations`, `component_namespaces`, `make_command`, `csp_safe`. + +## Alpine & JavaScript + +- `wire:transition` uses browser View Transitions API; `$errors` and `$intercept` magic properties available. +- Non-blocking `wire:poll` and parallel `wire:model.live` updates improve performance. + +For interceptors and hooks, see [reference/javascript-hooks.md](reference/javascript-hooks.md). + +## Testing + + +```php +Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1); +``` + +## Verification + +1. Browser console: Check for JS errors +2. Network tab: Verify Livewire requests return 200 +3. Ensure `wire:key` on all `@foreach` loops + +## Common Pitfalls + +- Missing `wire:key` in loops → unexpected re-rendering +- Expecting `wire:model` real-time → use `wire:model.live` +- Unclosed component tags → syntax errors in v4 +- Using deprecated config keys or JS hooks +- Including Alpine.js separately (already bundled in Livewire 4) diff --git a/.cursor/skills/livewire-development/reference/javascript-hooks.md b/.cursor/skills/livewire-development/reference/javascript-hooks.md new file mode 100644 index 00000000..660d66b5 --- /dev/null +++ b/.cursor/skills/livewire-development/reference/javascript-hooks.md @@ -0,0 +1,39 @@ +# Livewire 4 JavaScript Integration + +## Interceptor System (v4) + +### Intercept Messages + +```js +Livewire.interceptMessage(({ component, message, onFinish, onSuccess, onError }) => { + onFinish(() => { /* After response, before processing */ }); + onSuccess(({ payload }) => { /* payload.snapshot, payload.effects */ }); + onError(() => { /* Server errors */ }); +}); +``` + +### Intercept Requests + +```js +Livewire.interceptRequest(({ request, onResponse, onSuccess, onError, onFailure }) => { + onResponse(({ response }) => { /* When received */ }); + onSuccess(({ response, responseJson }) => { /* Success */ }); + onError(({ response, responseBody, preventDefault }) => { /* 4xx/5xx */ }); + onFailure(({ error }) => { /* Network failures */ }); +}); +``` + +### Component-Scoped Interceptors + +```blade + +``` + +## Magic Properties + +- `$errors` - Access validation errors from JavaScript +- `$intercept` - Component-scoped interceptors diff --git a/.cursor/skills/tailwindcss-development/SKILL.md b/.cursor/skills/tailwindcss-development/SKILL.md new file mode 100644 index 00000000..7e3cd2ac --- /dev/null +++ b/.cursor/skills/tailwindcss-development/SKILL.md @@ -0,0 +1,96 @@ +--- +name: tailwindcss-development +description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS." +license: MIT +metadata: + author: laravel +--- + +# Tailwind CSS Development + +## Documentation + +Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. + +## Basic Usage + +- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns. +- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue). +- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically. + +## Tailwind CSS v4 Specifics + +- Always use Tailwind CSS v4 and avoid deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. + +### CSS-First Configuration + +In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: + + +```css +@theme { + --color-brand: oklch(0.72 0.11 178); +} +``` + +### Import Syntax + +In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: + + +```diff +- @tailwind base; +- @tailwind components; +- @tailwind utilities; ++ @import "tailwindcss"; +``` + +### Replaced Utilities + +Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric. + +| Deprecated | Replacement | +|------------|-------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + +## Spacing + +Use `gap` utilities instead of margins for spacing between siblings: + + +```html +
+
Item 1
+
Item 2
+
+``` + +## Dark Mode + +If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: + + +```html +
+ Content adapts to color scheme +
+``` + +## Common Pitfalls + +- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.) +- Using `@tailwind` directives instead of `@import "tailwindcss"` +- Trying to use `tailwind.config.js` instead of CSS `@theme` directive +- Using margins for spacing between siblings instead of gap utilities +- Forgetting to add dark mode variants when the project uses dark mode diff --git a/.cursor/skills/testing-best-practices/SKILL.md b/.cursor/skills/testing-best-practices/SKILL.md new file mode 100644 index 00000000..35d02a1d --- /dev/null +++ b/.cursor/skills/testing-best-practices/SKILL.md @@ -0,0 +1,58 @@ +--- +name: testing-best-practices +description: "Laravel test design and review. Use when selecting coverage, naming or structuring tests, choosing assertions or test data, isolating dependencies, testing HTTP or security boundaries, improving suite performance, or reviewing test value. Use framework guidance or search-docs for Pest and PHPUnit syntax." +license: MIT +metadata: + author: laravel +--- + +# Testing Best Practices + +This skill provides rules for designing Laravel tests. Each rule file explains what to do and why. Use `search-docs` for Laravel and Pest API syntax. +This project uses Pest. Follow the corresponding guidance in each rule. + +## Consistency First + +Read nearby tests before you choose syntax and organization. + +A pattern repeated throughout the project is a convention, and project conventions take precedence over this skill. Follow them and give new tests the same structure. + +These rules govern the tests you write now. An existing test that follows a project convention is not defective merely because it conflicts with this skill. Do not delete or rewrite it. If the convention has drawbacks, explain them and let the user decide. + +## What to Test + +Read this section before you write a test. + +- Test observable behavior and application contracts. A test must pass after an implementation change if the behavior stays the same. +- Cover every changed decision and each applicable high-value failure mode. A decision is a branch, a validation, a calculation, or an authorization. +- Exercise declarations through behavior instead of repeating their text. +- Leave framework behavior to framework tests. Testing project configuration is not testing the framework. A constrained relationship, cast, scope, or validation rule belongs to this project. +- Keep every test that can detect a distinct defect. When two tests detect the same defect, trim the higher-layer test to one case and report the duplication. Do not delete an existing test. +- Write a feature test first. Write a unit test only for logic that does not use the framework. +- Write a feature test for every behavior reachable through a request. Real-browser tests require `pestphp/pest-plugin-browser` and a browser download, neither of which this project installs. Mention the package only if the user asks for a real-browser test. +- Judge an architecture test by the convention it protects, not by the rules above. An `arch()` test declares a rule for an entire directory, such as the parent class of every model, the classes that may use an enum, or the methods every factory declares. It intentionally checks declarations and fails when a new file breaks the convention. +- Use the test tools that the project installs. Add a new test dependency, plugin, or browser only after the user asks for it. + +## How to Apply + +1. Read the code under test. Read the tests in the same directory. Identify every decision in the code. +2. Select every applicable branch in the rule index. Read every selected rule file. +3. Report each defect in the code before you write a test. Examples are a method with no body, a policy that no action calls, and a write action with no validation. Test the actual behavior. Report the defect to the user. +4. Write the tests. Run the smallest set of tests that covers the change. The tests must pass. +5. Check every applicable item in `rules/review.md` and every selected rule file. Resolve every mismatch before completion. + +## Rule Index + +Most changes need more than one rule file. + +| Subject | Rule File | +| --- | --- | +| Test framework features that may already do the work | [`rules/finding-features.md`](rules/finding-features.md) | +| File layout, test names, and groups | [`rules/naming.md`](rules/naming.md) | +| Arrange-act-assert and choosing the correct assertion | [`rules/assertions.md`](rules/assertions.md) | +| Endpoint coverage, authentication, authorization, tenant isolation, validation, and browser tests | [`rules/endpoint-tests.md`](rules/endpoint-tests.md) | +| Factories, test data ownership, and repeated input values | [`rules/test-data.md`](rules/test-data.md) | +| Fakes, mocks, outbound HTTP, time, randomness, and databases | [`rules/isolation.md`](rules/isolation.md) | +| Escaping, injection, cross-tenant access, and privilege checks | [`rules/security.md`](rules/security.md) | +| Environment and CI settings for a slow suite | [`rules/performance.md`](rules/performance.md) | +| Reviewing a test or suite | [`rules/review.md`](rules/review.md) | diff --git a/.cursor/skills/testing-best-practices/rules/assertions.md b/.cursor/skills/testing-best-practices/rules/assertions.md new file mode 100644 index 00000000..ba55fb25 --- /dev/null +++ b/.cursor/skills/testing-best-practices/rules/assertions.md @@ -0,0 +1,60 @@ +# Assertions + +## Arrange, Act, Assert + +Write each test in three parts: setup, one action, and assertions. Put one blank line between them so readers can identify each part without comments. + +Keep each test self-contained. Do not use values created by another test. + +## How to Find the Correct Assertion + +First identify the subject of the check, then find an assertion designed for it. A subject-specific assertion identifies the incorrect value when the test fails. + +1. Search Laravel's assertions for framework subjects such as responses, the database, sessions, models, queues, events, mail, and notifications. +2. Fetch `https://pestphp.com/docs/expectations.md` for the expectations of Pest for a plain value, a type, a format, or a shape. +3. Build the check by hand only if no assertion exists for the subject. +4. Confirm the name in the documentation before you use it. Do not write an assertion that you did not confirm. + +Use the assertion in this table for each subject. + +| Subject | Assertion to use | +| --- | --- | +| A return value, the state of an object, or a transformation of a value | an `expect()` chain | +| An HTTP status, JSON, a session, or Inertia | a Laravel response assertion | +| The state in the database | a Laravel database assertion | +| The existence of a model | `assertModelExists($model)` rather than `assertDatabaseHas('users', ['id' => $user->id])` | + +Use a PHPUnit assertion only if no Pest expectation and no Laravel assertion exists for the subject. + +Assert each fact once. Do not assert a 200 status before `assertSee`, because `assertSee` already shows that the page rendered. + +## Named Response Assertions + +Use a named response assertion, such as `assertNotFound()`, rather than `assertStatus(404)`. A failure then identifies the broken contract. Laravel provides named assertions for commonly tested status codes. + +Keep one `expect()` chain on one subject. Start a new chain when the subject changes, or when the chain is difficult to read. + +## Assert a Known Value + +Write the expected value in the test, or calculate the expected value by a different method. Do not calculate the expected value with the logic of the implementation, because the test then passes when that logic is wrong. + +```php +// The test calculates the value with the logic of the implementation... +$expected = now()->subHours(24)->floorSeconds(30)->toJson(); +expect($from)->toBe($expected); + +// The test sets a fixed input and asserts a known value... +travelTo('2025-01-01 00:00:00'); +expect($from)->toBe('2024-12-31T00:00:00.000000Z'); +``` + +## Assert the Complete Result + +A status code is not the complete result of a write operation. Assert each of the following if the operation changes it: + +- The response or the return value. +- The state in the database. +- The jobs and the events that the operation dispatches. +- The notifications and the mail that the operation sends. + +On the failure path, assert that the operation makes none of these changes. A test that asserts only `assertOk()` passes even when the application saves no record. diff --git a/.cursor/skills/testing-best-practices/rules/endpoint-tests.md b/.cursor/skills/testing-best-practices/rules/endpoint-tests.md new file mode 100644 index 00000000..32e32e64 --- /dev/null +++ b/.cursor/skills/testing-best-practices/rules/endpoint-tests.md @@ -0,0 +1,47 @@ +# Endpoint Tests + +## How to Write the Test + +Fetch `https://laravel.com/framework/docs/http-tests` for the request helpers, the authentication helpers, and the response assertions. Confirm the name before you use it, and do not guess an assertion. + +Choose an assertion based on the subject of the check: the status, a header, a redirect, the JSON body, the session, a validation error, or the view. Laravel provides a named assertion for each subject that identifies the incorrect value. + +## Endpoint Coverage + +Write a test for each applicable case: + +- The request has missing or invalid authentication. +- The request comes from a different tenant, team, or organization. +- The user has an insufficient role or permission. +- The request does not satisfy a route or scope constraint. +- The request fails the validation. +- The request is valid. Assert both the response and the persisted state. + +Assert the application's actual behavior rather than a generic status code. An API returns `401` for a missing or invalid token, while a browser endpoint redirects to the sign-in route. + +## Tenant Isolation + +Assert the status code returned for a cross-tenant request. Use `404` rather than `403` when one tenant must not learn that another tenant's record exists, because `403` confirms its existence. + +## Test Authorization at the Policy Level + +An HTTP test shows that the endpoint performs authorization. It cannot identify which mechanism refused the request because middleware, a policy, and a call to `abort()` can all return `403`. + +- Assert the complete matrix of the permissions against the policy or the gate. A failure then names the rule that is not correct. +- Write one HTTP test for one refused role, which shows that the endpoint calls the authorization. +- Use the helper of the project that asserts the ability and the arguments of the gate, if such a helper exists. + +## Testing Validation + +- Write one test for each validation rule when each failure represents a separate contract. +- Write one test with an empty payload to assert several required fields together. +- Assert the text of the message that the user gets. A message that is present but wrong is a defect. +- Use a dataset for input values that need the same setup and the same assertions. + +Send an input value that is not valid through the application, and assert the error. Do not assert that an array of rules contains a string, because that assertion tests the declaration and not the behavior. Use such an assertion only for a rule that no request can reach, and write the reason in the test. + +### Which Layer Owns Which Case + +The rule-class test owns the matrix of values that pass and fail. The endpoint test proves that the endpoint applies the rule and that the user receives the message. + +When both tests contain the matrix, move it to the rule-class test and retain one case in the endpoint test. Never remove the last case, because the rule-class test still passes if the request omits the rule. The same division applies to policies, scopes, and other classes called by a request. diff --git a/.cursor/skills/testing-best-practices/rules/finding-features.md b/.cursor/skills/testing-best-practices/rules/finding-features.md new file mode 100644 index 00000000..83ce650b --- /dev/null +++ b/.cursor/skills/testing-best-practices/rules/finding-features.md @@ -0,0 +1,34 @@ +# How to Find Test Framework Features + +Pest adds features faster than this skill can list them. Find an existing feature before implementing the behavior by hand. + +- Give `search-docs` the capability you need rather than the name of a function you remember. It returns features available in the installed version. +- Fetch `https://pestphp.com/llms.txt` for the complete feature list and additions in each release. +- If a search returns no results, tell the user that the installed version does not provide the feature. Do not write an API that you have not confirmed. + +Search for a feature in this table before you write the code by hand. + +| Work that you need | Term to search for | +| --- | --- | +| Run one test with many input values | datasets, bound datasets | +| Assert over many values or over a collection | higher-order expectations | +| Remove the same setup from each test in a file | hooks, higher-order tests | +| Apply a convention to the complete codebase | architecture testing | +| Measure if the suite finds a defect | mutation testing | +| Find code with no types | type coverage | +| Reduce the time of a slow suite | parallel, profiling | +| Run one test while you debug | filtering, `--bail`, `--dirty` | + +## Built-in Laravel Assertion Methods + +Laravel provides assertions for each part of the framework. Fetch `https://laravel.com/framework/docs/testing` for the complete list, and search for an assertion before building a check by hand. Examples include `assertDatabaseHas()`, `assertModelExists()`, `assertSoftDeleted()`, response assertions such as `assertRedirectToRoute()` and `assertJsonPath()`, and fake assertions such as `Queue::assertPushed()` and `Notification::assertSentTo()`. + +A hand-built check fails with `false is not true`, which identifies nothing. A framework assertion names the incorrect table, value, or response, so the failure indicates what to fix. + +```php +// The failure says that false is not true. Instead of this... +expect(User::where('email', 'taylor@laravel.com')->exists())->toBeTrue(); + +// Use this... the failure names the table and the attributes that it did not find... +$this->assertDatabaseHas('users', ['email' => 'taylor@laravel.com']); +``` diff --git a/.cursor/skills/testing-best-practices/rules/isolation.md b/.cursor/skills/testing-best-practices/rules/isolation.md new file mode 100644 index 00000000..e1a21fdb --- /dev/null +++ b/.cursor/skills/testing-best-practices/rules/isolation.md @@ -0,0 +1,52 @@ +# Fakes, Mocks, and Determinism + +Tests that depend on actual time, randomness, sleeping, or network calls can fail for reasons unrelated to the code under test. Control all four. + +## How to Isolate a Dependency + +Fetch `https://laravel.com/framework/docs/mocking` for Laravel's fakes, facade doubles, and fake assertions. Confirm each name before using it. + +Identify the dependency, then choose the first applicable option. A framework fake preserves the real code path, while a mock replaces the dependency. + +1. Always use framework fakes for facades such as events, queues, mail, notifications, storage, the HTTP client, time, and sleep. +2. Use a developer-defined fake implementation of a service if the application provides one. +3. Use a mock for a container-resolved contract only when the real implementation leaves the process or is nondeterministic. +4. Use the real implementation for everything else, including the database. + +## Framework Fakes + +- Create each fake inside the test that needs it. Do not create fakes in a file-level `beforeEach()`. +- Pass class names to `Event::fake()` and `Queue::fake()` when you know which classes the code dispatches. A fake without class names can hide an unexpected dispatch. +- Use a fake without class names only when the test asserts the complete result, including a call to `assertNothingPushed()`. +- Write one assertion for each fake. The assertion states that the code dispatches the item, or that the code does not dispatch the item. +- Assert the data of a job or of an event if that data is part of the behavior. +- Use `Exceptions::fake()` to assert that the application reports the correct exception. Do not use `withoutExceptionHandling()`, because it changes the response under test. + +Create prerequisite factory records before calling `Event::fake()`. Factories use model events, such as a `creating` hook that generates a UUID, and a fake without class names suppresses those events and can produce an invalid model. Call the fake first only when a factory event is under test, and pass that event's class name. + +## Mocking + +Use `shouldReceive()` before the action to declare an expectation. Use `shouldHaveReceived()` after the action for a spy. Use `Mockery::on()` or `withArgs()` if an equality check cannot state the expected argument, such as a check of one field of a value object. + +Import the mock function before you use it: `use function Pest\Laravel\mock;`. + +## Outbound HTTP Testing + +Call `Http::preventStrayRequests()`. Any request without a matching fake then fails without reaching the network. + +Fake the exact endpoint used by each test. Do not call `Http::fake()` without an endpoint because it accepts unexpected requests and can hide defects. + +## Time and Randomness + +- Freeze the time or move the time in each test that depends on a date, a period, or a timestamp. +- Use the framework helpers `freezeTime()`, `travelTo()`, `travel()`, and `travelBack()`. Do not call `Carbon::setTestNow()`. +- Use `Str::createRandomStringsUsing()` to fix a generated string, if the test asserts an identifier or a slug. +- Use `Sleep::fake()` instead of a real sleep, and assert the sleeps that the code requests. +- Restore the time and the randomness after each test, if the suite does not restore them for every test. + +## Database + +- Run real queries against the real records in the test database. Do not mock the query builder, because the test then asserts the mock. +- Assert the exact keys of `toArray()` if the shape of the serialized model is a contract. The test then fails when the model exposes a new attribute. +- Test application behavior caused by the schema, such as deleting dependent records through a cascade. Do not test the database engine's cascade implementation. +- Use `LazilyRefreshDatabase` instead of `RefreshDatabase`. A test that does not use the database then does not run the migrations. diff --git a/.cursor/skills/testing-best-practices/rules/naming.md b/.cursor/skills/testing-best-practices/rules/naming.md new file mode 100644 index 00000000..4f3f5b1d --- /dev/null +++ b/.cursor/skills/testing-best-practices/rules/naming.md @@ -0,0 +1,45 @@ +# Naming and Structure + +## File Layout + +- Name each test file `{ClassName}Test.php`. +- Place each test file at the same relative path as the class under test. The class `app/Actions/DeleteTeam.php` gets the test `tests/Unit/Actions/DeleteTeamTest.php`. +- Follow the project's convention for fixture files. If none exists, put fixtures in `tests/Fixtures/` and load them by path. +- Move large literal values out of the test body and into fixture files. + +## Test Function + +Use the test function used by other files in the same directory. If no neighboring test files exist: + +- Use `it()` for the behavior of the code, and write the name as a verb phrase. +- Use `test()` for a declarative fact, such as a grant in a policy, the labels of an enum, or the shape of a serialized model. + +Use one Pest declaration style in each file. Use either `it()` or `test()` consistently. + +## Naming Tests + +The name of a test is a specification. State the user-visible result and the condition that causes it. + +- Name the behavior, and not the method under test. The file name already gives the class. +- Give the exact status code in the name of a test for an API error. +- Do not write `Given`, `When`, or `Then` in the name. + +```php +it('returns 401 when no token is provided', function () { ... }); +it('does not include deployments from deleted environments', function () { ... }); +it('falls back to the default region when none is configured', function () { ... }); +``` + +Use a verb that describes a result, such as `returns`, `renders`, `creates`, `dispatches`, `rejects`, `forbids`, `falls back`, or `does not`. + +Do not write `it('works correctly')` or `it('returns data')`, because neither specifies a meaningful result. Do not write `it('handleMethod creates record')`, because it names a method rather than behavior. + +## Grouping + +Use `describe()` if one file covers separate actions in a lifecycle. An example is a controller with the actions `index`, `show`, `store`, `update`, and `destroy`. + +Do not use `describe()` in these cases: + +- The file covers one action or one flow. +- The tests are different only in the input value. Use a dataset instead. +- The group adds a level but does not make the file easier to read. diff --git a/.cursor/skills/testing-best-practices/rules/performance.md b/.cursor/skills/testing-best-practices/rules/performance.md new file mode 100644 index 00000000..ee993fbb --- /dev/null +++ b/.cursor/skills/testing-best-practices/rules/performance.md @@ -0,0 +1,46 @@ +# Test Suite Performance + +These settings apply to the project and CI, not to individual tests. Read `rules/isolation.md` for choices within a test. + +Fetch `https://pestphp.com/docs/optimizing-tests` for Pest options that make test runs faster. +Verify each flag in the documentation before adding it to CI. + +Measure before changing a setting. Find the slow test first, and apply a project-wide setting only after identifying the costly work. + +## Test Environment + +- Set `BCRYPT_ROUNDS=4` in `.env.testing` or in `phpunit.xml`. The default value is 12, and the hash then takes most of the time of each test that signs a user in. +- Disable XDebug. Disable pcov also, unless the run needs the coverage. +- Disable packages that perform work on every request in the test environment. Examples are Pulse, Telescope, and Nightwatch. +- Use the `WithCachedConfig` and `WithCachedRoutes` traits, so the run does not parse the configuration and the routes for every test. +- Call `withoutVite()`, or `withoutMix()`, so the framework does not resolve a built asset. + +## Global Fakes + +Put these three calls in the base `Pest.php` of the project: + +- `Http::preventStrayRequests()`, because one request that reaches the network can slow the suite. This catches requests made through Laravel's HTTP client. Check direct Guzzle and cURL usage separately. +- `Sleep::fake(syncWithCarbon: true)`, so a retry and a backoff do not sleep. +- `Exceptions::fake()`, so the suite does not report an exception to an external service. + +## How to Run the Suite in Parallel + +Run `vendor/bin/pest --parallel` to spread tests across the machine's CPU cores. Add `--processes=N` if the default count is unsuitable for the machine or CI. + +A parallel run gives each process a separate database. Tests must meet these conditions; a test that fails only in parallel breaks one of them: + +- The test creates each record that it reads. It does not read a record that another test creates. +- The test does not depend on the order of the run. +- The test does not share a file, a cache key, or a queue with another test. Give each process a separate name for such a resource. + +## How to Find a Slow Test + +Run `vendor/bin/pest --profile` to list the slowest tests. Start with the ten slowest tests, because the same cause often applies to the complete suite. + +If the cause of a slow test is unclear, add an event listener or temporary log entry to identify its work. + +## Common Errors + +- The run loads XDebug for a test that does not need it. +- `BCRYPT_ROUNDS` keeps the default value, because the project has no `.env.testing`. +- The code under test calls the real `sleep()`, and `Sleep::fake()` then does not help. diff --git a/.cursor/skills/testing-best-practices/rules/review.md b/.cursor/skills/testing-best-practices/rules/review.md new file mode 100644 index 00000000..e0cfca14 --- /dev/null +++ b/.cursor/skills/testing-best-practices/rules/review.md @@ -0,0 +1,44 @@ +# Reviewing Tests + +Check every item in this file. A passing test may still provide no value. For each test, identify the defect it would catch. + +Report each finding. Do not delete or rewrite a test without the user's approval. When an issue appears throughout the suite as a convention, report the pattern once rather than every affected file. + +## Test Value + +Apply this section to behavioral tests. An architecture test states a convention for a directory, so these items do not apply to it. + +- [ ] Each test covers observable behavior or an application contract, and passes after a change to the implementation that keeps the behavior. +- [ ] Each tested declaration is exercised through behavior, and no test asserts the behavior of the framework. A test of what this project configures, such as a relation with a constraint, a cast, or a scope, belongs to this project. +- [ ] Each test detects a distinct defect that no other test covers. A duplicate shrinks at the higher layer to the one case that proves the wiring. +- [ ] Every changed decision and each applicable high-value failure mode has coverage. + +## Names and Structure + +- [ ] Each file has the name `{ClassName}Test.php` and the relative path of the class under test. +- [ ] Each name states a result, the condition that causes it, and the status code for an API error. +- [ ] Each file uses one declaration style consistently, and each `describe()` group holds separate behavior. + +## Coverage + +- [ ] HTTP tests cover authentication, authorization, role, scope, and validation when applicable. +- [ ] A request for a record of a different tenant gets a status code that does not confirm that the record exists. +- [ ] The complete permission matrix belongs in policy tests, not controller tests. +- [ ] Each validation rule has one test that asserts the user-visible message. When a unit test owns a matrix, reduce duplicate higher-level coverage to one case rather than deleting it. +- [ ] Rendered user input and each dynamic part of a query have a security test. + +## Data and Determinism + +- [ ] Each test creates its mutable records directly or through a helper that it calls, and every created record arranges the behavior or supports an assertion. +- [ ] Each `beforeEach()` holds configuration only. +- [ ] Each factory state and each relationship gives the meaning of the data. +- [ ] Each call to `make()` is in a test that does not need the database. +- [ ] Time, randomness, sleep, and outbound HTTP are controlled. +- [ ] Each test passes alone, and passes in the complete suite in any order. + +## Assertions + +- [ ] Each expected value is a known value, and the test does not calculate the value with the logic of the implementation. +- [ ] Each test of a write operation asserts the response, the state in the database, and the side effects. +- [ ] Each fake has one assertion, and gives the class names unless the test asserts the complete result. +- [ ] Each `expect()` chain stays on one subject. diff --git a/.cursor/skills/testing-best-practices/rules/security.md b/.cursor/skills/testing-best-practices/rules/security.md new file mode 100644 index 00000000..234f5b00 --- /dev/null +++ b/.cursor/skills/testing-best-practices/rules/security.md @@ -0,0 +1,27 @@ +# Security Tests + +Test each security boundary where user input affects authorization, rendered output, or query construction. A defect at such a boundary can be difficult to detect because the feature may continue to work. + +Write a test for each of these cases: + +- **Cross-tenant access.** Request a record of a different tenant, team, or organization. Read `rules/endpoint-tests.md` for why the response should possibly be `404` rather than `403`. +- **Each unprivileged role.** Use a dataset over the roles that the endpoint must refuse. +- **Escaping user-provided content.** Test escaping in HTML and mail. Include names and every free-text field a template renders. Assert that dangerous characters are escaped and the raw value is absent. Do not assert an exact entity for a quote, because Markdown and mail CSS inliners may decode it. +- **Injection into dynamic query components.** Examples include sort columns, filter fields, and sort directions. +- **An unexpected key** in a payload or configuration array. A merge that accepts every key can set an attribute the user must not control. + +```php +it('escapes dangerous content in the notification', function () { + $organization = Organization::factory()->make([ + 'name' => "O'Reilly ", + ]); + + $content = (new QuotaApproaching($organization, 80))->toMail()->render(); + + expect($content) + ->toContain('<script>') + ->not->toContain(""); +}); +``` + +Laravel provides defenses against mass assignment, unauthorized access, and unescaped output. Test that the application applies the appropriate defense to each attribute, route, and template. diff --git a/.cursor/skills/testing-best-practices/rules/test-data.md b/.cursor/skills/testing-best-practices/rules/test-data.md new file mode 100644 index 00000000..65124683 --- /dev/null +++ b/.cursor/skills/testing-best-practices/rules/test-data.md @@ -0,0 +1,56 @@ +# Factories and Test Data + +## Each Test Makes Its Own Data + +Create mutable records inside the test that uses them. This keeps setup visible and lets each test select its factory state. + +Use `beforeEach()` only for configuration that applies to every test in the file. Do not create records in it. + +## Record Construction + +- Use `create()` if the test needs the record in the database. +- Use `make()` only if the test does not need the database. Examples include rendering a notification and testing a value object's behavior. +- Use a named factory state instead of a raw attribute. `User::factory()->unverified()->create()` gives the state meaning; `create(['email_verified_at' => null])` gives only its value. +- Use `for()` or the relationship helper of the project to declare the owner of a record. +- Use `recycle()` if several records must share one parent record. +- Use `sequence()` if several records need different attributes. + +```php +$organization = Organization::factory()->onPlan(BillingPlan::PRO)->create(); + +$environment = Environment::factory()->recycle($organization)->create(); + +$organizations = Organization::factory() + ->count(3) + ->sequence( + ['created_at' => now()->setSeconds(30)], + ['created_at' => now()->setSeconds(1)], + ) + ->create(); +``` + +Create only the records required to arrange the behavior or support an assertion. + +## Datasets + +Use a dataset when the setup, test body, and assertions remain the same across input values. + +```php +it('forbids roles other than admin', function (Role $role) { + actingAs(User::factory()->hasOrganization($role)->create()) + ->post('/settings') + ->assertForbidden(); +})->with(collect(Role::cases())->reject(fn (Role $role) => $role === Role::ADMIN)); +``` + +Use parameterized tests for: + +- enum cases +- roles and plans +- boundary values +- input values that are invalid in the same way +- input and output value pairs + +Write separate tests if the cases need a different setup, a different behavior, or different assertions. One test function with a branch in the body is two tests in one function. + +Give each dataset case a name that states the difference. A failure then identifies the case without requiring you to count positions. diff --git a/.env.example b/.env.example index c0660ea1..a964a55a 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ APP_NAME=Laravel APP_ENV=local APP_KEY= +# Always set APP_DEBUG=false in production. Debug mode must never be enabled with production data. APP_DEBUG=true APP_URL=http://localhost diff --git a/.gitignore b/.gitignore index c7cf1fa6..28cf127c 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ yarn-error.log /.nova /.vscode /.zed +.playwright-mcp/ diff --git a/AGENTS.md b/AGENTS.md index 296f2af0..e05592b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,3 +23,226 @@ The complete specification is in `specs/`. Start with `specs/09-IMPLEMENTATION-R - `specs/07-SEEDERS-AND-TEST-DATA.md` - Seeders and test data - `specs/08-PLAYWRIGHT-E2E-PLAN.md` - E2E browser tests - `specs/09-IMPLEMENTATION-ROADMAP.md` - Implementation roadmap + +=== + + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications. + +## Foundational Context + +This application is a Laravel application running on PHP 8.4. You are an expert with the Laravel ecosystem. Always use the APIs that match the installed major version of each package — do not assume a version. + +Before relying on a package's API, confirm its installed version: +- PHP packages: run `composer show --direct` to list direct dependencies with versions, or `composer show ` for a single package. +- JS packages: check `package.json` for the installed versions. + +## Skills Activation + +This project has domain-specific skills available in `**/skills/**`. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. + +## Conventions + +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts + +- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important. + +## Application Structure & Architecture + +- Stick to existing directory structure; don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling + +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Documentation Files + +- You must only create documentation files if explicitly requested by the user. + +## Replies + +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +=== boost rules === + +# Laravel Boost + +## Tools + +- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads. +- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker. +- Use `database-schema` to inspect table structure before writing migrations or models. +- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user. +- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries. + +## Searching Documentation (IMPORTANT) + +- Use `search-docs` before changes that depend on Laravel ecosystem APIs, behavior, configuration, or version-specific syntax. Skip it for copy-only edits and other changes where package documentation is irrelevant. Reuse sufficient results already in context instead of searching again. +- Pass a `packages` array to scope results when you know which packages are relevant. +- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first. +- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`. + +### Search Syntax + +1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit". +2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order. +3. Combine words and phrases for mixed queries: `middleware "rate limit"`. +4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`. + +## Project Rules + +- This project contains committed, area-grouped rules in `.ai/rules` when that directory exists (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If `.ai/rules` does not exist, continue without it. +- Record a rule with `record-rule` only when the user explicitly asks for one. Instructions for the work at hand are not rules, no matter how emphatic: "remove this typo", "use X here" are work to do, not rules to record. Never record a rule on your own initiative, as a byproduct of a change, or to summarize what you just did. When the user does ask, pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Use `record-rule` rather than your native memory or notes tool, because native memory is personal and session-scoped, while only `.ai/rules` is shared with the team and persists in the repo. + +## Artisan + +- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. +- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`. +- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory. + +## Tinker + +- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code. +- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'` + - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` + +=== php rules === + +# PHP + +- Always use curly braces for control structures, even for single-line bodies. +- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private. +- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool` +- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`. +- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic. +- Use array shape type definitions in PHPDoc blocks. + +=== deployments rules === + +# Deployment + +- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications. +- Activate the `deploying-to-cloud` skill whenever deploying to Laravel Cloud, configuring Cloud environments or resources, using the Cloud CLI, or troubleshooting Cloud deployments. + +=== herd rules === + +# Laravel Herd + +- The application is served by Laravel Herd at `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs. Never run commands to serve the site. It is always available. +- Use the `herd` CLI to manage services, PHP versions, and sites (e.g. `herd sites`, `herd services:start `, `herd php:list`). Run `herd list` to discover all available commands. + +=== tests rules === + +# Test Enforcement + +- Add or update tests for behavior and logic changes when a test provides meaningful regression coverage. +- Pure copy, styling, and layout-only changes do not require new or updated tests. +- When test coverage applies, run the affected tests and ensure they pass. +- Test the changed behavior and its important failure modes, but do not add tests beyond them. +- Read the `testing-best-practices` skill before writing tests. + +=== laravel-fortify/core rules === + +# Laravel Fortify + +- Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications. +- IMPORTANT: Always use the `search-docs` tool for detailed Laravel Fortify patterns and documentation. +- IMPORTANT: Activate `developing-with-fortify` skill when working with Fortify authentication features. + +=== laravel/core rules === + +# Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`. +- If you're creating a generic PHP class, use `php artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Model Creation + +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options. + +## APIs & Eloquent Resources + +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +## URL Generation + +- When generating links to other pages, prefer named routes and the `route()` function. + +## Testing + +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +## Vite Error + +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + +=== laravel/v12 rules === + +# Laravel 12 + +- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +## Laravel 12 Structure + +- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`. +- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- The `app/Console/Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. +- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration. + +## Database + +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. + +- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models + +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + +=== livewire/core rules === + +# Livewire + +- Livewire allows you to build dynamic, reactive interfaces in PHP without writing JavaScript. +- You can use Alpine.js for client-side interactions instead of JavaScript frameworks. +- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests. + +=== pint/core rules === + +# Laravel Pint Code Formatter + +- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues. + +=== pest/core rules === + +# Pest + +- This project uses Pest. Create tests with `php artisan make:test --pest {name}`. +- Do not include the test suite directory in `{name}`. Use `SomeFeatureTest`, not `Feature/SomeFeatureTest`. +- Read the `testing-best-practices` skill for guidance on coverage, naming, structure, dependency isolation, and review. +- Do not delete tests or test files without approval. They are part of the application. + +## Running Tests + +- Run the narrowest set of tests that covers the change. Pass a file path or `--filter=testName` to `php artisan test --compact`. +- Rerun a test after each change to it. +- Run `vendor/bin/pest` to call the test runner directly. It accepts the same file path and `--filter=testName` arguments. +- After the feature tests pass, ask the user to run the complete suite with `php artisan test --compact`. + + diff --git a/README.md b/README.md new file mode 100644 index 00000000..245fb805 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +Your mission is to implement an entire shop system based on the specifications im specs/*. You must do in one go without stopping. You must use sub-agents! 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 verified by a 2nd agent. + +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 and showcase all features (customer- and admin-side) to me. In case bugs appear, you must fix them all and restart the review meeting. + +Don't re-use any existing implementation in another branch. Build it from scratch. diff --git a/app/Auth/CustomerUserProvider.php b/app/Auth/CustomerUserProvider.php new file mode 100644 index 00000000..cc99b15a --- /dev/null +++ b/app/Auth/CustomerUserProvider.php @@ -0,0 +1,32 @@ + $credentials + */ + public function retrieveByCredentials(array $credentials): ?\Illuminate\Contracts\Auth\Authenticatable + { + $storeId = app(CurrentStore::class)->id(); + + if ($storeId === null) { + return null; + } + + $credentials['store_id'] = $storeId; + + return parent::retrieveByCredentials($credentials); + } +} diff --git a/app/Contracts/PaymentProvider.php b/app/Contracts/PaymentProvider.php new file mode 100644 index 00000000..aa5aa1aa --- /dev/null +++ b/app/Contracts/PaymentProvider.php @@ -0,0 +1,16 @@ +json([ + 'message' => $this->getMessage(), + ], 409); + } +} diff --git a/app/Exceptions/InsufficientInventoryException.php b/app/Exceptions/InsufficientInventoryException.php new file mode 100644 index 00000000..7835fdc2 --- /dev/null +++ b/app/Exceptions/InsufficientInventoryException.php @@ -0,0 +1,19 @@ +json([ + 'message' => $this->getMessage(), + 'errors' => [ + 'quantity' => [$this->getMessage()], + ], + ], 422); + } +} diff --git a/app/Exceptions/InvalidCheckoutTransitionException.php b/app/Exceptions/InvalidCheckoutTransitionException.php new file mode 100644 index 00000000..6c011a4d --- /dev/null +++ b/app/Exceptions/InvalidCheckoutTransitionException.php @@ -0,0 +1,7 @@ +errorCode, ['discount_expired', 'discount_usage_limit_reached'], true) ? 400 : 422; + + return response()->json([ + 'message' => $this->getMessage(), + 'error_code' => $this->errorCode, + ], $status); + } +} diff --git a/app/Exceptions/InvalidFulfillmentTransitionException.php b/app/Exceptions/InvalidFulfillmentTransitionException.php new file mode 100644 index 00000000..b7d2f3a9 --- /dev/null +++ b/app/Exceptions/InvalidFulfillmentTransitionException.php @@ -0,0 +1,7 @@ +json([ + 'message' => $this->getMessage(), + ], $this->status); + } +} diff --git a/app/Exceptions/PaymentFailedException.php b/app/Exceptions/PaymentFailedException.php new file mode 100644 index 00000000..8cdd16b3 --- /dev/null +++ b/app/Exceptions/PaymentFailedException.php @@ -0,0 +1,15 @@ +json([ + 'message' => $this->getMessage(), + 'errors' => [ + 'shipping_address.country_code' => [$this->getMessage()], + ], + ], 422); + } +} diff --git a/app/Http/Controllers/Admin/Auth/ForgotPasswordController.php b/app/Http/Controllers/Admin/Auth/ForgotPasswordController.php new file mode 100644 index 00000000..953acf4f --- /dev/null +++ b/app/Http/Controllers/Admin/Auth/ForgotPasswordController.php @@ -0,0 +1,36 @@ +validate(['email' => 'required|email']); + + ResetPassword::createUrlUsing(fn ($user, string $token): string => route('admin.password.reset', [ + 'token' => $token, + 'email' => $request->input('email'), + ])); + + $status = Password::broker('users')->sendResetLink( + $request->only('email') + ); + + return $status === Password::RESET_LINK_SENT + ? back()->with('status', __($status)) + : back()->withInput($request->only('email'))->withErrors(['email' => __($status)]); + } +} diff --git a/app/Http/Controllers/Admin/Auth/LoginController.php b/app/Http/Controllers/Admin/Auth/LoginController.php new file mode 100644 index 00000000..2996ef57 --- /dev/null +++ b/app/Http/Controllers/Admin/Auth/LoginController.php @@ -0,0 +1,52 @@ +validated(); + + if (! Auth::guard('web')->attempt($validated, $request->boolean('remember'))) { + return back() + ->withInput($request->only('email', 'remember')) + ->withErrors(['email' => 'Invalid credentials']); + } + + $request->session()->regenerate(); + + $user = Auth::guard('web')->user(); + $user->forceFill(['last_login_at' => now()])->save(); + + $storeIds = $user->stores()->pluck('stores.id'); + + if ($storeIds->count() === 1) { + $request->session()->put('current_store_id', $storeIds->first()); + } + + return redirect()->intended(route('admin.dashboard')); + } + + public function destroy(Request $request): RedirectResponse + { + Auth::guard('web')->logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('admin.login'); + } +} diff --git a/app/Http/Controllers/Admin/Auth/ResetPasswordController.php b/app/Http/Controllers/Admin/Auth/ResetPasswordController.php new file mode 100644 index 00000000..e6adef93 --- /dev/null +++ b/app/Http/Controllers/Admin/Auth/ResetPasswordController.php @@ -0,0 +1,51 @@ + $token, + 'email' => $request->query('email', ''), + ]); + } + + public function store(Request $request): RedirectResponse + { + $request->validate([ + 'token' => 'required|string', + 'email' => 'required|email', + 'password' => $this->passwordRules(), + ]); + + $status = Password::broker('users')->reset( + $request->only('email', 'password', 'password_confirmation', 'token'), + function ($user, string $password): void { + $user->forceFill([ + 'password' => Hash::make($password), + 'remember_token' => Str::random(60), + ])->save(); + + event(new PasswordReset($user)); + } + ); + + return $status === Password::PASSWORD_RESET + ? redirect()->route('admin.login')->with('status', __($status)) + : back()->withInput($request->only('email'))->withErrors(['email' => __($status)]); + } +} diff --git a/app/Http/Controllers/Admin/StoreSelectorController.php b/app/Http/Controllers/Admin/StoreSelectorController.php new file mode 100644 index 00000000..e6b2c82c --- /dev/null +++ b/app/Http/Controllers/Admin/StoreSelectorController.php @@ -0,0 +1,39 @@ + $request->user()->stores()->orderBy('name')->get(), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'store_id' => 'required|integer|exists:stores,id', + ]); + + $isMember = $request->user()->stores()->where('stores.id', $validated['store_id'])->exists(); + + if (! $isMember) { + abort(403); + } + + $request->session()->put('current_store_id', $validated['store_id']); + + return redirect()->intended(route('admin.dashboard')); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/AnalyticsController.php b/app/Http/Controllers/Api/Admin/V1/AnalyticsController.php new file mode 100644 index 00000000..9d8445cb --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/AnalyticsController.php @@ -0,0 +1,48 @@ +validate([ + 'from' => 'required|date_format:Y-m-d', + 'to' => 'required|date_format:Y-m-d|after_or_equal:from', + 'granularity' => ['nullable', Rule::in(['day', 'week', 'month'])], + ]); + + $from = $validated['from']; + $to = $validated['to']; + + if (now()->parse($from)->diffInDays(now()->parse($to)) > 365) { + return response()->json([ + 'message' => 'The date range must not exceed 365 days.', + 'errors' => ['to' => ['The date range must not exceed 365 days.']], + ], 422); + } + + $store = $this->currentStore->get(); + + abort_if($store === null, 404); + + return response()->json([ + 'data' => $this->analytics->summary($store, $from, $to, $validated['granularity'] ?? 'day'), + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/CollectionController.php b/app/Http/Controllers/Api/Admin/V1/CollectionController.php new file mode 100644 index 00000000..9976a856 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/CollectionController.php @@ -0,0 +1,187 @@ +validate([ + 'status' => 'nullable|in:draft,active,archived', + 'query' => 'nullable|string|max:255', + 'page' => 'nullable|integer|min:1', + 'per_page' => 'nullable|integer|min:1|max:100', + ]); + + $query = Collection::query() + ->where('store_id', $this->currentStore->id()) + ->withCount('products') + ->orderByDesc('updated_at') + ->orderBy('id'); + + if (! empty($validated['status'])) { + $query->where('status', $validated['status']); + } + + if (! empty($validated['query'])) { + $query->where('title', 'like', "%{$validated['query']}%"); + } + + $paginator = $query->paginate($validated['per_page'] ?? 25); + + return response()->json([ + 'data' => CollectionResource::collection($paginator->items()), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + public function store(StoreCollectionRequest $request, int $storeId): JsonResponse + { + Gate::authorize('create', Collection::class); + + $validated = $request->validated(); + $storeId = $this->currentStore->id(); + + $handle = $validated['handle'] ?? $this->handles->generate($validated['title'], 'collections', $storeId); + + if (isset($validated['handle']) && $this->handles->exists('collections', $storeId, $handle)) { + throw ValidationException::withMessages(['handle' => ['The handle has already been taken.']]); + } + + $collection = Collection::query()->create([ + 'store_id' => $storeId, + 'title' => $validated['title'], + 'handle' => $handle, + 'description_html' => $validated['description_html'] ?? null, + 'type' => CollectionType::from($validated['type']), + 'status' => isset($validated['status']) ? CollectionStatus::from($validated['status']) : CollectionStatus::Active, + ]); + + $this->syncProducts($collection, $validated['product_ids'] ?? []); + + return (new CollectionResource($collection->loadCount('products')))->response()->setStatusCode(201); + } + + public function show(int $storeId, int $collectionId): JsonResponse + { + $collection = $this->findCollection($collectionId); + + Gate::authorize('view', $collection); + + $collection->load(['products' => fn ($query) => $query->orderByPivot('position')->orderBy('products.id')]); + $collection->loadCount('products'); + + return (new CollectionResource($collection))->response(); + } + + public function update(UpdateCollectionRequest $request, int $storeId, int $collectionId): JsonResponse + { + $collection = $this->findCollection($collectionId); + + Gate::authorize('update', $collection); + + $validated = $request->validated(); + + foreach (['title', 'description_html'] as $field) { + if (array_key_exists($field, $validated)) { + $collection->{$field} = $validated[$field]; + } + } + + if (array_key_exists('handle', $validated) && $validated['handle'] !== null && $validated['handle'] !== $collection->handle) { + if ($this->handles->exists('collections', $collection->store_id, $validated['handle'], $collection->getKey())) { + throw ValidationException::withMessages(['handle' => ['The handle has already been taken.']]); + } + + $collection->handle = $validated['handle']; + } + + if (array_key_exists('type', $validated)) { + $collection->type = CollectionType::from($validated['type']); + } + + if (array_key_exists('status', $validated)) { + $collection->status = CollectionStatus::from($validated['status']); + } + + $collection->save(); + + if (array_key_exists('product_ids', $validated)) { + $this->syncProducts($collection, $validated['product_ids'] ?? []); + } else { + if (! empty($validated['add_product_ids'])) { + $position = (int) ($collection->products()->max('collection_products.position') ?? -1); + + foreach ($validated['add_product_ids'] as $productId) { + $position++; + $collection->products()->syncWithoutDetaching([$productId => ['position' => $position]]); + } + } + + if (! empty($validated['remove_product_ids'])) { + $collection->products()->detach($validated['remove_product_ids']); + } + } + + return (new CollectionResource($collection->loadCount('products')))->response(); + } + + public function destroy(int $storeId, int $collectionId): JsonResponse + { + $collection = $this->findCollection($collectionId); + + Gate::authorize('delete', $collection); + + $collection->delete(); + + return response()->json(['message' => 'Collection deleted']); + } + + /** + * @param array $productIds + */ + protected function syncProducts(Collection $collection, array $productIds): void + { + $sync = []; + + foreach (array_values($productIds) as $position => $productId) { + $sync[$productId] = ['position' => $position]; + } + + $collection->products()->sync($sync); + } + + protected function findCollection(int $collectionId): Collection + { + return Collection::query() + ->where('store_id', $this->currentStore->id()) + ->whereKey($collectionId) + ->firstOrFail(); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/DiscountController.php b/app/Http/Controllers/Api/Admin/V1/DiscountController.php new file mode 100644 index 00000000..b30a206e --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/DiscountController.php @@ -0,0 +1,142 @@ +validate([ + 'type' => 'nullable|in:code,automatic', + 'status' => 'nullable|in:draft,active,expired,disabled,scheduled', + 'page' => 'nullable|integer|min:1', + 'per_page' => 'nullable|integer|min:1|max:100', + ]); + + $query = Discount::query() + ->where('store_id', $this->currentStore->id()) + ->orderByDesc('updated_at') + ->orderBy('id'); + + if (! empty($validated['type'])) { + $query->where('type', $validated['type']); + } + + if (! empty($validated['status'])) { + $status = $validated['status']; + + if ($status === 'scheduled') { + $query->where('starts_at', '>', now()); + } else { + $query->where('status', $status); + } + } + + $paginator = $query->paginate($validated['per_page'] ?? 25); + + return response()->json([ + 'data' => DiscountResource::collection($paginator->items()), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + public function store(StoreDiscountRequest $request, int $storeId): JsonResponse + { + Gate::authorize('create', Discount::class); + + $validated = $request->validated(); + + $discount = Discount::query()->create([ + 'store_id' => $this->currentStore->id(), + 'type' => DiscountType::from($validated['type']), + 'code' => $validated['code'] ?? null, + 'value_type' => DiscountValueType::from($validated['value_type']), + 'value_amount' => $validated['value_amount'], + 'starts_at' => $validated['starts_at'] ?? now(), + 'ends_at' => $validated['ends_at'] ?? null, + 'usage_limit' => $validated['usage_limit'] ?? null, + 'usage_count' => 0, + 'rules_json' => $validated['rules_json'] ?? [], + 'status' => isset($validated['status']) ? DiscountStatus::from($validated['status']) : DiscountStatus::Active, + ]); + + return (new DiscountResource($discount))->response()->setStatusCode(201); + } + + public function show(int $storeId, int $discountId): JsonResponse + { + $discount = $this->findDiscount($discountId); + + Gate::authorize('view', $discount); + + return (new DiscountResource($discount))->response(); + } + + public function update(UpdateDiscountRequest $request, int $storeId, int $discountId): JsonResponse + { + $discount = $this->findDiscount($discountId); + + Gate::authorize('update', $discount); + + $validated = $request->validated(); + + foreach (['value_amount', 'starts_at', 'ends_at', 'usage_limit', 'rules_json'] as $field) { + if (array_key_exists($field, $validated)) { + $discount->{$field} = $validated[$field]; + } + } + + if (array_key_exists('value_type', $validated) && $validated['value_type'] !== null) { + $discount->value_type = DiscountValueType::from($validated['value_type']); + } + + if (array_key_exists('status', $validated) && $validated['status'] !== null) { + $discount->status = DiscountStatus::from($validated['status']); + } + + $discount->save(); + + return (new DiscountResource($discount->refresh()))->response(); + } + + public function destroy(int $storeId, int $discountId): JsonResponse + { + $discount = $this->findDiscount($discountId); + + Gate::authorize('delete', $discount); + + $discount->delete(); + + return response()->json(['message' => 'Discount deleted']); + } + + protected function findDiscount(int $discountId): Discount + { + return Discount::query() + ->where('store_id', $this->currentStore->id()) + ->whereKey($discountId) + ->firstOrFail(); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/FulfillmentController.php b/app/Http/Controllers/Api/Admin/V1/FulfillmentController.php new file mode 100644 index 00000000..34d12572 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/FulfillmentController.php @@ -0,0 +1,89 @@ +findOrder($orderId); + + Gate::authorize('create', [Fulfillment::class, $order]); + + $validated = $request->validated(); + $lines = []; + + foreach ($validated['line_items'] as $item) { + $lines[(int) $item['order_line_id']] = (int) $item['quantity']; + } + + $fulfillment = $this->fulfillments->create($order, $lines, [ + 'tracking_company' => $validated['tracking_company'] ?? null, + 'tracking_number' => $validated['tracking_number'] ?? null, + 'tracking_url' => $validated['tracking_url'] ?? null, + ]); + + return (new FulfillmentResource($fulfillment->load('fulfillmentLines')))->response()->setStatusCode(201); + } + + public function ship(int $storeId, int $orderId, int $fulfillmentId): JsonResponse + { + $fulfillment = $this->findFulfillment($orderId, $fulfillmentId); + + Gate::authorize('ship', $fulfillment); + + try { + $fulfillment = $this->fulfillments->markAsShipped($fulfillment); + } catch (InvalidFulfillmentTransitionException $exception) { + return response()->json(['message' => $exception->getMessage()], 422); + } + + return (new FulfillmentResource($fulfillment->load('fulfillmentLines')))->response(); + } + + public function deliver(int $storeId, int $orderId, int $fulfillmentId): JsonResponse + { + $fulfillment = $this->findFulfillment($orderId, $fulfillmentId); + + Gate::authorize('deliver', $fulfillment); + + try { + $fulfillment = $this->fulfillments->markAsDelivered($fulfillment); + } catch (InvalidFulfillmentTransitionException $exception) { + return response()->json(['message' => $exception->getMessage()], 422); + } + + return (new FulfillmentResource($fulfillment->load('fulfillmentLines')))->response(); + } + + protected function findOrder(int $orderId): Order + { + return Order::query() + ->where('store_id', $this->currentStore->id()) + ->whereKey($orderId) + ->firstOrFail(); + } + + protected function findFulfillment(int $orderId, int $fulfillmentId): Fulfillment + { + $order = $this->findOrder($orderId); + + return $order->fulfillments()->whereKey($fulfillmentId)->firstOrFail(); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/MeController.php b/app/Http/Controllers/Api/Admin/V1/MeController.php new file mode 100644 index 00000000..59694dc2 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/MeController.php @@ -0,0 +1,33 @@ +findOrFail($storeId); + + $membership = StoreUser::query() + ->where('store_id', $store->getKey()) + ->where('user_id', $request->user()->getKey()) + ->first(); + + if ($membership === null) { + return response()->json([ + 'message' => 'You are not a member of this store.', + ], 403); + } + + $membership->load('user'); + + return (new MeResource($membership))->response(); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/OrderController.php b/app/Http/Controllers/Api/Admin/V1/OrderController.php new file mode 100644 index 00000000..80471a1f --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/OrderController.php @@ -0,0 +1,126 @@ +validate([ + 'status' => 'nullable|in:pending,paid,fulfilled,cancelled,refunded', + 'financial_status' => 'nullable|in:pending,authorized,paid,partially_refunded,refunded,voided', + 'fulfillment_status' => 'nullable|in:unfulfilled,partial,fulfilled', + 'customer_id' => 'nullable|integer|min:1', + 'created_after' => 'nullable|date', + 'created_before' => 'nullable|date', + 'query' => 'nullable|string|max:255', + 'page' => 'nullable|integer|min:1', + 'per_page' => 'nullable|integer|min:1|max:100', + 'sort' => 'nullable|in:placed_at_desc,placed_at_asc,total_desc,total_asc', + ]); + + $query = Order::query() + ->where('store_id', $this->currentStore->id()) + ->with('customer') + ->withCount('orderLines'); + + foreach (['status', 'financial_status', 'fulfillment_status', 'customer_id'] as $filter) { + if (! empty($validated[$filter])) { + $query->where($filter, $validated[$filter]); + } + } + + if (! empty($validated['created_after'])) { + $query->where('placed_at', '>=', $validated['created_after']); + } + + if (! empty($validated['created_before'])) { + $query->where('placed_at', '<=', $validated['created_before']); + } + + if (! empty($validated['query'])) { + $search = $validated['query']; + $query->where(fn ($inner) => $inner + ->where('order_number', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%")); + } + + match ($validated['sort'] ?? 'placed_at_desc') { + 'placed_at_asc' => $query->orderBy('placed_at')->orderBy('id'), + 'total_desc' => $query->orderByDesc('total_amount')->orderBy('id'), + 'total_asc' => $query->orderBy('total_amount')->orderBy('id'), + default => $query->orderByDesc('placed_at')->orderByDesc('id'), + }; + + $paginator = $query->paginate($validated['per_page'] ?? 25); + + return response()->json([ + 'data' => OrderSummaryResource::collection($paginator->items()), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + public function show(int $storeId, int $orderId): JsonResponse + { + $order = $this->findOrder($orderId); + + Gate::authorize('view', $order); + + return (new OrderResource($order))->response(); + } + + public function confirmPayment(int $storeId, int $orderId): JsonResponse + { + $order = $this->findOrder($orderId); + + Gate::authorize('confirmPayment', $order); + + try { + $order = $this->orders->confirmBankTransferPayment($order); + } catch (InvalidOrderTransitionException $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + ], 422); + } + + return (new OrderResource($this->findOrder($order->getKey())))->response(); + } + + protected function findOrder(int $orderId): Order + { + return Order::query() + ->where('store_id', $this->currentStore->id()) + ->whereKey($orderId) + ->with([ + 'customer', + 'orderLines', + 'payments', + 'fulfillments.fulfillmentLines', + 'refunds', + ]) + ->firstOrFail(); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/Platform/OrganizationController.php b/app/Http/Controllers/Api/Admin/V1/Platform/OrganizationController.php new file mode 100644 index 00000000..55d676ee --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/Platform/OrganizationController.php @@ -0,0 +1,19 @@ +create($request->validated()); + + return (new OrganizationResource($organization))->response()->setStatusCode(201); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/Platform/StoreController.php b/app/Http/Controllers/Api/Admin/V1/Platform/StoreController.php new file mode 100644 index 00000000..fb786ee9 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/Platform/StoreController.php @@ -0,0 +1,23 @@ +create([ + ...$request->validated(), + 'status' => StoreStatus::Active, + ]); + + return (new StoreResource($store))->response()->setStatusCode(201); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/ProductController.php b/app/Http/Controllers/Api/Admin/V1/ProductController.php new file mode 100644 index 00000000..961395b7 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/ProductController.php @@ -0,0 +1,162 @@ +validate([ + 'status' => 'nullable|in:draft,active,archived', + 'query' => 'nullable|string|max:255', + 'collection_id' => 'nullable|integer', + 'page' => 'nullable|integer|min:1', + 'per_page' => 'nullable|integer|min:1|max:100', + 'sort' => 'nullable|in:title_asc,title_desc,created_at_asc,created_at_desc,updated_at_desc', + ]); + + $query = Product::query() + ->where('store_id', $this->currentStore->id()) + ->withCount('productVariants') + ->with([ + 'productVariants.inventoryItem', + 'productMedia' => fn ($media) => $media->orderBy('position')->orderBy('id'), + ]); + + if (! empty($validated['status'])) { + $query->where('status', $validated['status']); + } + + if (! empty($validated['query'])) { + $term = $validated['query']; + $query->where(function ($inner) use ($term): void { + $inner->where('title', 'like', "%{$term}%") + ->orWhere('vendor', 'like', "%{$term}%") + ->orWhereHas('productVariants', fn ($variants) => $variants->where('sku', 'like', "%{$term}%")); + }); + } + + if (! empty($validated['collection_id'])) { + $query->whereHas('collections', fn ($collections) => $collections->where('collections.id', $validated['collection_id'])); + } + + match ($validated['sort'] ?? 'updated_at_desc') { + 'title_asc' => $query->orderBy('title')->orderBy('id'), + 'title_desc' => $query->orderByDesc('title')->orderBy('id'), + 'created_at_asc' => $query->orderBy('created_at')->orderBy('id'), + 'created_at_desc' => $query->orderByDesc('created_at')->orderBy('id'), + default => $query->orderByDesc('updated_at')->orderBy('id'), + }; + + $paginator = $query->paginate($validated['per_page'] ?? 25); + + return response()->json([ + 'data' => ProductSummaryResource::collection($paginator->items()), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + public function store(StoreProductRequest $request, int $storeId): JsonResponse + { + Gate::authorize('create', Product::class); + + $store = $this->currentStore->get(); + + abort_if($store === null || $store->getKey() !== $storeId, 404); + + try { + $product = $this->products->create($store, $request->validated()); + } catch (InvalidProductTransitionException $exception) { + return response()->json(['message' => $exception->getMessage()], 422); + } + + return (new ProductResource($product))->response()->setStatusCode(201); + } + + public function show(int $storeId, int $productId): JsonResponse + { + $product = $this->findProduct($productId); + + Gate::authorize('view', $product); + + $product->load([ + 'productOptions' => fn ($query) => $query->orderBy('position'), + 'productOptions.productOptionValues' => fn ($query) => $query->orderBy('position'), + 'productVariants' => fn ($query) => $query->orderBy('position')->orderBy('id'), + 'productVariants.inventoryItem', + 'productVariants.optionValues.productOption', + 'productMedia' => fn ($query) => $query->orderBy('position')->orderBy('id'), + 'collections', + ]); + + return (new ProductResource($product))->response(); + } + + public function update(UpdateProductRequest $request, int $storeId, int $productId): JsonResponse + { + $product = $this->findProduct($productId); + + Gate::authorize('update', $product); + + try { + $updated = $this->products->update($product, $request->validated()); + } catch (InvalidProductTransitionException $exception) { + return response()->json(['message' => $exception->getMessage()], 422); + } + + return (new ProductResource($updated))->response(); + } + + public function destroy(int $storeId, int $productId): JsonResponse + { + $product = $this->findProduct($productId); + + Gate::authorize('archive', $product); + + $archived = $product->status === ProductStatus::Archived + ? $product + : $this->products->archive($product); + + return response()->json([ + 'data' => [ + 'id' => $archived->id, + 'status' => $archived->status->value, + 'updated_at' => $archived->updated_at?->toIso8601String(), + ], + ]); + } + + protected function findProduct(int $productId): Product + { + return Product::query() + ->where('store_id', $this->currentStore->id()) + ->whereKey($productId) + ->firstOrFail(); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/ProductMediaController.php b/app/Http/Controllers/Api/Admin/V1/ProductMediaController.php new file mode 100644 index 00000000..f4cd2ca5 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/ProductMediaController.php @@ -0,0 +1,71 @@ +findProduct($productId); + + Gate::authorize('update', $product); + + $validated = $request->validated(); + + $media = $this->media->upload($product, $validated['file'], $validated['alt_text'] ?? null); + + return (new ProductMediaResource($media))->response()->setStatusCode(201); + } + + public function update(UpdateMediaRequest $request, int $storeId, int $productId, int $mediaId): JsonResponse + { + $product = $this->findProduct($productId); + + Gate::authorize('update', $product); + + $media = $this->findMedia($product, $mediaId); + $updated = $this->media->updateMedia($media, $request->validated()); + + return (new ProductMediaResource($updated))->response(); + } + + public function destroy(int $storeId, int $productId, int $mediaId): JsonResponse + { + $product = $this->findProduct($productId); + + Gate::authorize('update', $product); + + $this->media->deleteMedia($this->findMedia($product, $mediaId)); + + return response()->json(['message' => 'Media deleted']); + } + + protected function findProduct(int $productId): Product + { + return Product::query() + ->where('store_id', $this->currentStore->id()) + ->whereKey($productId) + ->firstOrFail(); + } + + protected function findMedia(Product $product, int $mediaId): ProductMedia + { + return $product->productMedia()->whereKey($mediaId)->firstOrFail(); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/RefundController.php b/app/Http/Controllers/Api/Admin/V1/RefundController.php new file mode 100644 index 00000000..7b1b1427 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/RefundController.php @@ -0,0 +1,68 @@ +findOrder($orderId); + + Gate::authorize('view', $order); + + return response()->json([ + 'data' => RefundResource::collection($order->refunds()->orderByDesc('id')->get()), + ]); + } + + public function store(StoreRefundRequest $request, int $storeId, int $orderId): JsonResponse + { + $order = $this->findOrder($orderId); + + Gate::authorize('create', [Refund::class, $order]); + + $validated = $request->validated(); + $lineItems = null; + + if (! empty($validated['line_items'])) { + $lineItems = []; + + foreach ($validated['line_items'] as $item) { + $lineItems[(int) $item['order_line_id']] = (int) $item['quantity']; + } + } + + $refund = $this->refunds->create( + $order, + $validated['amount'], + $lineItems, + $validated['reason'] ?? null, + (bool) ($validated['restock'] ?? false), + ); + + return (new RefundResource($refund))->response()->setStatusCode(201); + } + + protected function findOrder(int $orderId): Order + { + return Order::query() + ->where('store_id', $this->currentStore->id()) + ->whereKey($orderId) + ->firstOrFail(); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/SearchAdminController.php b/app/Http/Controllers/Api/Admin/V1/SearchAdminController.php new file mode 100644 index 00000000..cfc229f3 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/SearchAdminController.php @@ -0,0 +1,59 @@ +currentStore->get(); + + abort_if($store === null || $store->getKey() !== $storeId, 404); + + ReindexSearch::dispatch($store->getKey()); + + return response()->json([ + 'message' => 'Reindex job queued.', + 'job_id' => 'job_reindex_'.Str::random(12), + 'status' => 'queued', + ], 202); + } + + public function status(int $storeId): JsonResponse + { + Gate::authorize('manage-search-settings'); + + $store = $this->currentStore->get(); + + abort_if($store === null || $store->getKey() !== $storeId, 404); + + $settings = SearchSetting::query()->where('store_id', $store->getKey())->first(); + + return response()->json([ + 'data' => [ + 'store_id' => $store->getKey(), + 'index_status' => 'ready', + 'last_reindex_at' => $settings?->updated_at?->toIso8601String(), + 'last_reindex_duration_seconds' => null, + 'documents_count' => $this->search->documentCount($store), + 'pending_updates' => 0, + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/ShippingRateController.php b/app/Http/Controllers/Api/Admin/V1/ShippingRateController.php new file mode 100644 index 00000000..a5791082 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/ShippingRateController.php @@ -0,0 +1,55 @@ +findZone($zoneId); + + Gate::authorize('update', $zone); + + $validated = $request->validated(); + + $rate = $zone->shippingRates()->create([ + 'name' => $validated['name'], + 'type' => ShippingRateType::from($validated['type']), + 'config_json' => $validated['config_json'], + 'is_active' => (bool) ($validated['is_active'] ?? true), + ]); + + return (new ShippingRateResource($rate))->response()->setStatusCode(201); + } + + public function destroy(int $storeId, int $zoneId, int $rateId): JsonResponse + { + $zone = $this->findZone($zoneId); + + Gate::authorize('update', $zone); + + $rate = $zone->shippingRates()->whereKey($rateId)->firstOrFail(); + $rate->delete(); + + return response()->json(['message' => 'Shipping rate deleted']); + } + + protected function findZone(int $zoneId): ShippingZone + { + return ShippingZone::query() + ->where('store_id', $this->currentStore->id()) + ->whereKey($zoneId) + ->firstOrFail(); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/ShippingZoneController.php b/app/Http/Controllers/Api/Admin/V1/ShippingZoneController.php new file mode 100644 index 00000000..ed66f992 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/ShippingZoneController.php @@ -0,0 +1,92 @@ +where('store_id', $this->currentStore->id()) + ->with('shippingRates') + ->orderBy('id') + ->get(); + + return response()->json([ + 'data' => ShippingZoneResource::collection($zones), + ]); + } + + public function store(StoreShippingZoneRequest $request, int $storeId): JsonResponse + { + Gate::authorize('create', ShippingZone::class); + + $validated = $request->validated(); + + $zone = ShippingZone::query()->create([ + 'store_id' => $this->currentStore->id(), + 'name' => $validated['name'], + 'countries_json' => array_map('strtoupper', $validated['countries_json']), + 'regions_json' => array_map('strtoupper', $validated['regions_json'] ?? []), + ]); + + return (new ShippingZoneResource($zone->load('shippingRates')))->response()->setStatusCode(201); + } + + public function update(UpdateShippingZoneRequest $request, int $storeId, int $zoneId): JsonResponse + { + $zone = $this->findZone($zoneId); + + Gate::authorize('update', $zone); + + $validated = $request->validated(); + + if (array_key_exists('name', $validated) && $validated['name'] !== null) { + $zone->name = $validated['name']; + } + + if (array_key_exists('countries_json', $validated) && $validated['countries_json'] !== null) { + $zone->countries_json = array_map('strtoupper', $validated['countries_json']); + } + + if (array_key_exists('regions_json', $validated) && $validated['regions_json'] !== null) { + $zone->regions_json = array_map('strtoupper', $validated['regions_json']); + } + + $zone->save(); + + return (new ShippingZoneResource($zone->refresh()->load('shippingRates')))->response(); + } + + public function destroy(int $storeId, int $zoneId): JsonResponse + { + $zone = $this->findZone($zoneId); + + Gate::authorize('delete', $zone); + + $zone->delete(); + + return response()->json(['message' => 'Shipping zone deleted']); + } + + protected function findZone(int $zoneId): ShippingZone + { + return ShippingZone::query() + ->where('store_id', $this->currentStore->id()) + ->whereKey($zoneId) + ->firstOrFail(); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/StoreInviteController.php b/app/Http/Controllers/Api/Admin/V1/StoreInviteController.php new file mode 100644 index 00000000..cd1fc216 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/StoreInviteController.php @@ -0,0 +1,51 @@ +findOrFail($storeId); + $validated = $request->validated(); + + $user = User::query()->where('email', $validated['email'])->first(); + + if ($user === null) { + throw ValidationException::withMessages([ + 'email' => ['No user account exists for this email address.'], + ]); + } + + $alreadyMember = StoreUser::query() + ->where('store_id', $store->getKey()) + ->where('user_id', $user->getKey()) + ->exists(); + + if ($alreadyMember) { + return response()->json([ + 'message' => 'This user is already a member of the store.', + ], 409); + } + + $membership = StoreUser::query()->create([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => StoreUserRole::from($validated['role']), + ]); + + $membership->load('user'); + + return (new InviteResource($membership))->response()->setStatusCode(201); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/TaxSettingController.php b/app/Http/Controllers/Api/Admin/V1/TaxSettingController.php new file mode 100644 index 00000000..ae5a96ef --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/TaxSettingController.php @@ -0,0 +1,59 @@ +findOrCreateSettings(); + + return (new TaxSettingResource($settings))->response()->setStatusCode(200); + } + + public function update(UpdateTaxSettingsRequest $request, int $storeId): JsonResponse + { + Gate::authorize('viewAny', TaxSetting::class); + + $settings = $this->findOrCreateSettings(); + + Gate::authorize('update', $settings); + + $validated = $request->validated(); + + $settings->mode = TaxMode::from($validated['mode']); + $settings->provider = TaxProvider::from($validated['provider'] ?? TaxProvider::None->value); + $settings->prices_include_tax = (bool) $validated['prices_include_tax']; + $settings->config_json = $validated['config_json']; + $settings->save(); + + return (new TaxSettingResource($settings->refresh()))->response()->setStatusCode(200); + } + + protected function findOrCreateSettings(): TaxSetting + { + return TaxSetting::query()->firstOrCreate( + ['store_id' => $this->currentStore->id()], + [ + 'mode' => TaxMode::Manual, + 'provider' => TaxProvider::None, + 'prices_include_tax' => false, + 'config_json' => [], + ] + ); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/TokenController.php b/app/Http/Controllers/Api/Admin/V1/TokenController.php new file mode 100644 index 00000000..499134d9 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/TokenController.php @@ -0,0 +1,56 @@ +validated(); + $abilities = $validated['abilities'] ?? []; + + if (in_array('manage-platform', $abilities, true) && Gate::denies('manage-platform')) { + return response()->json([ + 'message' => 'Only store owners can issue tokens with the manage-platform ability.', + ], 403); + } + + $expiresAt = isset($validated['expires_at']) ? now()->parse($validated['expires_at']) : null; + + $token = $request->user()->createToken($validated['name'], $abilities, $expiresAt); + + app(AuditLogger::class)->log('api_token.created', userId: $request->user()->getKey(), extra: [ + 'token_name' => $validated['name'], + 'abilities' => $abilities, + ]); + + return response()->json([ + 'data' => [ + 'id' => $token->accessToken->getKey(), + 'name' => $validated['name'], + 'abilities' => $abilities, + 'expires_at' => $token->accessToken->expires_at?->toIso8601String(), + 'token' => $token->plainTextToken, + ], + ], 201); + } + + public function destroy(Request $request, int $tokenId): JsonResponse + { + $token = $request->user()->tokens()->where('id', $tokenId)->firstOrFail(); + $token->delete(); + + app(AuditLogger::class)->log('api_token.revoked', userId: $request->user()->getKey(), extra: [ + 'token_name' => $token->name, + ]); + + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/Api/Admin/V1/VariantInventoryController.php b/app/Http/Controllers/Api/Admin/V1/VariantInventoryController.php new file mode 100644 index 00000000..808fb1cf --- /dev/null +++ b/app/Http/Controllers/Api/Admin/V1/VariantInventoryController.php @@ -0,0 +1,84 @@ +where('store_id', $this->currentStore->id()) + ->whereKey($productId) + ->firstOrFail(); + + Gate::authorize('update', $product); + + $variant = $product->productVariants()->whereKey($variantId)->firstOrFail(); + + $item = InventoryItem::query()->firstOrCreate( + ['variant_id' => $variant->getKey()], + ['store_id' => $product->store_id, 'quantity_on_hand' => 0, 'quantity_reserved' => 0, 'policy' => InventoryPolicy::Deny] + ); + + $validated = $request->validated(); + + $adjusted = $this->inventory->adjust( + $item, + $validated['quantity_on_hand'] ?? null, + isset($validated['policy']) ? InventoryPolicy::from($validated['policy']) : null, + ); + + return response()->json([ + 'data' => [ + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => $adjusted->quantity_on_hand, + 'quantity_reserved' => $adjusted->quantity_reserved, + 'available' => $adjusted->quantity_on_hand - $adjusted->quantity_reserved, + 'policy' => $adjusted->policy->value, + 'low_stock' => $this->inventory->isLowStock($adjusted), + ], + ]); + } + + public function show(int $storeId, int $productId, int $variantId): JsonResponse + { + $product = Product::query() + ->where('store_id', $this->currentStore->id()) + ->whereKey($productId) + ->firstOrFail(); + + Gate::authorize('view', $product); + + $variant = $product->productVariants()->whereKey($variantId)->firstOrFail(); + + $item = $variant->inventoryItem; + + abort_if($item === null, 404); + + return response()->json([ + 'data' => [ + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => $item->quantity_on_hand, + 'quantity_reserved' => $item->quantity_reserved, + 'available' => $item->quantity_on_hand - $item->quantity_reserved, + 'policy' => $item->policy->value, + 'low_stock' => $this->inventory->isLowStock($item), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/OauthStubController.php b/app/Http/Controllers/Api/OauthStubController.php new file mode 100644 index 00000000..6ab7c366 --- /dev/null +++ b/app/Http/Controllers/Api/OauthStubController.php @@ -0,0 +1,35 @@ +notImplemented(); + } + + public function token(): JsonResponse + { + return $this->notImplemented(); + } + + public function appResource(): JsonResponse + { + return $this->notImplemented(); + } + + protected function notImplemented(): JsonResponse + { + return response()->json([ + 'message' => 'OAuth and app ecosystem endpoints are not implemented yet.', + ], 501); + } +} diff --git a/app/Http/Controllers/Api/Storefront/V1/AnalyticsEventController.php b/app/Http/Controllers/Api/Storefront/V1/AnalyticsEventController.php new file mode 100644 index 00000000..c0c48005 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/V1/AnalyticsEventController.php @@ -0,0 +1,49 @@ +validate([ + 'events' => 'required|array|min:1|max:50', + 'events.*.type' => ['required', 'string', Rule::in(AnalyticsService::EVENT_TYPES)], + 'events.*.session_id' => 'required|string|max:100', + 'events.*.client_event_id' => 'required|string|max:100', + 'events.*.properties' => 'nullable|array', + 'events.*.occurred_at' => 'required|date', + ]); + + foreach ($validated['events'] as $index => $event) { + $occurredAt = now()->parse($event['occurred_at']); + + if ($occurredAt->isFuture() || $occurredAt->lt(now()->subHour()->subMinute())) { + return response()->json([ + 'message' => 'The selected events.'.$index.'.occurred_at is invalid.', + 'errors' => ['events.'.$index.'.occurred_at' => ['The occurred at field must not be more than 1 hour in the past or in the future.']], + ], 422); + } + } + + $store = $this->currentStore->get(); + + abort_if($store === null, 404); + + $result = $this->analytics->recordBatch($store, $validated['events']); + + return response()->json($result, 202); + } +} diff --git a/app/Http/Controllers/Api/Storefront/V1/CartController.php b/app/Http/Controllers/Api/Storefront/V1/CartController.php new file mode 100644 index 00000000..832f3a26 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/V1/CartController.php @@ -0,0 +1,120 @@ +currentStore->get(); + + abort_if($store === null, 404); + + $cart = $this->carts->create($store, null, $request->validated('currency')); + + return response()->json((new CartResource($this->loadCart($cart)))->resolve(), 201); + } + + public function show(int $cartId): JsonResponse + { + $cart = $this->findCart($cartId); + + return response()->json((new CartResource($cart))->resolve()); + } + + public function addLine(AddCartLineRequest $request, int $cartId): JsonResponse + { + $cart = $this->findCart($cartId); + $validated = $request->validated(); + + try { + $this->carts->addLine($cart, $validated['variant_id'], $validated['quantity'], $validated['cart_version'] ?? null); + } catch (CartVersionMismatchException $exception) { + return $this->versionConflict($cart->refresh(), $exception->currentVersion); + } + + return response()->json((new CartResource($this->loadCart($cart->refresh())))->resolve(), 201); + } + + public function updateLine(UpdateCartLineRequest $request, int $cartId, int $lineId): JsonResponse + { + $cart = $this->findCart($cartId); + $validated = $request->validated(); + + if ($cart->cartLines()->whereKey($lineId)->doesntExist()) { + abort(404); + } + + try { + $this->carts->updateLineQuantity($cart, $lineId, $validated['quantity'], $validated['cart_version']); + } catch (CartVersionMismatchException $exception) { + return $this->versionConflict($cart->refresh(), $exception->currentVersion); + } + + return response()->json((new CartResource($this->loadCart($cart->refresh())))->resolve()); + } + + public function removeLine(RemoveCartLineRequest $request, int $cartId, int $lineId): JsonResponse + { + $cart = $this->findCart($cartId); + + if ($cart->cartLines()->whereKey($lineId)->doesntExist()) { + abort(404); + } + + try { + $this->carts->removeLine($cart, $lineId, $request->validated('cart_version')); + } catch (CartVersionMismatchException $exception) { + return $this->versionConflict($cart->refresh(), $exception->currentVersion); + } + + return response()->json((new CartResource($this->loadCart($cart->refresh())))->resolve()); + } + + protected function findCart(int $cartId): Cart + { + return $this->loadCart( + Cart::query() + ->where('store_id', $this->currentStore->id()) + ->whereKey($cartId) + ->firstOrFail() + ); + } + + protected function loadCart(Cart $cart): Cart + { + return $cart->load([ + 'cartLines.variant.product', + 'cartLines.variant.optionValues', + 'cartLines.variant.inventoryItem', + 'cartLines.variant.product.productMedia', + ]); + } + + protected function versionConflict(Cart $cart, int $currentVersion): JsonResponse + { + return response()->json([ + 'message' => 'The cart has been modified. Please refresh and try again.', + 'error_code' => 'version_conflict', + 'current_version' => $currentVersion, + 'cart' => (new CartResource($this->loadCart($cart)))->resolve(), + ], 409); + } +} diff --git a/app/Http/Controllers/Api/Storefront/V1/CheckoutController.php b/app/Http/Controllers/Api/Storefront/V1/CheckoutController.php new file mode 100644 index 00000000..d5cda8d6 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/V1/CheckoutController.php @@ -0,0 +1,212 @@ +validated(); + + $cart = Cart::query() + ->where('store_id', $this->currentStore->id()) + ->whereKey($validated['cart_id']) + ->first(); + + if ($cart === null) { + abort(404); + } + + $checkout = $this->checkouts->createFromCart($cart, $validated['email']); + + return response()->json((new CheckoutResource($this->loadCheckout($checkout)))->resolve(), 201); + } + + public function show(int $checkoutId): JsonResponse + { + $checkout = $this->findCheckout($checkoutId); + + return response()->json((new CheckoutResource($checkout))->resolve()); + } + + public function setAddress(SetCheckoutAddressRequest $request, int $checkoutId): JsonResponse + { + $checkout = $this->findCheckout($checkoutId); + $validated = $request->validated(); + + try { + $checkout = $this->checkouts->setAddress( + $checkout, + $validated['shipping_address'], + $validated['billing_address'] ?? null, + (bool) ($validated['use_shipping_as_billing'] ?? true), + $validated['email'] ?? null, + ); + } catch (InvalidCheckoutTransitionException $exception) { + throw ValidationException::withMessages(['status' => [$exception->getMessage()]]); + } + + return response()->json((new CheckoutResource($this->loadCheckout($checkout)))->resolve()); + } + + public function setShippingMethod(SetShippingMethodRequest $request, int $checkoutId): JsonResponse + { + $checkout = $this->findCheckout($checkoutId); + + try { + $checkout = $this->checkouts->setShippingMethod($checkout, $request->validated('shipping_method_id')); + } catch (InvalidCheckoutTransitionException $exception) { + throw ValidationException::withMessages(['status' => [$exception->getMessage()]]); + } + + return response()->json((new CheckoutResource($this->loadCheckout($checkout)))->resolve()); + } + + public function selectPaymentMethod(SelectPaymentMethodRequest $request, int $checkoutId): JsonResponse + { + $checkout = $this->findCheckout($checkoutId); + + try { + $checkout = $this->checkouts->selectPaymentMethod($checkout, PaymentMethod::from($request->validated('payment_method'))); + } catch (InvalidCheckoutTransitionException $exception) { + throw ValidationException::withMessages(['status' => [$exception->getMessage()]]); + } + + return response()->json((new CheckoutResource($this->loadCheckout($checkout)))->resolve()); + } + + public function applyDiscount(ApplyDiscountRequest $request, int $checkoutId): JsonResponse + { + $checkout = $this->findCheckout($checkoutId); + + try { + $checkout = $this->checkouts->applyDiscount($checkout, $request->validated('code'), $this->discounts); + } catch (InvalidCheckoutTransitionException $exception) { + throw ValidationException::withMessages(['status' => [$exception->getMessage()]]); + } + + return response()->json((new CheckoutResource($this->loadCheckout($checkout)))->resolve()); + } + + public function removeDiscount(int $checkoutId): JsonResponse + { + $checkout = $this->findCheckout($checkoutId); + + if ($checkout->discount_code === null) { + abort(404); + } + + try { + $checkout = $this->checkouts->removeDiscount($checkout); + } catch (InvalidCheckoutTransitionException $exception) { + throw ValidationException::withMessages(['status' => [$exception->getMessage()]]); + } + + return response()->json((new CheckoutResource($this->loadCheckout($checkout)))->resolve()); + } + + public function pay(PayCheckoutRequest $request, int $checkoutId): JsonResponse + { + $checkout = $this->findCheckout($checkoutId); + $validated = $request->validated(); + + if (! in_array($checkout->status, [CheckoutStatus::ShippingSelected, CheckoutStatus::PaymentSelected], true)) { + return response()->json([ + 'message' => 'This checkout is not ready for payment.', + ], 409); + } + + try { + $order = $this->checkouts->pay($checkout, PaymentMethod::from($validated['payment_method']), $validated); + } catch (PaymentFailedException $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + 'error_code' => $exception->errorCode, + ], 422); + } catch (InvalidCheckoutTransitionException $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + ], 409); + } + + $payload = [ + 'checkout_id' => $checkout->getKey(), + 'status' => CheckoutStatus::Completed->value, + 'order' => [ + 'id' => $order->getKey(), + 'order_number' => $order->order_number, + 'status' => $order->status->value, + 'financial_status' => $order->financial_status->value, + 'payment_method' => $order->payment_method->value, + 'total_amount' => $order->total_amount, + 'currency' => $order->currency, + 'access_token' => OrderAccessToken::generate($order), + ], + ]; + + if ($order->payment_method === PaymentMethod::BankTransfer) { + $payload['bank_transfer_instructions'] = [ + 'bank_name' => 'Mock Bank AG', + 'iban' => 'DE89 3704 0044 0532 0130 00', + 'bic' => 'COBADEFFXXX', + 'reference' => $order->order_number, + 'amount_formatted' => number_format($order->total_amount / 100, 2).' '.$order->currency, + ]; + } + + return response()->json($payload); + } + + protected function findCheckout(int $checkoutId): Checkout + { + $checkout = Checkout::query() + ->where('store_id', $this->currentStore->id()) + ->whereKey($checkoutId) + ->first(); + + if ($checkout === null) { + abort(404); + } + + if ($checkout->status === CheckoutStatus::Expired) { + abort(410); + } + + return $this->loadCheckout($checkout); + } + + protected function loadCheckout(Checkout $checkout): Checkout + { + return $checkout->load([ + 'cart.cartLines.variant.product', + 'cart.cartLines.variant.optionValues', + ]); + } +} diff --git a/app/Http/Controllers/Api/Storefront/V1/OrderController.php b/app/Http/Controllers/Api/Storefront/V1/OrderController.php new file mode 100644 index 00000000..00751568 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/V1/OrderController.php @@ -0,0 +1,40 @@ +where('store_id', $this->currentStore->id()) + ->where('order_number', $orderNumber) + ->with([ + 'orderLines.variant.optionValues', + 'fulfillments.fulfillmentLines', + ]) + ->first(); + + if ($order === null) { + abort(404); + } + + if (! OrderAccessToken::verify($order, $request->query('token'))) { + return response()->json([ + 'message' => 'Invalid or missing access token.', + ], 401); + } + + return response()->json((new StorefrontOrderResource($order))->resolve()); + } +} diff --git a/app/Http/Controllers/Api/Storefront/V1/SearchController.php b/app/Http/Controllers/Api/Storefront/V1/SearchController.php new file mode 100644 index 00000000..80d2fab3 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/V1/SearchController.php @@ -0,0 +1,74 @@ +currentStore->get(); + + abort_if($store === null, 404); + + $validated = $request->validated(); + $filters = $request->parsedFilters(); + + if (isset($filters['__invalid'])) { + return response()->json(['message' => 'The filters are malformed.'], 422); + } + + $perPage = $validated['per_page'] ?? 24; + $page = $validated['page'] ?? 1; + + $result = $this->search->search( + $store, + $validated['q'], + $filters, + $validated['sort'] ?? 'relevance', + $perPage, + $page, + ); + + $paginator = $result['results']; + + return response()->json([ + 'query' => $validated['q'], + 'results' => $paginator->items(), + 'facets' => $result['facets'], + 'pagination' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + public function suggest(SuggestRequest $request): JsonResponse + { + $store = $this->currentStore->get(); + + abort_if($store === null, 404); + + $validated = $request->validated(); + + $suggestions = $this->search->autocomplete($store, $validated['q'], $validated['limit'] ?? 5); + + return response()->json([ + 'query' => $validated['q'], + 'suggestions' => $suggestions->all(), + ]); + } +} diff --git a/app/Http/Controllers/Storefront/Auth/ForgotPasswordController.php b/app/Http/Controllers/Storefront/Auth/ForgotPasswordController.php new file mode 100644 index 00000000..620f5b40 --- /dev/null +++ b/app/Http/Controllers/Storefront/Auth/ForgotPasswordController.php @@ -0,0 +1,58 @@ +validate([ + 'email' => 'required|string|email|max:255', + ]); + + $store = app(CurrentStore::class)->get(); + $email = strtolower($validated['email']); + + $existing = CustomerPasswordResetToken::query() + ->where('store_id', $store->getKey()) + ->where('email', $email) + ->first(); + + $throttled = $existing !== null + && $existing->created_at !== null + && $existing->created_at->diffInSeconds(now()) < 60; + + $customer = Customer::query() + ->where('email', $email) + ->first(); + + if (! $throttled && $customer !== null) { + $token = Str::random(64); + + CustomerPasswordResetToken::query()->updateOrCreate( + ['store_id' => $store->getKey(), 'email' => $email], + ['token' => Hash::make($token), 'created_at' => now()] + ); + + $customer->notify(new CustomerResetPasswordNotification($token, $email, $store->handle)); + } + + return back()->with('status', 'If that email exists, we sent a reset link.'); + } +} diff --git a/app/Http/Controllers/Storefront/Auth/LoginController.php b/app/Http/Controllers/Storefront/Auth/LoginController.php new file mode 100644 index 00000000..b9a815e3 --- /dev/null +++ b/app/Http/Controllers/Storefront/Auth/LoginController.php @@ -0,0 +1,45 @@ +attempt($request->validated())) { + return back() + ->withInput($request->only('email')) + ->withErrors(['email' => 'Invalid credentials']); + } + + $request->session()->regenerate(); + + $merger->mergeFromSession(Auth::guard('customer')->user()); + + return redirect()->intended(route('customer.account')); + } + + public function destroy(Request $request): RedirectResponse + { + Auth::guard('customer')->logout(); + + $request->session()->forget(['cart_id', 'storefront.discount_code']); + $request->session()->regenerate(); + $request->session()->regenerateToken(); + + return redirect()->route('customer.login'); + } +} diff --git a/app/Http/Controllers/Storefront/Auth/RegisterController.php b/app/Http/Controllers/Storefront/Auth/RegisterController.php new file mode 100644 index 00000000..b2e8cc60 --- /dev/null +++ b/app/Http/Controllers/Storefront/Auth/RegisterController.php @@ -0,0 +1,42 @@ +validated(); + + $customer = Customer::query()->create([ + 'store_id' => app(CurrentStore::class)->id(), + 'name' => $validated['name'], + 'email' => $validated['email'], + 'password_hash' => $validated['password'], + 'marketing_opt_in' => $validated['marketing_opt_in'] ?? false, + ]); + + Auth::guard('customer')->login($customer); + $request->session()->regenerate(); + + app(AuditLogger::class)->log('customer.registered', storeId: $customer->store_id, extra: [ + 'customer_id' => $customer->getKey(), + ]); + + return redirect()->route('customer.account'); + } +} diff --git a/app/Http/Controllers/Storefront/Auth/ResetPasswordController.php b/app/Http/Controllers/Storefront/Auth/ResetPasswordController.php new file mode 100644 index 00000000..66a98f6f --- /dev/null +++ b/app/Http/Controllers/Storefront/Auth/ResetPasswordController.php @@ -0,0 +1,60 @@ + $token, + 'email' => $request->query('email', ''), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'token' => 'required|string', + 'email' => 'required|string|email|max:255', + 'password' => 'required|string|min:8|confirmed', + ]); + + $storeId = app(CurrentStore::class)->id(); + $email = strtolower($validated['email']); + + $record = CustomerPasswordResetToken::query() + ->where('store_id', $storeId) + ->where('email', $email) + ->first(); + + $valid = $record !== null + && Hash::check($validated['token'], $record->token) + && $record->created_at !== null + && $record->created_at->diffInMinutes(now()) < 60; + + if (! $valid) { + return back()->withInput($request->only('email'))->withErrors(['email' => 'This reset link is invalid or has expired.']); + } + + $customer = Customer::query()->where('email', $email)->first(); + + if ($customer === null) { + return back()->withInput($request->only('email'))->withErrors(['email' => 'This reset link is invalid or has expired.']); + } + + $customer->forceFill(['password_hash' => $validated['password']])->save(); + $record->delete(); + + return redirect()->route('customer.login')->with('status', 'Your password has been reset. You can now log in.'); + } +} diff --git a/app/Http/Middleware/CheckStoreRole.php b/app/Http/Middleware/CheckStoreRole.php new file mode 100644 index 00000000..eedbde13 --- /dev/null +++ b/app/Http/Middleware/CheckStoreRole.php @@ -0,0 +1,49 @@ +get(); + $user = $request->user(); + + if ($store === null || $user === null) { + abort(403, 'You do not have access to this store.'); + } + + $storeUser = StoreUser::query() + ->where('store_id', $store->getKey()) + ->where('user_id', $user->getKey()) + ->first(); + + if ($storeUser === null) { + abort(403, 'You do not have access to this store.'); + } + + $allowed = collect($roles) + ->map(fn (string $role): ?StoreUserRole => StoreUserRole::tryFrom(strtolower(trim($role)))) + ->filter() + ->all(); + + if (! in_array($storeUser->role, $allowed, true)) { + abort(403, 'Insufficient permissions.'); + } + + $request->attributes->set('store_user', $storeUser); + + return $next($request); + } +} diff --git a/app/Http/Middleware/CustomerAuthenticate.php b/app/Http/Middleware/CustomerAuthenticate.php new file mode 100644 index 00000000..6244d79a --- /dev/null +++ b/app/Http/Middleware/CustomerAuthenticate.php @@ -0,0 +1,29 @@ +check()) { + return $next($request); + } + + if ($request->expectsJson()) { + abort(401); + } + + return redirect()->guest(route('customer.login')); + } +} diff --git a/app/Http/Middleware/RedirectCustomerToAccount.php b/app/Http/Middleware/RedirectCustomerToAccount.php new file mode 100644 index 00000000..d037416d --- /dev/null +++ b/app/Http/Middleware/RedirectCustomerToAccount.php @@ -0,0 +1,20 @@ +check()) { + return redirect()->route('customer.account'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/ResolveStore.php b/app/Http/Middleware/ResolveStore.php new file mode 100644 index 00000000..d53b7faf --- /dev/null +++ b/app/Http/Middleware/ResolveStore.php @@ -0,0 +1,168 @@ +isAdminContext($request)) { + return $this->resolveForAdmin($request, $next); + } + + return $this->resolveForStorefront($request, $next); + } + + protected function isAdminContext(Request $request): bool + { + return $request->is('admin') || $request->is('admin/*') || $request->is('api/admin/*'); + } + + protected function resolveForStorefront(Request $request, Closure $next): Response + { + $store = $this->findByHostname($request->getHost()); + + if ($store === null && is_string($request->query('store'))) { + $store = Store::query()->where('handle', $request->query('store'))->first(); + } + + // Unknown hostname (e.g. the bare Herd project host shop.test with no + // store_domains row) intentionally 404s per spec 05 section 1.1 step 5. + // No default-store fallback: every storefront hostname must be registered. + if ($store === null) { + abort(404); + } + + if ($store->status === StoreStatus::Suspended) { + abort(503, 'This store is currently unavailable.'); + } + + $this->bind($store); + + if ($request->hasSession()) { + $request->session()->put('storefront_store_id', $store->getKey()); + } + + return $next($request); + } + + protected function resolveForAdmin(Request $request, Closure $next): Response + { + $routeStoreId = $request->route('storeId') ?? $request->route('store'); + + if ($routeStoreId !== null) { + $store = Store::query()->find($routeStoreId); + + if ($store === null) { + abort(404); + } + + $this->guardSuspendedMutation($request, $store); + $this->bind($store); + + return $next($request); + } + + if ($request->is('api/admin/*')) { + return $next($request); + } + + $user = $request->user(); + + if ($user === null) { + return $next($request); + } + + $storeId = $request->session()->get('current_store_id'); + + if ($storeId === null) { + $storeId = $this->autoSelectSingleStore($request); + + if ($storeId === null) { + return redirect()->route('admin.store-selector'); + } + } + + $isMember = $user->stores()->where('stores.id', $storeId)->exists(); + + if (! $isMember) { + abort(403); + } + + $store = Store::query()->find($storeId); + + if ($store === null) { + $request->session()->forget('current_store_id'); + + return redirect()->route('admin.store-selector'); + } + + $this->guardSuspendedMutation($request, $store); + $this->bind($store); + + return $next($request); + } + + protected function findByHostname(string $hostname): ?Store + { + $storeId = Cache::remember( + "store_domain:{$hostname}", + now()->addMinutes(5), + fn (): ?int => StoreDomain::query()->where('hostname', $hostname)->value('store_id') + ); + + if ($storeId === null) { + return null; + } + + return Store::query()->find($storeId); + } + + protected function autoSelectSingleStore(Request $request): ?int + { + $storeIds = $request->user()->stores()->pluck('stores.id'); + + if ($storeIds->count() !== 1) { + return null; + } + + $request->session()->put('current_store_id', $storeIds->first()); + + return (int) $storeIds->first(); + } + + protected function guardSuspendedMutation(Request $request, Store $store): void + { + if ($store->status === StoreStatus::Suspended && ! $request->isMethodCacheable()) { + abort(403, 'This store is suspended and cannot be modified.'); + } + } + + protected function bind(Store $store): void + { + app(CurrentStore::class)->set($store); + view()->share('currentStore', $store); + } +} diff --git a/app/Http/Requests/Api/Admin/AdjustInventoryRequest.php b/app/Http/Requests/Api/Admin/AdjustInventoryRequest.php new file mode 100644 index 00000000..5a2bcc20 --- /dev/null +++ b/app/Http/Requests/Api/Admin/AdjustInventoryRequest.php @@ -0,0 +1,36 @@ + + */ + public function rules(): array + { + return [ + 'quantity_on_hand' => 'required_without:policy|nullable|integer|min:0', + 'policy' => ['required_without:quantity_on_hand', 'nullable', Rule::in(['deny', 'continue'])], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'quantity_on_hand.required_without' => 'Either quantity_on_hand or policy must be provided.', + 'policy.required_without' => 'Either quantity_on_hand or policy must be provided.', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/CreateOrganizationRequest.php b/app/Http/Requests/Api/Admin/CreateOrganizationRequest.php new file mode 100644 index 00000000..ce694c8e --- /dev/null +++ b/app/Http/Requests/Api/Admin/CreateOrganizationRequest.php @@ -0,0 +1,24 @@ + + */ + public function rules(): array + { + return [ + 'name' => 'required|string|max:255', + 'billing_email' => 'required|string|email|max:255', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/CreateStoreRequest.php b/app/Http/Requests/Api/Admin/CreateStoreRequest.php new file mode 100644 index 00000000..b37d85a4 --- /dev/null +++ b/app/Http/Requests/Api/Admin/CreateStoreRequest.php @@ -0,0 +1,28 @@ + + */ + public function rules(): array + { + return [ + 'organization_id' => 'required|integer|exists:organizations,id', + 'name' => 'required|string|max:255', + 'handle' => 'required|string|max:63|regex:/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/|unique:stores,handle', + 'default_currency' => 'required|string|size:3', + 'default_locale' => 'required|string|max:10', + 'timezone' => 'required|string|timezone', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/InviteStaffRequest.php b/app/Http/Requests/Api/Admin/InviteStaffRequest.php new file mode 100644 index 00000000..b12ed596 --- /dev/null +++ b/app/Http/Requests/Api/Admin/InviteStaffRequest.php @@ -0,0 +1,24 @@ + + */ + public function rules(): array + { + return [ + 'email' => 'required|string|email|max:255', + 'role' => 'required|string|in:owner,admin,staff,support', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/StoreCollectionRequest.php b/app/Http/Requests/Api/Admin/StoreCollectionRequest.php new file mode 100644 index 00000000..1ab2fcaf --- /dev/null +++ b/app/Http/Requests/Api/Admin/StoreCollectionRequest.php @@ -0,0 +1,44 @@ + + */ + public function rules(): array + { + $storeId = (int) $this->route('storeId'); + + return [ + 'title' => 'required|string|max:255', + 'handle' => ['nullable', 'string', 'max:255', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'], + 'description_html' => 'nullable|string|max:65535', + 'type' => ['required', Rule::in(['manual', 'automated'])], + 'status' => ['nullable', Rule::in(['draft', 'active', 'archived'])], + 'product_ids' => 'nullable|array', + 'product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $storeId)], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'title.required' => 'The title field is required.', + 'handle.regex' => 'The handle may only contain lowercase letters, numbers and hyphens.', + 'product_ids.*.exists' => 'One or more products do not belong to this store.', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/StoreDiscountRequest.php b/app/Http/Requests/Api/Admin/StoreDiscountRequest.php new file mode 100644 index 00000000..7e9ce1f1 --- /dev/null +++ b/app/Http/Requests/Api/Admin/StoreDiscountRequest.php @@ -0,0 +1,62 @@ + + */ + public function rules(): array + { + $storeId = (int) $this->route('storeId'); + + return [ + 'type' => ['required', Rule::in(['code', 'automatic'])], + 'code' => [ + 'required_if:type,code', + 'nullable', + 'string', + 'max:50', + Rule::unique('discounts', 'code')->where('store_id', $storeId), + ], + 'value_type' => ['required', Rule::in(['fixed', 'percent', 'free_shipping'])], + 'value_amount' => 'required|integer|min:0|max:100000000', + 'starts_at' => 'nullable|date', + 'ends_at' => 'nullable|date|after_or_equal:starts_at', + 'usage_limit' => 'nullable|integer|min:1', + 'status' => ['nullable', Rule::in(['draft', 'active', 'disabled'])], + 'rules_json' => 'nullable|array', + 'rules_json.minimum_purchase_amount' => 'nullable|integer|min:0', + 'rules_json.min_purchase_amount' => 'nullable|integer|min:0', + 'rules_json.applicable_product_ids' => 'nullable|array', + 'rules_json.applicable_product_ids.*' => 'integer|min:1', + 'rules_json.applicable_collection_ids' => 'nullable|array', + 'rules_json.applicable_collection_ids.*' => 'integer|min:1', + 'rules_json.customer_eligibility' => ['nullable', Rule::in(['all', 'specific_customers', 'specific_segments'])], + 'rules_json.once_per_customer' => 'nullable|boolean', + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'code.required_if' => 'The code field is required for code discounts.', + 'code.unique' => 'The code has already been taken.', + 'value_type.required' => 'The value type field is required.', + 'value_amount.required' => 'The value field is required.', + 'ends_at.after_or_equal' => 'The end date must be after or equal to the start date.', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/StoreFulfillmentRequest.php b/app/Http/Requests/Api/Admin/StoreFulfillmentRequest.php new file mode 100644 index 00000000..68449e11 --- /dev/null +++ b/app/Http/Requests/Api/Admin/StoreFulfillmentRequest.php @@ -0,0 +1,39 @@ + + */ + public function rules(): array + { + return [ + 'tracking_company' => 'nullable|string|max:255', + 'tracking_number' => 'nullable|string|max:255', + 'tracking_url' => 'nullable|url|max:2048', + 'line_items' => 'required|array|min:1', + 'line_items.*.order_line_id' => 'required|integer|min:1', + 'line_items.*.quantity' => 'required|integer|min:1', + 'notify_customer' => 'nullable|boolean', + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'line_items.required' => 'At least one line item is required.', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/StoreMediaRequest.php b/app/Http/Requests/Api/Admin/StoreMediaRequest.php new file mode 100644 index 00000000..e9dea108 --- /dev/null +++ b/app/Http/Requests/Api/Admin/StoreMediaRequest.php @@ -0,0 +1,36 @@ + + */ + public function rules(): array + { + return [ + 'file' => 'required|file|mimetypes:image/jpeg,image/png,image/webp,image/avif,video/mp4|max:51200', + 'alt_text' => 'nullable|string|max:255', + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'file.required' => 'The file field is required.', + 'file.mimetypes' => 'The file must be a JPEG, PNG, WebP, AVIF image or MP4 video.', + 'file.max' => 'The file must not be larger than 50 MB.', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/StoreProductRequest.php b/app/Http/Requests/Api/Admin/StoreProductRequest.php new file mode 100644 index 00000000..e583be84 --- /dev/null +++ b/app/Http/Requests/Api/Admin/StoreProductRequest.php @@ -0,0 +1,71 @@ + + */ + public function rules(): array + { + $storeId = (int) $this->route('storeId'); + + return [ + 'title' => 'required|string|max:255', + 'handle' => ['nullable', 'string', 'max:255', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'], + 'description_html' => 'nullable|string|max:65535', + 'vendor' => 'nullable|string|max:255', + 'product_type' => 'nullable|string|max:255', + 'status' => ['nullable', Rule::in(['draft', 'active'])], + 'tags' => 'nullable|array|max:50', + 'tags.*' => 'string|max:255', + 'options' => 'nullable|array|max:3', + 'options.*.name' => 'required|string|max:255', + 'options.*.position' => 'required|integer|min:1|max:3|distinct', + 'variants' => 'required|array|min:1|max:100', + 'variants.*.sku' => 'required|string|max:255', + 'variants.*.barcode' => 'nullable|string|max:255', + 'variants.*.price_amount' => 'required|integer|min:0', + 'variants.*.compare_at_amount' => 'nullable|integer|min:0', + 'variants.*.currency' => 'nullable|string|size:3', + 'variants.*.weight_g' => 'nullable|integer|min:0', + 'variants.*.requires_shipping' => 'nullable|boolean', + 'variants.*.is_default' => 'nullable|boolean', + 'variants.*.position' => 'nullable|integer|min:0', + 'variants.*.status' => ['nullable', Rule::in(['active', 'archived'])], + 'variants.*.option_values' => 'nullable|array', + 'variants.*.option_values.*.option_name' => 'required|string|max:255', + 'variants.*.option_values.*.value' => 'required|string|max:255', + 'variants.*.inventory' => 'nullable|array', + 'variants.*.inventory.quantity_on_hand' => 'nullable|integer|min:0', + 'variants.*.inventory.policy' => ['nullable', Rule::in(['deny', 'continue'])], + 'collections' => 'nullable|array', + 'collections.*' => ['integer', Rule::exists('collections', 'id')->where('store_id', $storeId)], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'title.required' => 'The title field is required.', + 'handle.regex' => 'The handle may only contain lowercase letters, numbers and hyphens.', + 'variants.required' => 'At least one variant is required.', + 'variants.*.sku.required' => 'The sku field is required.', + 'variants.*.price_amount.required' => 'The price field is required.', + 'variants.*.price_amount.min' => 'The price must be zero or greater.', + 'collections.*.exists' => 'One or more collections do not belong to this store.', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/StoreRefundRequest.php b/app/Http/Requests/Api/Admin/StoreRefundRequest.php new file mode 100644 index 00000000..378abe5d --- /dev/null +++ b/app/Http/Requests/Api/Admin/StoreRefundRequest.php @@ -0,0 +1,40 @@ + + */ + public function rules(): array + { + return [ + 'amount' => 'required|integer|min:1', + 'reason' => 'nullable|string|max:1000', + 'line_items' => 'nullable|array', + 'line_items.*.order_line_id' => 'required|integer|min:1', + 'line_items.*.quantity' => 'required|integer|min:1', + 'restock' => 'nullable|boolean', + 'notify_customer' => 'nullable|boolean', + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'amount.required' => 'The amount field is required.', + 'amount.min' => 'The amount must be at least 1.', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/StoreShippingRateRequest.php b/app/Http/Requests/Api/Admin/StoreShippingRateRequest.php new file mode 100644 index 00000000..974bf7fd --- /dev/null +++ b/app/Http/Requests/Api/Admin/StoreShippingRateRequest.php @@ -0,0 +1,39 @@ + + */ + public function rules(): array + { + return [ + 'name' => 'required|string|max:255', + 'type' => ['required', Rule::in(['flat', 'weight', 'price', 'carrier'])], + 'config_json' => 'required|array', + 'is_active' => 'nullable|boolean', + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'name.required' => 'The name field is required.', + 'type.required' => 'The type field is required.', + 'config_json.required' => 'The config field is required.', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/StoreShippingZoneRequest.php b/app/Http/Requests/Api/Admin/StoreShippingZoneRequest.php new file mode 100644 index 00000000..749f5f4f --- /dev/null +++ b/app/Http/Requests/Api/Admin/StoreShippingZoneRequest.php @@ -0,0 +1,39 @@ + + */ + public function rules(): array + { + return [ + 'name' => 'required|string|max:255', + 'countries_json' => 'required|array|min:1', + 'countries_json.*' => 'string|size:2', + 'regions_json' => 'nullable|array', + 'regions_json.*' => 'string|max:10', + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'name.required' => 'The name field is required.', + 'countries_json.required' => 'At least one country is required.', + 'countries_json.*.size' => 'Each country must be a valid ISO 3166-1 alpha-2 code.', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/StoreTokenRequest.php b/app/Http/Requests/Api/Admin/StoreTokenRequest.php new file mode 100644 index 00000000..b424315c --- /dev/null +++ b/app/Http/Requests/Api/Admin/StoreTokenRequest.php @@ -0,0 +1,28 @@ + + */ + public function rules(): array + { + return [ + 'name' => 'required|string|max:255', + 'abilities' => 'sometimes|array', + 'abilities.*' => ['string', Rule::in(RolePermissionMap::allAbilities())], + 'expires_at' => 'sometimes|nullable|date|after:now', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/UpdateCollectionRequest.php b/app/Http/Requests/Api/Admin/UpdateCollectionRequest.php new file mode 100644 index 00000000..852cd80e --- /dev/null +++ b/app/Http/Requests/Api/Admin/UpdateCollectionRequest.php @@ -0,0 +1,49 @@ + + */ + public function rules(): array + { + $storeId = (int) $this->route('storeId'); + $productExists = ['integer', Rule::exists('products', 'id')->where('store_id', $storeId)]; + + return [ + 'title' => 'sometimes|required|string|max:255', + 'handle' => ['sometimes', 'nullable', 'string', 'max:255', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'], + 'description_html' => 'sometimes|nullable|string|max:65535', + 'type' => ['sometimes', Rule::in(['manual', 'automated'])], + 'status' => ['sometimes', Rule::in(['draft', 'active', 'archived'])], + 'product_ids' => 'sometimes|nullable|array', + 'product_ids.*' => $productExists, + 'add_product_ids' => 'sometimes|array', + 'add_product_ids.*' => $productExists, + 'remove_product_ids' => 'sometimes|array', + 'remove_product_ids.*' => 'integer', + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'handle.regex' => 'The handle may only contain lowercase letters, numbers and hyphens.', + 'product_ids.*.exists' => 'One or more products do not belong to this store.', + 'add_product_ids.*.exists' => 'One or more products do not belong to this store.', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/UpdateDiscountRequest.php b/app/Http/Requests/Api/Admin/UpdateDiscountRequest.php new file mode 100644 index 00000000..8488a24e --- /dev/null +++ b/app/Http/Requests/Api/Admin/UpdateDiscountRequest.php @@ -0,0 +1,38 @@ + + */ + public function rules(): array + { + return [ + 'value_type' => ['nullable', Rule::in(['fixed', 'percent', 'free_shipping'])], + 'value_amount' => 'nullable|integer|min:0|max:100000000', + 'starts_at' => 'nullable|date', + 'ends_at' => 'nullable|date|after_or_equal:starts_at', + 'usage_limit' => 'nullable|integer|min:1', + 'status' => ['nullable', Rule::in(['draft', 'active', 'disabled', 'expired'])], + 'rules_json' => 'nullable|array', + 'rules_json.minimum_purchase_amount' => 'nullable|integer|min:0', + 'rules_json.min_purchase_amount' => 'nullable|integer|min:0', + 'rules_json.applicable_product_ids' => 'nullable|array', + 'rules_json.applicable_product_ids.*' => 'integer|min:1', + 'rules_json.applicable_collection_ids' => 'nullable|array', + 'rules_json.applicable_collection_ids.*' => 'integer|min:1', + 'rules_json.customer_eligibility' => ['nullable', Rule::in(['all', 'specific_customers', 'specific_segments'])], + 'rules_json.once_per_customer' => 'nullable|boolean', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/UpdateMediaRequest.php b/app/Http/Requests/Api/Admin/UpdateMediaRequest.php new file mode 100644 index 00000000..1009b39a --- /dev/null +++ b/app/Http/Requests/Api/Admin/UpdateMediaRequest.php @@ -0,0 +1,34 @@ + + */ + public function rules(): array + { + return [ + 'alt_text' => 'sometimes|nullable|string|max:255', + 'position' => 'sometimes|integer|min:0', + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'position.min' => 'The position must be zero or greater.', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/UpdateProductRequest.php b/app/Http/Requests/Api/Admin/UpdateProductRequest.php new file mode 100644 index 00000000..73112d0c --- /dev/null +++ b/app/Http/Requests/Api/Admin/UpdateProductRequest.php @@ -0,0 +1,67 @@ + + */ + public function rules(): array + { + $storeId = (int) $this->route('storeId'); + + return [ + 'title' => 'sometimes|required|string|max:255', + 'handle' => ['sometimes', 'nullable', 'string', 'max:255', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'], + 'description_html' => 'sometimes|nullable|string|max:65535', + 'vendor' => 'sometimes|nullable|string|max:255', + 'product_type' => 'sometimes|nullable|string|max:255', + 'status' => ['sometimes', Rule::in(['draft', 'active', 'archived'])], + 'tags' => 'sometimes|nullable|array|max:50', + 'tags.*' => 'string|max:255', + 'options' => 'sometimes|nullable|array|max:3', + 'options.*.name' => 'required|string|max:255', + 'options.*.position' => 'required|integer|min:1|max:3|distinct', + 'variants' => 'sometimes|array|min:1|max:100', + 'variants.*.id' => 'sometimes|integer', + 'variants.*.sku' => 'sometimes|nullable|string|max:255', + 'variants.*.barcode' => 'sometimes|nullable|string|max:255', + 'variants.*.price_amount' => 'sometimes|integer|min:0', + 'variants.*.compare_at_amount' => 'sometimes|nullable|integer|min:0', + 'variants.*.currency' => 'sometimes|nullable|string|size:3', + 'variants.*.weight_g' => 'sometimes|nullable|integer|min:0', + 'variants.*.requires_shipping' => 'sometimes|boolean', + 'variants.*.is_default' => 'sometimes|boolean', + 'variants.*.position' => 'sometimes|integer|min:0', + 'variants.*.status' => ['sometimes', Rule::in(['active', 'archived'])], + 'variants.*.option_values' => 'sometimes|nullable|array', + 'variants.*.option_values.*.option_name' => 'required|string|max:255', + 'variants.*.option_values.*.value' => 'required|string|max:255', + 'variants.*.inventory' => 'sometimes|nullable|array', + 'variants.*.inventory.quantity_on_hand' => 'sometimes|nullable|integer|min:0', + 'variants.*.inventory.policy' => ['sometimes', 'nullable', Rule::in(['deny', 'continue'])], + 'collections' => 'sometimes|nullable|array', + 'collections.*' => ['integer', Rule::exists('collections', 'id')->where('store_id', $storeId)], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'handle.regex' => 'The handle may only contain lowercase letters, numbers and hyphens.', + 'collections.*.exists' => 'One or more collections do not belong to this store.', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/UpdateShippingZoneRequest.php b/app/Http/Requests/Api/Admin/UpdateShippingZoneRequest.php new file mode 100644 index 00000000..a37dba8c --- /dev/null +++ b/app/Http/Requests/Api/Admin/UpdateShippingZoneRequest.php @@ -0,0 +1,27 @@ + + */ + public function rules(): array + { + return [ + 'name' => 'nullable|string|max:255', + 'countries_json' => 'nullable|array|min:1', + 'countries_json.*' => 'string|size:2', + 'regions_json' => 'nullable|array', + 'regions_json.*' => 'string|max:10', + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/UpdateTaxSettingsRequest.php b/app/Http/Requests/Api/Admin/UpdateTaxSettingsRequest.php new file mode 100644 index 00000000..a5934264 --- /dev/null +++ b/app/Http/Requests/Api/Admin/UpdateTaxSettingsRequest.php @@ -0,0 +1,39 @@ + + */ + public function rules(): array + { + return [ + 'mode' => ['required', Rule::in(['manual', 'provider'])], + 'provider' => ['required_if:mode,provider', 'nullable', Rule::in(['stripe_tax', 'none'])], + 'prices_include_tax' => 'required|boolean', + 'config_json' => 'required|array', + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'mode.required' => 'The mode field is required.', + 'prices_include_tax.required' => 'The prices include tax field is required.', + 'config_json.required' => 'The config field is required.', + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/AddCartLineRequest.php b/app/Http/Requests/Api/Storefront/AddCartLineRequest.php new file mode 100644 index 00000000..11cc6635 --- /dev/null +++ b/app/Http/Requests/Api/Storefront/AddCartLineRequest.php @@ -0,0 +1,37 @@ + + */ + public function rules(): array + { + return [ + 'variant_id' => 'required|integer|min:1', + 'quantity' => 'required|integer|min:1|max:9999', + 'cart_version' => 'nullable|integer|min:1', + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'variant_id.required' => 'The variant field is required.', + 'quantity.required' => 'The quantity field is required.', + 'quantity.min' => 'The quantity must be at least 1.', + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/ApplyDiscountRequest.php b/app/Http/Requests/Api/Storefront/ApplyDiscountRequest.php new file mode 100644 index 00000000..f996f619 --- /dev/null +++ b/app/Http/Requests/Api/Storefront/ApplyDiscountRequest.php @@ -0,0 +1,33 @@ + + */ + public function rules(): array + { + return [ + 'code' => 'required|string|max:50', + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'code.required' => 'The code field is required.', + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/CreateCartRequest.php b/app/Http/Requests/Api/Storefront/CreateCartRequest.php new file mode 100644 index 00000000..e2f95ed4 --- /dev/null +++ b/app/Http/Requests/Api/Storefront/CreateCartRequest.php @@ -0,0 +1,33 @@ + + */ + public function rules(): array + { + return [ + 'currency' => 'nullable|string|size:3', + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'currency.size' => 'The currency must be a valid ISO 4217 code.', + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/CreateCheckoutRequest.php b/app/Http/Requests/Api/Storefront/CreateCheckoutRequest.php new file mode 100644 index 00000000..955accec --- /dev/null +++ b/app/Http/Requests/Api/Storefront/CreateCheckoutRequest.php @@ -0,0 +1,36 @@ + + */ + public function rules(): array + { + return [ + 'cart_id' => 'required|integer|min:1', + 'email' => 'required|email|max:255', + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'cart_id.required' => 'The cart field is required.', + 'email.required' => 'The email field is required.', + 'email.email' => 'The email must be a valid email address.', + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/PayCheckoutRequest.php b/app/Http/Requests/Api/Storefront/PayCheckoutRequest.php new file mode 100644 index 00000000..305164e4 --- /dev/null +++ b/app/Http/Requests/Api/Storefront/PayCheckoutRequest.php @@ -0,0 +1,43 @@ + + */ + public function rules(): array + { + return [ + 'payment_method' => ['required', 'string', Rule::in(['credit_card', 'paypal', 'bank_transfer'])], + 'card_number' => 'required_if:payment_method,credit_card|nullable|string|max:32', + 'card_expiry' => 'required_if:payment_method,credit_card|nullable|string|max:7', + 'card_cvc' => 'required_if:payment_method,credit_card|nullable|string|max:4', + 'card_holder' => 'required_if:payment_method,credit_card|nullable|string|max:255', + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'payment_method.required' => 'The payment method field is required.', + 'payment_method.in' => 'The selected payment method is invalid.', + 'card_number.required_if' => 'The card number field is required.', + 'card_expiry.required_if' => 'The card expiry field is required.', + 'card_cvc.required_if' => 'The card cvc field is required.', + 'card_holder.required_if' => 'The card holder field is required.', + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/RemoveCartLineRequest.php b/app/Http/Requests/Api/Storefront/RemoveCartLineRequest.php new file mode 100644 index 00000000..3299eb49 --- /dev/null +++ b/app/Http/Requests/Api/Storefront/RemoveCartLineRequest.php @@ -0,0 +1,33 @@ + + */ + public function rules(): array + { + return [ + 'cart_version' => 'required|integer|min:1', + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'cart_version.required' => 'The cart version field is required.', + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/SearchRequest.php b/app/Http/Requests/Api/Storefront/SearchRequest.php new file mode 100644 index 00000000..631d7b46 --- /dev/null +++ b/app/Http/Requests/Api/Storefront/SearchRequest.php @@ -0,0 +1,66 @@ + + */ + public function rules(): array + { + return [ + 'q' => 'required|string|min:1|max:200', + 'filters' => 'nullable', + 'sort' => ['nullable', Rule::in(['relevance', 'price_asc', 'price_desc', 'newest', 'best_selling'])], + 'page' => 'nullable|integer|min:1', + 'per_page' => 'nullable|integer|min:1|max:50', + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'q.required' => 'The search query is required.', + 'q.max' => 'The search query must not be longer than 200 characters.', + 'per_page.max' => 'The per page value must not exceed 50.', + ]; + } + + /** + * @return array + */ + public function parsedFilters(): array + { + $filters = $this->input('filters'); + + if ($filters === null || $filters === '') { + return []; + } + + if (is_array($filters)) { + return $filters; + } + + if (is_string($filters)) { + $decoded = json_decode($filters, true); + + if (is_array($decoded)) { + return $decoded; + } + } + + return ['__invalid' => true]; + } +} diff --git a/app/Http/Requests/Api/Storefront/SelectPaymentMethodRequest.php b/app/Http/Requests/Api/Storefront/SelectPaymentMethodRequest.php new file mode 100644 index 00000000..e0ee5646 --- /dev/null +++ b/app/Http/Requests/Api/Storefront/SelectPaymentMethodRequest.php @@ -0,0 +1,35 @@ + + */ + public function rules(): array + { + return [ + 'payment_method' => ['required', 'string', Rule::in(['credit_card', 'paypal', 'bank_transfer'])], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'payment_method.required' => 'The payment method field is required.', + 'payment_method.in' => 'The selected payment method is invalid.', + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/SetCheckoutAddressRequest.php b/app/Http/Requests/Api/Storefront/SetCheckoutAddressRequest.php new file mode 100644 index 00000000..5551de90 --- /dev/null +++ b/app/Http/Requests/Api/Storefront/SetCheckoutAddressRequest.php @@ -0,0 +1,61 @@ + + */ + public function rules(): array + { + return [ + 'email' => 'nullable|email|max:255', + 'shipping_address' => 'required|array', + 'shipping_address.first_name' => 'required|string|max:255', + 'shipping_address.last_name' => 'required|string|max:255', + 'shipping_address.company' => 'nullable|string|max:255', + 'shipping_address.address1' => 'required|string|max:500', + 'shipping_address.address2' => 'nullable|string|max:500', + 'shipping_address.city' => 'required|string|max:255', + 'shipping_address.province' => 'nullable|string|max:255', + 'shipping_address.province_code' => 'nullable|string|max:10', + 'shipping_address.country' => 'required|string|max:255', + 'shipping_address.country_code' => 'required|string|size:2', + 'shipping_address.postal_code' => 'required|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.company' => 'nullable|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', + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'shipping_address.required' => 'The shipping address field is required.', + 'shipping_address.country_code.size' => 'The country code must be a valid ISO 3166-1 alpha-2 code.', + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/SetShippingMethodRequest.php b/app/Http/Requests/Api/Storefront/SetShippingMethodRequest.php new file mode 100644 index 00000000..4ada2dc3 --- /dev/null +++ b/app/Http/Requests/Api/Storefront/SetShippingMethodRequest.php @@ -0,0 +1,23 @@ + + */ + public function rules(): array + { + return [ + 'shipping_method_id' => 'nullable|integer|min:1', + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/SuggestRequest.php b/app/Http/Requests/Api/Storefront/SuggestRequest.php new file mode 100644 index 00000000..c0658bed --- /dev/null +++ b/app/Http/Requests/Api/Storefront/SuggestRequest.php @@ -0,0 +1,36 @@ + + */ + public function rules(): array + { + return [ + 'q' => 'required|string|min:1|max:100', + 'limit' => 'nullable|integer|min:1|max:10', + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'q.required' => 'The search prefix is required.', + 'q.max' => 'The search prefix must not be longer than 100 characters.', + 'limit.max' => 'The limit must not exceed 10.', + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/UpdateCartLineRequest.php b/app/Http/Requests/Api/Storefront/UpdateCartLineRequest.php new file mode 100644 index 00000000..4ee11422 --- /dev/null +++ b/app/Http/Requests/Api/Storefront/UpdateCartLineRequest.php @@ -0,0 +1,35 @@ + + */ + public function rules(): array + { + return [ + 'quantity' => 'required|integer|min:1|max:9999', + 'cart_version' => 'required|integer|min:1', + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'quantity.required' => 'The quantity field is required.', + 'cart_version.required' => 'The cart version field is required.', + ]; + } +} diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php new file mode 100644 index 00000000..635d5cc5 --- /dev/null +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -0,0 +1,24 @@ + + */ + public function rules(): array + { + return [ + 'email' => 'required|string|email|max:255', + 'password' => 'required|string', + ]; + } +} diff --git a/app/Http/Requests/Auth/RegisterCustomerRequest.php b/app/Http/Requests/Auth/RegisterCustomerRequest.php new file mode 100644 index 00000000..e31ba117 --- /dev/null +++ b/app/Http/Requests/Auth/RegisterCustomerRequest.php @@ -0,0 +1,34 @@ +has(); + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'name' => 'required|string|max:255', + 'email' => [ + 'required', + 'string', + 'email', + 'max:255', + Rule::unique('customers', 'email')->where('store_id', app(CurrentStore::class)->id()), + ], + 'password' => 'required|string|min:8|confirmed', + 'marketing_opt_in' => 'sometimes|boolean', + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/CollectionResource.php b/app/Http/Resources/Api/Admin/CollectionResource.php new file mode 100644 index 00000000..de998bf3 --- /dev/null +++ b/app/Http/Resources/Api/Admin/CollectionResource.php @@ -0,0 +1,37 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'title' => $this->title, + 'handle' => $this->handle, + 'description_html' => $this->description_html, + 'type' => $this->type->value, + 'status' => $this->status->value, + 'products_count' => $this->whenCounted('products'), + 'products' => $this->whenLoaded('products', fn () => $this->products->map(fn ($product): array => [ + 'id' => $product->id, + 'title' => $product->title, + 'handle' => $product->handle, + 'position' => $product->pivot?->position, + ])->all()), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/DiscountResource.php b/app/Http/Resources/Api/Admin/DiscountResource.php new file mode 100644 index 00000000..75c0d397 --- /dev/null +++ b/app/Http/Resources/Api/Admin/DiscountResource.php @@ -0,0 +1,32 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'type' => $this->type->value, + 'code' => $this->code, + 'value_type' => $this->value_type->value, + 'value_amount' => $this->value_amount, + 'starts_at' => $this->starts_at?->toIso8601String(), + 'ends_at' => $this->ends_at?->toIso8601String(), + 'usage_limit' => $this->usage_limit, + 'usage_count' => $this->usage_count, + 'rules_json' => $this->rules_json, + 'status' => $this->status->value, + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/FulfillmentResource.php b/app/Http/Resources/Api/Admin/FulfillmentResource.php new file mode 100644 index 00000000..7aeee269 --- /dev/null +++ b/app/Http/Resources/Api/Admin/FulfillmentResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'order_id' => $this->order_id, + 'status' => $this->status->value, + 'tracking_company' => $this->tracking_company, + 'tracking_number' => $this->tracking_number, + 'tracking_url' => $this->tracking_url, + 'shipped_at' => $this->shipped_at?->toIso8601String(), + 'line_items' => $this->fulfillmentLines->map(fn ($line): array => [ + 'order_line_id' => $line->order_line_id, + 'quantity' => $line->quantity, + ])->all(), + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/InviteResource.php b/app/Http/Resources/Api/Admin/InviteResource.php new file mode 100644 index 00000000..bb317050 --- /dev/null +++ b/app/Http/Resources/Api/Admin/InviteResource.php @@ -0,0 +1,25 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'email' => $this->user->email, + 'role' => $this->role->value, + 'invited_at' => now()->toIso8601String(), + 'expires_at' => now()->addDays(7)->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/MeResource.php b/app/Http/Resources/Api/Admin/MeResource.php new file mode 100644 index 00000000..322d312f --- /dev/null +++ b/app/Http/Resources/Api/Admin/MeResource.php @@ -0,0 +1,28 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'user_id' => $this->user_id, + 'store_id' => $this->store_id, + 'role' => $this->role->value, + 'email' => $this->user->email, + 'name' => $this->user->name, + 'permissions' => RolePermissionMap::for($this->role), + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/OrderResource.php b/app/Http/Resources/Api/Admin/OrderResource.php new file mode 100644 index 00000000..b0d4fbf4 --- /dev/null +++ b/app/Http/Resources/Api/Admin/OrderResource.php @@ -0,0 +1,57 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'order_number' => $this->order_number, + 'status' => $this->status->value, + 'financial_status' => $this->financial_status->value, + 'fulfillment_status' => $this->fulfillment_status->value, + 'payment_method' => $this->payment_method->value, + 'customer' => $this->customer !== null ? [ + 'id' => $this->customer->id, + 'name' => $this->customer->name, + 'email' => $this->customer->email, + ] : null, + 'email' => $this->email, + 'currency' => $this->currency, + 'subtotal_amount' => $this->subtotal_amount, + 'discount_amount' => $this->discount_amount, + 'shipping_amount' => $this->shipping_amount, + 'tax_amount' => $this->tax_amount, + 'total_amount' => $this->total_amount, + 'billing_address_json' => $this->billing_address_json, + 'shipping_address_json' => $this->shipping_address_json, + 'lines' => $this->orderLines->map(fn ($line): array => [ + 'id' => $line->id, + 'product_id' => $line->product_id, + 'variant_id' => $line->variant_id, + 'title_snapshot' => $line->title_snapshot, + 'sku_snapshot' => $line->sku_snapshot, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'total_amount' => $line->total_amount, + 'tax_lines_json' => $line->tax_lines_json, + 'discount_allocations_json' => $line->discount_allocations_json, + ])->all(), + 'payments' => PaymentResource::collection($this->payments), + 'fulfillments' => FulfillmentResource::collection($this->fulfillments), + 'refunds' => RefundResource::collection($this->refunds), + 'placed_at' => $this->placed_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/OrderSummaryResource.php b/app/Http/Resources/Api/Admin/OrderSummaryResource.php new file mode 100644 index 00000000..01d04944 --- /dev/null +++ b/app/Http/Resources/Api/Admin/OrderSummaryResource.php @@ -0,0 +1,37 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'order_number' => $this->order_number, + 'status' => $this->status->value, + 'financial_status' => $this->financial_status->value, + 'fulfillment_status' => $this->fulfillment_status->value, + 'customer' => $this->customer !== null ? [ + 'id' => $this->customer->id, + 'name' => $this->customer->name, + 'email' => $this->customer->email, + ] : null, + 'currency' => $this->currency, + 'subtotal_amount' => $this->subtotal_amount, + 'discount_amount' => $this->discount_amount, + 'shipping_amount' => $this->shipping_amount, + 'tax_amount' => $this->tax_amount, + 'total_amount' => $this->total_amount, + 'line_count' => $this->order_lines_count ?? $this->orderLines->count(), + 'placed_at' => $this->placed_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/OrganizationResource.php b/app/Http/Resources/Api/Admin/OrganizationResource.php new file mode 100644 index 00000000..29d6132a --- /dev/null +++ b/app/Http/Resources/Api/Admin/OrganizationResource.php @@ -0,0 +1,26 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'billing_email' => $this->billing_email, + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/PaymentResource.php b/app/Http/Resources/Api/Admin/PaymentResource.php new file mode 100644 index 00000000..bcf0d841 --- /dev/null +++ b/app/Http/Resources/Api/Admin/PaymentResource.php @@ -0,0 +1,26 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'provider' => $this->provider->value, + 'method' => $this->method->value, + 'provider_payment_id' => $this->provider_payment_id, + 'status' => $this->status->value, + 'amount' => $this->amount, + 'currency' => $this->currency, + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/ProductMediaResource.php b/app/Http/Resources/Api/Admin/ProductMediaResource.php new file mode 100644 index 00000000..84ab2cb1 --- /dev/null +++ b/app/Http/Resources/Api/Admin/ProductMediaResource.php @@ -0,0 +1,33 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'type' => $this->type->value, + 'storage_key' => $this->storage_key, + 'url' => Storage::disk('public')->url($this->storage_key), + 'alt_text' => $this->alt_text, + 'width' => $this->width, + 'height' => $this->height, + 'mime_type' => $this->mime_type, + 'byte_size' => $this->byte_size, + 'position' => $this->position, + 'status' => $this->status->value, + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/ProductOptionResource.php b/app/Http/Resources/Api/Admin/ProductOptionResource.php new file mode 100644 index 00000000..3567bd8d --- /dev/null +++ b/app/Http/Resources/Api/Admin/ProductOptionResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'position' => $this->position, + 'values' => $this->whenLoaded('productOptionValues', fn () => $this->productOptionValues->map(fn ($value): array => [ + 'id' => $value->id, + 'value' => $value->value, + 'position' => $value->position, + ])->all(), []), + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/ProductResource.php b/app/Http/Resources/Api/Admin/ProductResource.php new file mode 100644 index 00000000..2423cfe6 --- /dev/null +++ b/app/Http/Resources/Api/Admin/ProductResource.php @@ -0,0 +1,41 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'title' => $this->title, + 'handle' => $this->handle, + 'description_html' => $this->description_html, + 'vendor' => $this->vendor, + 'product_type' => $this->product_type, + 'status' => $this->status->value, + 'tags' => $this->tags ?? [], + 'published_at' => $this->published_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + 'options' => ProductOptionResource::collection($this->whenLoaded('productOptions', $this->productOptions ?? collect())), + 'variants' => VariantResource::collection($this->whenLoaded('productVariants', $this->productVariants ?? collect())), + 'media' => ProductMediaResource::collection($this->whenLoaded('productMedia', $this->productMedia ?? collect())), + 'collections' => $this->whenLoaded('collections', fn () => $this->collections->map(fn ($collection): array => [ + 'id' => $collection->id, + 'title' => $collection->title, + 'handle' => $collection->handle, + ])->all(), []), + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/ProductSummaryResource.php b/app/Http/Resources/Api/Admin/ProductSummaryResource.php new file mode 100644 index 00000000..e2e8b240 --- /dev/null +++ b/app/Http/Resources/Api/Admin/ProductSummaryResource.php @@ -0,0 +1,43 @@ + + */ + public function toArray(Request $request): array + { + $featured = $this->whenLoaded('productMedia', fn () => $this->productMedia->first()); + + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'title' => $this->title, + 'handle' => $this->handle, + 'status' => $this->status->value, + 'vendor' => $this->vendor, + 'product_type' => $this->product_type, + 'tags' => $this->tags ?? [], + 'variants_count' => $this->whenCounted('productVariants'), + 'total_inventory' => $this->whenLoaded('productVariants', fn () => $this->productVariants->sum( + fn ($variant): int => $variant->inventoryItem?->quantity_on_hand ?? 0 + ), 0), + 'published_at' => $this->published_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + 'featured_image' => $featured instanceof \App\Models\ProductMedia ? [ + 'url' => Storage::disk('public')->url($featured->storage_key), + 'alt_text' => $featured->alt_text, + ] : null, + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/RefundResource.php b/app/Http/Resources/Api/Admin/RefundResource.php new file mode 100644 index 00000000..dfba0b54 --- /dev/null +++ b/app/Http/Resources/Api/Admin/RefundResource.php @@ -0,0 +1,26 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'order_id' => $this->order_id, + 'payment_id' => $this->payment_id, + 'provider_refund_id' => $this->provider_refund_id, + 'amount' => $this->amount, + 'reason' => $this->reason, + 'status' => $this->status->value, + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/ShippingRateResource.php b/app/Http/Resources/Api/Admin/ShippingRateResource.php new file mode 100644 index 00000000..73218b12 --- /dev/null +++ b/app/Http/Resources/Api/Admin/ShippingRateResource.php @@ -0,0 +1,23 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'type' => $this->type->value, + 'config_json' => $this->config_json, + 'is_active' => (bool) $this->is_active, + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/ShippingZoneResource.php b/app/Http/Resources/Api/Admin/ShippingZoneResource.php new file mode 100644 index 00000000..13824cbe --- /dev/null +++ b/app/Http/Resources/Api/Admin/ShippingZoneResource.php @@ -0,0 +1,24 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'name' => $this->name, + 'countries_json' => $this->countries_json, + 'regions_json' => $this->regions_json, + 'rates' => ShippingRateResource::collection($this->whenLoaded('shippingRates')), + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/StoreResource.php b/app/Http/Resources/Api/Admin/StoreResource.php new file mode 100644 index 00000000..c294e72d --- /dev/null +++ b/app/Http/Resources/Api/Admin/StoreResource.php @@ -0,0 +1,30 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'organization_id' => $this->organization_id, + 'name' => $this->name, + 'handle' => $this->handle, + 'status' => $this->status->value, + 'default_currency' => $this->default_currency, + 'default_locale' => $this->default_locale, + 'timezone' => $this->timezone, + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/TaxSettingResource.php b/app/Http/Resources/Api/Admin/TaxSettingResource.php new file mode 100644 index 00000000..333daff2 --- /dev/null +++ b/app/Http/Resources/Api/Admin/TaxSettingResource.php @@ -0,0 +1,24 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'store_id' => $this->store_id, + 'mode' => $this->mode->value, + 'provider' => $this->provider->value, + 'prices_include_tax' => (bool) $this->prices_include_tax, + 'config_json' => $this->config_json, + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/VariantResource.php b/app/Http/Resources/Api/Admin/VariantResource.php new file mode 100644 index 00000000..07292b63 --- /dev/null +++ b/app/Http/Resources/Api/Admin/VariantResource.php @@ -0,0 +1,42 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'sku' => $this->sku, + 'barcode' => $this->barcode, + 'price_amount' => $this->price_amount, + 'compare_at_amount' => $this->compare_at_amount, + 'currency' => $this->currency, + 'weight_g' => $this->weight_g, + 'requires_shipping' => (bool) $this->requires_shipping, + 'is_default' => (bool) $this->is_default, + 'position' => $this->position, + 'status' => $this->status->value, + 'option_values' => $this->whenLoaded('optionValues', fn () => $this->optionValues->map(fn ($value): array => [ + 'option_name' => $value->productOption?->name, + 'value' => $value->value, + ])->all(), []), + 'inventory' => $this->whenLoaded('inventoryItem', fn () => $this->inventoryItem === null ? null : [ + 'quantity_on_hand' => $this->inventoryItem->quantity_on_hand, + 'quantity_reserved' => $this->inventoryItem->quantity_reserved, + 'available' => $this->inventoryItem->quantity_on_hand - $this->inventoryItem->quantity_reserved, + 'policy' => $this->inventoryItem->policy->value, + ]), + ]; + } +} diff --git a/app/Http/Resources/Api/Storefront/CartLineResource.php b/app/Http/Resources/Api/Storefront/CartLineResource.php new file mode 100644 index 00000000..d500eb1f --- /dev/null +++ b/app/Http/Resources/Api/Storefront/CartLineResource.php @@ -0,0 +1,41 @@ + + */ + public function toArray(Request $request): array + { + $variant = $this->variant; + $product = $variant->product; + $labels = $variant->optionValues->pluck('value')->all(); + $inventory = $variant->inventoryItem; + + $media = $product->productMedia->sortBy('position')->first(); + + return [ + 'id' => $this->id, + 'variant_id' => $this->variant_id, + 'product_title' => $product->title, + 'variant_title' => $labels === [] ? 'Default' : implode(' / ', $labels), + 'sku' => $variant->sku, + 'quantity' => $this->quantity, + 'unit_price_amount' => $this->unit_price_amount, + 'line_subtotal_amount' => $this->line_subtotal_amount, + 'line_discount_amount' => $this->line_discount_amount, + 'line_total_amount' => $this->line_total_amount, + 'image_url' => $media !== null ? Storage::disk('public')->url($media->storage_key) : null, + 'requires_shipping' => (bool) $variant->requires_shipping, + 'available_quantity' => $inventory !== null + ? max(0, $inventory->quantity_on_hand - $inventory->quantity_reserved) + : 0, + ]; + } +} diff --git a/app/Http/Resources/Api/Storefront/CartResource.php b/app/Http/Resources/Api/Storefront/CartResource.php new file mode 100644 index 00000000..d566c032 --- /dev/null +++ b/app/Http/Resources/Api/Storefront/CartResource.php @@ -0,0 +1,37 @@ + + */ + public function toArray(Request $request): array + { + $lines = $this->cartLines; + + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'customer_id' => $this->customer_id, + 'currency' => $this->currency, + 'cart_version' => $this->cart_version, + 'status' => $this->status->value, + 'lines' => CartLineResource::collection($lines), + 'totals' => [ + 'subtotal' => (int) $lines->sum('line_subtotal_amount'), + 'discount' => (int) $lines->sum('line_discount_amount'), + 'total' => (int) $lines->sum('line_total_amount'), + 'currency' => $this->currency, + 'line_count' => $lines->count(), + 'item_count' => (int) $lines->sum('quantity'), + ], + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/Api/Storefront/CheckoutResource.php b/app/Http/Resources/Api/Storefront/CheckoutResource.php new file mode 100644 index 00000000..b6eff1e9 --- /dev/null +++ b/app/Http/Resources/Api/Storefront/CheckoutResource.php @@ -0,0 +1,89 @@ + + */ + public function toArray(Request $request): array + { + $totals = (array) ($this->totals_json ?? []); + $cart = $this->cart; + $lines = $cart !== null ? $cart->cartLines : collect(); + + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'cart_id' => $this->cart_id, + 'customer_id' => $this->customer_id, + 'status' => $this->status->value, + 'email' => $this->email, + 'shipping_address_json' => $this->shipping_address_json, + 'billing_address_json' => $this->billing_address_json, + 'shipping_method_id' => $this->shipping_method_id, + 'payment_method' => $this->payment_method?->value, + 'discount_code' => $this->discount_code, + 'lines' => $lines->map(fn ($line): array => [ + 'variant_id' => $line->variant_id, + 'product_title' => $line->variant->product->title, + 'variant_title' => $this->variantTitle($line->variant), + 'sku' => $line->variant->sku, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'line_total_amount' => $line->line_total_amount, + ])->values()->all(), + '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' => $totals['currency'] ?? $cart?->currency, + ], + 'applied_discounts' => $totals['applied_discounts'] ?? [], + 'available_shipping_methods' => $this->availableShippingMethods(), + 'tax_provider_snapshot_json' => $this->tax_provider_snapshot_json, + 'expires_at' => $this->expires_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } + + /** + * @return array> + */ + protected function availableShippingMethods(): array + { + if ($this->shipping_address_json === null || $this->cart === null) { + return []; + } + + $options = app(ShippingCalculator::class)->getAvailableRates( + $this->store, + (array) $this->shipping_address_json, + $this->cart, + ); + + return $options->map(fn ($option): array => [ + 'id' => $option->id, + 'name' => $option->name, + 'type' => $option->type, + 'price_amount' => $option->amount, + 'currency' => $this->cart->currency, + 'estimated_days_min' => null, + 'estimated_days_max' => null, + ])->all(); + } + + protected function variantTitle(mixed $variant): string + { + $labels = $variant->optionValues->pluck('value')->all(); + + return $labels === [] ? 'Default' : implode(' / ', $labels); + } +} diff --git a/app/Http/Resources/Api/Storefront/StorefrontOrderResource.php b/app/Http/Resources/Api/Storefront/StorefrontOrderResource.php new file mode 100644 index 00000000..7f036678 --- /dev/null +++ b/app/Http/Resources/Api/Storefront/StorefrontOrderResource.php @@ -0,0 +1,66 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'order_number' => $this->order_number, + 'status' => $this->status->value, + 'financial_status' => $this->financial_status->value, + 'fulfillment_status' => $this->fulfillment_status->value, + 'email' => $this->email, + 'currency' => $this->currency, + 'placed_at' => $this->placed_at?->toIso8601String(), + 'lines' => $this->orderLines->map(fn ($line): array => [ + 'title_snapshot' => $line->title_snapshot, + 'variant_title' => $this->variantTitle($line), + 'sku_snapshot' => $line->sku_snapshot, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'total_amount' => $line->total_amount, + ])->all(), + 'totals' => [ + 'subtotal_amount' => $this->subtotal_amount, + 'discount_amount' => $this->discount_amount, + 'shipping_amount' => $this->shipping_amount, + 'tax_amount' => $this->tax_amount, + 'total_amount' => $this->total_amount, + ], + 'shipping_address' => $this->shipping_address_json, + 'fulfillments' => $this->fulfillments->map(fn ($fulfillment): array => [ + 'id' => $fulfillment->id, + 'status' => $fulfillment->status->value, + 'tracking_company' => $fulfillment->tracking_company, + 'tracking_number' => $fulfillment->tracking_number, + 'tracking_url' => $fulfillment->tracking_url, + 'shipped_at' => $fulfillment->shipped_at?->toIso8601String(), + 'line_items' => $fulfillment->fulfillmentLines->map(fn ($fulfillmentLine): array => [ + 'order_line_id' => $fulfillmentLine->order_line_id, + 'quantity' => $fulfillmentLine->quantity, + ])->all(), + ])->all(), + ]; + } + + protected function variantTitle(mixed $line): ?string + { + $variant = $line->variant; + + if ($variant === null) { + return null; + } + + $labels = $variant->optionValues->pluck('value')->all(); + + return $labels === [] ? 'Default' : implode(' / ', $labels); + } +} diff --git a/app/Jobs/AggregateAnalytics.php b/app/Jobs/AggregateAnalytics.php new file mode 100644 index 00000000..7958c351 --- /dev/null +++ b/app/Jobs/AggregateAnalytics.php @@ -0,0 +1,80 @@ +date !== null ? CarbonImmutable::parse($this->date) : CarbonImmutable::yesterday(); + + foreach (Store::query()->pluck('id') as $storeId) { + $this->aggregateStore((int) $storeId, $target); + } + } + + protected function aggregateStore(int $storeId, CarbonImmutable $day): void + { + $from = $day->startOfDay(); + $to = $day->endOfDay(); + $dateString = $day->toDateString(); + + $completed = AnalyticsEvent::query() + ->where('store_id', $storeId) + ->where('type', 'checkout_completed') + ->whereBetween('occurred_at', [$from, $to]) + ->get(['properties_json']); + + $ordersCount = $completed->count(); + $revenueAmount = $completed->sum( + fn ($event): int => (int) (is_array($event->properties_json) ? ($event->properties_json['total_amount'] ?? 0) : 0) + ); + + $visitsCount = AnalyticsEvent::query() + ->where('store_id', $storeId) + ->where('type', 'page_view') + ->whereBetween('occurred_at', [$from, $to]) + ->distinct('session_id') + ->count('session_id'); + + $addToCartCount = $this->countForDay($storeId, 'add_to_cart', $from, $to); + $checkoutStartedCount = $this->countForDay($storeId, 'checkout_started', $from, $to); + + AnalyticsDaily::query()->updateOrCreate( + ['store_id' => $storeId, 'date' => $dateString], + [ + 'orders_count' => $ordersCount, + 'revenue_amount' => $revenueAmount, + 'aov_amount' => $ordersCount > 0 ? intdiv($revenueAmount, $ordersCount) : 0, + 'visits_count' => $visitsCount, + 'add_to_cart_count' => $addToCartCount, + 'checkout_started_count' => $checkoutStartedCount, + 'checkout_completed_count' => $ordersCount, + ] + ); + } + + protected function countForDay(int $storeId, string $type, CarbonImmutable $from, CarbonImmutable $to): int + { + return AnalyticsEvent::query() + ->where('store_id', $storeId) + ->where('type', $type) + ->whereBetween('occurred_at', [$from, $to]) + ->count(); + } +} diff --git a/app/Jobs/CancelUnpaidBankTransferOrders.php b/app/Jobs/CancelUnpaidBankTransferOrders.php new file mode 100644 index 00000000..f624bcb1 --- /dev/null +++ b/app/Jobs/CancelUnpaidBankTransferOrders.php @@ -0,0 +1,74 @@ +withoutGlobalScopes() + ->where('payment_method', PaymentMethod::BankTransfer->value) + ->where('financial_status', FinancialStatus::Pending->value) + ->orderBy('id') + ->chunkById(100, function ($orders): void { + foreach ($orders as $order) { + $cancelDays = $this->cancelDays($order->store_id); + + if ($order->placed_at === null || $order->placed_at->gt(now()->subDays($cancelDays))) { + continue; + } + + DB::transaction(function () use ($order): void { + foreach ($order->orderLines()->get() as $line) { + if ($line->variant_id === null) { + continue; + } + + $item = \App\Models\InventoryItem::query() + ->withoutGlobalScopes() + ->where('store_id', $order->store_id) + ->where('variant_id', $line->variant_id) + ->first(); + + if ($item !== null) { + $this->inventory->release($item, $line->quantity); + } + } + + $order->financial_status = FinancialStatus::Voided; + $order->status = OrderStatus::Cancelled; + $order->save(); + + $order->payments()->where('status', PaymentStatus::Pending->value) + ->update(['status' => PaymentStatus::Failed->value]); + + event(new OrderCancelled($order->refresh())); + }); + } + }); + } + + protected function cancelDays(int $storeId): int + { + $settings = StoreSetting::query()->where('store_id', $storeId)->first()?->settings_json ?? []; + + return (int) ($settings['bank_transfer_cancel_days'] ?? 7); + } +} diff --git a/app/Jobs/CleanupAbandonedCarts.php b/app/Jobs/CleanupAbandonedCarts.php new file mode 100644 index 00000000..8027ac7f --- /dev/null +++ b/app/Jobs/CleanupAbandonedCarts.php @@ -0,0 +1,22 @@ +withoutGlobalScopes() + ->where('status', CartStatus::Active->value) + ->where('updated_at', '<', now()->subDays(14)) + ->update(['status' => CartStatus::Abandoned->value]); + } +} diff --git a/app/Jobs/DeliverWebhook.php b/app/Jobs/DeliverWebhook.php new file mode 100644 index 00000000..b8842e93 --- /dev/null +++ b/app/Jobs/DeliverWebhook.php @@ -0,0 +1,144 @@ + $payload + */ + public function __construct( + public int $deliveryId, + public string $eventType, + public array $payload, + ) {} + + /** + * @return array + */ + public function backoff(): array + { + return [60, 300, 1800, 7200, 43200]; + } + + public function handle(WebhookService $webhooks): void + { + $delivery = WebhookDelivery::query()->with('subscription')->find($this->deliveryId); + + if ($delivery === null || $delivery->subscription === null) { + return; + } + + $subscription = $delivery->subscription; + + if ($subscription->status !== WebhookSubscriptionStatus::Active) { + $delivery->status = WebhookDeliveryStatus::Failed; + $delivery->last_attempt_at = now(); + $delivery->save(); + + return; + } + + $body = json_encode($this->payload); + $secret = (string) $subscription->signing_secret_encrypted; + $timestamp = (string) now()->timestamp; + + $delivery->attempt_count = $delivery->attempt_count + 1; + $delivery->last_attempt_at = now(); + + try { + $response = Http::timeout(10) + ->withHeaders([ + 'X-Platform-Signature' => $webhooks->sign($body, $secret), + 'X-Platform-Event' => $this->eventType, + 'X-Platform-Delivery-Id' => $delivery->event_id, + 'X-Platform-Timestamp' => $timestamp, + 'Content-Type' => 'application/json', + ]) + ->withBody($body, 'application/json') + ->post($subscription->target_url); + + $delivery->response_code = $response->status(); + $delivery->response_body_snippet = mb_substr((string) $response->body(), 0, 2000); + + if ($response->successful()) { + $delivery->status = WebhookDeliveryStatus::Success; + $delivery->save(); + $webhooks->recordSuccess($subscription); + + return; + } + + $delivery->save(); + $this->failOrRetry($delivery, $webhooks); + } catch (Throwable $exception) { + $delivery->response_code = null; + $delivery->response_body_snippet = mb_substr($exception->getMessage(), 0, 2000); + $delivery->save(); + $this->failOrRetry($delivery, $webhooks); + } + } + + public function failed(?Throwable $exception = null): void + { + $delivery = WebhookDelivery::query()->with('subscription')->find($this->deliveryId); + + if ($delivery === null) { + return; + } + + $delivery->status = WebhookDeliveryStatus::Failed; + $delivery->last_attempt_at = now(); + + if ($exception !== null && $delivery->response_body_snippet === null) { + $delivery->response_body_snippet = mb_substr($exception->getMessage(), 0, 2000); + } + + $delivery->save(); + + if ($delivery->subscription !== null) { + app(WebhookService::class)->recordFailure($delivery->subscription); + + Log::warning('Webhook delivery failed permanently', [ + 'delivery_id' => $delivery->getKey(), + 'subscription_id' => $delivery->subscription_id, + ]); + } + } + + protected function failOrRetry(WebhookDelivery $delivery, WebhookService $webhooks): void + { + if ($delivery->attempt_count >= $this->tries) { + $delivery->status = WebhookDeliveryStatus::Failed; + $delivery->save(); + $webhooks->recordFailure($delivery->subscription); + + Log::warning('Webhook delivery failed permanently', [ + 'delivery_id' => $delivery->getKey(), + 'subscription_id' => $delivery->subscription_id, + ]); + + return; + } + + $this->release($this->backoff()[$delivery->attempt_count - 1] ?? 60); + } +} diff --git a/app/Jobs/ExpireAbandonedCheckouts.php b/app/Jobs/ExpireAbandonedCheckouts.php new file mode 100644 index 00000000..ab9df16a --- /dev/null +++ b/app/Jobs/ExpireAbandonedCheckouts.php @@ -0,0 +1,33 @@ +withoutGlobalScopes() + ->whereNotIn('status', [CheckoutStatus::Completed->value, CheckoutStatus::Expired->value]) + ->where(function ($query): void { + $query->where('expires_at', '<', now()) + ->orWhere('updated_at', '<', now()->subHours(24)); + }) + ->orderBy('id') + ->chunkById(100, function ($expired): void { + foreach ($expired as $checkout) { + $this->checkouts->expireCheckout($checkout); + } + }); + } +} diff --git a/app/Jobs/ProcessMediaUpload.php b/app/Jobs/ProcessMediaUpload.php new file mode 100644 index 00000000..f1d2fc8b --- /dev/null +++ b/app/Jobs/ProcessMediaUpload.php @@ -0,0 +1,126 @@ + + */ + protected array $sizes = [ + 'thumbnail' => 150, + 'small' => 300, + 'medium' => 600, + 'large' => 1200, + ]; + + public function __construct(public int $mediaId) {} + + public function handle(): void + { + $media = ProductMedia::query()->find($this->mediaId); + + if ($media === null) { + return; + } + + try { + if ($media->type === MediaType::Image) { + $this->generateSizes($media); + } + + $media->update(['status' => MediaStatus::Ready]); + } catch (Throwable $exception) { + $media->update(['status' => MediaStatus::Failed]); + + report($exception); + } + } + + protected function generateSizes(ProductMedia $media): void + { + $disk = Storage::disk('public'); + + if (! $disk->exists($media->storage_key)) { + return; + } + + $binary = $disk->get($media->storage_key); + + if ($binary === null) { + return; + } + + $source = @imagecreatefromstring($binary); + + if ($source === false) { + return; + } + + $sourceWidth = imagesx($source); + $sourceHeight = imagesy($source); + + if ($sourceWidth <= 0 || $sourceHeight <= 0) { + imagedestroy($source); + + return; + } + + if ($media->width === null || $media->height === null) { + $media->update(['width' => $sourceWidth, 'height' => $sourceHeight]); + } + + $extension = strtolower(pathinfo($media->storage_key, PATHINFO_EXTENSION)); + + foreach ($this->sizes as $name => $maxDimension) { + $ratio = min(1, $maxDimension / max($sourceWidth, $sourceHeight)); + $targetWidth = max(1, (int) round($sourceWidth * $ratio)); + $targetHeight = max(1, (int) round($sourceHeight * $ratio)); + + $resized = imagescale($source, $targetWidth, $targetHeight); + + if ($resized === false) { + continue; + } + + $this->persistResized($resized, $extension, "media/{$media->product_id}/{$media->getKey()}/{$name}.{$extension}"); + imagedestroy($resized); + } + + imagedestroy($source); + } + + protected function persistResized(\GdImage $image, string $extension, string $key): void + { + ob_start(); + + $written = match ($extension) { + 'jpg', 'jpeg' => imagejpeg($image, null, 85), + 'png' => imagepng($image, null, 8), + 'webp' => imagewebp($image, null, 85), + 'gif' => imagegif($image), + default => false, + }; + + $binary = (string) ob_get_clean(); + + if ($written && $binary !== '') { + Storage::disk('public')->put($key, $binary); + } + } +} diff --git a/app/Jobs/ReindexSearch.php b/app/Jobs/ReindexSearch.php new file mode 100644 index 00000000..be6c70e2 --- /dev/null +++ b/app/Jobs/ReindexSearch.php @@ -0,0 +1,26 @@ +find($this->storeId); + + if ($store === null) { + return; + } + + $search->reindex($store); + } +} diff --git a/app/Listeners/DispatchWebhooks.php b/app/Listeners/DispatchWebhooks.php new file mode 100644 index 00000000..b8706c9d --- /dev/null +++ b/app/Listeners/DispatchWebhooks.php @@ -0,0 +1,195 @@ + $events + */ + public function subscribe(Dispatcher $events): array + { + return [ + OrderCreated::class => 'onOrderCreated', + OrderPaid::class => 'onOrderPaid', + OrderRefunded::class => 'onOrderRefunded', + OrderCancelled::class => 'onOrderCancelled', + CheckoutCompleted::class => 'onCheckoutCompleted', + FulfillmentCreated::class => 'onFulfillmentCreated', + FulfillmentShipped::class => 'onFulfillmentShipped', + FulfillmentDelivered::class => 'onFulfillmentDelivered', + ProductStatusChanged::class => 'onProductStatusChanged', + ]; + } + + public function onOrderCreated(OrderCreated $event): void + { + $order = $event->order; + + $this->webhooks->dispatch($order->store, 'order.created', $this->orderPayload($order)); + } + + public function onOrderPaid(OrderPaid $event): void + { + $order = $event->order; + + $this->webhooks->dispatch($order->store, 'order.paid', $this->orderPayload($order)); + $this->webhooks->dispatch($order->store, 'order.updated', $this->orderPayload($order)); + } + + public function onOrderRefunded(OrderRefunded $event): void + { + $order = $event->order; + + $this->webhooks->dispatch($order->store, 'order.refunded', array_merge( + $this->orderPayload($order), + ['refund' => [ + 'id' => $event->refund->getKey(), + 'amount' => $event->refund->amount, + 'reason' => $event->refund->reason, + ]] + )); + $this->webhooks->dispatch($order->store, 'refund.created', [ + 'id' => $event->refund->getKey(), + 'order_id' => $order->getKey(), + 'amount' => $event->refund->amount, + ]); + } + + public function onOrderCancelled(OrderCancelled $event): void + { + $order = $event->order; + + $this->webhooks->dispatch($order->store, 'order.cancelled', $this->orderPayload($order)); + $this->webhooks->dispatch($order->store, 'order.updated', $this->orderPayload($order)); + } + + public function onCheckoutCompleted(CheckoutCompleted $event): void + { + $order = $event->order; + + $this->webhooks->dispatch($order->store, 'checkout.completed', [ + 'checkout_id' => $event->checkout->getKey(), + 'order_id' => $order->getKey(), + 'order_number' => $order->order_number, + 'total_amount' => $order->total_amount, + 'currency' => $order->currency, + ]); + } + + public function onFulfillmentCreated(FulfillmentCreated $event): void + { + $fulfillment = $event->fulfillment; + $order = $fulfillment->order; + + $this->webhooks->dispatch($order->store, 'fulfillment.created', $this->fulfillmentPayload($fulfillment)); + } + + public function onFulfillmentShipped(FulfillmentShipped $event): void + { + $this->dispatchFulfillmentStatus($event->fulfillment); + } + + public function onFulfillmentDelivered(FulfillmentDelivered $event): void + { + $this->dispatchFulfillmentStatus($event->fulfillment); + } + + public function onProductStatusChanged(ProductStatusChanged $event): void + { + $product = $event->product; + + $eventType = match (true) { + $event->to === ProductStatus::Archived => 'product.deleted', + $event->from === ProductStatus::Draft && $event->to === ProductStatus::Active => 'product.created', + default => 'product.updated', + }; + + $this->webhooks->dispatch($product->store, $eventType, $this->productPayload($product)); + } + + public function dispatchCustomerCreated(Customer $customer): void + { + $this->webhooks->dispatch($customer->store, 'customer.created', [ + 'id' => $customer->getKey(), + 'email' => $customer->email, + 'name' => $customer->name, + ]); + } + + protected function dispatchFulfillmentStatus(Fulfillment $fulfillment): void + { + $order = $fulfillment->order()->first() ?? $fulfillment->order; + + if ($order->fulfillment_status === FulfillmentStatus::Fulfilled) { + $this->webhooks->dispatch($order->store, 'order.fulfilled', $this->orderPayload($order)); + } + } + + /** + * @return array + */ + protected function orderPayload(Order $order): array + { + return [ + 'id' => $order->getKey(), + 'order_number' => $order->order_number, + 'status' => $order->status->value, + 'financial_status' => $order->financial_status->value, + 'fulfillment_status' => $order->fulfillment_status->value, + 'total_amount' => $order->total_amount, + 'currency' => $order->currency, + 'email' => $order->email, + ]; + } + + /** + * @return array + */ + protected function fulfillmentPayload(Fulfillment $fulfillment): array + { + return [ + 'id' => $fulfillment->getKey(), + 'order_id' => $fulfillment->order_id, + 'status' => $fulfillment->status->value, + 'tracking_company' => $fulfillment->tracking_company, + 'tracking_number' => $fulfillment->tracking_number, + ]; + } + + /** + * @return array + */ + protected function productPayload(Product $product): array + { + return [ + 'id' => $product->getKey(), + 'title' => $product->title, + 'handle' => $product->handle, + 'status' => $product->status->value, + ]; + } +} diff --git a/app/Livewire/Admin/Analytics/Index.php b/app/Livewire/Admin/Analytics/Index.php new file mode 100644 index 00000000..7f401cee --- /dev/null +++ b/app/Livewire/Admin/Analytics/Index.php @@ -0,0 +1,112 @@ +customStartDate = null; + $this->customEndDate = null; + } + + public function exportCsv(): void + { + Gate::authorize('view-analytics'); + + $this->isExporting = true; + $this->exportUrl = null; + + $store = $this->store(); + [$from, $to] = $this->resolveRange(); + $summary = app(AnalyticsService::class)->summary($store, $from, $to); + + $lines = ['date,orders_count,revenue_amount,aov_amount,visits_count,add_to_cart_count,checkout_started_count']; + + foreach ($summary['daily'] as $row) { + $lines[] = implode(',', [ + $row['date'], + $row['orders_count'], + $row['revenue_amount'], + $row['aov_amount'], + $row['visits_count'], + $row['add_to_cart_count'], + $row['checkout_started_count'], + ]); + } + + $path = 'exports/analytics-'.$store->getKey().'-'.$from.'-'.$to.'.csv'; + \Illuminate\Support\Facades\Storage::disk('local')->put($path, implode("\n", $lines)."\n"); + + $this->isExporting = false; + $this->exportUrl = $path; + $this->toast('success', 'Export ready.'); + } + + public function render() + { + $store = $this->store(); + $analytics = app(AnalyticsService::class); + [$from, $to] = $this->resolveRange(); + $summary = $analytics->summary($store, $from, $to); + + $topProducts = $analytics->topProductsByRevenue($store, $from, $to, 20); + $totalRevenue = array_sum(array_column($topProducts, 'revenue_amount')); + + return view('livewire.admin.analytics.index', [ + 'from' => $from, + 'to' => $to, + 'summary' => $summary['summary'], + 'daily' => $summary['daily'], + 'topProducts' => $topProducts, + 'totalRevenue' => $totalRevenue, + 'topReferrers' => $analytics->topReferrers($store, $from, $to), + 'funnelData' => $analytics->funnel($store, $from, $to), + ])->layout('livewire.admin.layout.app', ['title' => 'Analytics']); + } + + /** + * @return array{0: string, 1: string} + */ + protected function resolveRange(): array + { + $today = CarbonImmutable::today(); + + if ($this->dateRange === 'custom' && $this->customStartDate && $this->customEndDate) { + return [$this->customStartDate, $this->customEndDate]; + } + + return match ($this->dateRange) { + 'today' => [$today->toDateString(), $today->toDateString()], + 'last_7_days' => [$today->subDays(6)->toDateString(), $today->toDateString()], + default => [$today->subDays(29)->toDateString(), $today->toDateString()], + }; + } +} diff --git a/app/Livewire/Admin/Apps/Index.php b/app/Livewire/Admin/Apps/Index.php new file mode 100644 index 00000000..6391e5a5 --- /dev/null +++ b/app/Livewire/Admin/Apps/Index.php @@ -0,0 +1,57 @@ +uninstallingId = $installationId; + $this->showUninstallModal = true; + } + + public function uninstallApp(): void + { + Gate::authorize('manage-apps'); + + $installation = AppInstallation::query() + ->where('store_id', $this->store()->getKey()) + ->whereKey($this->uninstallingId) + ->firstOrFail(); + + $installation->update(['status' => AppInstallationStatus::Uninstalled]); + + $this->showUninstallModal = false; + $this->uninstallingId = null; + $this->toast('success', 'App uninstalled.'); + } + + public function render() + { + return view('livewire.admin.apps.index', [ + 'installedApps' => AppInstallation::query() + ->where('store_id', $this->store()->getKey()) + ->where('status', '!=', AppInstallationStatus::Uninstalled) + ->with('app') + ->orderByDesc('installed_at') + ->get(), + ])->layout('livewire.admin.layout.app', ['title' => 'Apps']); + } +} diff --git a/app/Livewire/Admin/Apps/Show.php b/app/Livewire/Admin/Apps/Show.php new file mode 100644 index 00000000..0ea5fa3b --- /dev/null +++ b/app/Livewire/Admin/Apps/Show.php @@ -0,0 +1,48 @@ +store_id !== $this->store()->getKey(), 404); + + Gate::authorize('manage-apps'); + + $this->installation = $installation; + } + + public function render() + { + $installation = AppInstallation::query() + ->where('store_id', $this->store()->getKey()) + ->whereKey($this->installation->getKey()) + ->with(['app', 'webhookSubscriptions', 'oauthTokens']) + ->firstOrFail(); + + $subscriptionIds = $installation->webhookSubscriptions->pluck('id'); + + $deliveryStats = $subscriptionIds->isEmpty() + ? ['count' => 0, 'last_at' => null] + : [ + 'count' => WebhookDelivery::query()->whereIn('subscription_id', $subscriptionIds)->count(), + 'last_at' => WebhookDelivery::query()->whereIn('subscription_id', $subscriptionIds)->max('last_attempt_at'), + ]; + + return view('livewire.admin.apps.show', [ + 'installation' => $installation, + 'deliveryStats' => $deliveryStats, + ])->layout('livewire.admin.layout.app', ['title' => $installation->app?->name ?? 'App']); + } +} diff --git a/app/Livewire/Admin/Collections/Form.php b/app/Livewire/Admin/Collections/Form.php new file mode 100644 index 00000000..cc721eee --- /dev/null +++ b/app/Livewire/Admin/Collections/Form.php @@ -0,0 +1,220 @@ + */ + public array $assignedProductIds = []; + + public function mount(?Collection $collection = null): void + { + if ($collection !== null && $collection->exists) { + abort_if($collection->store_id !== $this->store()->getKey(), 404); + + Gate::authorize('view', $collection); + + $this->collection = $collection; + $this->title = $collection->title; + $this->handle = $collection->handle; + $this->descriptionHtml = (string) $collection->description_html; + $this->status = $collection->status->value; + $this->assignedProductIds = $collection->products() + ->orderByPivot('position') + ->orderBy('products.id') + ->pluck('products.id') + ->map(fn ($id): int => (int) $id) + ->all(); + } else { + Gate::authorize('create', Collection::class); + + $this->collection = null; + } + } + + public function updatedTitle(): void + { + if ($this->collection === null && $this->handle === '') { + $this->handle = Str::slug($this->title); + } + } + + public function addProduct(int $productId): void + { + if (! in_array($productId, $this->assignedProductIds, true)) { + $this->assignedProductIds[] = $productId; + } + + $this->productSearch = ''; + } + + public function removeProduct(int $productId): void + { + $this->assignedProductIds = array_values(array_filter( + $this->assignedProductIds, + fn (int $id): bool => $id !== $productId + )); + } + + /** + * @param array $order + */ + public function reorderProducts(array $order): void + { + $ordered = array_values(array_filter( + array_map('intval', $order), + fn (int $id): bool => in_array($id, $this->assignedProductIds, true) + )); + + foreach ($this->assignedProductIds as $id) { + if (! in_array($id, $ordered, true)) { + $ordered[] = $id; + } + } + + $this->assignedProductIds = $ordered; + } + + public function moveProductUp(int $productId): void + { + $index = array_search($productId, $this->assignedProductIds, true); + + if ($index !== false && $index > 0) { + $ids = $this->assignedProductIds; + [$ids[$index - 1], $ids[$index]] = [$ids[$index], $ids[$index - 1]]; + $this->assignedProductIds = $ids; + } + } + + public function moveProductDown(int $productId): void + { + $index = array_search($productId, $this->assignedProductIds, true); + + if ($index !== false && $index < count($this->assignedProductIds) - 1) { + $ids = $this->assignedProductIds; + [$ids[$index + 1], $ids[$index]] = [$ids[$index], $ids[$index + 1]]; + $this->assignedProductIds = $ids; + } + } + + public function save(): mixed + { + if ($this->collection !== null) { + Gate::authorize('update', $this->collection); + } else { + Gate::authorize('create', Collection::class); + } + + $validated = $this->validate([ + 'title' => 'required|string|max:255', + 'handle' => 'nullable|string|max:255', + 'descriptionHtml' => 'nullable|string|max:65535', + 'status' => 'required|in:active,archived,draft', + ]); + + $handle = $validated['handle'] !== '' && $validated['handle'] !== null + ? $validated['handle'] + : Str::slug($validated['title']); + + $exists = Collection::query() + ->where('store_id', $this->store()->getKey()) + ->where('handle', $handle) + ->when($this->collection !== null, fn ($query) => $query->whereKeyNot($this->collection->getKey())) + ->exists(); + + if ($exists) { + $this->addError('handle', 'The handle has already been taken.'); + + return null; + } + + $data = [ + 'title' => $validated['title'], + 'handle' => $handle, + 'description_html' => $validated['descriptionHtml'] !== '' ? $validated['descriptionHtml'] : null, + 'status' => CollectionStatus::from($validated['status']), + ]; + + if ($this->collection !== null) { + $this->collection->update($data); + $this->syncProducts($this->collection); + $this->toast('success', 'Collection saved.'); + + return null; + } + + $collection = Collection::query()->create([ + 'store_id' => $this->store()->getKey(), + 'type' => CollectionType::Manual, + ...$data, + ]); + + $this->syncProducts($collection); + $this->flashToast('success', 'Collection saved.'); + + return redirect()->route('admin.collections.index'); + } + + public function render() + { + $searchResults = $this->productSearch !== '' + ? Product::query() + ->where('store_id', $this->store()->getKey()) + ->whereNotIn('id', $this->assignedProductIds) + ->where('title', 'like', '%'.$this->productSearch.'%') + ->orderBy('title') + ->limit(10) + ->get() + : collect(); + + $assignedProducts = $this->assignedProductIds === [] + ? collect() + : Product::query() + ->where('store_id', $this->store()->getKey()) + ->whereIn('id', $this->assignedProductIds) + ->with(['productMedia' => fn ($query) => $query->orderBy('position')->orderBy('id')]) + ->get() + ->sortBy(fn (Product $product): int => array_search($product->getKey(), $this->assignedProductIds, true) ?: 0) + ->values(); + + return view('livewire.admin.collections.form', [ + 'searchResults' => $searchResults, + 'assignedProducts' => $assignedProducts, + 'isEditing' => $this->collection !== null, + ])->layout('livewire.admin.layout.app', ['title' => $this->collection !== null ? $this->collection->title : 'Add collection']); + } + + protected function syncProducts(Collection $collection): void + { + $sync = []; + + foreach (array_values($this->assignedProductIds) as $position => $productId) { + $sync[$productId] = ['position' => $position]; + } + + $collection->products()->sync($sync); + } +} diff --git a/app/Livewire/Admin/Collections/Index.php b/app/Livewire/Admin/Collections/Index.php new file mode 100644 index 00000000..534044c6 --- /dev/null +++ b/app/Livewire/Admin/Collections/Index.php @@ -0,0 +1,79 @@ +resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function confirmDelete(int $id): void + { + $this->deletingId = $id; + $this->showDeleteModal = true; + } + + public function deleteCollection(): void + { + $collection = Collection::query() + ->where('store_id', $this->store()->getKey()) + ->whereKey($this->deletingId) + ->firstOrFail(); + + Gate::authorize('delete', $collection); + + $collection->delete(); + + $this->showDeleteModal = false; + $this->deletingId = null; + $this->toast('success', 'Collection deleted.'); + } + + public function render() + { + $query = Collection::query() + ->where('store_id', $this->store()->getKey()) + ->withCount('products') + ->orderByDesc('updated_at'); + + if ($this->search !== '') { + $query->where('title', 'like', '%'.$this->search.'%'); + } + + if ($this->statusFilter !== 'all') { + $query->where('status', $this->statusFilter); + } + + return view('livewire.admin.collections.index', [ + 'collections' => $query->paginate(25), + ])->layout('livewire.admin.layout.app', ['title' => 'Collections']); + } +} diff --git a/app/Livewire/Admin/Concerns/ResolvesAdminStore.php b/app/Livewire/Admin/Concerns/ResolvesAdminStore.php new file mode 100644 index 00000000..b3ee9086 --- /dev/null +++ b/app/Livewire/Admin/Concerns/ResolvesAdminStore.php @@ -0,0 +1,83 @@ +get(); + + if ($store !== null) { + return $store; + } + + $store = $this->resolveAdminStoreFromSession(); + + if ($store !== null) { + $current->set($store); + + return $store; + } + + abort(404); + } + + protected function resolveAdminStoreFromSession(): ?Store + { + if (! app()->bound('session')) { + return null; + } + + $storeId = session()->get('current_store_id'); + + if ($storeId === null) { + return null; + } + + $store = Store::query()->find($storeId); + + if ($store === null) { + return null; + } + + $user = request()->user(); + + if ($user !== null && ! $user->stores()->where('stores.id', $store->getKey())->exists()) { + abort(403); + } + + return $store; + } + + protected function toast(string $type, string $message): void + { + $this->dispatch('toast', type: $type, message: $message); + } + + protected function flashToast(string $type, string $message): void + { + session()->flash('toast', ['type' => $type, 'message' => $message]); + } + + public function money(int $cents, ?string $currency = null): string + { + $currency = $currency ?? $this->store()->default_currency; + + $symbol = match (strtoupper($currency)) { + 'EUR' => '€', + 'GBP' => '£', + default => '$', + }; + + return $symbol.number_format($cents / 100, 2); + } +} diff --git a/app/Livewire/Admin/Customers/Index.php b/app/Livewire/Admin/Customers/Index.php new file mode 100644 index 00000000..5b37fb3b --- /dev/null +++ b/app/Livewire/Admin/Customers/Index.php @@ -0,0 +1,46 @@ +resetPage(); + } + + public function render() + { + $query = Customer::query() + ->where('store_id', $this->store()->getKey()) + ->withCount('orders') + ->withSum('orders', 'total_amount') + ->orderByDesc('created_at'); + + if ($this->search !== '') { + $query->where(function ($inner): void { + $inner->where('name', 'like', '%'.$this->search.'%') + ->orWhere('email', 'like', '%'.$this->search.'%'); + }); + } + + return view('livewire.admin.customers.index', [ + 'customers' => $query->paginate(25), + ])->layout('livewire.admin.layout.app', ['title' => 'Customers']); + } +} diff --git a/app/Livewire/Admin/Customers/Show.php b/app/Livewire/Admin/Customers/Show.php new file mode 100644 index 00000000..349ce1cc --- /dev/null +++ b/app/Livewire/Admin/Customers/Show.php @@ -0,0 +1,148 @@ + */ + public array $addressForm = []; + + public bool $showAddressModal = false; + + public function mount(Customer $customer): void + { + abort_if($customer->store_id !== $this->store()->getKey(), 404); + + Gate::authorize('view', $customer); + + $this->customer = $customer; + } + + public function openAddressForm(?int $addressId = null): void + { + Gate::authorize('update', $this->customer); + + $this->editingAddressId = $addressId; + + if ($addressId !== null) { + $address = $this->customer->customerAddresses()->whereKey($addressId)->firstOrFail(); + $this->addressLabel = (string) $address->label; + $this->addressForm = array_merge($this->emptyAddress(), is_array($address->address_json) ? $address->address_json : []); + } else { + $this->addressLabel = ''; + $this->addressForm = $this->emptyAddress(); + } + + $this->showAddressModal = true; + } + + public function saveAddress(): void + { + Gate::authorize('update', $this->customer); + + $validated = $this->validate([ + 'addressLabel' => 'required|string|max:50', + 'addressForm.first_name' => 'required|string|max:100', + 'addressForm.last_name' => 'required|string|max:100', + 'addressForm.address1' => 'required|string|max:255', + 'addressForm.city' => 'required|string|max:100', + 'addressForm.zip' => 'required|string|max:20', + 'addressForm.country_code' => 'required|string|size:2', + ]); + + $payload = array_merge($this->emptyAddress(), $this->addressForm); + + if ($this->editingAddressId !== null) { + $address = $this->customer->customerAddresses()->whereKey($this->editingAddressId)->firstOrFail(); + $address->update(['label' => $validated['addressLabel'], 'address_json' => $payload]); + } else { + $this->customer->customerAddresses()->create([ + 'label' => $validated['addressLabel'], + 'address_json' => $payload, + 'is_default' => ! $this->customer->customerAddresses()->exists(), + ]); + } + + $this->showAddressModal = false; + $this->toast('success', 'Address saved.'); + } + + public function deleteAddress(int $addressId): void + { + Gate::authorize('update', $this->customer); + + $address = $this->customer->customerAddresses()->whereKey($addressId)->firstOrFail(); + $wasDefault = (bool) $address->is_default; + $address->delete(); + + if ($wasDefault) { + $first = $this->customer->customerAddresses()->orderBy('id')->first(); + + if ($first !== null) { + $first->update(['is_default' => true]); + } + } + + $this->toast('success', 'Address deleted.'); + } + + public function setDefaultAddress(int $addressId): void + { + Gate::authorize('update', $this->customer); + + $this->customer->customerAddresses()->update(['is_default' => false]); + $this->customer->customerAddresses()->whereKey($addressId)->update(['is_default' => true]); + + $this->toast('success', 'Default address updated.'); + } + + public function render() + { + $customer = Customer::query() + ->where('store_id', $this->store()->getKey()) + ->whereKey($this->customer->getKey()) + ->with(['customerAddresses' => fn ($query) => $query->orderByDesc('is_default')->orderBy('id')]) + ->firstOrFail(); + + $orders = $customer->orders()->orderByDesc('placed_at')->paginate(10); + + return view('livewire.admin.customers.show', [ + 'customer' => $customer, + 'orders' => $orders, + ])->layout('livewire.admin.layout.app', ['title' => $customer->name]); + } + + /** + * @return array + */ + protected function emptyAddress(): array + { + return [ + 'first_name' => '', + 'last_name' => '', + 'company' => '', + 'address1' => '', + 'address2' => '', + 'city' => '', + 'province' => '', + 'province_code' => '', + 'country' => '', + 'country_code' => '', + 'zip' => '', + 'phone' => '', + ]; + } +} diff --git a/app/Livewire/Admin/Dashboard.php b/app/Livewire/Admin/Dashboard.php new file mode 100644 index 00000000..20a7e7a0 --- /dev/null +++ b/app/Livewire/Admin/Dashboard.php @@ -0,0 +1,135 @@ +user() === null, 403); + } + + public function updatedDateRange(): void + { + $this->customStartDate = null; + $this->customEndDate = null; + } + + public function render() + { + $store = $this->store(); + $analytics = app(AnalyticsService::class); + [$from, $to] = $this->resolveRange(); + + $daily = $analytics->getDailyMetrics($store, $from, $to); + $summary = $analytics->summary($store, $from, $to); + + $ordersQuery = Order::query() + ->where('store_id', $store->getKey()) + ->whereBetween('placed_at', [$from.' 00:00:00', $to.' 23:59:59']); + + $ordersCount = (clone $ordersQuery)->count(); + $totalSales = (int) (clone $ordersQuery)->sum('total_amount'); + $averageOrderValue = $ordersCount > 0 ? intdiv($totalSales, $ordersCount) : 0; + $visitorsCount = (int) $daily->sum('visits_count'); + + [$prevFrom, $prevTo] = $this->previousRange($from, $to); + $prevDaily = $analytics->getDailyMetrics($store, $prevFrom, $prevTo); + $prevOrders = Order::query() + ->where('store_id', $store->getKey()) + ->whereBetween('placed_at', [$prevFrom.' 00:00:00', $prevTo.' 23:59:59']); + $prevOrdersCount = $prevOrders->count(); + $prevSales = (int) (clone $prevOrders)->sum('total_amount'); + $prevAov = $prevOrdersCount > 0 ? intdiv($prevSales, $prevOrdersCount) : 0; + $prevVisitors = (int) $prevDaily->sum('visits_count'); + + $ordersChartData = Order::query() + ->where('store_id', $store->getKey()) + ->whereBetween('placed_at', [$from.' 00:00:00', $to.' 23:59:59']) + ->selectRaw('DATE(placed_at) as date, COUNT(*) as count') + ->groupBy('date') + ->orderBy('date') + ->get() + ->map(fn ($row): array => ['date' => $row->date, 'count' => (int) $row->count]) + ->all(); + + $recentOrders = Order::query() + ->where('store_id', $store->getKey()) + ->with('customer') + ->orderByDesc('placed_at') + ->limit(10) + ->get(); + + return view('livewire.admin.dashboard', [ + 'store' => $store, + 'from' => $from, + 'to' => $to, + 'totalSales' => $totalSales, + 'ordersCount' => $ordersCount, + 'averageOrderValue' => $averageOrderValue, + 'visitorsCount' => $visitorsCount, + 'salesChange' => $this->change($totalSales, $prevSales), + 'ordersChange' => $this->change($ordersCount, $prevOrdersCount), + 'aovChange' => $this->change($averageOrderValue, $prevAov), + 'visitorsChange' => $this->change($visitorsCount, $prevVisitors), + 'ordersChartData' => $ordersChartData, + 'topProducts' => $summary['top_products'], + 'funnelData' => $analytics->funnel($store, $from, $to), + 'recentOrders' => $recentOrders, + ])->layout('livewire.admin.layout.app', ['title' => 'Dashboard']); + } + + /** + * @return array{0: string, 1: string} + */ + protected function resolveRange(): array + { + $today = CarbonImmutable::today(); + + if ($this->dateRange === 'custom' && $this->customStartDate && $this->customEndDate) { + return [$this->customStartDate, $this->customEndDate]; + } + + return match ($this->dateRange) { + 'today' => [$today->toDateString(), $today->toDateString()], + 'last_7_days' => [$today->subDays(6)->toDateString(), $today->toDateString()], + default => [$today->subDays(29)->toDateString(), $today->toDateString()], + }; + } + + /** + * @return array{0: string, 1: string} + */ + protected function previousRange(string $from, string $to): array + { + $days = CarbonImmutable::parse($from)->diffInDays(CarbonImmutable::parse($to)) + 1; + + return [ + CarbonImmutable::parse($from)->subDays($days)->toDateString(), + CarbonImmutable::parse($from)->subDay()->toDateString(), + ]; + } + + protected function change(int|float $current, int|float $previous): float + { + if ($previous == 0) { + return $current > 0 ? 100.0 : 0.0; + } + + return round((($current - $previous) / $previous) * 100, 1); + } +} diff --git a/app/Livewire/Admin/Developers/Index.php b/app/Livewire/Admin/Developers/Index.php new file mode 100644 index 00000000..59d4d1e5 --- /dev/null +++ b/app/Livewire/Admin/Developers/Index.php @@ -0,0 +1,170 @@ + */ + public array $newTokenAbilities = []; + + public ?string $generatedToken = null; + + public bool $showTokenModal = false; + + public ?int $editingWebhookId = null; + + public string $webhookEventType = 'order.created'; + + public string $webhookUrl = ''; + + public bool $showWebhookModal = false; + + public function mount(): void + { + Gate::authorize('manage-developers'); + } + + public function generateToken(): void + { + Gate::authorize('manage-developers'); + + $validated = $this->validate([ + 'newTokenName' => 'required|string|max:255', + 'newTokenAbilities' => 'nullable|array', + ]); + + if (in_array('manage-platform', $validated['newTokenAbilities'] ?? [], true) + && Gate::denies('manage-platform')) { + $this->addError('newTokenAbilities', 'Only store owners can issue tokens with the manage-platform ability.'); + + return; + } + + $token = auth()->user()->createToken( + $validated['newTokenName'], + array_map('strval', $validated['newTokenAbilities'] ?? ['*']) + ); + + $this->generatedToken = $token->plainTextToken; + $this->newTokenName = ''; + $this->newTokenAbilities = []; + $this->showTokenModal = false; + } + + public function revokeToken(int $tokenId): void + { + Gate::authorize('manage-developers'); + + auth()->user()->tokens()->whereKey($tokenId)->delete(); + + $this->toast('success', 'Token revoked.'); + } + + public function dismissGeneratedToken(): void + { + $this->generatedToken = null; + } + + public function openWebhookModal(?int $webhookId = null): void + { + Gate::authorize('manage-developers'); + + $this->editingWebhookId = $webhookId; + + if ($webhookId !== null) { + $webhook = $this->findWebhook($webhookId); + $this->webhookEventType = $webhook->event_type; + $this->webhookUrl = $webhook->target_url; + } else { + $this->webhookEventType = 'order.created'; + $this->webhookUrl = ''; + } + + $this->showWebhookModal = true; + } + + public function saveWebhook(): void + { + Gate::authorize('manage-developers'); + + $validated = $this->validate([ + 'webhookEventType' => 'required|string|in:'.implode(',', WebhookService::EVENT_TYPES), + 'webhookUrl' => 'required|url|max:2048', + ]); + + if ($this->editingWebhookId !== null) { + $this->findWebhook($this->editingWebhookId)->update([ + 'event_type' => $validated['webhookEventType'], + 'target_url' => $validated['webhookUrl'], + ]); + } else { + app(WebhookService::class)->subscribe( + $this->store(), + $validated['webhookEventType'], + $validated['webhookUrl'], + ); + } + + $this->showWebhookModal = false; + $this->toast('success', 'Webhook saved.'); + } + + public function deleteWebhook(int $webhookId): void + { + Gate::authorize('manage-developers'); + + $this->findWebhook($webhookId)->delete(); + + $this->toast('success', 'Webhook deleted.'); + } + + public function toggleWebhookStatus(int $webhookId): void + { + Gate::authorize('manage-developers'); + + $webhook = $this->findWebhook($webhookId); + + $webhook->update([ + 'status' => $webhook->status === WebhookSubscriptionStatus::Active + ? WebhookSubscriptionStatus::Paused + : WebhookSubscriptionStatus::Active, + ]); + + $this->toast('success', 'Webhook status updated.'); + } + + public function render() + { + return view('livewire.admin.developers.index', [ + 'tokens' => auth()->user()->tokens()->orderByDesc('created_at')->get(), + 'webhooks' => WebhookSubscription::query() + ->where('store_id', $this->store()->getKey()) + ->withCount('webhookDeliveries') + ->orderBy('event_type') + ->orderBy('id') + ->get(), + 'abilities' => RolePermissionMap::allAbilities(), + 'eventTypes' => WebhookService::EVENT_TYPES, + ])->layout('livewire.admin.layout.app', ['title' => 'Developers']); + } + + protected function findWebhook(int $webhookId): WebhookSubscription + { + return WebhookSubscription::query() + ->where('store_id', $this->store()->getKey()) + ->whereKey($webhookId) + ->firstOrFail(); + } +} diff --git a/app/Livewire/Admin/Discounts/Form.php b/app/Livewire/Admin/Discounts/Form.php new file mode 100644 index 00000000..8ab95726 --- /dev/null +++ b/app/Livewire/Admin/Discounts/Form.php @@ -0,0 +1,251 @@ + */ + public array $specificProductIds = []; + + /** @var array */ + public array $specificCollectionIds = []; + + public string $usageLimit = ''; + + public bool $onePerCustomer = false; + + public string $startsAt = ''; + + public string $endsAt = ''; + + public bool $isActive = true; + + public string $productSearch = ''; + + public string $collectionSearch = ''; + + public function mount(?Discount $discount = null): void + { + if ($discount !== null && $discount->exists) { + abort_if($discount->store_id !== $this->store()->getKey(), 404); + + Gate::authorize('view', $discount); + + $this->discount = $discount; + $this->type = $discount->type->value; + $this->code = (string) $discount->code; + $this->valueType = $discount->value_type->value; + $this->valueAmount = $discount->value_type === DiscountValueType::Percent + ? (string) $discount->value_amount + : number_format($discount->value_amount / 100, 2, '.', ''); + + $rules = $discount->rules_json ?? []; + $minimum = $rules['min_purchase_amount'] ?? $rules['minimum_purchase_amount'] ?? null; + $this->minimumPurchaseAmount = $minimum !== null ? number_format(((int) $minimum) / 100, 2, '.', '') : ''; + $this->specificProductIds = array_map('intval', (array) ($rules['applicable_product_ids'] ?? [])); + $this->specificCollectionIds = array_map('intval', (array) ($rules['applicable_collection_ids'] ?? [])); + $this->onePerCustomer = (bool) ($rules['once_per_customer'] ?? false); + $this->usageLimit = $discount->usage_limit !== null ? (string) $discount->usage_limit : ''; + $this->startsAt = $discount->starts_at?->format('Y-m-d\TH:i') ?? ''; + $this->endsAt = $discount->ends_at?->format('Y-m-d\TH:i') ?? ''; + $this->isActive = $discount->status === DiscountStatus::Active; + } else { + Gate::authorize('create', Discount::class); + + $this->discount = null; + $this->startsAt = now()->format('Y-m-d\TH:i'); + } + } + + public function generateCode(): void + { + $this->code = strtoupper(Str::random(8)); + } + + public function addProduct(int $productId): void + { + if (! in_array($productId, $this->specificProductIds, true)) { + $this->specificProductIds[] = $productId; + } + + $this->productSearch = ''; + } + + public function removeProduct(int $productId): void + { + $this->specificProductIds = array_values(array_filter( + $this->specificProductIds, + fn (int $id): bool => $id !== $productId + )); + } + + public function addCollection(int $collectionId): void + { + if (! in_array($collectionId, $this->specificCollectionIds, true)) { + $this->specificCollectionIds[] = $collectionId; + } + + $this->collectionSearch = ''; + } + + public function removeCollection(int $collectionId): void + { + $this->specificCollectionIds = array_values(array_filter( + $this->specificCollectionIds, + fn (int $id): bool => $id !== $collectionId + )); + } + + public function save(): mixed + { + if ($this->discount !== null) { + Gate::authorize('update', $this->discount); + } else { + Gate::authorize('create', Discount::class); + } + + $validated = $this->validate([ + 'type' => 'required|in:code,automatic', + 'code' => 'required_if:type,code|nullable|string|max:50', + 'valueType' => 'required|in:percent,fixed,free_shipping', + 'valueAmount' => 'required_unless:valueType,free_shipping|nullable|numeric|min:0', + 'minimumPurchaseAmount' => 'nullable|numeric|min:0', + 'usageLimit' => 'nullable|integer|min:1', + 'startsAt' => 'required|date', + 'endsAt' => 'nullable|date|after_or_equal:startsAt', + ]); + + if ($validated['type'] === 'code') { + $exists = Discount::query() + ->where('store_id', $this->store()->getKey()) + ->where('code', $validated['code']) + ->when($this->discount !== null, fn ($query) => $query->whereKeyNot($this->discount->getKey())) + ->exists(); + + if ($exists) { + $this->addError('code', 'The code has already been taken.'); + + return null; + } + } + + $valueAmount = $validated['valueType'] === 'free_shipping' + ? 0 + : ($validated['valueType'] === 'percent' + ? (int) $validated['valueAmount'] + : (int) round(((float) $validated['valueAmount']) * 100)); + + $rules = []; + + if ($validated['minimumPurchaseAmount'] !== '' && $validated['minimumPurchaseAmount'] !== null) { + $rules['min_purchase_amount'] = (int) round(((float) $validated['minimumPurchaseAmount']) * 100); + } + + if ($this->specificProductIds !== []) { + $rules['applicable_product_ids'] = array_map('intval', $this->specificProductIds); + } + + if ($this->specificCollectionIds !== []) { + $rules['applicable_collection_ids'] = array_map('intval', $this->specificCollectionIds); + } + + if ($this->onePerCustomer) { + $rules['once_per_customer'] = true; + } + + $data = [ + 'type' => DiscountType::from($validated['type']), + 'code' => $validated['type'] === 'code' ? $validated['code'] : null, + 'value_type' => DiscountValueType::from($validated['valueType']), + 'value_amount' => $valueAmount, + 'starts_at' => $validated['startsAt'], + 'ends_at' => $validated['endsAt'] !== '' && $validated['endsAt'] !== null ? $validated['endsAt'] : null, + 'usage_limit' => $validated['usageLimit'] !== '' && $validated['usageLimit'] !== null ? (int) $validated['usageLimit'] : null, + 'rules_json' => $rules, + 'status' => $this->isActive ? DiscountStatus::Active : DiscountStatus::Disabled, + ]; + + if ($this->discount !== null) { + $this->discount->update($data); + $this->toast('success', 'Discount saved.'); + + return null; + } + + Discount::query()->create([ + 'store_id' => $this->store()->getKey(), + 'usage_count' => 0, + ...$data, + ]); + + $this->flashToast('success', 'Discount saved.'); + + return redirect()->route('admin.discounts.index'); + } + + public function render() + { + $storeId = $this->store()->getKey(); + + $productResults = $this->productSearch !== '' + ? Product::query() + ->where('store_id', $storeId) + ->whereNotIn('id', $this->specificProductIds) + ->where('title', 'like', '%'.$this->productSearch.'%') + ->orderBy('title') + ->limit(10) + ->get() + : collect(); + + $collectionResults = $this->collectionSearch !== '' + ? Collection::query() + ->where('store_id', $storeId) + ->whereNotIn('id', $this->specificCollectionIds) + ->where('title', 'like', '%'.$this->collectionSearch.'%') + ->orderBy('title') + ->limit(10) + ->get() + : collect(); + + $selectedProducts = $this->specificProductIds === [] + ? collect() + : Product::query()->where('store_id', $storeId)->whereIn('id', $this->specificProductIds)->orderBy('title')->get(); + + $selectedCollections = $this->specificCollectionIds === [] + ? collect() + : Collection::query()->where('store_id', $storeId)->whereIn('id', $this->specificCollectionIds)->orderBy('title')->get(); + + return view('livewire.admin.discounts.form', [ + 'productResults' => $productResults, + 'collectionResults' => $collectionResults, + 'selectedProducts' => $selectedProducts, + 'selectedCollections' => $selectedCollections, + 'isEditing' => $this->discount !== null, + ])->layout('livewire.admin.layout.app', ['title' => $this->discount !== null ? ($this->discount->code ?? 'Automatic discount') : 'Create discount']); + } +} diff --git a/app/Livewire/Admin/Discounts/Index.php b/app/Livewire/Admin/Discounts/Index.php new file mode 100644 index 00000000..bdf25046 --- /dev/null +++ b/app/Livewire/Admin/Discounts/Index.php @@ -0,0 +1,80 @@ +resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function confirmDelete(int $id): void + { + $this->deletingId = $id; + $this->showDeleteModal = true; + } + + public function deleteDiscount(): void + { + $discount = Discount::query() + ->where('store_id', $this->store()->getKey()) + ->whereKey($this->deletingId) + ->firstOrFail(); + + Gate::authorize('delete', $discount); + + $discount->delete(); + + $this->showDeleteModal = false; + $this->deletingId = null; + $this->toast('success', 'Discount deleted.'); + } + + public function render() + { + $query = Discount::query() + ->where('store_id', $this->store()->getKey()) + ->orderByDesc('updated_at'); + + if ($this->search !== '') { + $query->where('code', 'like', '%'.$this->search.'%'); + } + + if ($this->statusFilter === 'scheduled') { + $query->where('starts_at', '>', now()); + } elseif ($this->statusFilter !== 'all') { + $query->where('status', $this->statusFilter); + } + + return view('livewire.admin.discounts.index', [ + 'discounts' => $query->paginate(25), + ])->layout('livewire.admin.layout.app', ['title' => 'Discounts']); + } +} diff --git a/app/Livewire/Admin/Inventory/Index.php b/app/Livewire/Admin/Inventory/Index.php new file mode 100644 index 00000000..28c77114 --- /dev/null +++ b/app/Livewire/Admin/Inventory/Index.php @@ -0,0 +1,110 @@ +resetPage(); + } + + public function updatedStockFilter(): void + { + $this->resetPage(); + } + + public function updateQuantity(int $itemId, int $quantity): void + { + $item = InventoryItem::query() + ->where('store_id', $this->store()->getKey()) + ->whereKey($itemId) + ->firstOrFail(); + + Gate::authorize('viewAny', Product::class); + + $this->authorizeMutation(); + + app(InventoryService::class)->adjust($item->refresh(), max(0, $quantity)); + + $this->toast('success', 'Inventory updated.'); + } + + public function updatePolicy(int $itemId, string $policy): void + { + $item = InventoryItem::query() + ->where('store_id', $this->store()->getKey()) + ->whereKey($itemId) + ->firstOrFail(); + + $this->authorizeMutation(); + + app(InventoryService::class)->adjust( + $item->refresh(), + null, + InventoryPolicy::from($policy) + ); + + $this->toast('success', 'Inventory policy updated.'); + } + + public function render() + { + $query = InventoryItem::query() + ->where('inventory_items.store_id', $this->store()->getKey()) + ->with(['variant.product', 'variant.optionValues']) + ->join('product_variants', 'product_variants.id', '=', 'inventory_items.variant_id') + ->join('products', 'products.id', '=', 'product_variants.product_id') + ->select('inventory_items.*') + ->orderBy('products.title') + ->orderBy('product_variants.position'); + + if ($this->search !== '') { + $query->where(function ($inner): void { + $inner->where('products.title', 'like', '%'.$this->search.'%') + ->orWhere('product_variants.sku', 'like', '%'.$this->search.'%'); + }); + } + + if ($this->stockFilter === 'in_stock') { + $query->where('inventory_items.quantity_on_hand', '>', 5); + } elseif ($this->stockFilter === 'low_stock') { + $query->whereBetween('inventory_items.quantity_on_hand', [1, 5]); + } elseif ($this->stockFilter === 'out_of_stock') { + $query->where('inventory_items.quantity_on_hand', '<=', 0); + } + + return view('livewire.admin.inventory.index', [ + 'inventoryItems' => $query->paginate(25), + ])->layout('livewire.admin.layout.app', ['title' => 'Inventory']); + } + + protected function authorizeMutation(): void + { + $user = auth()->user(); + + abort_if($user === null, 403); + abort_if(! $this->isOwnerAdminOrStaff($user, $this->store()->getKey()), 403); + } +} diff --git a/app/Livewire/Admin/Layout/Sidebar.php b/app/Livewire/Admin/Layout/Sidebar.php new file mode 100644 index 00000000..c4a1e3f6 --- /dev/null +++ b/app/Livewire/Admin/Layout/Sidebar.php @@ -0,0 +1,22 @@ +collapsed = ! $this->collapsed; + } + + public function render() + { + return view('livewire.admin.layout.sidebar', [ + 'currentRoute' => request()->route()?->getName() ?? '', + ]); + } +} diff --git a/app/Livewire/Admin/Layout/TopBar.php b/app/Livewire/Admin/Layout/TopBar.php new file mode 100644 index 00000000..30ea0efe --- /dev/null +++ b/app/Livewire/Admin/Layout/TopBar.php @@ -0,0 +1,37 @@ +user(); + + abort_if($user === null, 403); + + $isMember = $user->stores()->where('stores.id', $storeId)->exists(); + + abort_if(! $isMember, 403); + + session()->put('current_store_id', $storeId); + + return redirect()->route('admin.dashboard'); + } + + public function render() + { + $user = auth()->user(); + + return view('livewire.admin.layout.top-bar', [ + 'currentStore' => $this->store(), + 'stores' => $user ? $user->stores()->orderBy('name')->get() : collect(), + 'user' => $user, + ]); + } +} diff --git a/app/Livewire/Admin/Navigation/Index.php b/app/Livewire/Admin/Navigation/Index.php new file mode 100644 index 00000000..3140f32e --- /dev/null +++ b/app/Livewire/Admin/Navigation/Index.php @@ -0,0 +1,227 @@ +> */ + public array $menuItems = []; + + public ?int $editingItemIndex = null; + + public string $itemLabel = ''; + + public string $itemType = 'link'; + + public string $itemUrl = ''; + + public ?int $itemResourceId = null; + + public bool $showItemModal = false; + + public function mount(): void + { + Gate::authorize('viewAny', NavigationMenu::class); + } + + public function selectMenu(int $menuId): void + { + $menu = $this->findMenu($menuId); + + $this->editingMenuId = $menu->getKey(); + $this->menuItems = $menu->navigationItems() + ->orderBy('position') + ->orderBy('id') + ->get() + ->map(fn (NavigationItem $item): array => [ + 'id' => $item->getKey(), + 'label' => $item->label, + 'type' => $item->type->value, + 'url' => (string) $item->url, + 'resource_id' => $item->resource_id, + ]) + ->all(); + } + + public function addItem(): void + { + Gate::authorize('manage', NavigationMenu::class); + + $this->editingItemIndex = null; + $this->itemLabel = ''; + $this->itemType = 'link'; + $this->itemUrl = ''; + $this->itemResourceId = null; + $this->showItemModal = true; + } + + public function editItem(int $index): void + { + Gate::authorize('manage', NavigationMenu::class); + + $item = $this->menuItems[$index] ?? null; + + abort_if($item === null, 404); + + $this->editingItemIndex = $index; + $this->itemLabel = $item['label']; + $this->itemType = $item['type']; + $this->itemUrl = $item['url']; + $this->itemResourceId = $item['resource_id']; + $this->showItemModal = true; + } + + public function saveItem(): void + { + Gate::authorize('manage', NavigationMenu::class); + + $validated = $this->validate([ + 'itemLabel' => 'required|string|max:255', + 'itemType' => 'required|in:link,page,collection,product', + 'itemUrl' => 'required_if:itemType,link|nullable|string|max:2048', + 'itemResourceId' => 'required_unless:itemType,link|nullable|integer|min:1', + ]); + + $entry = [ + 'id' => $this->editingItemIndex !== null + ? ($this->menuItems[$this->editingItemIndex]['id'] ?? null) + : null, + 'label' => $validated['itemLabel'], + 'type' => $validated['itemType'], + 'url' => $validated['itemType'] === 'link' ? $validated['itemUrl'] : null, + 'resource_id' => $validated['itemType'] === 'link' ? null : $validated['itemResourceId'], + ]; + + if ($this->editingItemIndex !== null) { + $this->menuItems[$this->editingItemIndex] = $entry; + } else { + $this->menuItems[] = $entry; + } + + $this->showItemModal = false; + } + + public function removeItem(int $index): void + { + Gate::authorize('manage', NavigationMenu::class); + + unset($this->menuItems[$index]); + $this->menuItems = array_values($this->menuItems); + } + + public function moveItemUp(int $index): void + { + if ($index > 0) { + [$this->menuItems[$index - 1], $this->menuItems[$index]] = [$this->menuItems[$index], $this->menuItems[$index - 1]]; + } + } + + public function moveItemDown(int $index): void + { + if ($index < count($this->menuItems) - 1) { + [$this->menuItems[$index + 1], $this->menuItems[$index]] = [$this->menuItems[$index], $this->menuItems[$index + 1]]; + } + } + + /** + * @param array $order Item indexes in the new order. + */ + public function reorderItems(array $order): void + { + Gate::authorize('manage', NavigationMenu::class); + + $reordered = []; + + foreach ($order as $index) { + if (isset($this->menuItems[(int) $index])) { + $reordered[] = $this->menuItems[(int) $index]; + } + } + + if (count($reordered) === count($this->menuItems)) { + $this->menuItems = $reordered; + } + } + + public function saveMenu(): void + { + Gate::authorize('manage', NavigationMenu::class); + + abort_if($this->editingMenuId === null, 404); + + $menu = $this->findMenu($this->editingMenuId); + $keptIds = []; + + foreach (array_values($this->menuItems) as $position => $entry) { + $data = [ + 'type' => NavigationItemType::from($entry['type']), + 'label' => $entry['label'], + 'url' => $entry['url'], + 'resource_id' => $entry['resource_id'], + 'position' => $position, + ]; + + if (! empty($entry['id'])) { + $item = $menu->navigationItems()->whereKey($entry['id'])->first(); + + if ($item !== null) { + $item->update($data); + $keptIds[] = $item->getKey(); + + continue; + } + } + + $keptIds[] = $menu->navigationItems()->create($data)->getKey(); + } + + $menu->navigationItems()->whereNotIn('id', $keptIds)->delete(); + + $this->selectMenu($menu->getKey()); + $this->toast('success', 'Navigation saved.'); + } + + public function render() + { + $storeId = $this->store()->getKey(); + + $editingMenu = $this->editingMenuId !== null + ? NavigationMenu::query()->where('store_id', $storeId)->whereKey($this->editingMenuId)->first() + : null; + + $resources = match ($this->itemType) { + 'page' => Page::query()->where('store_id', $storeId)->orderBy('title')->get(['id', 'title']), + 'collection' => Collection::query()->where('store_id', $storeId)->orderBy('title')->get(['id', 'title']), + 'product' => Product::query()->where('store_id', $storeId)->orderBy('title')->get(['id', 'title']), + default => collect(), + }; + + return view('livewire.admin.navigation.index', [ + 'menus' => NavigationMenu::query()->where('store_id', $storeId)->orderBy('id')->get(), + 'editingMenu' => $editingMenu, + 'resources' => $resources, + ])->layout('livewire.admin.layout.app', ['title' => 'Navigation']); + } + + protected function findMenu(int $menuId): NavigationMenu + { + return NavigationMenu::query() + ->where('store_id', $this->store()->getKey()) + ->whereKey($menuId) + ->firstOrFail(); + } +} diff --git a/app/Livewire/Admin/Orders/Index.php b/app/Livewire/Admin/Orders/Index.php new file mode 100644 index 00000000..b8b33978 --- /dev/null +++ b/app/Livewire/Admin/Orders/Index.php @@ -0,0 +1,77 @@ +resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function sortBy(string $field): void + { + if ($this->sortField === $field) { + $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + $this->sortField = $field; + $this->sortDirection = 'desc'; + } + } + + public function render() + { + $query = Order::query() + ->where('store_id', $this->store()->getKey()) + ->with('customer'); + + if ($this->search !== '') { + $query->where(function ($inner): void { + $inner->where('order_number', 'like', '%'.$this->search.'%') + ->orWhere('email', 'like', '%'.$this->search.'%'); + }); + } + + if ($this->statusFilter !== 'all') { + $query->where('status', $this->statusFilter); + } + + $sortable = ['placed_at', 'total_amount', 'order_number']; + + if (in_array($this->sortField, $sortable, true)) { + $query->orderBy($this->sortField, $this->sortDirection); + } else { + $query->orderByDesc('placed_at'); + } + + return view('livewire.admin.orders.index', [ + 'orders' => $query->paginate(25), + ])->layout('livewire.admin.layout.app', ['title' => 'Orders']); + } +} diff --git a/app/Livewire/Admin/Orders/Show.php b/app/Livewire/Admin/Orders/Show.php new file mode 100644 index 00000000..9eacef03 --- /dev/null +++ b/app/Livewire/Admin/Orders/Show.php @@ -0,0 +1,236 @@ + order_line_id => quantity */ + public array $fulfillmentLines = []; + + public string $trackingCompany = ''; + + public string $trackingNumber = ''; + + public string $trackingUrl = ''; + + /** @var array */ + public array $refundSelected = []; + + /** @var array */ + public array $refundLines = []; + + public string $refundAmount = ''; + + public string $refundReason = ''; + + public bool $restock = false; + + public bool $showFulfillmentModal = false; + + public bool $showRefundModal = false; + + public function mount(Order $order): void + { + abort_if($order->store_id !== app(\App\Tenancy\CurrentStore::class)->id(), 404); + + Gate::authorize('view', $order); + + $this->order = $order; + } + + public function confirmPayment(): void + { + Gate::authorize('confirmPayment', $this->order); + + try { + $this->order = app(OrderService::class)->confirmBankTransferPayment($this->order); + $this->toast('success', 'Payment confirmed.'); + } catch (\Throwable $exception) { + $this->toast('error', $exception->getMessage()); + } + } + + public function openFulfillmentModal(): void + { + Gate::authorize('createFulfillment', $this->order); + + $this->fulfillmentLines = []; + + foreach ($this->order->orderLines as $line) { + $unfulfilled = $line->quantity - $line->fulfillmentLines->sum('quantity'); + + if ($unfulfilled > 0) { + $this->fulfillmentLines[$line->getKey()] = $unfulfilled; + } + } + + $this->showFulfillmentModal = true; + } + + public function createFulfillment(): void + { + Gate::authorize('createFulfillment', $this->order); + + $lines = array_filter( + array_map('intval', $this->fulfillmentLines), + fn (int $quantity): bool => $quantity > 0 + ); + + try { + app(FulfillmentService::class)->create($this->order, $lines, [ + 'tracking_company' => $this->trackingCompany !== '' ? $this->trackingCompany : null, + 'tracking_number' => $this->trackingNumber !== '' ? $this->trackingNumber : null, + 'tracking_url' => $this->trackingUrl !== '' ? $this->trackingUrl : null, + ]); + + $this->showFulfillmentModal = false; + $this->fulfillmentLines = []; + $this->trackingCompany = ''; + $this->trackingNumber = ''; + $this->trackingUrl = ''; + $this->order = $this->order->refresh(); + $this->toast('success', 'Fulfillment created.'); + } catch (\Throwable $exception) { + $this->toast('error', $exception->getMessage()); + } + } + + public function markAsShipped(int $fulfillmentId): void + { + $fulfillment = $this->order->fulfillments()->whereKey($fulfillmentId)->firstOrFail(); + + Gate::authorize('update', $this->order); + + try { + app(FulfillmentService::class)->markAsShipped($fulfillment); + $this->order = $this->order->refresh(); + $this->toast('success', 'Fulfillment marked as shipped.'); + } catch (\Throwable $exception) { + $this->toast('error', $exception->getMessage()); + } + } + + public function markAsDelivered(int $fulfillmentId): void + { + $fulfillment = $this->order->fulfillments()->whereKey($fulfillmentId)->firstOrFail(); + + Gate::authorize('update', $this->order); + + try { + app(FulfillmentService::class)->markAsDelivered($fulfillment); + $this->order = $this->order->refresh(); + $this->toast('success', 'Fulfillment marked as delivered.'); + } catch (\Throwable $exception) { + $this->toast('error', $exception->getMessage()); + } + } + + public function openRefundModal(): void + { + Gate::authorize('createRefund', $this->order); + + $this->refundSelected = []; + $this->refundLines = []; + $this->refundAmount = ''; + $this->refundReason = ''; + $this->restock = false; + $this->showRefundModal = true; + } + + public function createRefund(): void + { + Gate::authorize('createRefund', $this->order); + + $lineItems = []; + + foreach ($this->refundSelected as $lineId => $selected) { + if ($selected) { + $line = $this->order->orderLines->firstWhere('id', (int) $lineId); + + if ($line !== null) { + $quantity = (int) ($this->refundLines[$lineId] ?? $line->quantity); + $lineItems[(int) $lineId] = max(1, min($quantity, $line->quantity)); + } + } + } + + $amount = 0; + + if ($this->refundAmount !== '') { + $amount = (int) round(((float) $this->refundAmount) * 100); + } else { + foreach ($lineItems as $lineId => $quantity) { + $line = $this->order->orderLines->firstWhere('id', $lineId); + + if ($line !== null) { + $amount += $line->unit_price_amount * $quantity; + } + } + } + + try { + app(RefundService::class)->create( + $this->order, + max(1, $amount), + $lineItems === [] ? null : $lineItems, + $this->refundReason !== '' ? $this->refundReason : null, + $this->restock, + ); + + $this->showRefundModal = false; + $this->order = $this->order->refresh(); + $this->toast('success', 'Refund issued.'); + } catch (\Throwable $exception) { + $this->toast('error', $exception->getMessage()); + } + } + + public function render() + { + $order = Order::query() + ->where('store_id', $this->store()->getKey()) + ->whereKey($this->order->getKey()) + ->with([ + 'orderLines.variant.product.productMedia', + 'orderLines.fulfillmentLines', + 'payments', + 'refunds', + 'fulfillments.fulfillmentLines.orderLine', + 'customer', + ]) + ->firstOrFail(); + + $this->order = $order; + + $canFulfill = in_array($order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true) + && $order->fulfillment_status !== FulfillmentStatus::Fulfilled; + + $showConfirmPayment = $order->payment_method === PaymentMethod::BankTransfer + && $order->financial_status === FinancialStatus::Pending; + + $showRefund = in_array($order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true); + + return view('livewire.admin.orders.show', [ + 'order' => $order, + 'canFulfill' => $canFulfill, + 'showConfirmPayment' => $showConfirmPayment, + 'showRefund' => $showRefund, + 'payment' => $order->payments->sortByDesc('id')->first(), + ])->layout('livewire.admin.layout.app', ['title' => 'Order '.$order->order_number]); + } +} diff --git a/app/Livewire/Admin/Pages/Form.php b/app/Livewire/Admin/Pages/Form.php new file mode 100644 index 00000000..7dbb9789 --- /dev/null +++ b/app/Livewire/Admin/Pages/Form.php @@ -0,0 +1,134 @@ +exists) { + abort_if($page->store_id !== $this->store()->getKey(), 404); + + Gate::authorize('view', $page); + + $this->page = $page; + $this->title = $page->title; + $this->handle = $page->handle; + $this->bodyHtml = (string) $page->body_html; + $this->status = $page->status->value; + $this->publishedAt = $page->published_at?->format('Y-m-d\TH:i'); + } else { + Gate::authorize('create', Page::class); + + $this->page = null; + } + } + + public function updatedTitle(): void + { + if ($this->page === null && $this->handle === '') { + $this->handle = Str::slug($this->title); + } + } + + public function save(): mixed + { + if ($this->page !== null) { + Gate::authorize('update', $this->page); + } else { + Gate::authorize('create', Page::class); + } + + $validated = $this->validate([ + 'title' => 'required|string|max:255', + 'handle' => 'nullable|string|max:255', + 'bodyHtml' => 'nullable|string|max:65535', + 'status' => 'required|in:draft,published,archived', + 'publishedAt' => 'nullable|date', + ]); + + $handle = $validated['handle'] !== '' && $validated['handle'] !== null + ? $validated['handle'] + : Str::slug($validated['title']); + + $exists = Page::query() + ->where('store_id', $this->store()->getKey()) + ->where('handle', $handle) + ->when($this->page !== null, fn ($query) => $query->whereKeyNot($this->page->getKey())) + ->exists(); + + if ($exists) { + $this->addError('handle', 'The handle has already been taken.'); + + return null; + } + + $status = PageStatus::from($validated['status']); + + $data = [ + 'title' => $validated['title'], + 'handle' => $handle, + 'body_html' => $validated['bodyHtml'] !== '' ? $validated['bodyHtml'] : null, + 'status' => $status, + 'published_at' => $validated['publishedAt'] !== '' && $validated['publishedAt'] !== null + ? $validated['publishedAt'] + : ($status === PageStatus::Published ? now() : null), + ]; + + if ($this->page !== null) { + $this->page->update($data); + $this->toast('success', 'Page saved.'); + + return null; + } + + Page::query()->create(['store_id' => $this->store()->getKey(), ...$data]); + + $this->flashToast('success', 'Page saved.'); + + return redirect()->route('admin.pages.index'); + } + + public function deletePage(): mixed + { + abort_if($this->page === null, 404); + + Gate::authorize('delete', $this->page); + + $this->page->delete(); + + $this->flashToast('success', 'Page deleted.'); + + return redirect()->route('admin.pages.index'); + } + + public function render() + { + return view('livewire.admin.pages.form', [ + 'isEditing' => $this->page !== null, + ])->layout('livewire.admin.layout.app', ['title' => $this->page !== null ? $this->page->title : 'Add page']); + } +} diff --git a/app/Livewire/Admin/Pages/Index.php b/app/Livewire/Admin/Pages/Index.php new file mode 100644 index 00000000..f5ba9d76 --- /dev/null +++ b/app/Livewire/Admin/Pages/Index.php @@ -0,0 +1,67 @@ +resetPage(); + } + + public function confirmDelete(int $id): void + { + $this->deletingId = $id; + $this->showDeleteModal = true; + } + + public function deletePage(): void + { + $page = Page::query() + ->where('store_id', $this->store()->getKey()) + ->whereKey($this->deletingId) + ->firstOrFail(); + + Gate::authorize('delete', $page); + + $page->delete(); + + $this->showDeleteModal = false; + $this->deletingId = null; + $this->toast('success', 'Page deleted.'); + } + + public function render() + { + $query = Page::query() + ->where('store_id', $this->store()->getKey()) + ->orderByDesc('updated_at'); + + if ($this->search !== '') { + $query->where('title', 'like', '%'.$this->search.'%'); + } + + return view('livewire.admin.pages.index', [ + 'pages' => $query->paginate(25), + ])->layout('livewire.admin.layout.app', ['title' => 'Pages']); + } +} diff --git a/app/Livewire/Admin/Products/Form.php b/app/Livewire/Admin/Products/Form.php new file mode 100644 index 00000000..966e4bbc --- /dev/null +++ b/app/Livewire/Admin/Products/Form.php @@ -0,0 +1,525 @@ + */ + public array $collectionIds = []; + + /** @var array */ + public array $options = []; + + /** @var array> */ + public array $variants = []; + + /** @var array> */ + public array $media = []; + + /** @var array */ + public array $newMedia = []; + + public bool $showDeleteModal = false; + + public bool $seoOpen = false; + + public function mount(?Product $product = null): void + { + if ($product !== null && $product->exists) { + abort_if($product->store_id !== $this->store()->getKey(), 404); + + Gate::authorize('view', $product); + + $this->product = $product->load([ + 'productOptions.productOptionValues', + 'productVariants.inventoryItem', + 'productVariants.optionValues.productOption', + 'productMedia', + 'collections', + ]); + + $this->title = $product->title; + $this->descriptionHtml = (string) $product->description_html; + $this->status = $product->status->value; + $this->vendor = (string) $product->vendor; + $this->productType = (string) $product->product_type; + $this->tags = implode(', ', $product->tags ?? []); + $this->handle = $product->handle; + $this->publishedAt = $product->published_at?->format('Y-m-d\TH:i'); + $this->collectionIds = $product->collections->pluck('id')->map(fn ($id): int => (int) $id)->all(); + + $this->options = $product->productOptions + ->sortBy('position') + ->map(fn ($option): array => [ + 'name' => $option->name, + 'values' => $option->productOptionValues->sortBy('position')->pluck('value')->implode(', '), + ]) + ->values() + ->all(); + + $this->variants = $product->productVariants + ->sortBy('position') + ->map(fn ($variant): array => [ + 'id' => $variant->getKey(), + 'label' => $variant->optionValues->sortBy('product_option_id')->pluck('value')->implode(' / ') ?: 'Default', + 'sku' => (string) $variant->sku, + 'price' => number_format($variant->price_amount / 100, 2, '.', ''), + 'compare_at' => $variant->compare_at_amount !== null ? number_format($variant->compare_at_amount / 100, 2, '.', '') : '', + 'quantity' => $variant->inventoryItem?->quantity_on_hand ?? 0, + 'requires_shipping' => (bool) $variant->requires_shipping, + 'is_default' => (bool) $variant->is_default, + 'option_values' => $variant->optionValues->map(fn ($value): array => [ + 'option_name' => $value->productOption?->name ?? '', + 'value' => $value->value, + ])->all(), + ]) + ->values() + ->all(); + + $this->media = $product->productMedia + ->sortBy('position') + ->map(fn ($item): array => [ + 'id' => $item->getKey(), + 'url' => Storage::disk('public')->url($item->storage_key), + 'alt_text' => (string) $item->alt_text, + 'position' => $item->position, + ]) + ->values() + ->all(); + } else { + Gate::authorize('create', Product::class); + + $this->product = null; + $this->variants = [ + [ + 'id' => null, + 'label' => 'Default', + 'sku' => '', + 'price' => '', + 'compare_at' => '', + 'quantity' => 0, + 'requires_shipping' => true, + 'is_default' => true, + 'option_values' => [], + ], + ]; + } + } + + public function updatedTitle(): void + { + if ($this->product === null && $this->handle === '') { + $this->handle = Str::slug($this->title); + } + } + + public function updatedOptions(): void + { + $this->generateVariants(); + } + + public function updatedNewMedia(): void + { + $this->validate(['newMedia.*' => 'image|max:10240']); + + if ($this->product !== null) { + $this->uploadMedia(); + } + } + + public function addOption(): void + { + $this->options[] = ['name' => '', 'values' => '']; + } + + public function removeOption(int $index): void + { + unset($this->options[$index]); + $this->options = array_values($this->options); + $this->generateVariants(); + } + + public function addOptionValue(int $optionIndex, string $value = ''): void + { + $value = trim($value); + + if ($value === '' || ! isset($this->options[$optionIndex])) { + return; + } + + $current = $this->options[$optionIndex]['values']; + $this->options[$optionIndex]['values'] = $current === '' ? $value : $current.', '.$value; + $this->generateVariants(); + } + + public function removeOptionValue(int $optionIndex, string $value): void + { + if (! isset($this->options[$optionIndex])) { + return; + } + + $values = array_values(array_filter( + array_map(trim(...), explode(',', $this->options[$optionIndex]['values'])), + fn (string $existing): bool => $existing !== '' && $existing !== $value, + )); + + $this->options[$optionIndex]['values'] = implode(', ', $values); + $this->generateVariants(); + } + + public function generateVariants(): void + { + $parsed = []; + + foreach ($this->options as $option) { + $name = trim($option['name']); + $values = array_values(array_filter(array_map(trim(...), explode(',', $option['values'])))); + + if ($name !== '' && $values !== []) { + $parsed[] = ['name' => $name, 'values' => $values]; + } + } + + if ($parsed === []) { + if ($this->variants === []) { + $this->variants = [[ + 'id' => null, 'label' => 'Default', 'sku' => '', 'price' => '', + 'compare_at' => '', 'quantity' => 0, 'requires_shipping' => true, + 'is_default' => true, 'option_values' => [], + ]]; + } + + return; + } + + $combos = [[]]; + + foreach ($parsed as $option) { + $next = []; + + foreach ($combos as $combo) { + foreach ($option['values'] as $value) { + $next[] = [...$combo, ['option_name' => $option['name'], 'value' => $value]]; + } + } + + $combos = $next; + } + + $existing = []; + + foreach ($this->variants as $variant) { + $existing[$variant['label']] = $variant; + } + + $this->variants = array_map(function (array $combo, int $index) use ($existing): array { + $label = implode(' / ', array_column($combo, 'value')); + $prior = $existing[$label] ?? []; + + return [ + 'id' => $prior['id'] ?? null, + 'label' => $label, + 'sku' => $prior['sku'] ?? '', + 'price' => $prior['price'] ?? '', + 'compare_at' => $prior['compare_at'] ?? '', + 'quantity' => $prior['quantity'] ?? 0, + 'requires_shipping' => $prior['requires_shipping'] ?? true, + 'is_default' => $index === 0, + 'option_values' => $combo, + ]; + }, $combos, array_keys($combos)); + } + + public function uploadMedia(): void + { + if ($this->product === null || $this->newMedia === []) { + return; + } + + Gate::authorize('update', $this->product); + + $media = app(MediaService::class); + + foreach ($this->newMedia as $file) { + $record = $media->upload($this->product->refresh(), $file); + + $this->media[] = [ + 'id' => $record->getKey(), + 'url' => Storage::disk('public')->url($record->storage_key), + 'alt_text' => '', + 'position' => $record->position, + ]; + } + + $this->newMedia = []; + $this->toast('success', 'Media uploaded.'); + } + + public function removeMedia(int $mediaId): void + { + if ($this->product === null) { + return; + } + + Gate::authorize('update', $this->product); + + $record = ProductMedia::query() + ->where('product_id', $this->product->getKey()) + ->whereKey($mediaId) + ->firstOrFail(); + + app(MediaService::class)->deleteMedia($record); + + $this->media = array_values(array_filter($this->media, fn (array $item): bool => $item['id'] !== $mediaId)); + $this->toast('success', 'Media removed.'); + } + + /** + * @param array $order Media IDs in the new order. + */ + public function reorderMedia(array $order): void + { + if ($this->product === null) { + return; + } + + Gate::authorize('update', $this->product); + + $media = app(MediaService::class); + + foreach (array_values($order) as $position => $mediaId) { + $record = ProductMedia::query() + ->where('product_id', $this->product->getKey()) + ->whereKey((int) $mediaId) + ->first(); + + if ($record !== null) { + $media->updateMedia($record, ['position' => $position]); + } + } + + $ordered = []; + + foreach (array_values($order) as $mediaId) { + foreach ($this->media as $item) { + if ($item['id'] === (int) $mediaId) { + $ordered[] = $item; + } + } + } + + $this->media = $ordered === [] ? $this->media : $ordered; + } + + public function updateMediaAlt(int $mediaId, string $alt): void + { + if ($this->product === null) { + return; + } + + Gate::authorize('update', $this->product); + + $record = ProductMedia::query() + ->where('product_id', $this->product->getKey()) + ->whereKey($mediaId) + ->firstOrFail(); + + app(MediaService::class)->updateMedia($record, ['alt_text' => $alt]); + + foreach ($this->media as $index => $item) { + if ($item['id'] === $mediaId) { + $this->media[$index]['alt_text'] = $alt; + } + } + } + + public function save(): mixed + { + if ($this->product !== null) { + Gate::authorize('update', $this->product); + } else { + Gate::authorize('create', Product::class); + } + + $validated = $this->validate([ + 'title' => 'required|string|max:255', + 'descriptionHtml' => 'nullable|string|max:65535', + 'status' => 'required|in:draft,active,archived', + 'vendor' => 'nullable|string|max:255', + 'productType' => 'nullable|string|max:255', + 'tags' => 'nullable|string', + 'handle' => 'nullable|string|max:255', + 'publishedAt' => 'nullable|date', + 'collectionIds' => 'nullable|array', + 'collectionIds.*' => 'integer|exists:collections,id', + 'variants' => 'required|array|min:1', + 'variants.*.sku' => 'nullable|string|max:255', + 'variants.*.price' => 'required|numeric|min:0', + 'variants.*.compare_at' => 'nullable|numeric|min:0', + 'variants.*.quantity' => 'required|integer|min:0', + ]); + + $services = app(ProductService::class); + $payload = $this->servicePayload($validated); + + if ($this->product !== null) { + $product = $services->update($this->product, $payload); + $this->applyPublishedAt($product); + $this->product = $product->refresh(); + $this->toast('success', 'Product saved.'); + + return null; + } + + $product = $services->create($this->store(), $payload); + $this->applyPublishedAt($product); + + foreach ($this->newMedia as $file) { + app(MediaService::class)->upload($product->refresh(), $file); + } + + $this->flashToast('success', 'Product saved.'); + + return redirect()->route('admin.products.index'); + } + + public function deleteProduct(): mixed + { + abort_if($this->product === null, 404); + + Gate::authorize('delete', $this->product); + + $services = app(ProductService::class); + + if ($this->product->status === ProductStatus::Draft) { + $services->delete($this->product); + } else { + $services->archive($this->product); + } + + $this->flashToast('success', 'Product archived.'); + + return redirect()->route('admin.products.index'); + } + + /** + * @return array + */ + public function getIsEditingProperty(): bool + { + return $this->product !== null; + } + + public function render() + { + return view('livewire.admin.products.form', [ + 'availableCollections' => Collection::query() + ->where('store_id', $this->store()->getKey()) + ->orderBy('title') + ->get(), + 'isEditing' => $this->product !== null, + ])->layout('livewire.admin.layout.app', ['title' => $this->product !== null ? $this->product->title : 'Add product']); + } + + /** + * @param array $validated + * @return array + */ + protected function servicePayload(array $validated): array + { + $options = []; + $position = 1; + + foreach ($this->options as $option) { + $name = trim($option['name']); + $values = array_values(array_filter(array_map(trim(...), explode(',', $option['values'])))); + + if ($name === '' || $values === []) { + continue; + } + + $options[] = ['name' => $name, 'position' => $position++, 'values' => $values]; + } + + $variants = []; + + foreach ($validated['variants'] as $index => $variant) { + $entry = [ + 'sku' => $variant['sku'] !== '' ? $variant['sku'] : null, + 'price_amount' => (int) round(((float) $variant['price']) * 100), + 'compare_at_amount' => $variant['compare_at'] !== '' && $variant['compare_at'] !== null + ? (int) round(((float) $variant['compare_at']) * 100) + : null, + 'requires_shipping' => (bool) ($this->variants[$index]['requires_shipping'] ?? true), + 'is_default' => $index === 0, + 'position' => $index + 1, + 'status' => 'active', + 'option_values' => $this->variants[$index]['option_values'] ?? [], + 'inventory' => [ + 'quantity_on_hand' => (int) $variant['quantity'], + 'policy' => 'deny', + ], + ]; + + if (! empty($this->variants[$index]['id'])) { + $entry['id'] = (int) $this->variants[$index]['id']; + } + + $variants[] = $entry; + } + + return [ + 'title' => $validated['title'], + 'description_html' => $validated['descriptionHtml'] !== '' ? $validated['descriptionHtml'] : null, + 'status' => $validated['status'], + 'vendor' => $validated['vendor'] !== '' ? $validated['vendor'] : null, + 'product_type' => $validated['productType'] !== '' ? $validated['productType'] : null, + 'tags' => array_values(array_filter(array_map(trim(...), explode(',', (string) ($validated['tags'] ?? ''))))), + 'handle' => $validated['handle'] !== '' && $validated['handle'] !== null ? $validated['handle'] : Str::slug($validated['title']), + 'collections' => array_map('intval', $validated['collectionIds'] ?? []), + 'options' => $options, + 'variants' => $variants, + ]; + } + + protected function applyPublishedAt(Product $product): void + { + if ($this->publishedAt !== null && $this->publishedAt !== '') { + $product->published_at = $this->publishedAt; + $product->save(); + } + } +} diff --git a/app/Livewire/Admin/Products/Index.php b/app/Livewire/Admin/Products/Index.php new file mode 100644 index 00000000..a13ca754 --- /dev/null +++ b/app/Livewire/Admin/Products/Index.php @@ -0,0 +1,196 @@ + */ + public array $selectedIds = []; + + public bool $selectAll = false; + + public string $sortField = 'updated_at'; + + public string $sortDirection = 'desc'; + + public bool $showBulkDeleteModal = false; + + public function mount(): void + { + Gate::authorize('viewAny', Product::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function updatedTypeFilter(): void + { + $this->resetPage(); + } + + public function sortBy(string $field): void + { + if ($this->sortField === $field) { + $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + $this->sortField = $field; + $this->sortDirection = 'asc'; + } + } + + public function toggleSelectAll(): void + { + if ($this->selectAll) { + $this->selectedIds = $this->products()->pluck('id')->map(fn ($id): int => (int) $id)->all(); + } else { + $this->selectedIds = []; + } + } + + public function bulkSetActive(): void + { + $products = $this->selectedProducts(); + + foreach ($products as $product) { + Gate::authorize('update', $product); + } + + foreach ($products as $product) { + app(ProductService::class)->transitionStatus($product, ProductStatus::Active); + } + + $this->selectedIds = []; + $this->selectAll = false; + $this->toast('success', 'Products set to active.'); + } + + public function bulkArchive(): void + { + $products = $this->selectedProducts(); + + foreach ($products as $product) { + Gate::authorize('archive', $product); + } + + foreach ($products as $product) { + app(ProductService::class)->archive($product); + } + + $this->selectedIds = []; + $this->selectAll = false; + $this->toast('success', 'Products archived.'); + } + + public function confirmBulkDelete(): void + { + $this->showBulkDeleteModal = true; + } + + public function bulkDelete(): void + { + $products = $this->selectedProducts(); + + foreach ($products as $product) { + Gate::authorize('delete', $product); + } + + $services = app(ProductService::class); + + foreach ($products as $product) { + if ($product->status === ProductStatus::Draft) { + $services->delete($product); + } else { + $services->archive($product); + } + } + + $this->showBulkDeleteModal = false; + $this->selectedIds = []; + $this->selectAll = false; + $this->toast('success', 'Products deleted.'); + } + + public function render() + { + return view('livewire.admin.products.index', [ + 'products' => $this->products(), + 'productTypes' => Product::query() + ->where('store_id', $this->store()->getKey()) + ->whereNotNull('product_type') + ->distinct() + ->orderBy('product_type') + ->pluck('product_type'), + ])->layout('livewire.admin.layout.app', ['title' => 'Products']); + } + + protected function products() + { + $query = Product::query() + ->where('store_id', $this->store()->getKey()) + ->with([ + 'productMedia' => fn ($query) => $query->orderBy('position')->orderBy('id'), + 'productVariants.inventoryItem', + ]) + ->withCount('productVariants'); + + if ($this->search !== '') { + $query->where(function ($inner): void { + $inner->where('title', 'like', '%'.$this->search.'%') + ->orWhere('vendor', 'like', '%'.$this->search.'%') + ->orWhere('handle', 'like', '%'.$this->search.'%'); + }); + } + + if ($this->statusFilter !== 'all') { + $query->where('status', $this->statusFilter); + } + + if ($this->typeFilter !== 'all') { + $query->where('product_type', $this->typeFilter); + } + + $sortable = ['title', 'updated_at', 'created_at']; + + if (in_array($this->sortField, $sortable, true)) { + $query->orderBy($this->sortField, $this->sortDirection); + } else { + $query->orderByDesc('updated_at'); + } + + return $query->paginate(25); + } + + /** + * @return \Illuminate\Support\Collection + */ + protected function selectedProducts() + { + return Product::query() + ->where('store_id', $this->store()->getKey()) + ->whereIn('id', $this->selectedIds) + ->get(); + } +} diff --git a/app/Livewire/Admin/Search/Settings.php b/app/Livewire/Admin/Search/Settings.php new file mode 100644 index 00000000..782a8de4 --- /dev/null +++ b/app/Livewire/Admin/Search/Settings.php @@ -0,0 +1,118 @@ + */ + public array $synonymGroups = []; + + public string $stopWords = ''; + + public bool $isReindexing = false; + + public ?int $reindexProgress = null; + + public function mount(): void + { + Gate::authorize('manage-search-settings'); + + $settings = $this->settings(); + $synonyms = is_array($settings->synonyms_json) ? $settings->synonyms_json : []; + $stopWords = is_array($settings->stop_words_json) ? $settings->stop_words_json : []; + + $this->synonymGroups = array_map( + fn ($group): string => is_array($group) ? implode(', ', $group) : (string) $group, + array_values($synonyms) + ); + $this->stopWords = implode(', ', array_map('strval', $stopWords)); + } + + public function addSynonymGroup(): void + { + $this->synonymGroups[] = ''; + } + + public function removeSynonymGroup(int $index): void + { + unset($this->synonymGroups[$index]); + $this->synonymGroups = array_values($this->synonymGroups); + } + + public function save(): void + { + Gate::authorize('manage-search-settings'); + + $validated = $this->validate([ + 'synonymGroups' => 'nullable|array', + 'synonymGroups.*' => 'nullable|string|max:1000', + 'stopWords' => 'nullable|string|max:5000', + ]); + + $synonyms = []; + + foreach ($validated['synonymGroups'] ?? [] as $group) { + $words = array_values(array_filter(array_map(trim(...), explode(',', (string) $group)))); + + if ($words !== []) { + $synonyms[] = $words; + } + } + + $this->settings()->update([ + 'synonyms_json' => $synonyms, + 'stop_words_json' => array_values(array_filter(array_map(trim(...), explode(',', (string) $validated['stopWords'])))), + ]); + + $this->toast('success', 'Search settings saved.'); + } + + public function triggerReindex(): void + { + Gate::authorize('manage-search-settings'); + + $this->isReindexing = true; + $this->reindexProgress = 0; + + ReindexSearch::dispatch($this->store()->getKey()); + + $this->pollReindexStatus(); + } + + public function pollReindexStatus(): void + { + $count = app(SearchService::class)->documentCount($this->store()); + + $this->reindexProgress = 100; + $this->isReindexing = false; + + if ($count >= 0) { + $this->toast('success', "Reindex complete. {$count} documents indexed."); + } + } + + public function render() + { + return view('livewire.admin.search.settings', [ + 'lastIndexedAt' => null, + 'documentCount' => app(SearchService::class)->documentCount($this->store()), + ])->layout('livewire.admin.layout.app', ['title' => 'Search settings']); + } + + protected function settings(): SearchSetting + { + return SearchSetting::query()->firstOrCreate( + ['store_id' => $this->store()->getKey()], + ['synonyms_json' => [], 'stop_words_json' => []] + ); + } +} diff --git a/app/Livewire/Admin/Settings/Domains.php b/app/Livewire/Admin/Settings/Domains.php new file mode 100644 index 00000000..cab049b0 --- /dev/null +++ b/app/Livewire/Admin/Settings/Domains.php @@ -0,0 +1,90 @@ +store()); + } + + public function addDomain(): void + { + Gate::authorize('updateSettings', $this->store()); + + $validated = $this->validate([ + 'newHostname' => 'required|string|max:255|unique:store_domains,hostname', + 'newType' => 'required|in:storefront,admin,api', + ]); + + $store = $this->store(); + $isFirst = ! $store->storeDomains()->exists(); + + $store->storeDomains()->create([ + 'hostname' => strtolower($validated['newHostname']), + 'type' => StoreDomainType::from($validated['newType']), + 'is_primary' => $isFirst, + 'tls_mode' => TlsMode::Managed, + ]); + + $this->newHostname = ''; + $this->newType = 'storefront'; + $this->showAddModal = false; + $this->toast('success', 'Domain added.'); + } + + public function removeDomain(int $domainId): void + { + Gate::authorize('updateSettings', $this->store()); + + $domain = StoreDomain::query() + ->where('store_id', $this->store()->getKey()) + ->whereKey($domainId) + ->firstOrFail(); + + if ($domain->is_primary) { + $this->toast('error', 'The primary domain cannot be removed.'); + + return; + } + + $domain->delete(); + $this->toast('success', 'Domain removed.'); + } + + public function setPrimary(int $domainId): void + { + Gate::authorize('updateSettings', $this->store()); + + $store = $this->store(); + $domain = $store->storeDomains()->whereKey($domainId)->firstOrFail(); + + $store->storeDomains()->update(['is_primary' => false]); + $domain->update(['is_primary' => true]); + + $this->toast('success', 'Primary domain updated.'); + } + + public function render() + { + return view('livewire.admin.settings.domains', [ + 'domains' => $this->store()->storeDomains()->orderByDesc('is_primary')->orderBy('hostname')->get(), + ]); + } +} diff --git a/app/Livewire/Admin/Settings/General.php b/app/Livewire/Admin/Settings/General.php new file mode 100644 index 00000000..fb37f456 --- /dev/null +++ b/app/Livewire/Admin/Settings/General.php @@ -0,0 +1,76 @@ +store()); + + $store = $this->store(); + $this->storeName = $store->name; + $this->storeHandle = $store->handle; + $this->defaultCurrency = $store->default_currency; + $this->defaultLocale = $store->default_locale; + $this->timezone = $store->timezone; + $this->contactEmail = (string) ($store->storeSetting?->settings_json['contact_email'] ?? ''); + } + + public function save(): void + { + Gate::authorize('updateSettings', $this->store()); + + $validated = $this->validate([ + 'storeName' => 'required|string|max:255', + 'defaultCurrency' => 'required|string|size:3', + 'defaultLocale' => 'required|string|max:10', + 'timezone' => 'required|string|timezone', + 'contactEmail' => 'nullable|email|max:255', + ]); + + $store = $this->store(); + $store->update([ + 'name' => $validated['storeName'], + 'default_currency' => strtoupper($validated['defaultCurrency']), + 'default_locale' => $validated['defaultLocale'], + 'timezone' => $validated['timezone'], + ]); + + $settings = $store->storeSetting?->settings_json ?? []; + $settings['store_name'] = $validated['storeName']; + $settings['contact_email'] = $validated['contactEmail'] ?? ''; + + $store->storeSetting()->updateOrCreate( + ['store_id' => $store->getKey()], + ['settings_json' => $settings] + ); + + $this->toast('success', 'Settings saved.'); + } + + public function render() + { + return view('livewire.admin.settings.general', [ + 'timezones' => timezone_identifiers_list(), + ]); + } +} diff --git a/app/Livewire/Admin/Settings/Index.php b/app/Livewire/Admin/Settings/Index.php new file mode 100644 index 00000000..cdba0bb8 --- /dev/null +++ b/app/Livewire/Admin/Settings/Index.php @@ -0,0 +1,26 @@ +store()); + } + + public function render() + { + return view('livewire.admin.settings.index', [ + 'store' => $this->store(), + ])->layout('livewire.admin.layout.app', ['title' => 'Settings']); + } +} diff --git a/app/Livewire/Admin/Settings/Shipping.php b/app/Livewire/Admin/Settings/Shipping.php new file mode 100644 index 00000000..e157d46b --- /dev/null +++ b/app/Livewire/Admin/Settings/Shipping.php @@ -0,0 +1,292 @@ + */ + public array $zoneCountries = []; + + public ?int $editingRateId = null; + + public ?int $rateZoneId = null; + + public string $rateName = ''; + + public string $rateType = 'flat'; + + public string $ratePrice = ''; + + public string $rateMinWeight = ''; + + public string $rateMaxWeight = ''; + + public string $rateMinAmount = ''; + + public string $rateMaxAmount = ''; + + public bool $rateActive = true; + + public string $testCountry = 'DE'; + + public string $testState = ''; + + public string $testCity = ''; + + public string $testZip = ''; + + /** @var array|null */ + public ?array $testResult = null; + + public bool $showZoneModal = false; + + public bool $showRateModal = false; + + public function mount(): void + { + Gate::authorize('viewAny', ShippingZone::class); + } + + public function openZoneModal(?int $zoneId = null): void + { + Gate::authorize('viewAny', ShippingZone::class); + + $this->editingZoneId = $zoneId; + + if ($zoneId !== null) { + $zone = $this->findZone($zoneId); + $this->zoneName = $zone->name; + $this->zoneCountries = array_map('strval', (array) ($zone->countries_json ?? [])); + } else { + $this->zoneName = ''; + $this->zoneCountries = []; + } + + $this->showZoneModal = true; + } + + public function saveZone(): void + { + if ($this->editingZoneId !== null) { + Gate::authorize('update', $this->findZone($this->editingZoneId)); + } else { + Gate::authorize('create', ShippingZone::class); + } + + $validated = $this->validate([ + 'zoneName' => 'required|string|max:255', + 'zoneCountries' => 'required|array|min:1', + 'zoneCountries.*' => 'string|size:2', + ]); + + $data = [ + 'name' => $validated['zoneName'], + 'countries_json' => array_map('strtoupper', $validated['zoneCountries']), + 'regions_json' => [], + ]; + + if ($this->editingZoneId !== null) { + $this->findZone($this->editingZoneId)->update($data); + } else { + ShippingZone::query()->create(['store_id' => $this->store()->getKey(), ...$data]); + } + + $this->showZoneModal = false; + $this->toast('success', 'Shipping zone saved.'); + } + + public function deleteZone(int $zoneId): void + { + $zone = $this->findZone($zoneId); + + Gate::authorize('delete', $zone); + + $zone->shippingRates()->delete(); + $zone->delete(); + + $this->toast('success', 'Shipping zone deleted.'); + } + + public function openRateModal(int $zoneId, ?int $rateId = null): void + { + Gate::authorize('viewAny', ShippingZone::class); + + $this->rateZoneId = $zoneId; + $this->editingRateId = $rateId; + + if ($rateId !== null) { + $rate = $this->findRate($zoneId, $rateId); + $config = is_array($rate->config_json) ? $rate->config_json : []; + $this->rateName = $rate->name; + $this->rateType = $rate->type->value; + $this->ratePrice = isset($config['amount']) ? number_format(((int) $config['amount']) / 100, 2, '.', '') : ''; + $this->rateMinWeight = isset($config['min_weight_g']) ? (string) $config['min_weight_g'] : ''; + $this->rateMaxWeight = isset($config['max_weight_g']) ? (string) $config['max_weight_g'] : ''; + $this->rateMinAmount = isset($config['min_amount']) ? number_format(((int) $config['min_amount']) / 100, 2, '.', '') : ''; + $this->rateMaxAmount = isset($config['max_amount']) ? number_format(((int) $config['max_amount']) / 100, 2, '.', '') : ''; + $this->rateActive = (bool) $rate->is_active; + } else { + $this->rateName = ''; + $this->rateType = 'flat'; + $this->ratePrice = ''; + $this->rateMinWeight = ''; + $this->rateMaxWeight = ''; + $this->rateMinAmount = ''; + $this->rateMaxAmount = ''; + $this->rateActive = true; + } + + $this->showRateModal = true; + } + + public function saveRate(): void + { + $zone = $this->findZone((int) $this->rateZoneId); + + Gate::authorize('update', $zone); + + $validated = $this->validate([ + 'rateName' => 'required|string|max:255', + 'rateType' => 'required|in:flat,weight,price,carrier', + 'ratePrice' => 'required_unless:rateType,carrier|nullable|numeric|min:0', + ]); + + $config = []; + + if ($validated['rateType'] !== 'carrier') { + $config['amount'] = (int) round(((float) $validated['ratePrice']) * 100); + } + + if ($validated['rateType'] === 'weight') { + $config['min_weight_g'] = $this->rateMinWeight !== '' ? (int) $this->rateMinWeight : 0; + $config['max_weight_g'] = $this->rateMaxWeight !== '' ? (int) $this->rateMaxWeight : null; + } + + if ($validated['rateType'] === 'price') { + $config['min_amount'] = $this->rateMinAmount !== '' ? (int) round(((float) $this->rateMinAmount) * 100) : 0; + $config['max_amount'] = $this->rateMaxAmount !== '' ? (int) round(((float) $this->rateMaxAmount) * 100) : null; + } + + $data = [ + 'name' => $validated['rateName'], + 'type' => ShippingRateType::from($validated['rateType']), + 'config_json' => $config, + 'is_active' => $this->rateActive, + ]; + + if ($this->editingRateId !== null) { + $this->findRate($zone->getKey(), $this->editingRateId)->update($data); + } else { + $zone->shippingRates()->create($data); + } + + $this->showRateModal = false; + $this->toast('success', 'Shipping rate saved.'); + } + + public function deleteRate(int $rateId): void + { + $rate = ShippingRate::query()->whereKey($rateId)->firstOrFail(); + $zone = $this->findZone($rate->zone_id); + + Gate::authorize('update', $zone); + + $rate->delete(); + + $this->toast('success', 'Shipping rate deleted.'); + } + + public function toggleRateActive(int $rateId): void + { + $rate = ShippingRate::query()->whereKey($rateId)->firstOrFail(); + $zone = $this->findZone($rate->zone_id); + + Gate::authorize('update', $zone); + + $rate->update(['is_active' => ! $rate->is_active]); + } + + public function testShippingAddress(): void + { + Gate::authorize('viewAny', ShippingZone::class); + + $zone = app(ShippingCalculator::class)->getMatchingZone($this->store(), [ + 'country_code' => $this->testCountry, + 'province_code' => $this->testState, + 'city' => $this->testCity, + 'zip' => $this->testZip, + ]); + + if ($zone === null) { + $this->testResult = ['matched' => false]; + } else { + $this->testResult = [ + 'matched' => true, + 'zone_name' => $zone->name, + 'rates' => $zone->shippingRates()->where('is_active', true)->get() + ->map(fn (ShippingRate $rate): array => [ + 'name' => $rate->name, + 'price' => $this->money((int) (is_array($rate->config_json) ? ($rate->config_json['amount'] ?? 0) : 0)), + ])->all(), + ]; + } + } + + public function render() + { + return view('livewire.admin.settings.shipping', [ + 'zones' => ShippingZone::query() + ->where('store_id', $this->store()->getKey()) + ->with(['shippingRates' => fn ($query) => $query->orderBy('id')]) + ->orderBy('id') + ->get(), + 'countries' => $this->countryList(), + ])->layout('livewire.admin.layout.app', ['title' => 'Shipping settings']); + } + + protected function findZone(int $zoneId): ShippingZone + { + return ShippingZone::query() + ->where('store_id', $this->store()->getKey()) + ->whereKey($zoneId) + ->firstOrFail(); + } + + protected function findRate(int $zoneId, int $rateId): ShippingRate + { + $this->findZone($zoneId); + + return ShippingRate::query() + ->where('zone_id', $zoneId) + ->whereKey($rateId) + ->firstOrFail(); + } + + /** + * @return array ISO code => name. + */ + protected function countryList(): array + { + return [ + 'DE' => 'Germany', 'AT' => 'Austria', 'FR' => 'France', 'IT' => 'Italy', + 'ES' => 'Spain', 'NL' => 'Netherlands', 'BE' => 'Belgium', 'PL' => 'Poland', + 'GB' => 'United Kingdom', 'US' => 'United States', 'CA' => 'Canada', 'AU' => 'Australia', + 'CH' => 'Switzerland', 'CZ' => 'Czechia', 'DK' => 'Denmark', 'SE' => 'Sweden', + 'NO' => 'Norway', 'FI' => 'Finland', 'IE' => 'Ireland', 'PT' => 'Portugal', + ]; + } +} diff --git a/app/Livewire/Admin/Settings/Taxes.php b/app/Livewire/Admin/Settings/Taxes.php new file mode 100644 index 00000000..e2d00759 --- /dev/null +++ b/app/Livewire/Admin/Settings/Taxes.php @@ -0,0 +1,118 @@ + */ + public array $manualRates = []; + + public function mount(): void + { + Gate::authorize('viewAny', TaxSetting::class); + + $settings = $this->settings(); + $config = is_array($settings->config_json) ? $settings->config_json : []; + + $this->mode = $settings->mode->value; + $this->pricesIncludeTax = (bool) $settings->prices_include_tax; + $this->provider = $settings->provider->value; + $this->providerApiKey = (string) ($config['provider_api_key'] ?? ''); + + $this->manualRates = array_map( + fn (array $entry): array => [ + 'zone_name' => (string) ($entry['zone_name'] ?? $entry['name'] ?? $entry['country_code'] ?? ''), + 'rate_percentage' => number_format(((int) ($entry['rate'] ?? 0)) / 100, 2, '.', ''), + ], + array_values((array) ($config['tax_rates'] ?? [])) + ); + } + + public function addManualRate(): void + { + $this->manualRates[] = ['zone_name' => '', 'rate_percentage' => '']; + } + + public function removeManualRate(int $index): void + { + unset($this->manualRates[$index]); + $this->manualRates = array_values($this->manualRates); + } + + public function save(): void + { + $settings = $this->settings(); + + Gate::authorize('update', $settings); + + $validated = $this->validate([ + 'mode' => 'required|in:manual,provider', + 'provider' => 'required|in:none,stripe_tax', + 'manualRates' => 'nullable|array', + 'manualRates.*.zone_name' => 'required|string|max:100', + 'manualRates.*.rate_percentage' => 'required|numeric|min:0|max:100', + ]); + + $config = is_array($settings->config_json) ? $settings->config_json : []; + $config['provider_api_key'] = $this->providerApiKey; + + if ($validated['mode'] === 'manual') { + $config['tax_rates'] = array_map(function (array $entry): array { + $zoneName = trim($entry['zone_name']); + + return [ + 'zone_name' => $zoneName, + 'country_code' => strlen($zoneName) === 2 ? strtoupper($zoneName) : '', + 'name' => $zoneName, + 'rate' => (int) round(((float) $entry['rate_percentage']) * 100), + 'shipping_taxed' => true, + ]; + }, array_values($validated['manualRates'] ?? [])); + } + + $settings->update([ + 'mode' => TaxMode::from($validated['mode']), + 'provider' => TaxProvider::from($validated['provider']), + 'prices_include_tax' => $this->pricesIncludeTax, + 'config_json' => $config, + ]); + + $this->toast('success', 'Tax settings saved.'); + } + + public function render() + { + return view('livewire.admin.settings.taxes') + ->layout('livewire.admin.layout.app', ['title' => 'Tax settings']); + } + + protected function settings(): TaxSetting + { + return TaxSetting::query()->firstOrCreate( + ['store_id' => $this->store()->getKey()], + [ + 'mode' => TaxMode::Manual, + 'provider' => TaxProvider::None, + 'prices_include_tax' => true, + 'config_json' => [], + ] + ); + } +} diff --git a/app/Livewire/Admin/Themes/Editor.php b/app/Livewire/Admin/Themes/Editor.php new file mode 100644 index 00000000..c34a992d --- /dev/null +++ b/app/Livewire/Admin/Themes/Editor.php @@ -0,0 +1,177 @@ + */ + public array $sectionSettings = []; + + /** @var array */ + public array $allSettings = []; + + public function mount(Theme $theme): void + { + abort_if($theme->store_id !== $this->store()->getKey(), 404); + + Gate::authorize('view', $theme); + + $this->theme = $theme; + $this->allSettings = is_array($theme->themeSetting?->settings_json) + ? $theme->themeSetting->settings_json + : []; + } + + public function selectSection(string $sectionKey): void + { + $this->persistSection(); + $this->selectedSection = $sectionKey; + $this->sectionSettings = $this->allSettings[$sectionKey] ?? $this->sectionDefaults($sectionKey); + } + + public function updateSetting(string $key, mixed $value): void + { + $this->sectionSettings[$key] = $value; + } + + public function save(): void + { + Gate::authorize('update', $this->theme); + + $this->persistSection(); + + $this->theme->themeSetting()->updateOrCreate( + ['theme_id' => $this->theme->getKey()], + ['settings_json' => $this->allSettings] + ); + + $this->toast('success', 'Theme settings saved.'); + } + + public function publish(): void + { + Gate::authorize('publish', $this->theme); + + $this->save(); + + Theme::query() + ->where('store_id', $this->store()->getKey()) + ->where('status', ThemeStatus::Published) + ->update(['status' => ThemeStatus::Draft->value]); + + $this->theme->update(['status' => ThemeStatus::Published, 'published_at' => now()]); + + $this->toast('success', 'Theme published.'); + } + + public function refreshPreview(): void + { + $this->dispatch('refresh-theme-preview'); + } + + public function render() + { + $store = $this->store(); + $domain = $store->storeDomains()->where('is_primary', true)->first() + ?? $store->storeDomains()->first(); + + return view('livewire.admin.themes.editor', [ + 'sections' => $this->sections(), + 'previewUrl' => $domain !== null ? 'http://'.$domain->hostname.'/' : url('/'), + ])->layout('livewire.admin.layout.app', ['title' => 'Customize '.$this->theme->name]); + } + + /** + * @return array>}> + */ + protected function sections(): array + { + return [ + 'announcement' => [ + 'label' => 'Announcement bar', + 'fields' => [ + ['key' => 'show', 'label' => 'Show announcement', 'type' => 'checkbox'], + ['key' => 'text', 'label' => 'Announcement text', 'type' => 'text'], + ['key' => 'link', 'label' => 'Announcement link', 'type' => 'text'], + ], + ], + 'header' => [ + 'label' => 'Header', + 'fields' => [ + ['key' => 'logo_text', 'label' => 'Logo text', 'type' => 'text'], + ['key' => 'logo_url', 'label' => 'Logo URL', 'type' => 'text'], + ['key' => 'sticky', 'label' => 'Sticky header', 'type' => 'checkbox'], + ], + ], + 'hero' => [ + 'label' => 'Hero', + 'fields' => [ + ['key' => 'show', 'label' => 'Show hero', 'type' => 'checkbox'], + ['key' => 'heading', 'label' => 'Heading', 'type' => 'text'], + ['key' => 'subheading', 'label' => 'Subheading', 'type' => 'textarea'], + ['key' => 'cta_text', 'label' => 'Button text', 'type' => 'text'], + ['key' => 'cta_url', 'label' => 'Button link', 'type' => 'text'], + ['key' => 'background_color', 'label' => 'Background color', 'type' => 'color'], + ], + ], + 'home' => [ + 'label' => 'Homepage', + 'fields' => [ + ['key' => 'featured_collections_count', 'label' => 'Featured collections count', 'type' => 'text'], + ['key' => 'featured_products_count', 'label' => 'Featured products count', 'type' => 'text'], + ['key' => 'show_newsletter', 'label' => 'Show newsletter', 'type' => 'checkbox'], + ], + ], + 'footer' => [ + 'label' => 'Footer', + 'fields' => [ + ['key' => 'store_address', 'label' => 'Store address', 'type' => 'textarea'], + ['key' => 'contact_email', 'label' => 'Contact email', 'type' => 'text'], + ['key' => 'footer_text', 'label' => 'Footer text', 'type' => 'text'], + ], + ], + 'styling' => [ + 'label' => 'Styling', + 'fields' => [ + ['key' => 'primary_color', 'label' => 'Primary color', 'type' => 'color'], + ['key' => 'secondary_color', 'label' => 'Secondary color', 'type' => 'color'], + ['key' => 'font_family', 'label' => 'Font family', 'type' => 'select', 'options' => ['Inter, sans-serif' => 'Inter', 'Georgia, serif' => 'Georgia', 'monospace' => 'Monospace']], + ['key' => 'layout', 'label' => 'Layout', 'type' => 'select', 'options' => ['centered' => 'Centered', 'full_width' => 'Full width']], + ], + ], + ]; + } + + /** + * @return array + */ + protected function sectionDefaults(string $sectionKey): array + { + $defaults = []; + + foreach ($this->sections()[$sectionKey]['fields'] ?? [] as $field) { + $defaults[$field['key']] = $field['type'] === 'checkbox' ? false : ''; + } + + return $defaults; + } + + protected function persistSection(): void + { + if ($this->selectedSection !== null) { + $this->allSettings[$this->selectedSection] = $this->sectionSettings; + } + } +} diff --git a/app/Livewire/Admin/Themes/Index.php b/app/Livewire/Admin/Themes/Index.php new file mode 100644 index 00000000..30c5418b --- /dev/null +++ b/app/Livewire/Admin/Themes/Index.php @@ -0,0 +1,108 @@ +findTheme($themeId); + + Gate::authorize('publish', $theme); + + Theme::query() + ->where('store_id', $this->store()->getKey()) + ->where('status', ThemeStatus::Published) + ->update(['status' => ThemeStatus::Draft->value]); + + $theme->update(['status' => ThemeStatus::Published, 'published_at' => now()]); + + $this->toast('success', 'Theme published.'); + } + + public function duplicateTheme(int $themeId): void + { + $theme = $this->findTheme($themeId); + + Gate::authorize('create', Theme::class); + + $copy = Theme::query()->create([ + 'store_id' => $this->store()->getKey(), + 'name' => $theme->name.' (copy)', + 'version' => $theme->version, + 'status' => ThemeStatus::Draft, + 'published_at' => null, + ]); + + if ($theme->themeSetting !== null) { + $copy->themeSetting()->create(['settings_json' => $theme->themeSetting->settings_json]); + } + + $this->toast('success', 'Theme duplicated.'); + } + + public function confirmDelete(int $themeId): void + { + $this->deletingId = $themeId; + $this->showDeleteModal = true; + } + + public function deleteTheme(): void + { + $theme = $this->findTheme((int) $this->deletingId); + + Gate::authorize('delete', $theme); + + if ($theme->status === ThemeStatus::Published) { + $this->showDeleteModal = false; + $this->toast('error', 'The published theme cannot be deleted.'); + + return; + } + + $theme->themeSetting?->delete(); + $theme->themeFiles()->delete(); + $theme->delete(); + + $this->showDeleteModal = false; + $this->deletingId = null; + $this->toast('success', 'Theme deleted.'); + } + + public function render() + { + return view('livewire.admin.themes.index', [ + 'themes' => Theme::query() + ->where('store_id', $this->store()->getKey()) + ->with('themeSetting') + ->orderByDesc('published_at') + ->orderBy('id') + ->get(), + ])->layout('livewire.admin.layout.app', ['title' => 'Themes']); + } + + protected function findTheme(int $themeId): Theme + { + return Theme::query() + ->where('store_id', $this->store()->getKey()) + ->whereKey($themeId) + ->firstOrFail(); + } +} diff --git a/app/Livewire/Hooks/ResolveStoreContext.php b/app/Livewire/Hooks/ResolveStoreContext.php new file mode 100644 index 00000000..379f6d71 --- /dev/null +++ b/app/Livewire/Hooks/ResolveStoreContext.php @@ -0,0 +1,110 @@ +ensureStoreResolved(); + } + + public function mount($params, $parent = null): void + { + $this->ensureStoreResolved(); + } + + protected function ensureStoreResolved(): void + { + $current = app(CurrentStore::class); + + if ($current->has()) { + return; + } + + $store = str_starts_with($this->component::class, 'App\\Livewire\\Admin') + ? $this->resolveAdminStore() + : $this->resolveStorefrontStore(); + + if ($store !== null) { + $current->set($store); + } + } + + protected function resolveAdminStore(): ?Store + { + $storeId = app(CurrentStore::class)->adminIdFromSession(); + + if ($storeId === null) { + return null; + } + + $store = Store::query()->find($storeId); + + if ($store === null) { + return null; + } + + $user = request()->user(); + + if ($user !== null && ! $user->stores()->where('stores.id', $store->getKey())->exists()) { + return null; + } + + return $store; + } + + protected function resolveStorefrontStore(): ?Store + { + $hostname = request()->getHost(); + + if ($hostname !== '') { + $storeId = Cache::remember( + "store_domain:{$hostname}", + now()->addMinutes(5), + fn (): ?int => StoreDomain::query()->where('hostname', $hostname)->value('store_id') + ); + + if ($storeId !== null) { + $store = Store::query()->find($storeId); + + if ($store !== null) { + if ($store->status === StoreStatus::Suspended) { + abort(503, 'This store is currently unavailable.'); + } + + return $store; + } + } + } + + if (app()->bound('session')) { + $sessionStoreId = session()->get('storefront_store_id'); + + if ($sessionStoreId !== null) { + $store = Store::query()->find($sessionStoreId); + + if ($store !== null && $store->status !== StoreStatus::Suspended) { + return $store; + } + } + } + + return null; + } +} diff --git a/app/Livewire/Storefront/Account/Addresses/Index.php b/app/Livewire/Storefront/Account/Addresses/Index.php new file mode 100644 index 00000000..59d3654d --- /dev/null +++ b/app/Livewire/Storefront/Account/Addresses/Index.php @@ -0,0 +1,219 @@ + */ + public array $address = []; + + public ?int $confirmingDelete = null; + + public function mount(): void + { + $this->address = $this->blankAddress(); + } + + public function openCreate(): void + { + $this->resetForm(); + $this->showModal = true; + } + + public function openEdit(int $id): void + { + $record = $this->customerAddresses()->whereKey($id)->first(); + + abort_if($record === null, 404); + + $this->editingId = $record->getKey(); + $this->label = (string) ($record->label ?? ''); + $this->address = $this->blankAddress((array) ($record->address_json ?? [])); + $this->showModal = true; + } + + public function save(): void + { + $this->validate(array_merge( + ['label' => 'nullable|string|max:255'], + $this->addressRules(), + )); + + $payload = [ + 'label' => $this->label !== '' ? $this->label : null, + 'address_json' => $this->addressPayload(), + ]; + + if ($this->editingId !== null) { + $record = $this->customerAddresses()->whereKey($this->editingId)->first(); + + abort_if($record === null, 404); + + $record->update($payload); + } else { + $payload['is_default'] = $this->customerAddresses()->count() === 0; + $this->customer()->customerAddresses()->create($payload); + } + + $this->resetForm(); + $this->showModal = false; + } + + public function setDefault(int $id): void + { + $record = $this->customerAddresses()->whereKey($id)->first(); + + abort_if($record === null, 404); + + $this->customerAddresses()->update(['is_default' => false]); + $record->update(['is_default' => true]); + } + + public function confirmDelete(int $id): void + { + $this->confirmingDelete = $id; + } + + public function delete(): void + { + if ($this->confirmingDelete === null) { + return; + } + + $record = $this->customerAddresses()->whereKey($this->confirmingDelete)->first(); + + abort_if($record === null, 404); + + $wasDefault = (bool) $record->is_default; + $record->delete(); + $this->confirmingDelete = null; + + if ($wasDefault) { + $first = $this->customerAddresses()->orderBy('id')->first(); + + if ($first !== null) { + $first->update(['is_default' => true]); + } + } + } + + public function render() + { + $store = $this->store(); + $customer = Auth::guard('customer')->user(); + + abort_if($customer === null, 401); + + $addresses = $customer->customerAddresses()->orderByDesc('is_default')->orderBy('id')->get(); + + return view('livewire.storefront.account.addresses.index', [ + 'store' => $store, + 'addresses' => $addresses, + ])->layout('storefront.layouts.app', [ + 'title' => 'Your Addresses - '.$store->name, + 'metaDescription' => 'Manage your addresses at '.$store->name, + ]); + } + + protected function customer(): \App\Models\Customer + { + $customer = Auth::guard('customer')->user(); + + abort_if($customer === null, 401); + + return $customer; + } + + protected function customerAddresses(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->customer()->customerAddresses(); + } + + protected function resetForm(): void + { + $this->editingId = null; + $this->label = ''; + $this->address = $this->blankAddress(); + $this->resetValidation(); + } + + /** + * @return array + */ + protected function blankAddress(array $values = []): array + { + return array_merge([ + 'first_name' => '', + 'last_name' => '', + 'company' => null, + 'address1' => '', + 'address2' => null, + 'city' => '', + 'province' => null, + 'province_code' => null, + 'country' => '', + 'country_code' => '', + 'postal_code' => '', + 'phone' => null, + ], $values); + } + + /** + * @return array + */ + protected function addressRules(): array + { + return [ + 'address.first_name' => 'required|string|max:255', + 'address.last_name' => 'required|string|max:255', + 'address.address1' => 'required|string|max:500', + 'address.address2' => 'nullable|string|max:500', + 'address.city' => 'required|string|max:255', + 'address.province' => 'nullable|string|max:255', + 'address.country_code' => 'required|string|size:2', + 'address.postal_code' => 'required|string|max:20', + 'address.phone' => 'nullable|string|max:50', + ]; + } + + /** + * @return array + */ + protected function addressPayload(): array + { + $names = [ + 'DE' => 'Germany', 'AT' => 'Austria', 'FR' => 'France', 'NL' => 'Netherlands', + 'BE' => 'Belgium', 'IT' => 'Italy', 'ES' => 'Spain', 'US' => 'United States', + 'GB' => 'United Kingdom', 'CH' => 'Switzerland', + ]; + + $code = strtoupper((string) ($this->address['country_code'] ?? '')); + + return [ + 'first_name' => $this->address['first_name'], + 'last_name' => $this->address['last_name'], + 'company' => $this->address['company'] ?? null, + 'address1' => $this->address['address1'], + 'address2' => $this->address['address2'] ?? null, + 'city' => $this->address['city'], + 'province' => $this->address['province'] ?? null, + 'province_code' => $this->address['province_code'] ?? null, + 'country' => $names[$code] ?? $code, + 'country_code' => $code, + 'postal_code' => $this->address['postal_code'], + 'phone' => $this->address['phone'] ?? null, + ]; + } +} diff --git a/app/Livewire/Storefront/Account/Dashboard.php b/app/Livewire/Storefront/Account/Dashboard.php new file mode 100644 index 00000000..bd1be452 --- /dev/null +++ b/app/Livewire/Storefront/Account/Dashboard.php @@ -0,0 +1,35 @@ +store(); + $customer = Auth::guard('customer')->user(); + + abort_if($customer === null, 401); + + $orders = $customer->orders()->latest('placed_at')->latest('id')->take(5)->get(); + $orderCount = $customer->orders()->count(); + $addressCount = $customer->customerAddresses()->count(); + + return view('livewire.storefront.account.dashboard', [ + 'store' => $store, + 'customer' => $customer, + 'orders' => $orders, + 'orderCount' => $orderCount, + 'addressCount' => $addressCount, + ])->layout('storefront.layouts.app', [ + 'title' => 'My Account - '.$store->name, + 'metaDescription' => 'Your account at '.$store->name, + ]); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Index.php b/app/Livewire/Storefront/Account/Orders/Index.php new file mode 100644 index 00000000..b766a600 --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Index.php @@ -0,0 +1,31 @@ +store(); + $customer = Auth::guard('customer')->user(); + + abort_if($customer === null, 401); + + $orders = $customer->orders()->latest('placed_at')->latest('id')->paginate(10); + + return view('livewire.storefront.account.orders.index', [ + 'store' => $store, + 'orders' => $orders, + ])->layout('storefront.layouts.app', [ + 'title' => 'Order History - '.$store->name, + 'metaDescription' => 'Your order history at '.$store->name, + ]); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Show.php b/app/Livewire/Storefront/Account/Orders/Show.php new file mode 100644 index 00000000..052b1db7 --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Show.php @@ -0,0 +1,45 @@ +orderNumber = $orderNumber; + } + + public function render() + { + $store = $this->store(); + $customer = Auth::guard('customer')->user(); + + abort_if($customer === null, 401); + + $candidates = [$this->orderNumber, '#'.ltrim($this->orderNumber, '#')]; + + $order = $customer->orders() + ->where('store_id', $store->getKey()) + ->whereIn('order_number', $candidates) + ->with(['orderLines', 'fulfillments.fulfillmentLines', 'payments']) + ->first(); + + abort_if($order === null, 404); + + return view('livewire.storefront.account.orders.show', [ + 'store' => $store, + 'order' => $order, + ])->layout('storefront.layouts.app', [ + 'title' => 'Order '.$order->order_number.' - '.$store->name, + 'metaDescription' => 'Order '.$order->order_number.' at '.$store->name, + ]); + } +} diff --git a/app/Livewire/Storefront/Account/Profile.php b/app/Livewire/Storefront/Account/Profile.php new file mode 100644 index 00000000..f9850c71 --- /dev/null +++ b/app/Livewire/Storefront/Account/Profile.php @@ -0,0 +1,69 @@ +user(); + + abort_if($customer === null, 401); + + $this->name = (string) ($customer->name ?? ''); + $this->email = (string) $customer->email; + $this->marketingOptIn = (bool) $customer->marketing_opt_in; + } + + public function save(): void + { + $customer = Auth::guard('customer')->user(); + + abort_if($customer === null, 401); + + $this->validate([ + 'name' => 'nullable|string|max:255', + 'email' => [ + 'required', 'email', 'max:255', + Rule::unique('customers', 'email') + ->where('store_id', $this->store()->getKey()) + ->ignore($customer->getKey()), + ], + 'marketingOptIn' => 'boolean', + ]); + + $customer->name = $this->name !== '' ? $this->name : null; + $customer->email = $this->email; + $customer->marketing_opt_in = $this->marketingOptIn; + $customer->save(); + + $this->status = 'Profile updated successfully.'; + } + + public function render() + { + $store = $this->store(); + + return view('livewire.storefront.account.profile', [ + 'store' => $store, + ])->layout('storefront.layouts.app', [ + 'title' => 'Profile - '.$store->name, + 'metaDescription' => 'Update your profile at '.$store->name, + ]); + } +} diff --git a/app/Livewire/Storefront/Cart/Show.php b/app/Livewire/Storefront/Cart/Show.php new file mode 100644 index 00000000..df4a9382 --- /dev/null +++ b/app/Livewire/Storefront/Cart/Show.php @@ -0,0 +1,38 @@ +syncCartVersion(); + } + + public function render() + { + $store = $this->store(); + $cart = $this->findCart(); + + if ($this->cartVersion === null) { + $this->cartVersion = $cart?->cart_version; + } + + return view('livewire.storefront.cart.show', [ + 'store' => $store, + 'cart' => $cart, + 'lines' => $this->cartLinesData($cart), + 'totals' => $this->cartTotals($cart), + 'appliedCode' => $this->sessionDiscountCode(), + ])->layout('storefront.layouts.app', [ + 'title' => 'Your Cart - '.$store->name, + 'metaDescription' => 'Review your cart at '.$store->name, + ]); + } +} diff --git a/app/Livewire/Storefront/CartCount.php b/app/Livewire/Storefront/CartCount.php new file mode 100644 index 00000000..a3717d8d --- /dev/null +++ b/app/Livewire/Storefront/CartCount.php @@ -0,0 +1,50 @@ +store(); + $customer = Auth::guard('customer')->user(); + $query = Cart::query()->where('store_id', $store->getKey())->where('status', CartStatus::Active); + + $cartId = session()->get('cart_id'); + + if ($cartId !== null) { + $cart = (clone $query)->whereKey($cartId)->first(); + + if ($cart !== null) { + return (int) $cart->cartLines()->sum('quantity'); + } + } + + if ($customer !== null) { + $cart = (clone $query)->where('customer_id', $customer->getKey())->first(); + + if ($cart !== null) { + return (int) $cart->cartLines()->sum('quantity'); + } + } + + return 0; + } + + public function render() + { + return view('livewire.storefront.cart-count', ['count' => $this->count]); + } +} diff --git a/app/Livewire/Storefront/CartDrawer.php b/app/Livewire/Storefront/CartDrawer.php new file mode 100644 index 00000000..41907e51 --- /dev/null +++ b/app/Livewire/Storefront/CartDrawer.php @@ -0,0 +1,76 @@ +syncCartVersion(); + } + + #[On('open-cart-drawer')] + public function openDrawer(): void + { + $this->open = true; + $this->syncCartVersion(); + } + + #[On('close-cart-drawer')] + public function closeDrawer(): void + { + $this->open = false; + } + + #[On('cart-updated')] + public function onCartUpdated(): void + { + $this->syncCartVersion(); + + if ($this->open || $this->isCartPageRequest()) { + return; + } + + $this->open = true; + } + + public function render() + { + $cart = $this->findCart(); + + if ($this->cartVersion === null) { + $this->cartVersion = $cart?->cart_version; + } + + return view('livewire.storefront.cart-drawer', [ + 'cart' => $cart, + 'lines' => $this->cartLinesData($cart), + 'totals' => $this->cartTotals($cart), + 'appliedCode' => $this->sessionDiscountCode(), + ]); + } + + /** + * The drawer must not auto-open over the dedicated cart page: its overlay + * would intercept the page's own quantity and checkout controls. + */ + protected function isCartPageRequest(): bool + { + $refererPath = parse_url((string) request()->headers->get('referer'), PHP_URL_PATH); + + if (! is_string($refererPath) || $refererPath === '') { + return false; + } + + return $refererPath === parse_url(route('storefront.cart'), PHP_URL_PATH); + } +} diff --git a/app/Livewire/Storefront/Checkout/Confirmation.php b/app/Livewire/Storefront/Checkout/Confirmation.php new file mode 100644 index 00000000..c7d7f494 --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Confirmation.php @@ -0,0 +1,101 @@ +checkoutId = $checkoutId; + + $checkout = Checkout::query() + ->where('store_id', $this->store()->getKey()) + ->whereKey($this->checkoutId) + ->first(); + + if ($checkout !== null && $checkout->status !== CheckoutStatus::Completed) { + $this->redirect(route('storefront.checkout.show', ['checkoutId' => $checkout->getKey()]), navigate: true); + } + } + + public function render() + { + $store = $this->store(); + + $checkout = Checkout::query() + ->where('store_id', $store->getKey()) + ->whereKey($this->checkoutId) + ->first(); + + abort_if($checkout === null, 404); + abort_if($checkout->status !== CheckoutStatus::Completed, 404); + + $order = app(OrderService::class)->findByCheckout($checkout); + + abort_if($order === null, 404); + + $order->load(['orderLines', 'payments']); + $catalog = app(StorefrontCatalog::class); + $customer = Auth::guard('customer')->user(); + + $payment = $order->payments->first(); + $snapshot = []; + + if ($payment !== null && is_string($payment->raw_json_encrypted)) { + $snapshot = json_decode($payment->raw_json_encrypted, true) ?? []; + } + + return view('livewire.storefront.checkout.confirmation', [ + 'store' => $store, + 'checkout' => $checkout, + 'order' => $order, + 'paymentLabel' => $this->paymentLabel($order->payment_method, $snapshot), + 'bankInstructions' => $order->payment_method === PaymentMethod::BankTransfer ? [ + 'bank_name' => 'Mock Bank AG', + 'iban' => 'DE89 3704 0044 0532 0130 00', + 'bic' => 'COBADEFFXXX', + 'reference' => $order->order_number, + 'amount_formatted' => \App\Support\Money::format($order->total_amount, $order->currency), + ] : null, + 'ownsOrder' => $customer !== null && $order->customer_id !== null && (int) $order->customer_id === (int) $customer->getKey(), + ])->layout('storefront.layouts.app', [ + 'title' => 'Thank you for your order - '.$store->name, + 'metaDescription' => 'Order confirmation at '.$store->name, + ]); + } + + /** + * @param array $snapshot + */ + protected function paymentLabel(?PaymentMethod $method, array $snapshot): string + { + if ($method === PaymentMethod::CreditCard) { + $last4 = $snapshot['card_last4'] ?? null; + + return $last4 ? 'Credit Card ending in '.$last4 : 'Credit Card'; + } + + if ($method === PaymentMethod::Paypal) { + return 'PayPal'; + } + + if ($method === PaymentMethod::BankTransfer) { + return 'Bank Transfer'; + } + + return 'Unknown'; + } +} diff --git a/app/Livewire/Storefront/Checkout/Show.php b/app/Livewire/Storefront/Checkout/Show.php new file mode 100644 index 00000000..f7aef3f2 --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Show.php @@ -0,0 +1,402 @@ + */ + public array $shipping = []; + + /** @var array */ + public array $billing = []; + + public bool $useShippingAsBilling = true; + + public ?int $shippingRateId = null; + + public string $paymentMethod = 'credit_card'; + + public string $cardNumber = ''; + + public string $cardExpiry = ''; + + public string $cardCvc = ''; + + public string $cardHolder = ''; + + public string $discountCodeInput = ''; + + public ?string $payError = null; + + public ?int $savedAddressId = null; + + public function mount(int $checkoutId): void + { + $this->checkoutId = $checkoutId; + $checkout = $this->checkout(); + + abort_if($checkout === null, 404); + abort_if($checkout->status === CheckoutStatus::Expired, 410); + + if ($checkout->status === CheckoutStatus::Completed) { + $this->redirect(route('storefront.checkout.confirmation', ['checkoutId' => $checkout->getKey()]), navigate: true); + + return; + } + + $this->email = (string) ($checkout->email ?? Auth::guard('customer')->user()?->email ?? ''); + $this->shipping = $this->blankAddress((array) ($checkout->shipping_address_json ?? [])); + $this->billing = $this->blankAddress((array) ($checkout->billing_address_json ?? [])); + $this->shippingRateId = $checkout->shipping_method_id; + $this->paymentMethod = $checkout->payment_method?->value ?? 'credit_card'; + + if ($checkout->status === CheckoutStatus::Started && empty($checkout->shipping_address_json)) { + $this->prefillFromDefaultAddress(); + } + } + + public function saveContact(): void + { + $this->validate(['email' => 'required|email|max:255']); + + $checkout = $this->checkoutOrFail(); + $checkout->email = $this->email; + $checkout->save(); + } + + public function saveAddress(): void + { + $this->validate(array_merge( + ['email' => 'required|email|max:255'], + $this->addressRules('shipping'), + $this->useShippingAsBilling ? [] : $this->addressRules('billing'), + )); + + $checkout = $this->checkoutOrFail(); + + $shipping = $this->addressPayload($this->shipping); + $billing = $this->useShippingAsBilling ? null : $this->addressPayload($this->billing); + + try { + app(CheckoutService::class)->setAddress($checkout, $shipping, $billing, $this->useShippingAsBilling, $this->email); + } catch (UnserviceableAddressException $exception) { + $this->addError('shipping.country_code', $exception->getMessage()); + + return; + } catch (InvalidCheckoutTransitionException $exception) { + $this->addError('shipping.address1', $exception->getMessage()); + + return; + } + + $this->shippingRateId = $this->checkoutOrFail()->shipping_method_id; + } + + public function saveShipping(): void + { + $checkout = $this->checkoutOrFail(); + + if (! app(ShippingCalculator::class)->requiresShipping($checkout->cart)) { + app(CheckoutService::class)->setShippingMethod($checkout, null); + + return; + } + + $this->validate(['shippingRateId' => 'required|integer']); + + try { + app(CheckoutService::class)->setShippingMethod($checkout, $this->shippingRateId); + } catch (ValidationException $exception) { + $this->addError('shippingRateId', collect($exception->errors())->flatten()->first() ?? 'Invalid shipping method.'); + + return; + } catch (InvalidCheckoutTransitionException $exception) { + $this->addError('shippingRateId', $exception->getMessage()); + } + } + + public function applyDiscount(): void + { + $code = trim($this->discountCodeInput); + + if ($code === '') { + $this->addError('discountCodeInput', 'Please enter a discount code.'); + + return; + } + + try { + app(CheckoutService::class)->applyDiscount($this->checkoutOrFail(), $code, app(DiscountService::class)); + } catch (InvalidDiscountException $exception) { + $this->addError('discountCodeInput', $exception->getMessage()); + + return; + } catch (InvalidCheckoutTransitionException $exception) { + $this->addError('discountCodeInput', $exception->getMessage()); + + return; + } + + $this->discountCodeInput = ''; + } + + public function removeDiscount(): void + { + try { + app(CheckoutService::class)->removeDiscount($this->checkoutOrFail()); + } catch (InvalidCheckoutTransitionException) { + } + } + + public function selectSavedAddress(): void + { + $customer = Auth::guard('customer')->user(); + + if ($customer === null || $this->savedAddressId === null) { + return; + } + + $address = $customer->customerAddresses()->whereKey($this->savedAddressId)->first(); + + if ($address !== null) { + $this->shipping = $this->blankAddress((array) ($address->address_json ?? [])); + } + } + + public function pay(): mixed + { + $this->payError = null; + $this->validate($this->paymentRules()); + + $method = PaymentMethod::from($this->paymentMethod); + $details = [ + 'payment_method' => $method->value, + 'card_number' => preg_replace('/\s+/', '', $this->cardNumber), + 'card_expiry' => $this->cardExpiry, + 'card_cvc' => $this->cardCvc, + 'card_holder' => $this->cardHolder, + ]; + + try { + $order = app(CheckoutService::class)->pay($this->checkoutOrFail(), $method, $details); + } catch (PaymentFailedException $exception) { + $this->payError = 'Payment declined: '.$exception->getMessage(); + + return null; + } catch (InvalidCheckoutTransitionException $exception) { + $this->payError = $exception->getMessage(); + + return null; + } + + session()->forget(['cart_id', 'storefront.discount_code']); + + return $this->redirect( + route('storefront.checkout.confirmation', ['checkoutId' => $order->checkout_id]), + navigate: true + ); + } + + public function step(): int + { + $status = $this->checkoutOrFail()->status; + + return match ($status) { + CheckoutStatus::Started => 1, + CheckoutStatus::Addressed => 3, + CheckoutStatus::ShippingSelected, CheckoutStatus::PaymentSelected => 4, + default => 1, + }; + } + + public function render() + { + $store = $this->store(); + $checkout = $this->checkout(); + + abort_if($checkout === null, 404); + abort_if($checkout->status === CheckoutStatus::Expired, 410); + + $catalog = app(StorefrontCatalog::class); + $lines = $checkout->cart->cartLines()->with(['variant.product.productMedia', 'variant.optionValues'])->orderBy('id')->get(); + $totals = (array) ($checkout->totals_json ?? []); + + $shippingOptions = collect(); + + if ($checkout->status !== CheckoutStatus::Started) { + $shippingOptions = app(ShippingCalculator::class)->getAvailableRates( + $store, + (array) ($checkout->shipping_address_json ?? []), + $checkout->cart + ); + } + + $customer = Auth::guard('customer')->user(); + + return view('livewire.storefront.checkout.show', [ + 'store' => $store, + 'checkout' => $checkout, + 'step' => $this->step(), + 'summaryLines' => $lines->map(fn ($line): array => [ + 'id' => $line->getKey(), + 'title' => $line->variant->product->title, + 'variant' => $catalog->variantOptionLabels($line->variant), + 'quantity' => $line->quantity, + 'total' => $line->line_total_amount, + 'image_url' => $catalog->primaryImageUrl($line->variant->product), + ])->all(), + 'summaryTotals' => [ + 'subtotal' => $totals['subtotal'] ?? 0, + 'discount' => $totals['discount'] ?? 0, + 'shipping' => $totals['shipping'] ?? 0, + 'tax' => $totals['tax_total'] ?? $totals['tax'] ?? 0, + 'total' => $totals['total'] ?? 0, + ], + 'currency' => $checkout->cart->currency, + 'shippingOptions' => $shippingOptions, + 'requiresShipping' => app(ShippingCalculator::class)->requiresShipping($checkout->cart), + 'savedAddresses' => $customer?->customerAddresses()->orderByDesc('is_default')->orderBy('id')->get() ?? collect(), + 'isGuest' => $customer === null, + ])->layout('storefront.layouts.app', [ + 'title' => 'Checkout - '.$store->name, + 'metaDescription' => 'Complete your purchase at '.$store->name, + ]); + } + + protected function checkout(): ?Checkout + { + return Checkout::query() + ->where('store_id', $this->store()->getKey()) + ->whereKey($this->checkoutId) + ->with(['cart.cartLines.variant.product', 'store']) + ->first(); + } + + protected function checkoutOrFail(): Checkout + { + $checkout = $this->checkout(); + + abort_if($checkout === null, 404); + + return $checkout; + } + + /** + * @return array + */ + protected function blankAddress(array $values = []): array + { + return array_merge([ + 'first_name' => '', + 'last_name' => '', + 'company' => null, + 'address1' => '', + 'address2' => null, + 'city' => '', + 'province' => null, + 'province_code' => null, + 'country' => '', + 'country_code' => '', + 'postal_code' => '', + 'phone' => null, + ], $values); + } + + /** + * @return array + */ + protected function addressRules(string $prefix): array + { + return [ + $prefix.'.first_name' => 'required|string|max:255', + $prefix.'.last_name' => 'required|string|max:255', + $prefix.'.address1' => 'required|string|max:500', + $prefix.'.address2' => 'nullable|string|max:500', + $prefix.'.city' => 'required|string|max:255', + $prefix.'.province' => 'nullable|string|max:255', + $prefix.'.country_code' => 'required|string|size:2', + $prefix.'.postal_code' => 'required|string|max:20', + $prefix.'.phone' => 'nullable|string|max:50', + ]; + } + + /** + * @param array $input + * @return array + */ + protected function addressPayload(array $input): array + { + $names = [ + 'DE' => 'Germany', 'AT' => 'Austria', 'FR' => 'France', 'NL' => 'Netherlands', + 'BE' => 'Belgium', 'IT' => 'Italy', 'ES' => 'Spain', 'US' => 'United States', + 'GB' => 'United Kingdom', 'CH' => 'Switzerland', + ]; + + $code = strtoupper((string) ($input['country_code'] ?? '')); + + return [ + 'first_name' => $input['first_name'], + 'last_name' => $input['last_name'], + 'company' => $input['company'] ?? null, + 'address1' => $input['address1'], + 'address2' => $input['address2'] ?? null, + 'city' => $input['city'], + 'province' => $input['province'] ?? null, + 'province_code' => $input['province_code'] ?? null, + 'country' => $names[$code] ?? $code, + 'country_code' => $code, + 'postal_code' => $input['postal_code'], + 'phone' => $input['phone'] ?? null, + ]; + } + + /** + * @return array + */ + protected function paymentRules(): array + { + $rules = ['paymentMethod' => 'required|in:credit_card,paypal,bank_transfer']; + + if ($this->paymentMethod === PaymentMethod::CreditCard->value) { + $rules['cardNumber'] = 'required|string|max:32'; + $rules['cardExpiry'] = 'required|string|max:7'; + $rules['cardCvc'] = 'required|string|max:4'; + $rules['cardHolder'] = 'required|string|max:255'; + } + + return $rules; + } + + protected function prefillFromDefaultAddress(): void + { + $customer = Auth::guard('customer')->user(); + $default = $customer?->customerAddresses()->where('is_default', true)->first() + ?? $customer?->customerAddresses()->orderBy('id')->first(); + + if ($default !== null) { + $this->shipping = $this->blankAddress((array) ($default->address_json ?? [])); + } + } +} diff --git a/app/Livewire/Storefront/Collections/Index.php b/app/Livewire/Storefront/Collections/Index.php new file mode 100644 index 00000000..675fa544 --- /dev/null +++ b/app/Livewire/Storefront/Collections/Index.php @@ -0,0 +1,34 @@ +store(); + + $collections = Collection::query() + ->where('store_id', $store->getKey()) + ->where('status', CollectionStatus::Active) + ->withCount(['products' => fn ($query) => $query->where('status', \App\Enums\ProductStatus::Active)->whereNotNull('published_at')]) + ->orderBy('title') + ->paginate(12); + + return view('livewire.storefront.collections.index', [ + 'store' => $store, + 'collections' => $collections, + ])->layout('storefront.layouts.app', [ + 'title' => 'Collections - '.$store->name, + 'metaDescription' => 'Browse all collections at '.$store->name, + ]); + } +} diff --git a/app/Livewire/Storefront/Collections/Show.php b/app/Livewire/Storefront/Collections/Show.php new file mode 100644 index 00000000..e8868fb2 --- /dev/null +++ b/app/Livewire/Storefront/Collections/Show.php @@ -0,0 +1,191 @@ + */ + #[Url] + public array $vendors = []; + + /** @var array */ + #[Url] + public array $types = []; + + public bool $showMobileFilters = false; + + public function mount(string $handle): void + { + $this->handle = $handle; + } + + public function updatedSort(): void + { + $this->resetPage(); + } + + public function updatedInStock(): void + { + $this->resetPage(); + } + + public function updatedPriceMin(): void + { + $this->resetPage(); + } + + public function updatedPriceMax(): void + { + $this->resetPage(); + } + + public function updatedVendors(): void + { + $this->resetPage(); + } + + public function updatedTypes(): void + { + $this->resetPage(); + } + + public function clearFilters(): void + { + $this->reset(['inStock', 'priceMin', 'priceMax', 'vendors', 'types']); + $this->resetPage(); + } + + public function removeVendor(string $vendor): void + { + $this->vendors = array_values(array_diff($this->vendors, [$vendor])); + $this->resetPage(); + } + + public function removeType(string $type): void + { + $this->types = array_values(array_diff($this->types, [$type])); + $this->resetPage(); + } + + public function hasActiveFilters(): bool + { + return $this->inStock || $this->priceMin !== null || $this->priceMax !== null || $this->vendors !== [] || $this->types !== []; + } + + public function render() + { + $store = $this->store(); + + $collection = Collection::query() + ->where('store_id', $store->getKey()) + ->where('handle', $this->handle) + ->where('status', CollectionStatus::Active) + ->first(); + + abort_if($collection === null, 404); + + $catalog = app(StorefrontCatalog::class); + $query = $catalog->visibleProducts($store) + ->whereHas('collections', fn ($query) => $query->where('collections.id', $collection->getKey())); + + $this->applyFilters($query); + $this->applySort($query); + + $products = $catalog->withStorefrontRelations($query)->paginate(12); + + $baseQuery = $catalog->visibleProducts($store) + ->whereHas('collections', fn ($query) => $query->where('collections.id', $collection->getKey())); + + return view('livewire.storefront.collections.show', [ + 'store' => $store, + 'collection' => $collection, + 'products' => $products, + 'availableVendors' => (clone $baseQuery)->whereNotNull('vendor')->distinct()->orderBy('vendor')->pluck('vendor')->all(), + 'availableTypes' => (clone $baseQuery)->whereNotNull('product_type')->distinct()->orderBy('product_type')->pluck('product_type')->all(), + ])->layout('storefront.layouts.app', [ + 'title' => $collection->title.' - '.$store->name, + 'metaDescription' => trim(strip_tags((string) $collection->description_html)) ?: $collection->title, + ]); + } + + protected function applyFilters(\Illuminate\Database\Eloquent\Builder $query): void + { + if ($this->vendors !== []) { + $query->whereIn('vendor', $this->vendors); + } + + if ($this->types !== []) { + $query->whereIn('product_type', $this->types); + } + + if ($this->priceMin !== null || $this->priceMax !== null) { + $min = $this->priceMin; + $max = $this->priceMax; + + $query->whereHas('productVariants', function ($query) use ($min, $max): void { + $query->where('status', VariantStatus::Active); + + if ($min !== null) { + $query->where('price_amount', '>=', $min * 100); + } + + if ($max !== null) { + $query->where('price_amount', '<=', $max * 100); + } + }); + } + + if ($this->inStock) { + $query->whereHas('productVariants', function ($query): void { + $query->where('status', VariantStatus::Active)->where(function ($query): void { + $query->whereDoesntHave('inventoryItem')->orWhereHas('inventoryItem', function ($query): void { + $query->where('policy', \App\Enums\InventoryPolicy::Continue) + ->orWhereRaw('quantity_on_hand - quantity_reserved > 0'); + }); + }); + }); + } + } + + protected function applySort(\Illuminate\Database\Eloquent\Builder $query): void + { + $active = VariantStatus::Active->value; + + match ($this->sort) { + 'price_asc' => $query->orderByRaw( + "(SELECT MIN(price_amount) FROM product_variants WHERE product_variants.product_id = products.id AND status = '{$active}') ASC" + ), + 'price_desc' => $query->orderByRaw( + "(SELECT MIN(price_amount) FROM product_variants WHERE product_variants.product_id = products.id AND status = '{$active}') DESC" + ), + 'newest' => $query->orderByDesc('created_at'), + 'best_selling' => $query->orderByRaw('(SELECT COALESCE(SUM(quantity), 0) FROM order_lines WHERE order_lines.product_id = products.id) DESC'), + default => $query->orderBy('id'), + }; + } +} diff --git a/app/Livewire/Storefront/Concerns/ManagesCartLines.php b/app/Livewire/Storefront/Concerns/ManagesCartLines.php new file mode 100644 index 00000000..e6792f45 --- /dev/null +++ b/app/Livewire/Storefront/Concerns/ManagesCartLines.php @@ -0,0 +1,298 @@ +store(); + $customer = Auth::guard('customer')->user(); + + $cartId = session()->get('cart_id'); + + if ($cartId !== null) { + $cart = Cart::query() + ->where('store_id', $store->getKey()) + ->whereKey($cartId) + ->where('status', CartStatus::Active) + ->first(); + + if ($cart !== null) { + return $cart; + } + } + + if ($customer !== null) { + return Cart::query() + ->where('store_id', $store->getKey()) + ->where('customer_id', $customer->getKey()) + ->where('status', CartStatus::Active) + ->first(); + } + + return null; + } + + protected function cartLinesData(?Cart $cart): array + { + if ($cart === null) { + return []; + } + + $catalog = app(StorefrontCatalog::class); + $lines = $cart->cartLines()->with(['variant.product.productMedia', 'variant.optionValues', 'variant.inventoryItem'])->orderBy('id')->get(); + + return $lines->map(fn ($line): array => [ + 'id' => $line->getKey(), + 'variant_id' => $line->variant_id, + 'title' => $line->variant->product->title, + 'variant' => $catalog->variantOptionLabels($line->variant), + 'sku' => $line->variant->sku, + 'quantity' => $line->quantity, + 'unit_price' => $line->unit_price_amount, + 'total' => $line->line_total_amount, + 'image_url' => $catalog->primaryImageUrl($line->variant->product), + 'max_quantity' => $catalog->availableQuantity($line->variant), + 'currency' => $line->variant->currency ?? $cart->currency, + 'is_backorder' => $line->variant->inventoryItem !== null + && $line->variant->inventoryItem->policy === \App\Enums\InventoryPolicy::Continue + && ($line->variant->inventoryItem->quantity_on_hand - $line->variant->inventoryItem->quantity_reserved) <= 0, + ])->all(); + } + + /** + * @return array{subtotal: int, discount: int, total: int, currency: string, item_count: int} + */ + protected function cartTotals(?Cart $cart): array + { + $store = $this->store(); + + if ($cart === null) { + return ['subtotal' => 0, 'discount' => 0, 'total' => 0, 'currency' => $store->default_currency, 'item_count' => 0]; + } + + $subtotal = (int) $cart->cartLines()->sum('line_subtotal_amount'); + $itemCount = (int) $cart->cartLines()->sum('quantity'); + $discount = $this->sessionDiscountAmount($cart, $subtotal); + + return [ + 'subtotal' => $subtotal, + 'discount' => $discount, + 'total' => max(0, $subtotal - $discount), + 'currency' => $cart->currency, + 'item_count' => $itemCount, + ]; + } + + protected function sessionDiscountCode(): ?string + { + $code = session()->get('storefront.discount_code'); + + return is_string($code) && $code !== '' ? $code : null; + } + + protected function sessionDiscountAmount(Cart $cart, int $subtotal): int + { + $code = $this->sessionDiscountCode(); + + if ($code === null) { + return 0; + } + + $discounts = app(DiscountService::class); + $result = $discounts->validate($code, $this->store(), $cart); + + if (! $result->valid || $result->discount === null) { + return 0; + } + + $lines = $cart->cartLines()->with('variant')->orderBy('id')->get()->map(fn ($line): array => [ + 'line_id' => $line->getKey(), + 'subtotal' => $line->line_subtotal_amount, + 'product_id' => $line->variant->product_id, + ])->all(); + + return (int) $discounts->calculate($result->discount, $subtotal, $lines)['total']; + } + + public function incrementLine(int $lineId): void + { + $cart = $this->findCart(); + + if ($cart === null) { + return; + } + + $line = $cart->cartLines()->whereKey($lineId)->first(); + + if ($line === null) { + return; + } + + $this->updateLine($cart, $lineId, $line->quantity + 1); + } + + public function decrementLine(int $lineId): void + { + $cart = $this->findCart(); + + if ($cart === null) { + return; + } + + $line = $cart->cartLines()->whereKey($lineId)->first(); + + if ($line === null) { + return; + } + + $this->updateLine($cart, $lineId, $line->quantity - 1); + } + + public function removeLine(int $lineId): void + { + $cart = $this->findCart(); + + if ($cart === null) { + return; + } + + try { + app(CartService::class)->removeLine($cart, $lineId, $this->cartVersion); + $this->cartNotice = 'Item removed'; + } catch (CartVersionMismatchException $exception) { + $this->cartVersion = $exception->currentVersion; + $this->cartNotice = 'Your cart was updated elsewhere. Please review and try again.'; + + return; + } + + $this->syncCartVersion(); + $this->dispatch('cart-updated'); + } + + public function applyDiscount(): void + { + $cart = $this->findCart(); + + if ($cart === null) { + return; + } + + $code = trim($this->discountCodeInput); + + if ($code === '') { + $this->addError('discountCodeInput', 'Please enter a discount code.'); + + return; + } + + $result = app(DiscountService::class)->validate($code, $this->store(), $cart); + + if (! $result->valid) { + $this->addError('discountCodeInput', $result->errorMessage ?? 'Invalid discount code.'); + + return; + } + + session()->put('storefront.discount_code', $result->discount->code); + $this->discountCodeInput = ''; + $this->cartNotice = 'Discount applied'; + } + + public function removeDiscount(): void + { + session()->forget('storefront.discount_code'); + $this->discountCodeInput = ''; + } + + public function startCheckout(): mixed + { + $cart = $this->findCart(); + + if ($cart === null || $cart->cartLines()->count() === 0) { + $this->cartNotice = 'Your cart is empty'; + + return null; + } + + $customer = Auth::guard('customer')->user(); + $checkouts = app(CheckoutService::class); + + $existing = Checkout::query() + ->where('store_id', $this->store()->getKey()) + ->where('cart_id', $cart->getKey()) + ->whereNotIn('status', [\App\Enums\CheckoutStatus::Completed, \App\Enums\CheckoutStatus::Expired]) + ->latest('id') + ->first(); + + $checkout = $existing ?? $checkouts->createFromCart($cart->refresh(), (string) ($customer?->email ?? ''), $customer); + + $code = $this->sessionDiscountCode(); + + if ($code !== null && $checkout->discount_code === null) { + try { + $checkouts->applyDiscount($checkout->refresh(), $code, app(DiscountService::class)); + } catch (\Throwable) { + } + } + + return $this->redirect(route('storefront.checkout.show', ['checkoutId' => $checkout->getKey()]), navigate: true); + } + + protected function updateLine(Cart $cart, int $lineId, int $quantity): void + { + if ($quantity < 1) { + $this->removeLine($lineId); + + return; + } + + try { + app(CartService::class)->updateLineQuantity($cart, $lineId, $quantity, $this->cartVersion); + $this->cartNotice = null; + } catch (CartVersionMismatchException $exception) { + $this->cartVersion = $exception->currentVersion; + $this->cartNotice = 'Your cart was updated elsewhere. Please review and try again.'; + + return; + } catch (InsufficientInventoryException) { + $this->cartNotice = 'Only limited stock is available for this item.'; + + return; + } catch (ValidationException $exception) { + $this->cartNotice = collect($exception->errors())->flatten()->first() ?? 'Unable to update quantity.'; + + return; + } + + $this->syncCartVersion(); + $this->dispatch('cart-updated'); + } + + protected function syncCartVersion(): void + { + $cart = $this->findCart(); + + $this->cartVersion = $cart?->cart_version; + } +} diff --git a/app/Livewire/Storefront/Concerns/ResolvesStore.php b/app/Livewire/Storefront/Concerns/ResolvesStore.php new file mode 100644 index 00000000..8b3b6cb7 --- /dev/null +++ b/app/Livewire/Storefront/Concerns/ResolvesStore.php @@ -0,0 +1,82 @@ +get(); + + if ($store !== null) { + return $store; + } + + $store = $this->resolveStoreFromRequest(); + + if ($store !== null) { + $current->set($store); + + return $store; + } + + abort(404); + } + + /** + * @return array + */ + protected function themeSettings(): array + { + return app(ThemeService::class)->settings($this->store()); + } + + protected function resolveStoreFromRequest(): ?Store + { + $request = request(); + + $hostname = $request->getHost(); + + if ($hostname !== '') { + $storeId = Cache::remember( + "store_domain:{$hostname}", + now()->addMinutes(5), + fn (): ?int => StoreDomain::query()->where('hostname', $hostname)->value('store_id') + ); + + if ($storeId !== null) { + $store = Store::query()->find($storeId); + + if ($store !== null) { + if ($store->status === StoreStatus::Suspended) { + abort(503, 'This store is currently unavailable.'); + } + + return $store; + } + } + } + + if (app()->bound('session')) { + $sessionStoreId = session()->get('storefront_store_id'); + + if ($sessionStoreId !== null) { + $store = Store::query()->find($sessionStoreId); + + if ($store !== null && $store->status !== StoreStatus::Suspended) { + return $store; + } + } + } + + return null; + } +} diff --git a/app/Livewire/Storefront/FeaturedProducts.php b/app/Livewire/Storefront/FeaturedProducts.php new file mode 100644 index 00000000..f923117f --- /dev/null +++ b/app/Livewire/Storefront/FeaturedProducts.php @@ -0,0 +1,36 @@ +count = $count; + $this->collectionId = $collectionId; + } + + public function render() + { + $catalog = app(StorefrontCatalog::class); + $query = $catalog->visibleProducts($this->store()); + + if ($this->collectionId !== null) { + $query->whereHas('collections', fn ($query) => $query->where('collections.id', $this->collectionId)); + } + + $products = $catalog->withStorefrontRelations($query)->orderBy('id')->take($this->count)->get(); + + return view('livewire.storefront.featured-products', ['products' => $products]); + } +} diff --git a/app/Livewire/Storefront/Home.php b/app/Livewire/Storefront/Home.php new file mode 100644 index 00000000..9f954866 --- /dev/null +++ b/app/Livewire/Storefront/Home.php @@ -0,0 +1,35 @@ +store(); + $settings = $this->themeSettings(); + + $collections = Collection::query() + ->where('store_id', $store->getKey()) + ->where('status', CollectionStatus::Active) + ->orderBy('title') + ->take((int) ($settings['home']['featured_collections_count'] ?? 4)) + ->get(); + + return view('livewire.storefront.home', [ + 'store' => $store, + 'settings' => $settings, + 'collections' => $collections, + ])->layout('storefront.layouts.app', [ + 'title' => $store->name, + 'metaDescription' => 'Welcome to '.$store->name, + ]); + } +} diff --git a/app/Livewire/Storefront/NewsletterSignup.php b/app/Livewire/Storefront/NewsletterSignup.php new file mode 100644 index 00000000..fe62c7a3 --- /dev/null +++ b/app/Livewire/Storefront/NewsletterSignup.php @@ -0,0 +1,24 @@ +validate(['email' => 'required|email|max:255']); + + $this->subscribed = true; + } + + public function render() + { + return view('livewire.storefront.newsletter-signup'); + } +} diff --git a/app/Livewire/Storefront/Pages/Show.php b/app/Livewire/Storefront/Pages/Show.php new file mode 100644 index 00000000..e6b54044 --- /dev/null +++ b/app/Livewire/Storefront/Pages/Show.php @@ -0,0 +1,41 @@ +handle = $handle; + } + + public function render() + { + $store = $this->store(); + + $page = Page::query() + ->where('store_id', $store->getKey()) + ->where('handle', $this->handle) + ->where('status', PageStatus::Published) + ->first(); + + abort_if($page === null, 404); + + return view('livewire.storefront.pages.show', [ + 'store' => $store, + 'page' => $page, + ])->layout('storefront.layouts.app', [ + 'title' => $page->title.' - '.$store->name, + 'metaDescription' => mb_substr(trim(strip_tags((string) $page->body_html)) ?: $page->title, 0, 160), + ]); + } +} diff --git a/app/Livewire/Storefront/Products/Show.php b/app/Livewire/Storefront/Products/Show.php new file mode 100644 index 00000000..2e101105 --- /dev/null +++ b/app/Livewire/Storefront/Products/Show.php @@ -0,0 +1,239 @@ + option name => selected value */ + public array $selectedOptions = []; + + public int $quantity = 1; + + public int $selectedImage = 0; + + public ?string $notice = null; + + public function mount(string $handle): void + { + $this->handle = $handle; + + $product = $this->product(); + + abort_if($product === null, 404); + + foreach ($product->productOptions as $option) { + $defaultValue = $this->defaultValueForOption($product, $option->getKey()); + + if ($defaultValue !== null) { + $this->selectedOptions[$option->name] = $defaultValue; + } + } + } + + public function updatedQuantity(int $value): void + { + $max = $this->maxQuantity(); + + if ($value < 1) { + $this->quantity = 1; + } elseif ($max !== null && $value > $max) { + $this->quantity = $max; + } + } + + public function updatedSelectedOptions(): void + { + $this->quantity = 1; + $this->notice = null; + } + + public function selectImage(int $index): void + { + $this->selectedImage = $index; + } + + public function incrementQuantity(): void + { + $max = $this->maxQuantity(); + $this->quantity++; + + if ($max !== null && $this->quantity > $max) { + $this->quantity = $max; + } + } + + public function decrementQuantity(): void + { + if ($this->quantity > 1) { + $this->quantity--; + } + } + + public function maxQuantity(): ?int + { + $variant = $this->selectedVariant(); + + if ($variant === null) { + return 1; + } + + return app(StorefrontCatalog::class)->availableQuantity($variant); + } + + public function selectedVariant(): ?\App\Models\ProductVariant + { + $product = $this->product(); + + if ($product === null) { + return null; + } + + $options = $product->productOptions; + + if ($options->isEmpty()) { + return app(StorefrontCatalog::class)->displayVariant($product); + } + + foreach ($product->productVariants->where('status', VariantStatus::Active) as $variant) { + if ($this->variantMatches($product, $variant)) { + return $variant; + } + } + + return null; + } + + public function addToCart(): void + { + $variant = $this->selectedVariant(); + + if ($variant === null) { + $this->notice = 'Please select all options.'; + + return; + } + + $max = app(StorefrontCatalog::class)->availableQuantity($variant); + + if ($max !== null && $this->quantity > $max) { + $this->notice = 'Only '.$max.' left in stock.'; + + return; + } + + $store = $this->store(); + $customer = Auth::guard('customer')->user(); + $carts = app(CartService::class); + + try { + $cart = $carts->getOrCreateForSession($store, $customer); + $carts->addLine($cart, $variant->getKey(), $this->quantity); + } catch (InsufficientInventoryException) { + $this->notice = 'This product is currently out of stock'; + + return; + } catch (ValidationException $exception) { + $this->notice = collect($exception->errors())->flatten()->first() ?? 'Unable to add to cart.'; + + return; + } + + $this->notice = 'Added to cart'; + $this->dispatch('cart-updated'); + } + + public function render() + { + $product = $this->product(); + + abort_if($product === null, 404); + + $store = $this->store(); + $catalog = app(StorefrontCatalog::class); + $variant = $this->selectedVariant(); + $currency = $variant?->currency ?? $store->default_currency; + $collection = $product->collections->first(); + $available = $variant !== null ? $catalog->availableQuantity($variant) : null; + $inventoryItem = $variant?->inventoryItem; + $isBackorder = $inventoryItem !== null + && $inventoryItem->policy === \App\Enums\InventoryPolicy::Continue + && ($inventoryItem->quantity_on_hand - $inventoryItem->quantity_reserved) <= 0; + + return view('livewire.storefront.products.show', [ + 'store' => $store, + 'product' => $product, + 'collection' => $collection, + 'variant' => $variant, + 'currency' => $currency, + 'available' => $available, + 'isBackorder' => $isBackorder, + 'price' => $catalog, + ])->layout('storefront.layouts.app', [ + 'title' => $product->title.' - '.$store->name, + 'metaDescription' => mb_substr(trim(strip_tags((string) $product->description_html)) ?: $product->title, 0, 160), + ]); + } + + protected function product(): ?Product + { + return Product::query() + ->where('store_id', $this->store()->getKey()) + ->where('handle', $this->handle) + ->where('status', ProductStatus::Active) + ->whereNotNull('published_at') + ->with([ + 'productOptions.productOptionValues', + 'productVariants' => fn ($query) => $query->orderBy('position')->orderBy('id'), + 'productVariants.inventoryItem', + 'productVariants.optionValues', + 'productMedia' => fn ($query) => $query->orderBy('position')->orderBy('id'), + 'collections', + ]) + ->first(); + } + + protected function defaultValueForOption(Product $product, int $optionId): ?string + { + $default = $product->productVariants->firstWhere('is_default', true) ?? $product->productVariants->first(); + + $match = $default?->optionValues->firstWhere('product_option_id', $optionId); + + return $match?->value; + } + + protected function variantMatches(Product $product, \App\Models\ProductVariant $variant): bool + { + foreach ($product->productOptions as $option) { + $selected = $this->selectedOptions[$option->name] ?? null; + + if ($selected === null) { + return false; + } + + $has = $variant->optionValues + ->where('product_option_id', $option->getKey()) + ->contains('value', $selected); + + if (! $has) { + return false; + } + } + + return true; + } +} diff --git a/app/Livewire/Storefront/QuickAdd.php b/app/Livewire/Storefront/QuickAdd.php new file mode 100644 index 00000000..d091d434 --- /dev/null +++ b/app/Livewire/Storefront/QuickAdd.php @@ -0,0 +1,52 @@ +variantId = $variantId; + } + + public function add(): void + { + $store = $this->store(); + $customer = Auth::guard('customer')->user(); + $carts = app(CartService::class); + + try { + $cart = $carts->getOrCreateForSession($store, $customer); + $carts->addLine($cart, $this->variantId, 1); + } catch (InsufficientInventoryException) { + $this->message = 'This product is currently out of stock'; + + return; + } catch (ValidationException $exception) { + $this->message = collect($exception->errors())->flatten()->first() ?? 'Unable to add to cart.'; + + return; + } + + $this->message = null; + $this->dispatch('cart-updated'); + } + + public function render() + { + return view('livewire.storefront.quick-add'); + } +} diff --git a/app/Livewire/Storefront/Search/Index.php b/app/Livewire/Storefront/Search/Index.php new file mode 100644 index 00000000..a8df155a --- /dev/null +++ b/app/Livewire/Storefront/Search/Index.php @@ -0,0 +1,86 @@ +query = (string) request()->query('q', ''); + } + + public function updatedQuery(): void + { + $this->page = 1; + } + + public function updatedSort(): void + { + $this->page = 1; + } + + public function updatedInStock(): void + { + $this->page = 1; + } + + public function nextPage(): void + { + $this->page++; + } + + public function previousPage(): void + { + if ($this->page > 1) { + $this->page--; + } + } + + public function search(): void + { + $this->page = 1; + } + + public function render() + { + $store = $this->store(); + $result = ['results' => null, 'facets' => ['vendors' => [], 'tags' => [], 'price_range' => ['min' => 0, 'max' => 0]]]; + + if (trim($this->query) !== '') { + $filters = []; + + if ($this->inStock) { + $filters['in_stock'] = true; + } + + $result = app(SearchService::class)->search($store, $this->query, $filters, $this->sort, 12, $this->page); + } + + return view('livewire.storefront.search.index', [ + 'store' => $store, + 'paginator' => $result['results'], + 'facets' => $result['facets'], + ])->layout('storefront.layouts.app', [ + 'title' => 'Search results - '.$store->name, + 'metaDescription' => 'Search results at '.$store->name, + ]); + } +} diff --git a/app/Livewire/Storefront/SearchModal.php b/app/Livewire/Storefront/SearchModal.php new file mode 100644 index 00000000..410863ff --- /dev/null +++ b/app/Livewire/Storefront/SearchModal.php @@ -0,0 +1,40 @@ +open = true; + } + + #[On('close-search-modal')] + public function closeModal(): void + { + $this->open = false; + } + + public function render() + { + $suggestions = collect(); + + if ($this->open && trim($this->query) !== '') { + $suggestions = app(SearchService::class)->autocomplete($this->store(), $this->query, 5); + } + + return view('livewire.storefront.search-modal', ['suggestions' => $suggestions]); + } +} diff --git a/app/Models/AnalyticsDaily.php b/app/Models/AnalyticsDaily.php new file mode 100644 index 00000000..e181eac5 --- /dev/null +++ b/app/Models/AnalyticsDaily.php @@ -0,0 +1,75 @@ + + */ + protected function casts(): array + { + return [ + 'store_id' => 'integer', + 'orders_count' => 'integer', + 'revenue_amount' => 'integer', + 'aov_amount' => 'integer', + 'visits_count' => 'integer', + 'add_to_cart_count' => 'integer', + 'checkout_started_count' => 'integer', + 'checkout_completed_count' => 'integer', + ]; + } + + 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 00000000..5972bbce --- /dev/null +++ b/app/Models/AnalyticsEvent.php @@ -0,0 +1,62 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'store_id' => 'integer', + 'customer_id' => 'integer', + '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 00000000..f796e0ad --- /dev/null +++ b/app/Models/App.php @@ -0,0 +1,54 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'status' => AppStatus::class, + 'created_at' => 'datetime', + ]; + } + + public function appInstallations(): 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 00000000..7107cfe9 --- /dev/null +++ b/app/Models/AppInstallation.php @@ -0,0 +1,71 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'store_id' => 'integer', + 'app_id' => 'integer', + 'scopes_json' => 'array', + 'status' => AppInstallationStatus::class, + 'installed_at' => 'datetime', + ]; + } + + public function oauthTokens(): HasMany + { + return $this->hasMany(OauthToken::class, 'installation_id'); + } + + public function webhookSubscriptions(): HasMany + { + return $this->hasMany(WebhookSubscription::class); + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function app(): BelongsTo + { + return $this->belongsTo(App::class); + } +} diff --git a/app/Models/Cart.php b/app/Models/Cart.php new file mode 100644 index 00000000..b31d56e4 --- /dev/null +++ b/app/Models/Cart.php @@ -0,0 +1,64 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'store_id' => 'integer', + 'customer_id' => 'integer', + 'cart_version' => 'integer', + 'status' => CartStatus::class, + ]; + } + + public function cartLines(): HasMany + { + return $this->hasMany(CartLine::class); + } + + public function checkouts(): HasMany + { + return $this->hasMany(Checkout::class); + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Models/CartLine.php b/app/Models/CartLine.php new file mode 100644 index 00000000..fb26ab08 --- /dev/null +++ b/app/Models/CartLine.php @@ -0,0 +1,63 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'cart_id' => 'integer', + 'variant_id' => 'integer', + 'quantity' => 'integer', + 'unit_price_amount' => 'integer', + 'line_subtotal_amount' => 'integer', + 'line_discount_amount' => 'integer', + 'line_total_amount' => 'integer', + ]; + } + + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class); + } +} diff --git a/app/Models/Checkout.php b/app/Models/Checkout.php new file mode 100644 index 00000000..3426e3f7 --- /dev/null +++ b/app/Models/Checkout.php @@ -0,0 +1,74 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'store_id' => 'integer', + 'cart_id' => 'integer', + 'customer_id' => 'integer', + 'status' => CheckoutStatus::class, + 'payment_method' => PaymentMethod::class, + 'shipping_address_json' => 'array', + 'billing_address_json' => 'array', + 'shipping_method_id' => 'integer', + '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); + } +} diff --git a/app/Models/Collection.php b/app/Models/Collection.php new file mode 100644 index 00000000..e3f2b86d --- /dev/null +++ b/app/Models/Collection.php @@ -0,0 +1,55 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'store_id' => 'integer', + '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')->withPivot('position'); + } +} diff --git a/app/Models/CollectionProduct.php b/app/Models/CollectionProduct.php new file mode 100644 index 00000000..cd2e639d --- /dev/null +++ b/app/Models/CollectionProduct.php @@ -0,0 +1,61 @@ + + */ + protected function casts(): array + { + return [ + 'collection_id' => 'integer', + 'product_id' => 'integer', + 'position' => 'integer', + ]; + } + + public function collection(): BelongsTo + { + return $this->belongsTo(Collection::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/Models/Concerns/BelongsToStore.php b/app/Models/Concerns/BelongsToStore.php new file mode 100644 index 00000000..dd9f7547 --- /dev/null +++ b/app/Models/Concerns/BelongsToStore.php @@ -0,0 +1,25 @@ +getAttribute('store_id')) && app(CurrentStore::class)->has()) { + $model->setAttribute('store_id', app(CurrentStore::class)->id()); + } + }); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php new file mode 100644 index 00000000..4ebb2657 --- /dev/null +++ b/app/Models/Customer.php @@ -0,0 +1,92 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'store_id' => 'integer', + 'password_hash' => 'hashed', + 'marketing_opt_in' => 'boolean', + ]; + } + + /** + * The database column uses `password_hash` instead of Laravel's + * default `password`, so Auth::attempt() needs this override. + */ + public function getAuthPassword(): string + { + return $this->password_hash ?? ''; + } + + public function customerAddresses(): HasMany + { + return $this->hasMany(CustomerAddress::class); + } + + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + public function carts(): HasMany + { + return $this->hasMany(Cart::class); + } + + public function checkouts(): HasMany + { + return $this->hasMany(Checkout::class); + } + + public function analyticsEvents(): HasMany + { + return $this->hasMany(AnalyticsEvent::class); + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/CustomerAddress.php b/app/Models/CustomerAddress.php new file mode 100644 index 00000000..f42cf00c --- /dev/null +++ b/app/Models/CustomerAddress.php @@ -0,0 +1,51 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'customer_id' => 'integer', + 'address_json' => 'array', + 'is_default' => 'boolean', + ]; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Models/CustomerPasswordResetToken.php b/app/Models/CustomerPasswordResetToken.php new file mode 100644 index 00000000..50018349 --- /dev/null +++ b/app/Models/CustomerPasswordResetToken.php @@ -0,0 +1,56 @@ + + */ + protected function casts(): array + { + return [ + 'store_id' => 'integer', + 'created_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/Discount.php b/app/Models/Discount.php new file mode 100644 index 00000000..fc0bff8d --- /dev/null +++ b/app/Models/Discount.php @@ -0,0 +1,62 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'store_id' => 'integer', + 'type' => DiscountType::class, + 'value_type' => DiscountValueType::class, + 'value_amount' => 'integer', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'usage_limit' => 'integer', + 'usage_count' => 'integer', + 'rules_json' => 'array', + 'status' => DiscountStatus::class, + ]; + } + + 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 00000000..f191cd1b --- /dev/null +++ b/app/Models/Fulfillment.php @@ -0,0 +1,61 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'order_id' => 'integer', + 'status' => FulfillmentShipmentStatus::class, + 'shipped_at' => 'datetime', + 'created_at' => 'datetime', + ]; + } + + public function fulfillmentLines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } +} diff --git a/app/Models/FulfillmentLine.php b/app/Models/FulfillmentLine.php new file mode 100644 index 00000000..07f1e38d --- /dev/null +++ b/app/Models/FulfillmentLine.php @@ -0,0 +1,55 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'fulfillment_id' => 'integer', + 'order_line_id' => 'integer', + 'quantity' => 'integer', + ]; + } + + 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 00000000..99e723be --- /dev/null +++ b/app/Models/InventoryItem.php @@ -0,0 +1,61 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'store_id' => 'integer', + 'variant_id' => 'integer', + 'quantity_on_hand' => 'integer', + 'quantity_reserved' => 'integer', + 'policy' => InventoryPolicy::class, + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class); + } +} diff --git a/app/Models/NavigationItem.php b/app/Models/NavigationItem.php new file mode 100644 index 00000000..544e3231 --- /dev/null +++ b/app/Models/NavigationItem.php @@ -0,0 +1,55 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'menu_id' => 'integer', + 'type' => NavigationItemType::class, + 'resource_id' => 'integer', + 'position' => 'integer', + ]; + } + + public function menu(): BelongsTo + { + return $this->belongsTo(NavigationMenu::class); + } +} diff --git a/app/Models/NavigationMenu.php b/app/Models/NavigationMenu.php new file mode 100644 index 00000000..53324533 --- /dev/null +++ b/app/Models/NavigationMenu.php @@ -0,0 +1,48 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'store_id' => 'integer', + ]; + } + + public function navigationItems(): HasMany + { + return $this->hasMany(NavigationItem::class, 'menu_id'); + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/OauthClient.php b/app/Models/OauthClient.php new file mode 100644 index 00000000..8bcfa65b --- /dev/null +++ b/app/Models/OauthClient.php @@ -0,0 +1,60 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'app_id' => 'integer', + 'client_secret_encrypted' => 'encrypted', + '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 00000000..bc170cd9 --- /dev/null +++ b/app/Models/OauthToken.php @@ -0,0 +1,50 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'installation_id' => 'integer', + 'expires_at' => 'datetime', + ]; + } + + public function installation(): BelongsTo + { + return $this->belongsTo(AppInstallation::class); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 00000000..6c7cbce8 --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,106 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'store_id' => 'integer', + 'checkout_id' => 'integer', + 'customer_id' => 'integer', + 'payment_method' => PaymentMethod::class, + 'status' => OrderStatus::class, + 'financial_status' => FinancialStatus::class, + 'fulfillment_status' => FulfillmentStatus::class, + 'subtotal_amount' => 'integer', + 'discount_amount' => 'integer', + 'shipping_amount' => 'integer', + 'tax_amount' => 'integer', + 'total_amount' => 'integer', + 'billing_address_json' => 'array', + 'shipping_address_json' => 'array', + 'placed_at' => 'datetime', + ]; + } + + public function orderLines(): 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 store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function checkout(): BelongsTo + { + return $this->belongsTo(Checkout::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Models/OrderLine.php b/app/Models/OrderLine.php new file mode 100644 index 00000000..f723bdbb --- /dev/null +++ b/app/Models/OrderLine.php @@ -0,0 +1,78 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'order_id' => 'integer', + 'product_id' => 'integer', + 'variant_id' => 'integer', + 'quantity' => 'integer', + 'unit_price_amount' => 'integer', + 'total_amount' => 'integer', + 'tax_lines_json' => 'array', + 'discount_allocations_json' => 'array', + ]; + } + + public function fulfillmentLines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } + + 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); + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php new file mode 100644 index 00000000..8ceb3dc1 --- /dev/null +++ b/app/Models/Organization.php @@ -0,0 +1,39 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + ]; + } + + 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 00000000..afe64ec3 --- /dev/null +++ b/app/Models/Page.php @@ -0,0 +1,48 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'store_id' => 'integer', + '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 00000000..22cf3004 --- /dev/null +++ b/app/Models/Payment.php @@ -0,0 +1,77 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'order_id' => 'integer', + 'provider' => PaymentProvider::class, + 'method' => PaymentMethod::class, + 'status' => PaymentStatus::class, + 'amount' => 'integer', + 'raw_json_encrypted' => 'encrypted', + 'created_at' => 'datetime', + ]; + } + + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 00000000..637bc1d7 --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,74 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'store_id' => 'integer', + 'status' => ProductStatus::class, + 'tags' => 'array', + 'published_at' => 'datetime', + ]; + } + + public function productOptions(): HasMany + { + return $this->hasMany(ProductOption::class); + } + + public function productVariants(): HasMany + { + return $this->hasMany(ProductVariant::class); + } + + public function productMedia(): HasMany + { + return $this->hasMany(ProductMedia::class); + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function collections(): BelongsToMany + { + return $this->belongsToMany(Collection::class, 'collection_products')->withPivot('position'); + } +} diff --git a/app/Models/ProductMedia.php b/app/Models/ProductMedia.php new file mode 100644 index 00000000..3c2b322f --- /dev/null +++ b/app/Models/ProductMedia.php @@ -0,0 +1,64 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'product_id' => 'integer', + 'type' => MediaType::class, + 'width' => 'integer', + 'height' => 'integer', + 'byte_size' => 'integer', + 'position' => 'integer', + 'status' => MediaStatus::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 00000000..635ec5e6 --- /dev/null +++ b/app/Models/ProductOption.php @@ -0,0 +1,55 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'product_id' => 'integer', + 'position' => 'integer', + ]; + } + + public function productOptionValues(): HasMany + { + return $this->hasMany(ProductOptionValue::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/Models/ProductOptionValue.php b/app/Models/ProductOptionValue.php new file mode 100644 index 00000000..9c842a27 --- /dev/null +++ b/app/Models/ProductOptionValue.php @@ -0,0 +1,55 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'product_option_id' => 'integer', + 'position' => 'integer', + ]; + } + + public function productOption(): BelongsTo + { + return $this->belongsTo(ProductOption::class); + } + + public function variants(): BelongsToMany + { + return $this->belongsToMany(ProductVariant::class, 'variant_option_values', 'product_option_value_id', 'variant_id'); + } +} diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php new file mode 100644 index 00000000..49cd326d --- /dev/null +++ b/app/Models/ProductVariant.php @@ -0,0 +1,69 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'product_id' => 'integer', + 'price_amount' => 'integer', + 'compare_at_amount' => 'integer', + 'weight_g' => 'integer', + 'requires_shipping' => 'boolean', + 'is_default' => 'boolean', + 'position' => 'integer', + 'status' => VariantStatus::class, + ]; + } + + public function inventoryItem(): HasOne + { + return $this->hasOne(InventoryItem::class, 'variant_id'); + } + + 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'); + } +} diff --git a/app/Models/Refund.php b/app/Models/Refund.php new file mode 100644 index 00000000..56dfbcde --- /dev/null +++ b/app/Models/Refund.php @@ -0,0 +1,61 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'order_id' => 'integer', + 'payment_id' => 'integer', + 'amount' => 'integer', + '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 00000000..d98dfb31 --- /dev/null +++ b/app/Models/Scopes/StoreScope.php @@ -0,0 +1,31 @@ + $builder + */ + public function apply(Builder $builder, Model $model): void + { + $storeId = app(CurrentStore::class)->id(); + + if ($storeId === null) { + return; + } + + $builder->where($model->getTable().'.store_id', $storeId); + } +} diff --git a/app/Models/SearchQuery.php b/app/Models/SearchQuery.php new file mode 100644 index 00000000..3a6e4b0f --- /dev/null +++ b/app/Models/SearchQuery.php @@ -0,0 +1,53 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'store_id' => 'integer', + 'filters_json' => 'array', + 'results_count' => 'integer', + '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 00000000..e94368a4 --- /dev/null +++ b/app/Models/SearchSetting.php @@ -0,0 +1,71 @@ + + */ + protected function casts(): array + { + return [ + 'store_id' => 'integer', + '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 00000000..9f7b6d0b --- /dev/null +++ b/app/Models/ShippingRate.php @@ -0,0 +1,54 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'zone_id' => 'integer', + 'type' => ShippingRateType::class, + 'config_json' => 'array', + 'is_active' => 'boolean', + ]; + } + + public function zone(): BelongsTo + { + return $this->belongsTo(ShippingZone::class); + } +} diff --git a/app/Models/ShippingZone.php b/app/Models/ShippingZone.php new file mode 100644 index 00000000..bbcce948 --- /dev/null +++ b/app/Models/ShippingZone.php @@ -0,0 +1,58 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'store_id' => 'integer', + 'countries_json' => 'array', + 'regions_json' => 'array', + ]; + } + + public function shippingRates(): HasMany + { + return $this->hasMany(ShippingRate::class, 'zone_id'); + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/Store.php b/app/Models/Store.php new file mode 100644 index 00000000..168b22b6 --- /dev/null +++ b/app/Models/Store.php @@ -0,0 +1,155 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'organization_id' => 'integer', + 'status' => StoreStatus::class, + ]; + } + + public function storeSetting(): HasOne + { + return $this->hasOne(StoreSetting::class); + } + + public function searchSetting(): HasOne + { + return $this->hasOne(SearchSetting::class); + } + + public function taxSetting(): HasOne + { + return $this->hasOne(TaxSetting::class); + } + + public function storeDomains(): HasMany + { + return $this->hasMany(StoreDomain::class); + } + + public function customers(): HasMany + { + return $this->hasMany(Customer::class); + } + + public function themes(): HasMany + { + return $this->hasMany(Theme::class); + } + + public function pages(): HasMany + { + return $this->hasMany(Page::class); + } + + public function navigationMenus(): HasMany + { + return $this->hasMany(NavigationMenu::class); + } + + public function carts(): HasMany + { + return $this->hasMany(Cart::class); + } + + public function checkouts(): HasMany + { + return $this->hasMany(Checkout::class); + } + + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + public function discounts(): HasMany + { + return $this->hasMany(Discount::class); + } + + public function shippingZones(): HasMany + { + return $this->hasMany(ShippingZone::class); + } + + public function products(): HasMany + { + return $this->hasMany(Product::class); + } + + public function collections(): HasMany + { + return $this->hasMany(Collection::class); + } + + public function searchQueries(): HasMany + { + return $this->hasMany(SearchQuery::class); + } + + public function analyticsEvents(): HasMany + { + return $this->hasMany(AnalyticsEvent::class); + } + + public function webhookSubscriptions(): HasMany + { + return $this->hasMany(WebhookSubscription::class); + } + + public function inventoryItems(): HasMany + { + return $this->hasMany(InventoryItem::class); + } + + public function appInstallations(): HasMany + { + return $this->hasMany(AppInstallation::class); + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class); + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'store_users')->using(StoreUser::class)->withPivot('role'); + } +} diff --git a/app/Models/StoreDomain.php b/app/Models/StoreDomain.php new file mode 100644 index 00000000..05ac06e3 --- /dev/null +++ b/app/Models/StoreDomain.php @@ -0,0 +1,56 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'store_id' => 'integer', + 'type' => StoreDomainType::class, + 'is_primary' => 'boolean', + 'tls_mode' => TlsMode::class, + 'created_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/StoreSetting.php b/app/Models/StoreSetting.php new file mode 100644 index 00000000..a2267c5f --- /dev/null +++ b/app/Models/StoreSetting.php @@ -0,0 +1,69 @@ + + */ + protected function casts(): array + { + return [ + 'store_id' => 'integer', + '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 00000000..2e4b90f6 --- /dev/null +++ b/app/Models/StoreUser.php @@ -0,0 +1,63 @@ + + */ + protected function casts(): array + { + return [ + 'store_id' => 'integer', + 'user_id' => 'integer', + '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 00000000..bb359bad --- /dev/null +++ b/app/Models/TaxSetting.php @@ -0,0 +1,76 @@ + + */ + protected function casts(): array + { + return [ + 'store_id' => 'integer', + '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 00000000..fe5479c0 --- /dev/null +++ b/app/Models/Theme.php @@ -0,0 +1,59 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'store_id' => 'integer', + 'status' => ThemeStatus::class, + 'published_at' => 'datetime', + ]; + } + + public function themeSetting(): HasOne + { + return $this->hasOne(ThemeSetting::class); + } + + public function themeFiles(): HasMany + { + return $this->hasMany(ThemeFile::class); + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/ThemeFile.php b/app/Models/ThemeFile.php new file mode 100644 index 00000000..4abbf5aa --- /dev/null +++ b/app/Models/ThemeFile.php @@ -0,0 +1,51 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'theme_id' => 'integer', + 'byte_size' => 'integer', + ]; + } + + 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 00000000..874e94aa --- /dev/null +++ b/app/Models/ThemeSetting.php @@ -0,0 +1,69 @@ + + */ + protected function casts(): array + { + return [ + 'theme_id' => 'integer', + '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 214bea4e..caf701b4 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,16 +3,20 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Enums\StoreUserRole; +use App\Enums\UserStatus; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; use Laravel\Fortify\TwoFactorAuthenticatable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable, TwoFactorAuthenticatable; + use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; /** * The attributes that are mass assignable. @@ -23,6 +27,7 @@ class User extends Authenticatable 'name', 'email', 'password', + 'status', ]; /** @@ -47,9 +52,23 @@ protected function casts(): array return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'status' => UserStatus::class, + 'last_login_at' => 'datetime', ]; } + public function stores(): BelongsToMany + { + return $this->belongsToMany(Store::class, 'store_users')->using(StoreUser::class)->withPivot('role'); + } + + public function roleForStore(Store $store): ?StoreUserRole + { + $role = $this->stores()->where('stores.id', $store->id)->first()?->pivot->role; + + return $role instanceof StoreUserRole ? $role : null; + } + /** * Get the user's initials */ diff --git a/app/Models/VariantOptionValue.php b/app/Models/VariantOptionValue.php new file mode 100644 index 00000000..139db217 --- /dev/null +++ b/app/Models/VariantOptionValue.php @@ -0,0 +1,59 @@ + + */ + protected function casts(): array + { + return [ + 'variant_id' => 'integer', + 'product_option_value_id' => 'integer', + ]; + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class); + } + + public function productOptionValue(): BelongsTo + { + return $this->belongsTo(ProductOptionValue::class); + } +} diff --git a/app/Models/WebhookDelivery.php b/app/Models/WebhookDelivery.php new file mode 100644 index 00000000..d2444fad --- /dev/null +++ b/app/Models/WebhookDelivery.php @@ -0,0 +1,57 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'subscription_id' => 'integer', + 'attempt_count' => 'integer', + 'status' => WebhookDeliveryStatus::class, + 'last_attempt_at' => 'datetime', + 'response_code' => 'integer', + ]; + } + + public function subscription(): BelongsTo + { + return $this->belongsTo(WebhookSubscription::class); + } +} diff --git a/app/Models/WebhookSubscription.php b/app/Models/WebhookSubscription.php new file mode 100644 index 00000000..b2b326c8 --- /dev/null +++ b/app/Models/WebhookSubscription.php @@ -0,0 +1,76 @@ + + */ + protected function casts(): array + { + return [ + 'id' => 'integer', + 'store_id' => 'integer', + 'app_installation_id' => 'integer', + 'signing_secret_encrypted' => 'encrypted', + 'status' => WebhookSubscriptionStatus::class, + ]; + } + + public function webhookDeliveries(): HasMany + { + return $this->hasMany(WebhookDelivery::class, 'subscription_id'); + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function appInstallation(): BelongsTo + { + return $this->belongsTo(AppInstallation::class); + } +} diff --git a/app/Notifications/CustomerResetPasswordNotification.php b/app/Notifications/CustomerResetPasswordNotification.php new file mode 100644 index 00000000..f3eb1205 --- /dev/null +++ b/app/Notifications/CustomerResetPasswordNotification.php @@ -0,0 +1,41 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $url = route('customer.password.reset', ['token' => $this->token]).'?'.http_build_query([ + 'email' => $this->email, + 'store' => $this->storeHandle, + ]); + + return (new MailMessage) + ->subject('Reset your password') + ->line('You requested a password reset for your store account.') + ->action('Reset Password', $url) + ->line('This link expires in 60 minutes.') + ->line('If you did not request this, no action is needed.'); + } +} diff --git a/app/Policies/CollectionPolicy.php b/app/Policies/CollectionPolicy.php new file mode 100644 index 00000000..b4706741 --- /dev/null +++ b/app/Policies/CollectionPolicy.php @@ -0,0 +1,41 @@ +currentStoreId(); + + return $storeId !== null && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, Collection $collection): bool + { + return $this->isAnyRole($user, $collection->store_id); + } + + public function create(User $user): bool + { + $storeId = $this->currentStoreId(); + + return $storeId !== null && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function update(User $user, Collection $collection): bool + { + return $this->isOwnerAdminOrStaff($user, $collection->store_id); + } + + public function delete(User $user, Collection $collection): bool + { + return $this->isOwnerOrAdmin($user, $collection->store_id); + } +} diff --git a/app/Policies/CustomerPolicy.php b/app/Policies/CustomerPolicy.php new file mode 100644 index 00000000..96996090 --- /dev/null +++ b/app/Policies/CustomerPolicy.php @@ -0,0 +1,29 @@ +currentStoreId(); + + return $storeId !== null && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, Customer $customer): bool + { + return $this->isAnyRole($user, $customer->store_id); + } + + public function update(User $user, Customer $customer): bool + { + return $this->isOwnerAdminOrStaff($user, $customer->store_id); + } +} diff --git a/app/Policies/DiscountPolicy.php b/app/Policies/DiscountPolicy.php new file mode 100644 index 00000000..eedc15cb --- /dev/null +++ b/app/Policies/DiscountPolicy.php @@ -0,0 +1,41 @@ +currentStoreId(); + + return $storeId !== null && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, Discount $discount): bool + { + return $this->isAnyRole($user, $discount->store_id); + } + + public function create(User $user): bool + { + $storeId = $this->currentStoreId(); + + return $storeId !== null && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function update(User $user, Discount $discount): bool + { + return $this->isOwnerAdminOrStaff($user, $discount->store_id); + } + + public function delete(User $user, Discount $discount): bool + { + return $this->isOwnerOrAdmin($user, $discount->store_id); + } +} diff --git a/app/Policies/FulfillmentPolicy.php b/app/Policies/FulfillmentPolicy.php new file mode 100644 index 00000000..8ef1fe90 --- /dev/null +++ b/app/Policies/FulfillmentPolicy.php @@ -0,0 +1,50 @@ +currentStoreId(); + + return $storeId !== null && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, Fulfillment $fulfillment): bool + { + return $this->isAnyRole($user, $fulfillment->order->store_id); + } + + public function create(User $user, Order $order): bool + { + return $this->isOwnerAdminOrStaff($user, $order->store_id); + } + + public function update(User $user, Fulfillment $fulfillment): bool + { + return $this->isOwnerAdminOrStaff($user, $fulfillment->order->store_id); + } + + public function ship(User $user, Fulfillment $fulfillment): bool + { + return $this->isOwnerAdminOrStaff($user, $fulfillment->order->store_id); + } + + public function deliver(User $user, Fulfillment $fulfillment): bool + { + return $this->isOwnerAdminOrStaff($user, $fulfillment->order->store_id); + } + + public function cancel(User $user, Fulfillment $fulfillment): bool + { + return $this->isOwnerAdminOrStaff($user, $fulfillment->order->store_id); + } +} diff --git a/app/Policies/NavigationMenuPolicy.php b/app/Policies/NavigationMenuPolicy.php new file mode 100644 index 00000000..e07e9bd3 --- /dev/null +++ b/app/Policies/NavigationMenuPolicy.php @@ -0,0 +1,25 @@ +currentStoreId(); + + return $storeId !== null && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function manage(User $user): bool + { + $storeId = $this->currentStoreId(); + + return $storeId !== null && $this->isOwnerOrAdmin($user, $storeId); + } +} diff --git a/app/Policies/OrderPolicy.php b/app/Policies/OrderPolicy.php new file mode 100644 index 00000000..d7cf1759 --- /dev/null +++ b/app/Policies/OrderPolicy.php @@ -0,0 +1,49 @@ +currentStoreId(); + + return $storeId !== null && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, Order $order): bool + { + return $this->isAnyRole($user, $order->store_id); + } + + public function update(User $user, Order $order): bool + { + return $this->isOwnerAdminOrStaff($user, $order->store_id); + } + + public function cancel(User $user, Order $order): bool + { + return $this->isOwnerOrAdmin($user, $order->store_id); + } + + public function createFulfillment(User $user, Order $order): bool + { + return $this->isOwnerAdminOrStaff($user, $order->store_id); + } + + public function createRefund(User $user, Order $order): bool + { + return $this->isOwnerOrAdmin($user, $order->store_id); + } + + public function confirmPayment(User $user, Order $order): bool + { + return $this->isOwnerOrAdmin($user, $order->store_id); + } +} diff --git a/app/Policies/PagePolicy.php b/app/Policies/PagePolicy.php new file mode 100644 index 00000000..d5beb9aa --- /dev/null +++ b/app/Policies/PagePolicy.php @@ -0,0 +1,41 @@ +currentStoreId(); + + return $storeId !== null && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function view(User $user, Page $page): bool + { + return $this->isOwnerAdminOrStaff($user, $page->store_id); + } + + public function create(User $user): bool + { + $storeId = $this->currentStoreId(); + + return $storeId !== null && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function update(User $user, Page $page): bool + { + return $this->isOwnerAdminOrStaff($user, $page->store_id); + } + + public function delete(User $user, Page $page): bool + { + return $this->isOwnerOrAdmin($user, $page->store_id); + } +} diff --git a/app/Policies/PaymentPolicy.php b/app/Policies/PaymentPolicy.php new file mode 100644 index 00000000..9bd7cc0b --- /dev/null +++ b/app/Policies/PaymentPolicy.php @@ -0,0 +1,29 @@ +currentStoreId(); + + return $storeId !== null && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, Payment $payment): bool + { + return $this->isAnyRole($user, $payment->order->store_id); + } + + public function confirm(User $user, Payment $payment): bool + { + return $this->isOwnerOrAdmin($user, $payment->order->store_id); + } +} diff --git a/app/Policies/ProductPolicy.php b/app/Policies/ProductPolicy.php new file mode 100644 index 00000000..daf709f9 --- /dev/null +++ b/app/Policies/ProductPolicy.php @@ -0,0 +1,51 @@ +currentStoreId(); + + return $storeId !== null && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, Product $product): bool + { + return $this->isAnyRole($user, $product->store_id); + } + + public function create(User $user): bool + { + $storeId = $this->currentStoreId(); + + return $storeId !== null && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function update(User $user, Product $product): bool + { + return $this->isOwnerAdminOrStaff($user, $product->store_id); + } + + public function delete(User $user, Product $product): bool + { + return $this->isOwnerOrAdmin($user, $product->store_id); + } + + public function archive(User $user, Product $product): bool + { + return $this->isOwnerOrAdmin($user, $product->store_id); + } + + public function restore(User $user, Product $product): bool + { + return $this->isOwnerOrAdmin($user, $product->store_id); + } +} diff --git a/app/Policies/RefundPolicy.php b/app/Policies/RefundPolicy.php new file mode 100644 index 00000000..3cbb01e9 --- /dev/null +++ b/app/Policies/RefundPolicy.php @@ -0,0 +1,30 @@ +currentStoreId(); + + return $storeId !== null && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, Refund $refund): bool + { + return $this->isAnyRole($user, $refund->order->store_id); + } + + public function create(User $user, Order $order): bool + { + return $this->isOwnerOrAdmin($user, $order->store_id); + } +} diff --git a/app/Policies/ShippingZonePolicy.php b/app/Policies/ShippingZonePolicy.php new file mode 100644 index 00000000..088675d1 --- /dev/null +++ b/app/Policies/ShippingZonePolicy.php @@ -0,0 +1,41 @@ +currentStoreId(); + + return $storeId !== null && $this->isOwnerOrAdmin($user, $storeId); + } + + public function view(User $user, ShippingZone $zone): bool + { + return $this->isOwnerOrAdmin($user, $zone->store_id); + } + + public function create(User $user): bool + { + $storeId = $this->currentStoreId(); + + return $storeId !== null && $this->isOwnerOrAdmin($user, $storeId); + } + + public function update(User $user, ShippingZone $zone): bool + { + return $this->isOwnerOrAdmin($user, $zone->store_id); + } + + public function delete(User $user, ShippingZone $zone): bool + { + return $this->isOwnerOrAdmin($user, $zone->store_id); + } +} diff --git a/app/Policies/StorePolicy.php b/app/Policies/StorePolicy.php new file mode 100644 index 00000000..f96546c7 --- /dev/null +++ b/app/Policies/StorePolicy.php @@ -0,0 +1,28 @@ +isOwnerOrAdmin($user, $store->getKey()); + } + + public function updateSettings(User $user, Store $store): bool + { + return $this->isOwnerOrAdmin($user, $store->getKey()); + } + + public function delete(User $user, Store $store): bool + { + return $this->hasRole($user, $store->getKey(), [StoreUserRole::Owner]); + } +} diff --git a/app/Policies/TaxSettingPolicy.php b/app/Policies/TaxSettingPolicy.php new file mode 100644 index 00000000..c270cc39 --- /dev/null +++ b/app/Policies/TaxSettingPolicy.php @@ -0,0 +1,29 @@ +currentStoreId(); + + return $storeId !== null && $this->isOwnerOrAdmin($user, $storeId); + } + + public function view(User $user, TaxSetting $settings): bool + { + return $this->isOwnerOrAdmin($user, $settings->store_id); + } + + public function update(User $user, TaxSetting $settings): bool + { + return $this->isOwnerOrAdmin($user, $settings->store_id); + } +} diff --git a/app/Policies/ThemePolicy.php b/app/Policies/ThemePolicy.php new file mode 100644 index 00000000..23f032cb --- /dev/null +++ b/app/Policies/ThemePolicy.php @@ -0,0 +1,46 @@ +currentStoreId(); + + return $storeId !== null && $this->isOwnerOrAdmin($user, $storeId); + } + + public function view(User $user, Theme $theme): bool + { + return $this->isOwnerOrAdmin($user, $theme->store_id); + } + + public function create(User $user): bool + { + $storeId = $this->currentStoreId(); + + return $storeId !== null && $this->isOwnerOrAdmin($user, $storeId); + } + + public function update(User $user, Theme $theme): bool + { + return $this->isOwnerOrAdmin($user, $theme->store_id); + } + + public function delete(User $user, Theme $theme): bool + { + return $this->isOwnerOrAdmin($user, $theme->store_id); + } + + public function publish(User $user, Theme $theme): bool + { + return $this->isOwnerOrAdmin($user, $theme->store_id); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8a29e6f5..4cab2cad 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,11 +2,28 @@ namespace App\Providers; +use App\Auth\CustomerUserProvider; +use App\Enums\StoreUserRole; +use App\Models\Customer; +use App\Models\StoreUser; +use App\Models\User; +use App\Services\AuditLogger; +use App\Tenancy\CurrentStore; use Carbon\CarbonImmutable; +use Illuminate\Auth\Events\Failed; +use Illuminate\Auth\Events\Login; +use Illuminate\Auth\Events\Logout; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; +use Livewire\Livewire; class AppServiceProvider extends ServiceProvider { @@ -15,7 +32,11 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->singleton(CurrentStore::class); + $this->app->bind( + \App\Contracts\PaymentProvider::class, + \App\Services\Payment\MockPaymentProvider::class + ); } /** @@ -24,6 +45,12 @@ public function register(): void public function boot(): void { $this->configureDefaults(); + $this->configureCustomerProvider(); + $this->configureGates(); + $this->configureRateLimiting(); + $this->configureAuditListeners(); + $this->configureWebhookListeners(); + $this->configureLivewireHooks(); } /** @@ -47,4 +74,116 @@ protected function configureDefaults(): void : null ); } + + protected function configureCustomerProvider(): void + { + Auth::provider('customer-eloquent', fn ($app, array $config): CustomerUserProvider => new CustomerUserProvider( + $app['hash'], + $config['model'], + )); + } + + protected function configureGates(): void + { + $hasStoreRole = function (User $user, array $roles): bool { + $current = app(CurrentStore::class); + $storeId = $current->id() ?? $current->adminIdFromSession(); + + if ($storeId === null) { + return false; + } + + $role = StoreUser::query() + ->where('store_id', $storeId) + ->where('user_id', $user->getKey()) + ->value('role'); + + $role = $role instanceof StoreUserRole ? $role : StoreUserRole::tryFrom((string) $role); + + return in_array($role, $roles, true); + }; + + $ownerAdmin = [StoreUserRole::Owner, StoreUserRole::Admin]; + $ownerAdminStaff = [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]; + + Gate::define('manage-store-settings', fn (User $user): bool => $hasStoreRole($user, $ownerAdmin)); + Gate::define('manage-staff', fn (User $user): bool => $hasStoreRole($user, $ownerAdmin)); + Gate::define('manage-developers', fn (User $user): bool => $hasStoreRole($user, $ownerAdmin)); + Gate::define('view-analytics', fn (User $user): bool => $hasStoreRole($user, $ownerAdminStaff)); + Gate::define('manage-shipping', fn (User $user): bool => $hasStoreRole($user, $ownerAdmin)); + Gate::define('manage-taxes', fn (User $user): bool => $hasStoreRole($user, $ownerAdmin)); + Gate::define('manage-search-settings', fn (User $user): bool => $hasStoreRole($user, $ownerAdmin)); + Gate::define('manage-navigation', fn (User $user): bool => $hasStoreRole($user, $ownerAdmin)); + Gate::define('manage-apps', fn (User $user): bool => $hasStoreRole($user, $ownerAdmin)); + + Gate::define('manage-platform', fn (User $user): bool => StoreUser::query() + ->where('user_id', $user->getKey()) + ->where('role', StoreUserRole::Owner->value) + ->exists()); + } + + protected function configureRateLimiting(): void + { + RateLimiter::for('api.admin', fn (Request $request): Limit => Limit::perMinute(60)->by( + $request->user()?->getKey() ?? $request->ip() + )); + + RateLimiter::for('api.storefront', fn (Request $request): Limit => Limit::perMinute(120)->by($request->ip())); + + RateLimiter::for('checkout', fn (Request $request): Limit => Limit::perMinute(10)->by($request->ip())); + + RateLimiter::for('search', fn (Request $request): Limit => Limit::perMinute(30)->by($request->ip())); + + RateLimiter::for('analytics', fn (Request $request): Limit => Limit::perMinute(60)->by($request->ip())); + + RateLimiter::for('webhooks', fn (Request $request): Limit => Limit::perMinute(100)->by($request->ip())); + } + + protected function configureAuditListeners(): void + { + Event::listen(Login::class, function (Login $event): void { + $audit = app(AuditLogger::class); + + if ($event->user instanceof Customer) { + $audit->log('customer.login', storeId: $event->user->store_id, extra: [ + 'customer_id' => $event->user->getKey(), + ]); + + return; + } + + $audit->log('auth.login', userId: $event->user->getKey()); + }); + + Event::listen(Failed::class, function (Failed $event): void { + $audit = app(AuditLogger::class); + $email = is_array($event->credentials) ? ($event->credentials['email'] ?? null) : null; + + if ($event->guard === 'customer') { + $audit->log('customer.failed_login', storeId: app(CurrentStore::class)->id(), extra: [ + 'email' => $email, + ]); + + return; + } + + $audit->log('auth.failed_login', extra: ['email' => $email]); + }); + + Event::listen(Logout::class, function (Logout $event): void { + if ($event->user instanceof User) { + app(AuditLogger::class)->log('auth.logout', userId: $event->user->getKey()); + } + }); + } + + protected function configureWebhookListeners(): void + { + Event::subscribe(\App\Listeners\DispatchWebhooks::class); + } + + protected function configureLivewireHooks(): void + { + Livewire::componentHook(\App\Livewire\Hooks\ResolveStoreContext::class); + } } diff --git a/app/Services/AnalyticsService.php b/app/Services/AnalyticsService.php new file mode 100644 index 00000000..f958879d --- /dev/null +++ b/app/Services/AnalyticsService.php @@ -0,0 +1,358 @@ + $properties + */ + public function track( + Store $store, + string $type, + array $properties = [], + ?string $sessionId = null, + ?int $customerId = null, + ?string $clientEventId = null, + ?CarbonImmutable $occurredAt = null, + ): ?AnalyticsEvent { + if (! in_array($type, self::EVENT_TYPES, true)) { + return null; + } + + if ($clientEventId !== null && AnalyticsEvent::query() + ->where('store_id', $store->getKey()) + ->where('client_event_id', $clientEventId) + ->exists()) { + return null; + } + + return AnalyticsEvent::query()->create([ + 'store_id' => $store->getKey(), + 'type' => $type, + 'session_id' => $sessionId, + 'customer_id' => $customerId, + 'properties_json' => $properties, + 'client_event_id' => $clientEventId, + 'occurred_at' => $occurredAt ?? now(), + 'created_at' => now(), + ]); + } + + /** + * Record a batch of endpoint-submitted events. + * + * @param array> $events + * @return array{accepted: int, rejected: int} + */ + public function recordBatch(Store $store, array $events): array + { + $accepted = 0; + $rejected = 0; + + foreach ($events as $event) { + $recorded = $this->track( + $store, + (string) ($event['type'] ?? ''), + is_array($event['properties'] ?? null) ? $event['properties'] : [], + isset($event['session_id']) ? (string) $event['session_id'] : null, + isset($event['customer_id']) ? (int) $event['customer_id'] : null, + isset($event['client_event_id']) ? (string) $event['client_event_id'] : null, + isset($event['occurred_at']) ? CarbonImmutable::parse($event['occurred_at']) : null, + ); + + if ($recorded === null) { + $rejected++; + } else { + $accepted++; + } + } + + return ['accepted' => $accepted, 'rejected' => $rejected]; + } + + /** + * @return Collection + */ + public function getDailyMetrics(Store $store, string $startDate, string $endDate): Collection + { + return AnalyticsDaily::query() + ->where('store_id', $store->getKey()) + ->whereBetween('date', [$startDate, $endDate]) + ->orderBy('date') + ->get(); + } + + /** + * Dashboard/API summary shape per spec 02 section 3.11. + * + * @return array{period: array{from: string, to: string}, summary: array, daily: array>, top_products: array>} + */ + public function summary(Store $store, string $from, string $to, string $granularity = 'day'): array + { + $daily = $this->getDailyMetrics($store, $from, $to); + + $real = $this->realOrderMetrics($store, $from, $to); + $ordersCount = $real['count']; + $revenueAmount = $real['revenue']; + $visitsCount = (int) $daily->sum('visits_count'); + + $summary = [ + 'orders_count' => $ordersCount, + 'revenue_amount' => $revenueAmount, + 'aov_amount' => $ordersCount > 0 ? intdiv($revenueAmount, $ordersCount) : 0, + 'visits_count' => $visitsCount, + 'add_to_cart_count' => (int) $daily->sum('add_to_cart_count'), + 'checkout_started_count' => (int) $daily->sum('checkout_started_count'), + 'conversion_rate' => $visitsCount > 0 ? round($ordersCount / $visitsCount, 4) : 0.0, + 'currency' => $store->default_currency, + ]; + + return [ + 'period' => ['from' => $from, 'to' => $to], + 'summary' => $summary, + 'daily' => $this->bucketizeWithRealOrders($store, $daily, $granularity, $from, $to), + 'top_products' => $this->topProducts($store, $from, $to), + ]; + } + + /** + * @return array + */ + public function topProductsByRevenue(Store $store, string $from, string $to, int $limit = 5): array + { + return $this->topProducts($store, $from, $to, $limit); + } + + /** + * @return array{visits: int, add_to_cart: int, checkout_started: int, checkout_completed: int} + */ + public function funnel(Store $store, string $from, string $to): array + { + $daily = $this->getDailyMetrics($store, $from, $to); + + return [ + 'visits' => (int) $daily->sum('visits_count'), + 'add_to_cart' => (int) $daily->sum('add_to_cart_count'), + 'checkout_started' => (int) $daily->sum('checkout_started_count'), + 'checkout_completed' => (int) $daily->sum('checkout_completed_count'), + ]; + } + + /** + * @return array + */ + public function topReferrers(Store $store, string $from, string $to, int $limit = 10): array + { + $rows = AnalyticsEvent::query() + ->where('store_id', $store->getKey()) + ->where('type', 'page_view') + ->whereBetween('created_at', [$from.' 00:00:00', $to.' 23:59:59']) + ->get(['session_id', 'properties_json']); + + $grouped = []; + + foreach ($rows as $row) { + $properties = is_array($row->properties_json) ? $row->properties_json : []; + $referrer = (string) ($properties['referrer'] ?? ''); + $source = $referrer === '' ? 'Direct' : (parse_url($referrer, PHP_URL_HOST) ?: $referrer); + + if (! isset($grouped[$source])) { + $grouped[$source] = ['sessions' => [], 'orders' => 0]; + } + + if ($row->session_id !== null) { + $grouped[$source]['sessions'][$row->session_id] = true; + } + } + + $result = []; + + foreach ($grouped as $source => $data) { + $sessions = count($data['sessions']); + $result[] = [ + 'source' => $source, + 'sessions' => $sessions, + 'orders' => 0, + 'conversion_rate' => 0.0, + ]; + } + + usort($result, fn (array $a, array $b): int => $b['sessions'] <=> $a['sessions']); + + return array_slice($result, 0, $limit); + } + + /** + * @param Collection $daily + * @return array> + */ + protected function bucketize(Collection $daily, string $granularity): array + { + if ($granularity === 'day') { + return $daily->map(fn (AnalyticsDaily $row): array => [ + 'date' => $row->date, + 'orders_count' => (int) $row->orders_count, + 'revenue_amount' => (int) $row->revenue_amount, + 'aov_amount' => (int) $row->aov_amount, + 'visits_count' => (int) $row->visits_count, + 'add_to_cart_count' => (int) $row->add_to_cart_count, + 'checkout_started_count' => (int) $row->checkout_started_count, + ])->all(); + } + + $buckets = []; + + foreach ($daily as $row) { + $date = CarbonImmutable::parse($row->date); + $key = $granularity === 'week' + ? $date->startOfWeek()->toDateString() + : $date->startOfMonth()->toDateString(); + + if (! isset($buckets[$key])) { + $buckets[$key] = [ + 'date' => $key, + 'orders_count' => 0, + 'revenue_amount' => 0, + 'visits_count' => 0, + 'add_to_cart_count' => 0, + 'checkout_started_count' => 0, + ]; + } + + $buckets[$key]['orders_count'] += (int) $row->orders_count; + $buckets[$key]['revenue_amount'] += (int) $row->revenue_amount; + $buckets[$key]['visits_count'] += (int) $row->visits_count; + $buckets[$key]['add_to_cart_count'] += (int) $row->add_to_cart_count; + $buckets[$key]['checkout_started_count'] += (int) $row->checkout_started_count; + } + + foreach ($buckets as $key => $bucket) { + $buckets[$key]['aov_amount'] = $bucket['orders_count'] > 0 + ? intdiv($bucket['revenue_amount'], $bucket['orders_count']) + : 0; + } + + return array_values($buckets); + } + + /** + * @return array> + */ + protected function topProducts(Store $store, string $from, string $to, int $limit = 5): array + { + // Group by product (not title_snapshot): new orders suffix the variant + // labels onto title_snapshot ("Shirt (M / White)") while older rows + // hold the bare product title, which would split one product into + // several rows. The current product title is the consistent label. + $rows = DB::table('order_lines') + ->join('orders', 'orders.id', '=', 'order_lines.order_id') + ->join('products', 'products.id', '=', 'order_lines.product_id') + ->where('orders.store_id', $store->getKey()) + ->whereBetween('orders.placed_at', [$from.' 00:00:00', $to.' 23:59:59']) + ->groupBy('order_lines.product_id', 'products.title') + ->orderByDesc(DB::raw('SUM(order_lines.total_amount)')) + ->limit($limit) + ->get([ + 'order_lines.product_id', + 'products.title as title_snapshot', + DB::raw('SUM(order_lines.quantity) as units_sold'), + DB::raw('SUM(order_lines.total_amount) as revenue_amount'), + ]); + + return $rows->map(fn ($row): array => [ + 'product_id' => (int) $row->product_id, + 'title' => (string) $row->title_snapshot, + 'units_sold' => (int) $row->units_sold, + 'revenue_amount' => (int) $row->revenue_amount, + 'revenue' => (int) $row->revenue_amount, + ])->all(); + } + + public function ordersCount(Store $store, string $from, string $to): int + { + return Order::query() + ->where('store_id', $store->getKey()) + ->whereBetween('placed_at', [$from.' 00:00:00', $to.' 23:59:59']) + ->count(); + } + + /** + * @return array{count: int, revenue: int} + */ + protected function realOrderMetrics(Store $store, string $from, string $to): array + { + $row = Order::query() + ->where('store_id', $store->getKey()) + ->whereBetween('placed_at', [$from.' 00:00:00', $to.' 23:59:59']) + ->selectRaw('COUNT(*) AS cnt, COALESCE(SUM(total_amount), 0) AS rev') + ->first(); + + return [ + 'count' => (int) ($row?->cnt ?? 0), + 'revenue' => (int) ($row?->rev ?? 0), + ]; + } + + /** + * @param Collection $daily + * @return array> + */ + protected function bucketizeWithRealOrders(Store $store, Collection $daily, string $granularity, string $from, string $to): array + { + $buckets = $this->bucketize($daily, $granularity); + + $orders = Order::query() + ->where('store_id', $store->getKey()) + ->whereBetween('placed_at', [$from.' 00:00:00', $to.' 23:59:59']) + ->get(['placed_at', 'total_amount']); + + $byBucket = []; + + foreach ($orders as $order) { + $date = CarbonImmutable::parse($order->placed_at); + $key = $granularity === 'day' + ? $date->toDateString() + : ($granularity === 'week' + ? $date->startOfWeek()->toDateString() + : $date->startOfMonth()->toDateString()); + + if (! isset($byBucket[$key])) { + $byBucket[$key] = ['count' => 0, 'revenue' => 0]; + } + + $byBucket[$key]['count']++; + $byBucket[$key]['revenue'] += (int) $order->total_amount; + } + + foreach ($buckets as $index => $bucket) { + $real = $byBucket[$bucket['date']] ?? ['count' => 0, 'revenue' => 0]; + $buckets[$index]['orders_count'] = $real['count']; + $buckets[$index]['revenue_amount'] = $real['revenue']; + $buckets[$index]['aov_amount'] = $real['count'] > 0 ? intdiv($real['revenue'], $real['count']) : 0; + } + + return $buckets; + } +} diff --git a/app/Services/AuditLogger.php b/app/Services/AuditLogger.php new file mode 100644 index 00000000..03d9f8d4 --- /dev/null +++ b/app/Services/AuditLogger.php @@ -0,0 +1,37 @@ + $extra + */ + public function log( + string $event, + ?int $userId = null, + ?int $storeId = null, + ?string $resourceType = null, + ?int $resourceId = null, + array $extra = [], + ): void { + $request = request(); + + Log::channel('audit')->info($event, array_merge([ + 'timestamp' => now()->toIso8601String(), + 'event' => $event, + 'user_id' => $userId, + 'store_id' => $storeId, + 'resource_type' => $resourceType, + 'resource_id' => $resourceId, + 'ip' => $request->ip(), + 'user_agent' => (string) $request->userAgent(), + ], $extra)); + } +} diff --git a/app/Services/CartService.php b/app/Services/CartService.php new file mode 100644 index 00000000..c7f81f85 --- /dev/null +++ b/app/Services/CartService.php @@ -0,0 +1,245 @@ +create([ + 'store_id' => $store->getKey(), + 'customer_id' => $customer?->getKey(), + 'currency' => $currency ?? $store->default_currency, + 'cart_version' => 1, + 'status' => CartStatus::Active, + ]); + } + + public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart + { + $cartId = session()->get('cart_id'); + + if ($cartId !== null) { + $cart = Cart::query() + ->where('store_id', $store->getKey()) + ->whereKey($cartId) + ->where('status', CartStatus::Active) + ->first(); + + if ($cart !== null) { + return $cart; + } + } + + if ($customer !== null) { + $customerCart = Cart::query() + ->where('store_id', $store->getKey()) + ->where('customer_id', $customer->getKey()) + ->where('status', CartStatus::Active) + ->first(); + + if ($customerCart !== null) { + return $customerCart; + } + } + + $cart = $this->create($store, $customer); + + if ($customer === null) { + session()->put('cart_id', $cart->getKey()); + } + + return $cart; + } + + public function addLine(Cart $cart, int $variantId, int $quantity, ?int $expectedVersion = null): CartLine + { + $this->assertVersion($cart, $expectedVersion); + + if ($quantity < 1 || $quantity > 9999) { + throw ValidationException::withMessages([ + 'quantity' => ['The quantity must be between 1 and 9999.'], + ]); + } + + return DB::transaction(function () use ($cart, $variantId, $quantity): CartLine { + $variant = $this->findActiveVariant($cart, $variantId); + + $line = $cart->cartLines()->where('variant_id', $variant->getKey())->first(); + $newQuantity = ($line?->quantity ?? 0) + $quantity; + + $this->assertInventoryAvailable($variant, $newQuantity); + + if ($line === null) { + $line = $cart->cartLines()->create([ + 'variant_id' => $variant->getKey(), + 'quantity' => $newQuantity, + 'unit_price_amount' => $variant->price_amount, + 'line_subtotal_amount' => $variant->price_amount * $newQuantity, + 'line_discount_amount' => 0, + 'line_total_amount' => $variant->price_amount * $newQuantity, + ]); + } else { + $line->quantity = $newQuantity; + $line->line_subtotal_amount = $line->unit_price_amount * $newQuantity; + $line->line_discount_amount = 0; + $line->line_total_amount = $line->line_subtotal_amount; + $line->save(); + } + + $cart->increment('cart_version'); + + return $line->refresh(); + }); + } + + public function updateLineQuantity(Cart $cart, int $lineId, int $quantity, ?int $expectedVersion = null): ?CartLine + { + $this->assertVersion($cart, $expectedVersion); + + if ($quantity < 0 || $quantity > 9999) { + throw ValidationException::withMessages([ + 'quantity' => ['The quantity must be between 0 and 9999.'], + ]); + } + + if ($quantity === 0) { + $this->removeLine($cart, $lineId); + + return null; + } + + return DB::transaction(function () use ($cart, $lineId, $quantity): CartLine { + $line = $cart->cartLines()->whereKey($lineId)->firstOrFail(); + + $this->assertInventoryAvailable($line->variant, $quantity); + + $line->quantity = $quantity; + $line->line_subtotal_amount = $line->unit_price_amount * $quantity; + $line->line_total_amount = $line->line_subtotal_amount - $line->line_discount_amount; + $line->save(); + + $cart->increment('cart_version'); + + return $line->refresh(); + }); + } + + public function removeLine(Cart $cart, int $lineId, ?int $expectedVersion = null): void + { + $this->assertVersion($cart, $expectedVersion); + + DB::transaction(function () use ($cart, $lineId): void { + $line = $cart->cartLines()->whereKey($lineId)->firstOrFail(); + $line->delete(); + + $cart->increment('cart_version'); + }); + } + + /** + * Merge a guest cart into the customer's cart on login per spec 05 4.1. + * Duplicate variants keep the higher quantity (MAX). + */ + public function mergeOnLogin(Cart $guestCart, Cart $customerCart): Cart + { + return DB::transaction(function () use ($guestCart, $customerCart): Cart { + foreach ($guestCart->cartLines as $line) { + $existing = $customerCart->cartLines()->where('variant_id', $line->variant_id)->first(); + + if ($existing === null) { + $line->cart_id = $customerCart->getKey(); + $line->save(); + + continue; + } + + $existing->quantity = max($existing->quantity, $line->quantity); + $existing->line_subtotal_amount = $existing->unit_price_amount * $existing->quantity; + $existing->line_discount_amount = 0; + $existing->line_total_amount = $existing->line_subtotal_amount; + $existing->save(); + } + + $guestCart->status = CartStatus::Abandoned; + $guestCart->save(); + + $customerCart->increment('cart_version'); + + return $customerCart->refresh(); + }); + } + + public function assertVersion(Cart $cart, ?int $expectedVersion): void + { + if ($expectedVersion !== null && $cart->cart_version !== $expectedVersion) { + throw new CartVersionMismatchException($cart->cart_version); + } + } + + protected function findActiveVariant(Cart $cart, int $variantId): ProductVariant + { + $variant = ProductVariant::query() + ->whereKey($variantId) + ->whereHas('product', fn ($query) => $query->where('store_id', $cart->store_id)) + ->first(); + + if ($variant === null) { + throw ValidationException::withMessages([ + 'variant_id' => ['The selected variant is invalid.'], + ]); + } + + if ($variant->product->status !== ProductStatus::Active) { + throw ValidationException::withMessages([ + 'variant_id' => ['The selected product is not available.'], + ]); + } + + if ($variant->status !== VariantStatus::Active) { + throw ValidationException::withMessages([ + 'variant_id' => ['The selected variant is not available.'], + ]); + } + + return $variant; + } + + protected function assertInventoryAvailable(ProductVariant $variant, int $quantity): void + { + $item = $variant->inventoryItem; + + if ($item === null) { + return; + } + + if ($item->policy === InventoryPolicy::Continue) { + return; + } + + if (! $this->inventory->checkAvailability($item, $quantity)) { + throw new InsufficientInventoryException( + "Insufficient inventory: requested {$quantity}, available {$this->inventory->available($item)}." + ); + } + } +} diff --git a/app/Services/CheckoutService.php b/app/Services/CheckoutService.php new file mode 100644 index 00000000..b7713b64 --- /dev/null +++ b/app/Services/CheckoutService.php @@ -0,0 +1,336 @@ +cartLines()->count() === 0) { + throw ValidationException::withMessages([ + 'cart_id' => ['The cart must contain at least one line item.'], + ]); + } + + return DB::transaction(function () use ($cart, $email, $customer): Checkout { + $checkout = Checkout::query()->create([ + 'store_id' => $cart->store_id, + 'cart_id' => $cart->getKey(), + 'customer_id' => $customer?->getKey() ?? $cart->customer_id, + 'status' => CheckoutStatus::Started, + 'email' => $email, + 'expires_at' => now()->addHours(24), + ]); + + $this->pricing->calculate($checkout->refresh()); + + return $checkout->refresh(); + }); + } + + /** + * @param array $shippingAddress + * @param array|null $billingAddress + */ + public function setAddress( + Checkout $checkout, + array $shippingAddress, + ?array $billingAddress = null, + bool $useShippingAsBilling = true, + ?string $email = null, + ): Checkout { + $this->assertStatus($checkout, [CheckoutStatus::Started, CheckoutStatus::Addressed]); + + return DB::transaction(function () use ($checkout, $shippingAddress, $billingAddress, $useShippingAsBilling, $email): Checkout { + $shipping = Address::fromArray($shippingAddress)->toArray(); + + if ($this->shipping->requiresShipping($checkout->cart)) { + $zone = $this->shipping->getMatchingZone($checkout->store, $shipping); + + if ($zone === null) { + throw new UnserviceableAddressException('Cannot ship to this address.'); + } + } + + $checkout->shipping_address_json = $shipping; + $checkout->billing_address_json = $useShippingAsBilling || $billingAddress === null + ? $shipping + : Address::fromArray($billingAddress)->toArray(); + + if ($email !== null) { + $checkout->email = $email; + } + + $checkout->status = CheckoutStatus::Addressed; + $checkout->save(); + + $this->pricing->calculate($checkout->refresh()); + + event(new CheckoutAddressed($checkout->refresh())); + + return $checkout->refresh(); + }); + } + + public function setShippingMethod(Checkout $checkout, ?int $shippingRateId): Checkout + { + $this->assertStatus($checkout, [CheckoutStatus::Addressed, CheckoutStatus::ShippingSelected]); + + return DB::transaction(function () use ($checkout, $shippingRateId): Checkout { + if (! $this->shipping->requiresShipping($checkout->cart)) { + $checkout->shipping_method_id = null; + $checkout->status = CheckoutStatus::ShippingSelected; + $checkout->save(); + + $this->pricing->calculate($checkout->refresh()); + + event(new CheckoutShippingSelected($checkout->refresh())); + + return $checkout->refresh(); + } + + if ($shippingRateId === null) { + throw ValidationException::withMessages([ + 'shipping_method_id' => ['The shipping method field is required.'], + ]); + } + + $rate = ShippingRate::query()->whereKey($shippingRateId)->first(); + + if ($rate === null || ! $rate->is_active) { + throw ValidationException::withMessages([ + 'shipping_method_id' => ['The selected shipping method is invalid.'], + ]); + } + + $available = $this->shipping->getAvailableRates( + $checkout->store, + (array) ($checkout->shipping_address_json ?? []), + $checkout->cart, + ); + + if (! $available->contains(fn ($option): bool => $option->id === $rate->getKey())) { + throw ValidationException::withMessages([ + 'shipping_method_id' => ['The selected shipping method is not available for this address.'], + ]); + } + + $checkout->shipping_method_id = $rate->getKey(); + $checkout->status = CheckoutStatus::ShippingSelected; + $checkout->save(); + + $this->pricing->calculate($checkout->refresh()); + + event(new CheckoutShippingSelected($checkout->refresh())); + + return $checkout->refresh(); + }); + } + + public function selectPaymentMethod(Checkout $checkout, PaymentMethod $method): Checkout + { + $this->assertStatus($checkout, [CheckoutStatus::ShippingSelected]); + + return DB::transaction(function () use ($checkout, $method): Checkout { + $checkout->payment_method = $method; + $checkout->expires_at = now()->addHours(24); + $checkout->status = CheckoutStatus::PaymentSelected; + $checkout->save(); + + $this->reserveInventory($checkout); + + return $checkout->refresh(); + }); + } + + /** + * @param array $paymentDetails + */ + public function pay(Checkout $checkout, ?PaymentMethod $method = null, array $paymentDetails = []): Order + { + if ($checkout->status === CheckoutStatus::Completed) { + $existing = $this->orders->findByCheckout($checkout); + + if ($existing !== null) { + return $existing; + } + + throw new InvalidCheckoutTransitionException('This checkout has already been completed.'); + } + + if ($checkout->status === CheckoutStatus::ShippingSelected && $method !== null) { + $checkout = $this->selectPaymentMethod($checkout, $method); + } + + $this->assertStatus($checkout, [CheckoutStatus::PaymentSelected]); + + $effectiveMethod = $method ?? $checkout->payment_method; + + if ($effectiveMethod === null) { + throw ValidationException::withMessages([ + 'payment_method' => ['The payment method field is required.'], + ]); + } + + $checkout->payment_method = $effectiveMethod; + $checkout->save(); + + $this->pricing->calculate($checkout->refresh()); + + $result = $this->payments->charge($checkout->refresh(), $effectiveMethod, $paymentDetails); + + if (! $result->success) { + DB::transaction(function () use ($checkout): void { + $this->releaseInventory($checkout->refresh()); + $checkout->status = CheckoutStatus::ShippingSelected; + $checkout->save(); + }); + + throw new PaymentFailedException($result->errorCode ?? 'payment_failed', $result->errorMessage ?? 'Payment failed.'); + } + + return $this->orders->createFromCheckout($checkout->refresh(), $result, $paymentDetails); + } + + public function applyDiscount(Checkout $checkout, string $code, DiscountService $discounts): Checkout + { + $this->assertActive($checkout); + + return DB::transaction(function () use ($checkout, $code, $discounts): Checkout { + $discount = $discounts->validateOrFail($code, $checkout->store, $checkout->cart); + + $checkout->discount_code = $discount->code; + $checkout->save(); + + $this->pricing->calculate($checkout->refresh()); + + return $checkout->refresh(); + }); + } + + public function removeDiscount(Checkout $checkout): Checkout + { + $this->assertActive($checkout); + + return DB::transaction(function () use ($checkout): Checkout { + $checkout->discount_code = null; + $checkout->save(); + + $this->pricing->calculate($checkout->refresh()); + + return $checkout->refresh(); + }); + } + + public function expireCheckout(Checkout $checkout): Checkout + { + return DB::transaction(function () use ($checkout): Checkout { + $fresh = $checkout->refresh(); + + if (in_array($fresh->status, [CheckoutStatus::Completed, CheckoutStatus::Expired], true)) { + return $fresh; + } + + if ($fresh->status === CheckoutStatus::PaymentSelected) { + $this->releaseInventory($fresh); + } + + $fresh->status = CheckoutStatus::Expired; + $fresh->save(); + + event(new CheckoutExpired($fresh)); + + return $fresh->refresh(); + }); + } + + public function reserveInventory(Checkout $checkout): void + { + foreach ($checkout->cart->cartLines()->with('variant')->get() as $line) { + $item = $this->inventoryItemForVariant($checkout, $line->variant_id); + + if ($item !== null) { + $this->inventory->reserve($item, $line->quantity); + } + } + } + + public function releaseInventory(Checkout $checkout): void + { + foreach ($checkout->cart->cartLines()->get() as $line) { + $item = $this->inventoryItemForVariant($checkout, $line->variant_id); + + if ($item !== null) { + $this->inventory->release($item, $line->quantity); + } + } + } + + protected function inventoryItemForVariant(Checkout $checkout, int $variantId): ?InventoryItem + { + return InventoryItem::query() + ->where('store_id', $checkout->store_id) + ->where('variant_id', $variantId) + ->first() + ?? InventoryItem::query()->create([ + 'store_id' => $checkout->store_id, + 'variant_id' => $variantId, + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ]); + } + + /** + * @param array $allowed + */ + protected function assertStatus(Checkout $checkout, array $allowed): void + { + if ($checkout->status === CheckoutStatus::Expired) { + throw new InvalidCheckoutTransitionException('This checkout has expired.'); + } + + if (! in_array($checkout->status, $allowed, true)) { + throw new InvalidCheckoutTransitionException( + 'This action is not available for a checkout with status "'.$checkout->status->value.'".' + ); + } + } + + protected function assertActive(Checkout $checkout): void + { + if (in_array($checkout->status, [CheckoutStatus::Completed, CheckoutStatus::Expired], true)) { + throw new InvalidCheckoutTransitionException('This checkout is no longer active.'); + } + } +} diff --git a/app/Services/DiscountService.php b/app/Services/DiscountService.php new file mode 100644 index 00000000..da101aa3 --- /dev/null +++ b/app/Services/DiscountService.php @@ -0,0 +1,264 @@ +where('store_id', $store->getKey()) + ->whereRaw('LOWER(code) = ?', [mb_strtolower($code)]) + ->first(); + + if ($discount === null) { + return DiscountValidationResult::invalid('discount_not_found', 'This discount code is invalid.'); + } + + if ($discount->status !== DiscountStatus::Active) { + return DiscountValidationResult::invalid('discount_expired', 'This discount code has expired.'); + } + + if ($discount->starts_at !== null && $discount->starts_at->isFuture()) { + return DiscountValidationResult::invalid('discount_not_yet_active', 'This discount code is not yet active.'); + } + + if ($discount->ends_at !== null && $discount->ends_at->isPast()) { + return DiscountValidationResult::invalid('discount_expired', 'This discount code has expired.'); + } + + if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) { + return DiscountValidationResult::invalid('discount_usage_limit_reached', 'This discount code has reached its usage limit.'); + } + + $subtotal = $this->cartSubtotal($cart); + $minimum = $this->minimumPurchaseAmount($discount); + + if ($minimum !== null && $subtotal < $minimum) { + return DiscountValidationResult::invalid('discount_min_purchase_not_met', 'This discount code requires a minimum purchase.'); + } + + if ($this->hasProductRestrictions($discount) && $this->qualifyingLines($discount, $cart)->isEmpty()) { + return DiscountValidationResult::invalid('discount_not_applicable', 'This discount code does not apply to any items in your cart.'); + } + + return DiscountValidationResult::valid($discount); + } + + /** + * Validate and throw on failure, for flows that need exceptions. + */ + public function validateOrFail(string $code, Store $store, Cart $cart): Discount + { + $result = $this->validate($code, $store, $cart); + + if (! $result->valid || $result->discount === null) { + throw new InvalidDiscountException($result->errorCode ?? 'discount_not_found', $result->errorMessage ?? 'Invalid discount code.'); + } + + return $result->discount; + } + + /** + * Calculate the discount amount for the given lines. + * + * @param array $lines + * @return array{total: int, allocations: array, free_shipping: bool} + */ + public function calculate(Discount $discount, int $subtotal, array $lines): array + { + if ($discount->value_type === DiscountValueType::FreeShipping) { + return ['total' => 0, 'allocations' => [], 'free_shipping' => true]; + } + + $qualifying = $this->filterQualifyingLines($discount, $lines); + $qualifyingSubtotal = array_sum(array_column($qualifying, 'subtotal')); + + if ($qualifyingSubtotal <= 0) { + return ['total' => 0, 'allocations' => [], 'free_shipping' => false]; + } + + if ($discount->value_type === DiscountValueType::Percent) { + $total = intdiv($qualifyingSubtotal * $discount->value_amount, 100); + } else { + $total = min($discount->value_amount, $qualifyingSubtotal); + } + + return [ + 'total' => $total, + 'allocations' => $this->allocate($total, $qualifying), + 'free_shipping' => false, + ]; + } + + /** + * Allocate a discount total across qualifying lines, largest-remainder to last. + * + * @param array $qualifyingLines + * @return array Map of line_id to discount amount. + */ + public function allocate(int $total, array $qualifyingLines): array + { + $allocations = []; + $qualifyingSubtotal = array_sum(array_column($qualifyingLines, 'subtotal')); + + if ($qualifyingSubtotal <= 0 || $total <= 0) { + return $allocations; + } + + $remaining = $total; + $lastIndex = count($qualifyingLines) - 1; + + foreach (array_values($qualifyingLines) as $index => $line) { + if ($index === $lastIndex) { + $allocations[$line['line_id']] = $remaining; + + continue; + } + + $share = intdiv($total * $line['subtotal'], $qualifyingSubtotal); + $allocations[$line['line_id']] = $share; + $remaining -= $share; + } + + return $allocations; + } + + /** + * @return \Illuminate\Support\Collection + */ + public function applicableAutomaticDiscounts(Store $store, Cart $cart): mixed + { + return Discount::query() + ->where('store_id', $store->getKey()) + ->where('type', DiscountType::Automatic) + ->where('status', DiscountStatus::Active) + ->orderBy('id') + ->get() + ->filter(function (Discount $discount) use ($cart): bool { + if ($discount->starts_at !== null && $discount->starts_at->isFuture()) { + return false; + } + + if ($discount->ends_at !== null && $discount->ends_at->isPast()) { + return false; + } + + if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) { + return false; + } + + $minimum = $this->minimumPurchaseAmount($discount); + + if ($minimum !== null && $this->cartSubtotal($cart) < $minimum) { + return false; + } + + if ($this->hasProductRestrictions($discount) && $this->qualifyingLines($discount, $cart)->isEmpty()) { + return false; + } + + return true; + }) + ->values(); + } + + public function cartSubtotal(Cart $cart): int + { + return (int) $cart->cartLines()->sum('line_subtotal_amount'); + } + + public function minimumPurchaseAmount(Discount $discount): ?int + { + $rules = $discount->rules_json ?? []; + + $minimum = $rules['min_purchase_amount'] ?? $rules['minimum_purchase_amount'] ?? null; + + return $minimum === null ? null : (int) $minimum; + } + + public function hasProductRestrictions(Discount $discount): bool + { + $rules = $discount->rules_json ?? []; + + return ! empty($rules['applicable_product_ids']) || ! empty($rules['applicable_collection_ids']); + } + + /** + * @return \Illuminate\Support\Collection + */ + public function qualifyingLines(Discount $discount, Cart $cart): mixed + { + $lines = $cart->cartLines()->with('variant.product')->get(); + + if (! $this->hasProductRestrictions($discount)) { + return $lines; + } + + $rules = $discount->rules_json ?? []; + $productIds = array_map('intval', (array) ($rules['applicable_product_ids'] ?? [])); + $collectionIds = array_map('intval', (array) ($rules['applicable_collection_ids'] ?? [])); + + $collectionProductIds = $collectionIds === [] + ? [] + : Collection::query() + ->where('store_id', $discount->store_id) + ->whereIn('id', $collectionIds) + ->with('products:id') + ->get() + ->flatMap(fn (Collection $collection) => $collection->products->pluck('id')) + ->map(fn ($id): int => (int) $id) + ->all(); + + return $lines->filter(function ($line) use ($productIds, $collectionProductIds): bool { + $productId = (int) $line->variant->product_id; + + return in_array($productId, $productIds, true) || in_array($productId, $collectionProductIds, true); + })->values(); + } + + /** + * @param array $lines + * @return array + */ + protected function filterQualifyingLines(Discount $discount, array $lines): array + { + if (! $this->hasProductRestrictions($discount)) { + return array_values($lines); + } + + $rules = $discount->rules_json ?? []; + $productIds = array_map('intval', (array) ($rules['applicable_product_ids'] ?? [])); + $collectionIds = array_map('intval', (array) ($rules['applicable_collection_ids'] ?? [])); + + $collectionProductIds = $collectionIds === [] + ? [] + : Collection::query() + ->where('store_id', $discount->store_id) + ->whereIn('id', $collectionIds) + ->with('products:id') + ->get() + ->flatMap(fn (Collection $collection) => $collection->products->pluck('id')) + ->map(fn ($id): int => (int) $id) + ->all(); + + return array_values(array_filter($lines, function (array $line) use ($productIds, $collectionProductIds): bool { + $productId = (int) ($line['product_id'] ?? 0); + + return in_array($productId, $productIds, true) || in_array($productId, $collectionProductIds, true); + })); + } +} diff --git a/app/Services/FulfillmentService.php b/app/Services/FulfillmentService.php new file mode 100644 index 00000000..8546ffa6 --- /dev/null +++ b/app/Services/FulfillmentService.php @@ -0,0 +1,225 @@ + $lines Map of order_line_id to quantity. + * @param array|null $tracking + */ + public function create(Order $order, array $lines, ?array $tracking = null): Fulfillment + { + return DB::transaction(function () use ($order, $lines, $tracking): Fulfillment { + $fresh = $order->refresh(); + + if (! in_array($fresh->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true)) { + throw new FulfillmentGuardException('Fulfillment cannot be created until payment is confirmed.'); + } + + if ($lines === []) { + throw ValidationException::withMessages([ + 'line_items' => ['At least one line item is required.'], + ]); + } + + $orderLines = $fresh->orderLines()->with('fulfillmentLines')->get()->keyBy('id'); + + foreach ($lines as $orderLineId => $quantity) { + $orderLine = $orderLines->get((int) $orderLineId); + + if ($orderLine === null) { + throw ValidationException::withMessages([ + 'line_items' => ['One or more line items do not belong to this order.'], + ]); + } + + if ($quantity < 1) { + throw ValidationException::withMessages([ + 'line_items' => ['The quantity must be at least 1.'], + ]); + } + + $fulfilledSoFar = $orderLine->fulfillmentLines->sum('quantity'); + $unfulfilled = $orderLine->quantity - $fulfilledSoFar; + + if ($quantity > $unfulfilled) { + throw ValidationException::withMessages([ + 'line_items' => ['The quantity exceeds the unfulfilled quantity for this line.'], + ]); + } + } + + $fulfillment = $fresh->fulfillments()->create([ + 'status' => FulfillmentShipmentStatus::Pending, + 'tracking_company' => $tracking['tracking_company'] ?? null, + 'tracking_number' => $tracking['tracking_number'] ?? null, + 'tracking_url' => $tracking['tracking_url'] ?? null, + 'shipped_at' => null, + ]); + + foreach ($lines as $orderLineId => $quantity) { + $fulfillment->fulfillmentLines()->create([ + 'order_line_id' => (int) $orderLineId, + 'quantity' => $quantity, + ]); + } + + $this->refreshOrderStatus($fresh->refresh()); + + event(new FulfillmentCreated($fulfillment->refresh())); + + return $fulfillment->refresh(); + }); + } + + /** + * @param array|null $tracking + */ + public function markAsShipped(Fulfillment $fulfillment, ?array $tracking = null): Fulfillment + { + return DB::transaction(function () use ($fulfillment, $tracking): Fulfillment { + $fresh = $fulfillment->refresh(); + + if ($fresh->status !== FulfillmentShipmentStatus::Pending) { + throw new InvalidFulfillmentTransitionException('Only pending fulfillments can be marked as shipped.'); + } + + if ($tracking !== null) { + foreach (['tracking_company', 'tracking_number', 'tracking_url'] as $field) { + if (array_key_exists($field, $tracking)) { + $fresh->{$field} = $tracking[$field]; + } + } + } + + $fresh->status = FulfillmentShipmentStatus::Shipped; + $fresh->shipped_at = now(); + $fresh->save(); + + event(new FulfillmentShipped($fresh->refresh())); + + return $fresh->refresh(); + }); + } + + public function markAsDelivered(Fulfillment $fulfillment): Fulfillment + { + return DB::transaction(function () use ($fulfillment): Fulfillment { + $fresh = $fulfillment->refresh(); + + if ($fresh->status !== FulfillmentShipmentStatus::Shipped) { + throw new InvalidFulfillmentTransitionException('Only shipped fulfillments can be marked as delivered.'); + } + + $fresh->status = FulfillmentShipmentStatus::Delivered; + $fresh->save(); + + event(new FulfillmentDelivered($fresh->refresh())); + + return $fresh->refresh(); + }); + } + + public function cancel(Fulfillment $fulfillment): void + { + DB::transaction(function () use ($fulfillment): void { + $fresh = $fulfillment->refresh(); + + if ($fresh->status !== FulfillmentShipmentStatus::Pending) { + throw new InvalidFulfillmentTransitionException('Only pending fulfillments can be cancelled.'); + } + + $order = $fresh->order; + $fresh->fulfillmentLines()->delete(); + $fresh->delete(); + + $this->refreshOrderStatus($order->refresh()); + }); + } + + public function autoFulfillDigital(Order $order): ?Fulfillment + { + return DB::transaction(function () use ($order): ?Fulfillment { + $fresh = $order->refresh(); + + if (! in_array($fresh->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true)) { + return null; + } + + $lines = $fresh->orderLines()->with('variant')->get(); + + if ($lines->isEmpty()) { + return null; + } + + foreach ($lines as $line) { + if ($line->variant === null || $line->variant->requires_shipping) { + return null; + } + } + + $fulfillment = $fresh->fulfillments()->create([ + 'status' => FulfillmentShipmentStatus::Delivered, + 'tracking_company' => null, + 'tracking_number' => null, + 'tracking_url' => null, + 'shipped_at' => now(), + ]); + + foreach ($lines as $line) { + $fulfillment->fulfillmentLines()->create([ + 'order_line_id' => $line->getKey(), + 'quantity' => $line->quantity, + ]); + } + + $fresh->fulfillment_status = FulfillmentStatus::Fulfilled; + $fresh->status = OrderStatus::Fulfilled; + $fresh->save(); + + event(new FulfillmentCreated($fulfillment->refresh())); + + return $fulfillment->refresh(); + }); + } + + protected function refreshOrderStatus(Order $order): void + { + $allFulfilled = true; + + foreach ($order->orderLines()->with('fulfillmentLines')->get() as $line) { + if ($line->fulfillmentLines->sum('quantity') < $line->quantity) { + $allFulfilled = false; + + break; + } + } + + if ($allFulfilled) { + $order->fulfillment_status = FulfillmentStatus::Fulfilled; + $order->status = OrderStatus::Fulfilled; + } else { + $order->fulfillment_status = FulfillmentStatus::Partial; + } + + $order->save(); + } +} diff --git a/app/Services/GuestCartMerger.php b/app/Services/GuestCartMerger.php new file mode 100644 index 00000000..dc98058c --- /dev/null +++ b/app/Services/GuestCartMerger.php @@ -0,0 +1,72 @@ +pull('cart_id'); + + if ($guestCartId === null) { + return; + } + + $guestCart = Cart::query() + ->where('id', $guestCartId) + ->where('status', CartStatus::Active) + ->whereNull('customer_id') + ->first(); + + if ($guestCart === null) { + return; + } + + DB::transaction(function () use ($guestCart, $customer): void { + $customerCart = Cart::query() + ->where('customer_id', $customer->getKey()) + ->where('status', CartStatus::Active) + ->first(); + + if ($customerCart === null) { + $guestCart->customer_id = $customer->getKey(); + $guestCart->save(); + + session()->forget('cart_id'); + + return; + } + + foreach ($guestCart->cartLines as $line) { + $existing = $customerCart->cartLines()->where('variant_id', $line->variant_id)->first(); + + if ($existing === null) { + $line->cart_id = $customerCart->getKey(); + $line->save(); + + continue; + } + + $existing->quantity = max($existing->quantity, $line->quantity); + $existing->line_subtotal_amount = $existing->unit_price_amount * $existing->quantity; + $existing->line_discount_amount = 0; + $existing->line_total_amount = $existing->line_subtotal_amount; + $existing->save(); + } + + $guestCart->status = CartStatus::Abandoned; + $guestCart->save(); + + $customerCart->increment('cart_version'); + }); + } +} diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php new file mode 100644 index 00000000..4d9be9b2 --- /dev/null +++ b/app/Services/InventoryService.php @@ -0,0 +1,108 @@ +quantity_on_hand - $item->quantity_reserved; + } + + public function checkAvailability(InventoryItem $item, int $quantity): bool + { + if ($item->policy === InventoryPolicy::Continue) { + return true; + } + + return $this->available($item) >= $quantity; + } + + public function isLowStock(InventoryItem $item, int $threshold = 5): bool + { + return $this->available($item) <= $threshold; + } + + public function reserve(InventoryItem $item, int $quantity): InventoryItem + { + return DB::transaction(function () use ($item, $quantity): InventoryItem { + $locked = $this->locked($item); + + if (! $this->checkAvailability($locked, $quantity)) { + throw new InsufficientInventoryException( + "Insufficient inventory: requested {$quantity}, available {$this->available($locked)}." + ); + } + + $locked->increment('quantity_reserved', $quantity); + + return $locked->refresh(); + }); + } + + public function release(InventoryItem $item, int $quantity): InventoryItem + { + return DB::transaction(function () use ($item, $quantity): InventoryItem { + $locked = $this->locked($item); + $locked->update(['quantity_reserved' => max(0, $locked->quantity_reserved - $quantity)]); + + return $locked->refresh(); + }); + } + + public function commit(InventoryItem $item, int $quantity): InventoryItem + { + return DB::transaction(function () use ($item, $quantity): InventoryItem { + $locked = $this->locked($item); + $locked->update([ + 'quantity_on_hand' => $locked->quantity_on_hand - $quantity, + 'quantity_reserved' => max(0, $locked->quantity_reserved - $quantity), + ]); + + return $locked->refresh(); + }); + } + + public function restock(InventoryItem $item, int $quantity): InventoryItem + { + return DB::transaction(function () use ($item, $quantity): InventoryItem { + $locked = $this->locked($item); + $locked->increment('quantity_on_hand', $quantity); + + return $locked->refresh(); + }); + } + + public function adjust(InventoryItem $item, ?int $quantityOnHand = null, ?InventoryPolicy $policy = null): InventoryItem + { + return DB::transaction(function () use ($item, $quantityOnHand, $policy): InventoryItem { + $locked = $this->locked($item); + + if ($quantityOnHand !== null) { + $locked->quantity_on_hand = $quantityOnHand; + } + + if ($policy !== null) { + $locked->policy = $policy; + } + + $locked->save(); + + return $locked->refresh(); + }); + } + + protected function locked(InventoryItem $item): InventoryItem + { + return InventoryItem::query()->whereKey($item->getKey())->lockForUpdate()->firstOrFail(); + } +} diff --git a/app/Services/MediaService.php b/app/Services/MediaService.php new file mode 100644 index 00000000..f1d47544 --- /dev/null +++ b/app/Services/MediaService.php @@ -0,0 +1,88 @@ +guessExtension() ?? $file->getClientOriginalExtension() ?: 'bin'); + $storageKey = "media/{$product->getKey()}/".((string) Str::uuid()).".{$extension}"; + + Storage::disk('public')->putFileAs( + dirname($storageKey), + $file, + basename($storageKey) + ); + + $mimeType = $file->getMimeType() ?? 'application/octet-stream'; + $width = null; + $height = null; + + if (str_starts_with($mimeType, 'image/')) { + $dimensions = @getimagesize($file->getRealPath()); + + if ($dimensions !== false) { + [$width, $height] = $dimensions; + } + } + + $media = ProductMedia::query()->create([ + 'product_id' => $product->getKey(), + 'type' => str_starts_with($mimeType, 'video/') ? MediaType::Video : MediaType::Image, + 'storage_key' => $storageKey, + 'alt_text' => $altText, + 'width' => $width, + 'height' => $height, + 'mime_type' => $mimeType, + 'byte_size' => $file->getSize(), + 'position' => ($product->productMedia()->max('position') ?? -1) + 1, + 'status' => MediaStatus::Processing, + 'created_at' => now(), + ]); + + ProcessMediaUpload::dispatch($media->getKey()); + + return $media->fresh(); + } + + /** + * @param array $data + */ + public function updateMedia(ProductMedia $media, array $data): ProductMedia + { + if (array_key_exists('alt_text', $data)) { + $media->alt_text = $data['alt_text']; + } + + if (array_key_exists('position', $data) && $data['position'] !== null) { + $media->position = $data['position']; + } + + $media->save(); + + return $media->refresh(); + } + + public function deleteMedia(ProductMedia $media): void + { + $disk = Storage::disk('public'); + + $disk->delete($media->storage_key); + $disk->deleteDirectory("media/{$media->product_id}/{$media->getKey()}"); + + $media->delete(); + } +} diff --git a/app/Services/NavigationService.php b/app/Services/NavigationService.php new file mode 100644 index 00000000..4788cb0f --- /dev/null +++ b/app/Services/NavigationService.php @@ -0,0 +1,104 @@ +getKey()}:{$handle}", + now()->addMinutes(5), + fn (): ?int => NavigationMenu::query() + ->where('store_id', $store->getKey()) + ->where('handle', $handle) + ->value('id') + ); + + if ($menuId === null) { + return null; + } + + return NavigationMenu::query() + ->whereKey($menuId) + ->with(['navigationItems' => fn ($query) => $query->orderBy('position')->orderBy('id')]) + ->first(); + } + + /** + * @return array + */ + public function buildTree(NavigationMenu $menu): array + { + return Cache::remember( + "navigation_tree_v2:{$menu->store_id}:{$menu->getKey()}", + now()->addMinutes(5), + function () use ($menu): array { + $items = $menu->relationLoaded('navigationItems') + ? $menu->navigationItems + : $menu->navigationItems()->orderBy('position')->orderBy('id')->get(); + + return $items->map(fn (NavigationItem $item): array => [ + 'label' => $item->label, + 'url' => $this->resolveUrl($item), + 'type' => $item->type->value, + ])->all(); + } + ); + } + + public function resolveUrl(NavigationItem $item): string + { + $type = $item->type->value; + + if ($type === 'link') { + return (string) ($item->url ?? '#'); + } + + if ($item->resource_id === null) { + return '#'; + } + + $storeId = $item->menu?->store_id; + + if ($type === 'page') { + $page = \App\Models\Page::query() + ->when($storeId !== null, fn ($query) => $query->where('store_id', $storeId)) + ->whereKey($item->resource_id) + ->first(); + + return $page !== null ? route('storefront.pages.show', ['handle' => $page->handle], false) : '#'; + } + + if ($type === 'collection') { + $collection = \App\Models\Collection::query() + ->when($storeId !== null, fn ($query) => $query->where('store_id', $storeId)) + ->whereKey($item->resource_id) + ->first(); + + return $collection !== null ? route('storefront.collections.show', ['handle' => $collection->handle], false) : '#'; + } + + if ($type === 'product') { + $product = \App\Models\Product::query() + ->when($storeId !== null, fn ($query) => $query->where('store_id', $storeId)) + ->whereKey($item->resource_id) + ->first(); + + return $product !== null ? route('storefront.products.show', ['handle' => $product->handle], false) : '#'; + } + + return '#'; + } +} diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php new file mode 100644 index 00000000..2e8359ec --- /dev/null +++ b/app/Services/OrderService.php @@ -0,0 +1,379 @@ +where('checkout_id', $checkout->getKey())->first(); + } + + /** + * @param array $paymentDetails + */ + public function createFromCheckout(Checkout $checkout, PaymentResult $result, array $paymentDetails = []): Order + { + return DB::transaction(function () use ($checkout, $result, $paymentDetails): Order { + $locked = Checkout::query()->whereKey($checkout->getKey())->lockForUpdate()->firstOrFail(); + + $existing = $this->findByCheckout($locked); + + if ($existing !== null) { + return $existing; + } + + if ($locked->status === CheckoutStatus::Completed) { + throw new InvalidOrderTransitionException('This checkout has already been completed.'); + } + + $pricing = $this->pricing->calculate($locked->refresh()); + $locked = $locked->refresh(); + + $method = $locked->payment_method; + $isInstantCapture = $method !== PaymentMethod::BankTransfer; + + $orderNumber = $this->generateOrderNumber($locked->store); + $customer = $this->resolveCustomer($locked); + + $order = Order::query()->create([ + 'store_id' => $locked->store_id, + 'checkout_id' => $locked->getKey(), + 'customer_id' => $customer?->getKey(), + 'order_number' => $orderNumber, + 'payment_method' => $method, + 'status' => $isInstantCapture ? OrderStatus::Paid : OrderStatus::Pending, + 'financial_status' => $isInstantCapture ? FinancialStatus::Paid : FinancialStatus::Pending, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled, + 'currency' => $pricing->currency, + 'subtotal_amount' => $pricing->subtotal, + 'discount_amount' => $pricing->discount, + 'shipping_amount' => $pricing->shipping, + 'tax_amount' => $pricing->taxTotal, + 'total_amount' => $pricing->total, + 'email' => $locked->email, + 'billing_address_json' => $locked->billing_address_json, + 'shipping_address_json' => $locked->shipping_address_json, + 'placed_at' => now(), + ]); + + $this->createOrderLines($locked, $order); + + $order->payments()->create([ + 'provider' => \App\Enums\PaymentProvider::Mock, + 'method' => $method, + 'provider_payment_id' => $result->referenceId, + 'status' => $isInstantCapture ? PaymentStatus::Captured : PaymentStatus::Pending, + 'amount' => $pricing->total, + 'currency' => $pricing->currency, + 'raw_json_encrypted' => json_encode($this->paymentSnapshot($result, $method, $paymentDetails)), + ]); + + if ($isInstantCapture) { + $this->commitInventory($locked); + } + + $this->incrementDiscountUsage($locked); + + $cart = $locked->cart; + $cart->status = CartStatus::Converted; + $cart->save(); + + $locked->customer_id = $customer?->getKey() ?? $locked->customer_id; + $locked->status = CheckoutStatus::Completed; + $locked->save(); + + if ($isInstantCapture) { + $this->fulfillments->autoFulfillDigital($order->refresh()); + } + + event(new OrderCreated($order->refresh())); + event(new CheckoutCompleted($locked->refresh(), $order->refresh())); + + return $order->refresh(); + }); + } + + public function generateOrderNumber(Store $store): string + { + $settings = $store->storeSetting?->settings_json ?? []; + $prefix = (string) ($settings['order_number_prefix'] ?? '#'); + + $max = 1000; + + foreach (Order::query()->where('store_id', $store->getKey())->pluck('order_number') as $number) { + $numeric = (int) preg_replace('/\D+/', '', (string) $number); + + if ($numeric > $max) { + $max = $numeric; + } + } + + return $prefix.($max + 1); + } + + public function confirmBankTransferPayment(Order $order): Order + { + return DB::transaction(function () use ($order): Order { + $fresh = $order->refresh(); + + if ($fresh->payment_method !== PaymentMethod::BankTransfer) { + throw new InvalidOrderTransitionException('Only bank transfer orders require manual payment confirmation.'); + } + + if ($fresh->financial_status !== FinancialStatus::Pending) { + throw new InvalidOrderTransitionException('This payment has already been confirmed.'); + } + + $payment = $fresh->payments()->where('status', PaymentStatus::Pending)->latest('id')->first(); + + if ($payment !== null) { + $payment->status = PaymentStatus::Captured; + $payment->save(); + } + + $fresh->financial_status = FinancialStatus::Paid; + $fresh->status = OrderStatus::Paid; + $fresh->save(); + + foreach ($fresh->orderLines()->get() as $line) { + if ($line->variant_id === null) { + continue; + } + + $item = $this->inventoryItem($fresh->store_id, $line->variant_id); + + if ($item !== null) { + $this->inventory->commit($item, $line->quantity); + } + } + + $this->fulfillments->autoFulfillDigital($fresh->refresh()); + + event(new OrderPaid($fresh->refresh())); + + return $fresh->refresh(); + }); + } + + public function cancel(Order $order, ?string $reason = null): Order + { + return DB::transaction(function () use ($order): Order { + $fresh = $order->refresh(); + + if (in_array($fresh->status, [OrderStatus::Cancelled, OrderStatus::Refunded, OrderStatus::Fulfilled], true)) { + throw new InvalidOrderTransitionException('This order cannot be cancelled.'); + } + + if ($fresh->fulfillment_status !== FulfillmentStatus::Unfulfilled) { + throw new InvalidOrderTransitionException('This order cannot be cancelled because fulfillment has started.'); + } + + if (in_array($fresh->financial_status, [FinancialStatus::Pending, FinancialStatus::Authorized], true)) { + foreach ($fresh->orderLines()->get() as $line) { + if ($line->variant_id === null) { + continue; + } + + $item = $this->inventoryItem($fresh->store_id, $line->variant_id); + + if ($item !== null) { + $this->inventory->release($item, $line->quantity); + } + } + + $fresh->financial_status = FinancialStatus::Voided; + $fresh->payments()->where('status', PaymentStatus::Pending)->update(['status' => PaymentStatus::Failed->value]); + } elseif ($fresh->financial_status === FinancialStatus::Paid) { + foreach ($fresh->orderLines()->get() as $line) { + if ($line->variant_id === null) { + continue; + } + + $item = $this->inventoryItem($fresh->store_id, $line->variant_id); + + if ($item !== null) { + $this->inventory->restock($item, $line->quantity); + } + } + + $fresh->financial_status = FinancialStatus::Refunded; + } else { + throw new InvalidOrderTransitionException('This order cannot be cancelled. Use refunds instead.'); + } + + $fresh->status = OrderStatus::Cancelled; + $fresh->save(); + + event(new OrderCancelled($fresh->refresh())); + + return $fresh->refresh(); + }); + } + + protected function createOrderLines(Checkout $checkout, Order $order): void + { + $totals = (array) ($checkout->totals_json ?? []); + $allocations = (array) ($totals['discount_allocations'] ?? []); + $taxSnapshot = (array) ($checkout->tax_provider_snapshot_json ?? []); + $taxLines = []; + + foreach ((array) ($taxSnapshot['lines'] ?? []) as $entry) { + $taxLines[(int) ($entry['line_id'] ?? 0)] = $entry; + } + + foreach ($checkout->cart->cartLines()->with(['variant.product', 'variant.optionValues'])->orderBy('id')->get() as $line) { + $variant = $line->variant; + $product = $variant->product; + $labels = $variant->optionValues->pluck('value')->all(); + + $title = $product->title; + + if ($labels !== []) { + $title .= ' ('.implode(' / ', $labels).')'; + } + + $taxEntry = $taxLines[$line->getKey()] ?? null; + + $order->orderLines()->create([ + 'product_id' => $product->getKey(), + 'variant_id' => $variant->getKey(), + 'title_snapshot' => $title, + 'sku_snapshot' => $variant->sku, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'total_amount' => $line->line_total_amount, + 'tax_lines_json' => $taxEntry !== null ? [[ + 'title' => 'Tax', + 'rate' => (int) ($taxEntry['rate'] ?? 0), + 'amount' => (int) ($taxEntry['tax_amount'] ?? 0), + 'jurisdiction' => $taxEntry['jurisdiction'] ?? null, + ]] : [], + 'discount_allocations_json' => array_values(array_map( + fn (array $allocation): array => [ + 'discount_id' => (int) $allocation['discount_id'], + 'amount' => (int) $allocation['amount'], + ], + (array) ($allocations[$line->getKey()] ?? []) + )), + ]); + } + } + + protected function resolveCustomer(Checkout $checkout): ?Customer + { + if ($checkout->customer_id !== null) { + return Customer::query()->whereKey($checkout->customer_id)->first(); + } + + if ($checkout->email === null || $checkout->email === '') { + return null; + } + + $existing = Customer::query() + ->where('store_id', $checkout->store_id) + ->where('email', $checkout->email) + ->first(); + + if ($existing !== null) { + return $existing; + } + + return Customer::query()->create([ + 'store_id' => $checkout->store_id, + 'email' => $checkout->email, + 'password_hash' => null, + 'name' => null, + 'marketing_opt_in' => false, + ]); + } + + protected function commitInventory(Checkout $checkout): void + { + foreach ($checkout->cart->cartLines()->get() as $line) { + $item = $this->inventoryItem($checkout->store_id, $line->variant_id); + + if ($item !== null) { + $this->inventory->commit($item, $line->quantity); + } + } + } + + protected function incrementDiscountUsage(Checkout $checkout): void + { + $totals = (array) ($checkout->totals_json ?? []); + $ids = []; + + foreach ((array) ($totals['applied_discounts'] ?? []) as $applied) { + if (isset($applied['discount_id'])) { + $ids[] = (int) $applied['discount_id']; + } + } + + $ids = array_values(array_unique($ids)); + + if ($ids !== []) { + Discount::query()->whereIn('id', $ids)->increment('usage_count'); + } + } + + protected function inventoryItem(int $storeId, int $variantId): ?InventoryItem + { + return InventoryItem::query() + ->where('store_id', $storeId) + ->where('variant_id', $variantId) + ->first(); + } + + /** + * @param array $paymentDetails + * @return array + */ + protected function paymentSnapshot(PaymentResult $result, ?PaymentMethod $method, array $paymentDetails): array + { + $snapshot = [ + 'reference_id' => $result->referenceId, + 'status' => $result->status, + 'method' => $method?->value, + ]; + + $cardNumber = preg_replace('/\D+/', '', (string) ($paymentDetails['card_number'] ?? '')); + + if ($cardNumber !== '') { + $snapshot['card_last4'] = substr($cardNumber, -4); + } + + return $snapshot; + } +} diff --git a/app/Services/Payment/MockPaymentProvider.php b/app/Services/Payment/MockPaymentProvider.php new file mode 100644 index 00000000..6e452add --- /dev/null +++ b/app/Services/Payment/MockPaymentProvider.php @@ -0,0 +1,84 @@ +referenceId('mock_'), + status: 'pending', + ); + } + + if ($method === PaymentMethod::Paypal) { + return new PaymentResult( + success: true, + referenceId: $this->referenceId('mock_'), + status: 'captured', + ); + } + + $cardNumber = preg_replace('/\D+/', '', (string) ($details['card_number'] ?? '')); + + if ($cardNumber === self::DECLINE_CARD) { + return new PaymentResult( + success: false, + referenceId: $this->referenceId('mock_'), + status: 'failed', + errorCode: 'card_declined', + errorMessage: 'Your card was declined.', + ); + } + + if ($cardNumber === self::INSUFFICIENT_FUNDS_CARD) { + return new PaymentResult( + success: false, + referenceId: $this->referenceId('mock_'), + status: 'failed', + errorCode: 'insufficient_funds', + errorMessage: 'Your card has insufficient funds.', + ); + } + + return new PaymentResult( + success: true, + referenceId: $this->referenceId('mock_'), + status: 'captured', + ); + } + + public function refund(Payment $payment, int $amount): RefundResult + { + return new RefundResult( + success: true, + providerRefundId: $this->referenceId('mock_rf_'), + status: 'processed', + ); + } + + protected function referenceId(string $prefix): string + { + return $prefix.Str::random(16); + } +} diff --git a/app/Services/PaymentService.php b/app/Services/PaymentService.php new file mode 100644 index 00000000..47f842ec --- /dev/null +++ b/app/Services/PaymentService.php @@ -0,0 +1,28 @@ +provider->charge($checkout, $method, $details); + } + + public function refund(Payment $payment, int $amount): RefundResult + { + return $this->provider->refund($payment, $amount); + } +} diff --git a/app/Services/PricingEngine.php b/app/Services/PricingEngine.php new file mode 100644 index 00000000..7d8e8c63 --- /dev/null +++ b/app/Services/PricingEngine.php @@ -0,0 +1,279 @@ + $amounts + */ + public function sumSubtotals(array $amounts): int + { + return array_sum($amounts); + } + + public function percentDiscount(int $subtotal, int $percent): int + { + if ($subtotal <= 0 || $percent <= 0) { + return 0; + } + + return intdiv($subtotal * $percent, 100); + } + + public function fixedDiscount(int $subtotal, int $amount): int + { + if ($subtotal <= 0 || $amount <= 0) { + return 0; + } + + return min($amount, $subtotal); + } + + public function discountedSubtotal(int $subtotal, int $discount): int + { + return max(0, $subtotal - $discount); + } + + public function total(int $discountedSubtotal, int $shipping, int $taxTotal, bool $pricesIncludeTax): int + { + if ($pricesIncludeTax) { + return $discountedSubtotal + $shipping; + } + + return $discountedSubtotal + $shipping + $taxTotal; + } + + public function calculate(Checkout $checkout): PricingResult + { + $cart = $checkout->cart; + $store = $checkout->store; + $lines = $cart->cartLines()->with('variant')->orderBy('id')->get(); + + $subtotals = []; + $remaining = []; + + foreach ($lines as $line) { + $subtotal = $this->lineSubtotal($line->unit_price_amount, $line->quantity); + $subtotals[$line->getKey()] = $subtotal; + $remaining[$line->getKey()] = $subtotal; + } + + $subtotal = $this->sumSubtotals(array_values($subtotals)); + + $lineDiscounts = array_fill_keys(array_keys($subtotals), 0); + $lineAllocations = []; + $appliedDiscounts = []; + $freeShipping = false; + + $discountQueue = []; + + if ($checkout->discount_code !== null && $checkout->discount_code !== '') { + $validation = $this->discounts->validate($checkout->discount_code, $store, $cart); + + if ($validation->valid && $validation->discount !== null) { + $discountQueue[] = $validation->discount; + } + } + + foreach ($this->discounts->applicableAutomaticDiscounts($store, $cart) as $automatic) { + $discountQueue[] = $automatic; + } + + foreach ($discountQueue as $discount) { + if ($discount->value_type === DiscountValueType::FreeShipping) { + $freeShipping = true; + $appliedDiscounts[] = $this->describeDiscount($discount, 0, $cart->currency); + + continue; + } + + $input = []; + + foreach ($lines as $line) { + $input[] = [ + 'line_id' => $line->getKey(), + 'subtotal' => $remaining[$line->getKey()], + 'product_id' => $line->variant->product_id, + ]; + } + + $result = $this->discounts->calculate($discount, $subtotal, $input); + + foreach ($result['allocations'] as $lineId => $amount) { + $lineDiscounts[$lineId] += $amount; + $remaining[$lineId] = max(0, $remaining[$lineId] - $amount); + $lineAllocations[$lineId][] = [ + 'discount_id' => $discount->getKey(), + 'amount' => $amount, + ]; + } + + $appliedDiscounts[] = $this->describeDiscount($discount, $result['total'], $cart->currency); + } + + $discountTotal = $this->sumSubtotals(array_values($lineDiscounts)); + $discountedSubtotal = $this->discountedSubtotal($subtotal, $discountTotal); + + foreach ($lines as $line) { + $line->line_subtotal_amount = $subtotals[$line->getKey()]; + $line->line_discount_amount = $lineDiscounts[$line->getKey()]; + $line->line_total_amount = $remaining[$line->getKey()]; + $line->save(); + } + + $shippingAmount = $this->resolveShippingAmount($checkout, $freeShipping); + + $address = (array) ($checkout->shipping_address_json ?? []); + $lineItems = []; + + foreach ($lines as $line) { + $lineItems[] = [ + 'line_id' => $line->getKey(), + 'variant_id' => $line->variant_id, + 'amount' => $remaining[$line->getKey()], + ]; + } + + $taxResult = $this->tax->calculate($store, $lineItems, $shippingAmount, $address); + + $settings = TaxSetting::query()->where('store_id', $store->getKey())->first(); + $pricesIncludeTax = $settings !== null && (bool) $settings->prices_include_tax; + + $total = $this->total($discountedSubtotal, $shippingAmount, $taxResult->totalAmount, $pricesIncludeTax); + + $result = new PricingResult( + subtotal: $subtotal, + discount: $discountTotal, + shipping: $shippingAmount, + taxLines: $taxResult->taxLines, + taxTotal: $taxResult->totalAmount, + total: $total, + currency: $cart->currency, + appliedDiscounts: $appliedDiscounts, + ); + + $this->storeSnapshots($checkout, $result, $lineAllocations, $lineItems, $taxResult->taxLines, $shippingAmount, $address); + + return $result; + } + + protected function resolveShippingAmount(Checkout $checkout, bool $freeShipping): int + { + $cart = $checkout->cart; + + if (! $this->shipping->requiresShipping($cart)) { + return 0; + } + + if ($freeShipping) { + return 0; + } + + if ($checkout->shipping_method_id === null) { + return 0; + } + + $rate = ShippingRate::query()->whereKey($checkout->shipping_method_id)->first(); + + if ($rate === null || ! $rate->is_active) { + return 0; + } + + return $this->shipping->calculate($rate, $cart) ?? 0; + } + + /** + * @param array> $lineAllocations + * @param array $lineItems + * @param array $taxLines + * @param array $address + */ + protected function storeSnapshots( + Checkout $checkout, + PricingResult $result, + array $lineAllocations, + array $lineItems, + array $taxLines, + int $shippingAmount, + array $address, + ): void { + $totals = $result->toArray(); + $totals['discount_allocations'] = $lineAllocations; + + $checkout->totals_json = $totals; + + $lineTaxes = []; + $lineCount = count($lineItems); + + foreach ($lineItems as $index => $item) { + $taxLine = $taxLines[$index] ?? null; + + $lineTaxes[] = [ + 'line_id' => $item['line_id'], + 'variant_id' => $item['variant_id'], + 'tax_amount' => $taxLine?->amount ?? 0, + 'rate' => $taxLine?->rate ?? 0, + 'jurisdiction' => strtoupper((string) ($address['country_code'] ?? $address['country'] ?? '')), + ]; + } + + $shippingTaxLine = $taxLines[$lineCount] ?? null; + + $checkout->tax_provider_snapshot_json = [ + 'provider' => 'manual', + 'calculated_at' => now()->toIso8601String(), + 'lines' => $lineTaxes, + 'shipping_tax_amount' => $shippingTaxLine?->amount ?? 0, + 'shipping_tax_rate' => $shippingTaxLine?->rate ?? 0, + ]; + + $checkout->save(); + } + + /** + * @return array + */ + protected function describeDiscount(\App\Models\Discount $discount, int $appliedAmount, string $currency): array + { + return [ + 'discount_id' => $discount->getKey(), + 'code' => $discount->code, + 'type' => $discount->value_type->value, + 'value_amount' => $discount->value_amount, + 'applied_amount' => $appliedAmount, + 'description' => $this->discountDescription($discount, $currency), + ]; + } + + protected function discountDescription(\App\Models\Discount $discount, string $currency): string + { + return match ($discount->value_type) { + DiscountValueType::Percent => "{$discount->value_amount}% off your order", + DiscountValueType::Fixed => number_format($discount->value_amount / 100, 2).' '.$currency.' off your order', + DiscountValueType::FreeShipping => 'Free shipping on your order', + }; + } +} diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php new file mode 100644 index 00000000..63c0a796 --- /dev/null +++ b/app/Services/ProductService.php @@ -0,0 +1,625 @@ +assertSkusUnique($store->id, $data['variants'] ?? [], []); + + $handle = $data['handle'] ?? $this->handles->generate($data['title'], 'products', $store->id); + + if (isset($data['handle']) && $this->handles->exists('products', $store->id, $handle)) { + throw ValidationException::withMessages(['handle' => ['The handle has already been taken.']]); + } + + $requestedStatus = isset($data['status']) ? ProductStatus::from($data['status']) : ProductStatus::Draft; + + $product = Product::query()->create([ + 'store_id' => $store->id, + 'title' => $data['title'], + 'handle' => $handle, + 'status' => ProductStatus::Draft, + 'description_html' => $data['description_html'] ?? null, + 'vendor' => $data['vendor'] ?? null, + 'product_type' => $data['product_type'] ?? null, + 'tags' => $data['tags'] ?? [], + ]); + + $this->syncOptions($product, $data['options'] ?? [], $data['variants'] ?? []); + + if (($data['variants'] ?? []) !== []) { + $this->createVariants($product, $data['variants']); + } elseif (($data['options'] ?? []) !== []) { + $this->matrix->rebuildMatrix($product->refresh()); + } else { + $this->createDefaultVariant($product); + } + + $this->syncCollections($product, $data['collections'] ?? null); + + if ($requestedStatus !== ProductStatus::Draft) { + $this->transitionStatus($product->refresh(), $requestedStatus); + } + + $fresh = $product->fresh(); + $this->search->syncProduct($fresh); + + return $this->loadRelations($fresh); + }); + } + + public function update(Product $product, array $data): Product + { + return DB::transaction(function () use ($product, $data): Product { + if (array_key_exists('variants', $data)) { + $excludeIds = collect($data['variants'])->pluck('id')->filter()->all(); + $this->assertSkusUnique($product->store_id, $data['variants'], $excludeIds); + } + + foreach (['title', 'description_html', 'vendor', 'product_type'] as $field) { + if (array_key_exists($field, $data)) { + $product->{$field} = $data[$field]; + } + } + + if (array_key_exists('tags', $data)) { + $product->tags = $data['tags'] ?? []; + } + + if (array_key_exists('handle', $data) && $data['handle'] !== null && $data['handle'] !== $product->handle) { + if ($this->handles->exists('products', $product->store_id, $data['handle'], $product->getKey())) { + throw ValidationException::withMessages(['handle' => ['The handle has already been taken.']]); + } + + $product->handle = $data['handle']; + } + + $product->save(); + + if (array_key_exists('collections', $data)) { + $this->syncCollections($product, $data['collections']); + } + + if (array_key_exists('options', $data)) { + $this->syncOptions($product, $data['options'] ?? [], $data['variants'] ?? []); + } + + if (array_key_exists('variants', $data)) { + $this->reconcileVariants($product, $data['variants'] ?? []); + } elseif (array_key_exists('options', $data)) { + $this->matrix->rebuildMatrix($product->refresh()); + } + + if (array_key_exists('status', $data) && $data['status'] !== null) { + $this->transitionStatus($product->refresh(), ProductStatus::from($data['status'])); + } + + $fresh = $product->fresh(); + $this->search->syncProduct($fresh); + + return $this->loadRelations($fresh); + }); + } + + public function transitionStatus(Product $product, ProductStatus $newStatus): Product + { + $from = $product->status instanceof ProductStatus + ? $product->status + : ProductStatus::from((string) $product->status); + + if ($from === $newStatus) { + return $product; + } + + $this->assertTransitionAllowed($product, $from, $newStatus); + + return DB::transaction(function () use ($product, $from, $newStatus): Product { + $product->status = $newStatus; + + if ($newStatus === ProductStatus::Active && $product->published_at === null) { + $product->published_at = now(); + } + + $product->save(); + $this->search->syncProduct($product->refresh()); + + event(new ProductStatusChanged($product, $from, $newStatus)); + + return $product->refresh(); + }); + } + + public function archive(Product $product): Product + { + return $this->transitionStatus($product, ProductStatus::Archived); + } + + public function delete(Product $product): void + { + $status = $product->status instanceof ProductStatus + ? $product->status + : ProductStatus::from((string) $product->status); + + if ($status !== ProductStatus::Draft) { + throw new InvalidProductTransitionException('Only draft products can be deleted. Archive this product instead.'); + } + + if ($this->hasOrderReferences($product)) { + throw new InvalidProductTransitionException('This product cannot be deleted because it has existing orders. Archive it instead.'); + } + + DB::transaction(function () use ($product): void { + $this->search->removeProduct($product->getKey()); + $product->delete(); + }); + } + + protected function assertTransitionAllowed(Product $product, ProductStatus $from, ProductStatus $to): void + { + $activating = ($from === ProductStatus::Draft && $to === ProductStatus::Active) + || ($from === ProductStatus::Archived && $to === ProductStatus::Active); + + if ($activating) { + if (trim((string) $product->title) === '') { + throw new InvalidProductTransitionException('A product title is required before publishing.'); + } + + $hasPricedVariant = $product->productVariants()->where('price_amount', '>', 0)->exists(); + + if (! $hasPricedVariant) { + throw new InvalidProductTransitionException('At least one variant with a price greater than zero is required before publishing.'); + } + + return; + } + + $revertingToDraft = ($from === ProductStatus::Active && $to === ProductStatus::Draft) + || ($from === ProductStatus::Archived && $to === ProductStatus::Draft); + + if ($revertingToDraft && $this->hasOrderReferences($product)) { + throw new InvalidProductTransitionException('This product cannot be reverted to draft because it has existing orders.'); + } + } + + protected function hasOrderReferences(Product $product): bool + { + $variantIds = $product->productVariants()->pluck('id'); + + if ($variantIds->isEmpty()) { + return false; + } + + return OrderLine::query()->whereIn('variant_id', $variantIds)->exists(); + } + + /** + * @param array> $variants + * @param array $excludeVariantIds + */ + protected function assertSkusUnique(int $storeId, array $variants, array $excludeVariantIds): void + { + $skusByIndex = []; + + foreach ($variants as $index => $variant) { + $sku = trim((string) ($variant['sku'] ?? '')); + + if ($sku === '') { + continue; + } + + $skusByIndex[$index] = $sku; + } + + if ($skusByIndex === []) { + return; + } + + $duplicates = array_diff_assoc($skusByIndex, array_unique($skusByIndex)); + $errors = []; + + foreach ($duplicates as $index => $sku) { + $errors["variants.{$index}.sku"] = ['The sku must be unique within this store.']; + } + + $taken = ProductVariant::query() + ->whereHas('product', fn ($query) => $query->where('store_id', $storeId)) + ->whereIn('sku', array_values(array_unique($skusByIndex))) + ->when($excludeVariantIds !== [], fn ($query) => $query->whereNotIn('id', $excludeVariantIds)) + ->pluck('sku') + ->all(); + + foreach ($skusByIndex as $index => $sku) { + if (in_array($sku, $taken, true)) { + $errors["variants.{$index}.sku"] = ['The sku has already been taken.']; + } + } + + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + } + + /** + * @param array> $options + * @param array> $variants + */ + protected function syncOptions(Product $product, array $options, array $variants): void + { + $names = collect($options)->pluck('name')->map(fn ($name) => trim((string) $name))->all(); + + $product->productOptions()->whereNotIn('name', $names)->delete(); + + // Two-phase position update: park existing rows at unique negative + // positions first so re-positioning never collides mid-update with the + // UNIQUE(product_id, position) constraint (e.g. seeded options are + // 0-based while form/API payloads are 1-based, so naive in-place + // updates clash with rows that have not been moved yet). + $product->productOptions()->update(['position' => DB::raw('-id - 1000000')]); + + foreach ($options as $index => $optionData) { + $name = trim((string) $optionData['name']); + + $option = $product->productOptions()->updateOrCreate( + ['name' => $name], + ['position' => $optionData['position'] ?? $index + 1] + ); + + $values = collect($optionData['values'] ?? [])->map(fn ($value) => trim((string) $value))->all(); + + foreach ($variants as $variantData) { + foreach ($variantData['option_values'] ?? [] as $optionValue) { + if (trim((string) ($optionValue['option_name'] ?? '')) === $name) { + $values[] = trim((string) ($optionValue['value'] ?? '')); + } + } + } + + $values = array_values(array_unique(array_filter($values))); + + $option->productOptionValues()->whereNotIn('value', $values)->delete(); + + // Park value positions as well: prepending/reordering values while + // adding new ones can otherwise collide mid-update with the + // UNIQUE(product_option_id, position) constraint. + $option->productOptionValues()->update(['position' => DB::raw('-id - 1000000')]); + + foreach ($values as $position => $value) { + $option->productOptionValues()->updateOrCreate( + ['value' => $value], + ['position' => $position + 1] + ); + } + } + } + + /** + * @param array> $variants + */ + protected function createVariants(Product $product, array $variants): void + { + $defaultIndex = $this->resolveDefaultIndex($variants); + + foreach ($variants as $index => $variantData) { + $this->assertCompareAtValid($variantData, $index); + + $variant = $product->productVariants()->create([ + 'sku' => $this->normalizeSku($variantData['sku'] ?? null), + 'barcode' => $variantData['barcode'] ?? null, + 'price_amount' => $variantData['price_amount'] ?? 0, + 'compare_at_amount' => $variantData['compare_at_amount'] ?? null, + 'currency' => $variantData['currency'] ?? $product->store?->default_currency ?? 'USD', + 'weight_g' => $variantData['weight_g'] ?? null, + 'requires_shipping' => $variantData['requires_shipping'] ?? true, + 'is_default' => $index === $defaultIndex, + 'position' => $variantData['position'] ?? $index, + 'status' => isset($variantData['status']) ? VariantStatus::from($variantData['status']) : VariantStatus::Active, + ]); + + $this->linkOptionValues($product, $variant, $variantData['option_values'] ?? [], $index); + $this->createInventoryItem($product, $variant, $variantData['inventory'] ?? []); + } + } + + /** + * @param array> $variants + */ + protected function reconcileVariants(Product $product, array $variants): void + { + $keptIds = []; + + foreach ($variants as $index => $variantData) { + $this->assertCompareAtValid($variantData, $index); + + if (isset($variantData['id'])) { + $variant = $product->productVariants()->whereKey($variantData['id'])->first(); + + if ($variant === null) { + throw ValidationException::withMessages(["variants.{$index}.id" => ['The selected variant does not belong to this product.']]); + } + + foreach (['sku', 'barcode', 'price_amount', 'compare_at_amount', 'currency', 'weight_g', 'requires_shipping', 'position'] as $field) { + if (array_key_exists($field, $variantData)) { + $variant->{$field} = $field === 'sku' ? $this->normalizeSku($variantData[$field]) : $variantData[$field]; + } + } + + if (array_key_exists('status', $variantData) && $variantData['status'] !== null) { + $variant->status = VariantStatus::from($variantData['status']); + } + + if (array_key_exists('is_default', $variantData)) { + $variant->is_default = (bool) $variantData['is_default']; + } + + $variant->save(); + + if (array_key_exists('option_values', $variantData)) { + $this->linkOptionValues($product, $variant, $variantData['option_values'] ?? [], $index, true); + } + + if (array_key_exists('inventory', $variantData)) { + $this->updateInventoryItem($product, $variant, $variantData['inventory'] ?? []); + } + + $keptIds[] = $variant->getKey(); + + continue; + } + + $variant = $product->productVariants()->create([ + 'sku' => $this->normalizeSku($variantData['sku'] ?? null), + 'barcode' => $variantData['barcode'] ?? null, + 'price_amount' => $variantData['price_amount'] ?? 0, + 'compare_at_amount' => $variantData['compare_at_amount'] ?? null, + 'currency' => $variantData['currency'] ?? $product->store?->default_currency ?? 'USD', + 'weight_g' => $variantData['weight_g'] ?? null, + 'requires_shipping' => $variantData['requires_shipping'] ?? true, + 'is_default' => false, + 'position' => $variantData['position'] ?? $product->productVariants()->max('position') + 1, + 'status' => isset($variantData['status']) ? VariantStatus::from($variantData['status']) : VariantStatus::Active, + ]); + + $this->linkOptionValues($product, $variant, $variantData['option_values'] ?? [], $index); + $this->createInventoryItem($product, $variant, $variantData['inventory'] ?? []); + $keptIds[] = $variant->getKey(); + } + + $orphans = $product->productVariants()->whereNotIn('id', $keptIds)->get(); + + foreach ($orphans as $orphan) { + if (OrderLine::query()->where('variant_id', $orphan->getKey())->exists()) { + $orphan->update(['status' => VariantStatus::Archived]); + } else { + $orphan->delete(); + } + } + + $this->ensureSingleDefault($product); + } + + protected function createDefaultVariant(Product $product): void + { + $variant = $product->productVariants()->create([ + 'sku' => null, + 'barcode' => null, + 'price_amount' => 0, + 'compare_at_amount' => null, + 'currency' => $product->store?->default_currency ?? 'USD', + 'weight_g' => null, + 'requires_shipping' => true, + 'is_default' => true, + 'position' => 0, + 'status' => VariantStatus::Active, + ]); + + $this->createInventoryItem($product, $variant, []); + } + + /** + * @param array> $optionValues + */ + protected function linkOptionValues(Product $product, ProductVariant $variant, array $optionValues, int $variantIndex, bool $detachFirst = false): void + { + if ($detachFirst) { + $variant->optionValues()->detach(); + } + + $valueIds = []; + + foreach ($optionValues as $entry) { + $optionName = trim((string) ($entry['option_name'] ?? '')); + $value = trim((string) ($entry['value'] ?? '')); + + if ($optionName === '' || $value === '') { + continue; + } + + $option = $product->productOptions()->where('name', $optionName)->first(); + + if ($option === null) { + throw ValidationException::withMessages([ + "variants.{$variantIndex}.option_values" => ["Option [{$optionName}] is not defined for this product."], + ]); + } + + $optionValue = $option->productOptionValues()->firstOrCreate( + ['value' => $value], + ['position' => ($option->productOptionValues()->max('position') ?? 0) + 1] + ); + + $valueIds[] = $optionValue->getKey(); + } + + if ($valueIds !== []) { + $variant->optionValues()->syncWithoutDetaching($valueIds); + } + } + + /** + * @param array $inventory + */ + protected function createInventoryItem(Product $product, ProductVariant $variant, array $inventory): void + { + InventoryItem::query()->create([ + 'store_id' => $product->store_id, + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => $inventory['quantity_on_hand'] ?? 0, + 'quantity_reserved' => 0, + 'policy' => isset($inventory['policy']) ? InventoryPolicy::from($inventory['policy']) : InventoryPolicy::Deny, + ]); + } + + /** + * @param array $inventory + */ + protected function updateInventoryItem(Product $product, ProductVariant $variant, array $inventory): void + { + $item = InventoryItem::query()->firstOrCreate( + ['variant_id' => $variant->getKey()], + ['store_id' => $product->store_id, 'quantity_on_hand' => 0, 'quantity_reserved' => 0, 'policy' => InventoryPolicy::Deny] + ); + + if (array_key_exists('quantity_on_hand', $inventory) && $inventory['quantity_on_hand'] !== null) { + $item->quantity_on_hand = $inventory['quantity_on_hand']; + } + + if (array_key_exists('policy', $inventory) && $inventory['policy'] !== null) { + $item->policy = InventoryPolicy::from($inventory['policy']); + } + + $item->save(); + } + + /** + * @param array|null $collectionIds + */ + protected function syncCollections(Product $product, ?array $collectionIds): void + { + if ($collectionIds === null) { + return; + } + + $validIds = Collection::query() + ->where('store_id', $product->store_id) + ->whereIn('id', $collectionIds) + ->pluck('id') + ->all(); + + $unknown = array_diff($collectionIds, $validIds); + + if ($unknown !== []) { + throw ValidationException::withMessages(['collections' => ['One or more collections do not belong to this store.']]); + } + + $sync = []; + + foreach (array_values($collectionIds) as $position => $collectionId) { + $sync[$collectionId] = ['position' => $position]; + } + + $product->collections()->sync($sync); + } + + /** + * @param array> $variants + */ + protected function resolveDefaultIndex(array $variants): int + { + foreach ($variants as $index => $variant) { + if (! empty($variant['is_default'])) { + return $index; + } + } + + return 0; + } + + protected function ensureSingleDefault(Product $product): void + { + $variants = $product->productVariants()->orderBy('position')->orderBy('id')->get(); + + if ($variants->isEmpty()) { + return; + } + + $default = $variants->firstWhere('is_default', true) ?? $variants->first(); + + foreach ($variants as $variant) { + $shouldBeDefault = $variant->getKey() === $default->getKey(); + + if ((bool) $variant->is_default !== $shouldBeDefault) { + $variant->update(['is_default' => $shouldBeDefault]); + } + } + } + + /** + * @param array $variantData + */ + protected function assertCompareAtValid(array $variantData, int $index): void + { + if (! array_key_exists('compare_at_amount', $variantData) || $variantData['compare_at_amount'] === null) { + return; + } + + if ($variantData['compare_at_amount'] <= ($variantData['price_amount'] ?? 0)) { + throw ValidationException::withMessages([ + "variants.{$index}.compare_at_amount" => ['The compare at amount must be greater than the price.'], + ]); + } + } + + protected function normalizeSku(mixed $sku): ?string + { + $sku = trim((string) $sku); + + return $sku === '' ? null : $sku; + } + + protected function loadRelations(Product $product): Product + { + return $product->load([ + 'productOptions' => fn ($query) => $query->orderBy('position'), + 'productOptions.productOptionValues' => fn ($query) => $query->orderBy('position'), + 'productVariants' => fn ($query) => $query->orderBy('position')->orderBy('id'), + 'productVariants.inventoryItem', + 'productVariants.optionValues.productOption', + 'productMedia' => fn ($query) => $query->orderBy('position')->orderBy('id'), + 'collections', + ]); + } + + public function findOption(Product $product, string $name): ?ProductOption + { + return $product->productOptions()->where('name', $name)->first(); + } +} diff --git a/app/Services/RefundService.php b/app/Services/RefundService.php new file mode 100644 index 00000000..e769d70d --- /dev/null +++ b/app/Services/RefundService.php @@ -0,0 +1,187 @@ +refunds()->where('status', '!=', RefundStatus::Failed)->sum('amount'); + + return max(0, $order->total_amount - $refunded); + } + + /** + * @param array|null $lineItems Map of order_line_id to quantity. + */ + public function create( + Order $order, + int $amount, + ?array $lineItems = null, + ?string $reason = null, + bool $restock = false, + ): Refund { + return DB::transaction(function () use ($order, $amount, $lineItems, $reason, $restock): Refund { + $fresh = $order->refresh(); + + if (! in_array($fresh->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true)) { + throw new InvalidRefundException('This order cannot be refunded.', 409); + } + + if ($amount < 1) { + throw ValidationException::withMessages([ + 'amount' => ['The amount must be at least 1.'], + ]); + } + + $refundable = $this->refundableAmount($fresh); + + if ($amount > $refundable) { + throw ValidationException::withMessages([ + 'amount' => ['The amount must not exceed the refundable total.'], + ]); + } + + $validatedLines = $this->validateLineItems($fresh, $lineItems); + + $payment = $fresh->payments()->where('status', PaymentStatus::Captured)->latest('id')->first() + ?? $fresh->payments()->latest('id')->first(); + + if ($payment === null) { + throw new InvalidRefundException('This order has no payment to refund.', 409); + } + + $result = $this->payments->refund($payment, $amount); + + $refund = $fresh->refunds()->create([ + 'payment_id' => $payment->getKey(), + 'amount' => $amount, + 'reason' => $reason, + 'status' => RefundStatus::Pending, + 'provider_refund_id' => $result->providerRefundId, + ]); + + if ($result->success) { + $refund->status = RefundStatus::Processed; + $refund->save(); + } else { + $refund->status = RefundStatus::Failed; + $refund->save(); + + throw new InvalidRefundException('The payment provider declined the refund.', 502); + } + + $totalRefunded = (int) $fresh->refunds()->where('status', '!=', RefundStatus::Failed)->sum('amount'); + + if ($totalRefunded >= $fresh->total_amount) { + $fresh->financial_status = FinancialStatus::Refunded; + $fresh->status = OrderStatus::Refunded; + $payment->status = PaymentStatus::Refunded; + $payment->save(); + } else { + $fresh->financial_status = FinancialStatus::PartiallyRefunded; + } + + $fresh->save(); + + if ($restock) { + $this->restock($fresh, $validatedLines, $amount >= $fresh->total_amount); + } + + event(new OrderRefunded($fresh->refresh(), $refund->refresh())); + + return $refund->refresh(); + }); + } + + /** + * @param array|null $lineItems + * @return array + */ + protected function validateLineItems(Order $order, ?array $lineItems): array + { + if ($lineItems === null || $lineItems === []) { + return []; + } + + $orderLines = $order->orderLines()->get()->keyBy('id'); + $validated = []; + + foreach ($lineItems as $orderLineId => $quantity) { + $orderLine = $orderLines->get((int) $orderLineId); + + if ($orderLine === null) { + throw ValidationException::withMessages([ + 'line_items' => ['One or more line items do not belong to this order.'], + ]); + } + + if ($quantity < 1 || $quantity > $orderLine->quantity) { + throw ValidationException::withMessages([ + 'line_items' => ['The quantity must be between 1 and the ordered quantity.'], + ]); + } + + $validated[(int) $orderLineId] = (int) $quantity; + } + + return $validated; + } + + /** + * @param array $validatedLines + */ + protected function restock(Order $order, array $validatedLines, bool $isFullRefund): void + { + $targets = $validatedLines; + + if ($targets === [] && $isFullRefund) { + foreach ($order->orderLines()->get() as $line) { + $targets[$line->getKey()] = $line->quantity; + } + } + + if ($targets === []) { + return; + } + + $orderLines = $order->orderLines()->get()->keyBy('id'); + + foreach ($targets as $orderLineId => $quantity) { + $line = $orderLines->get($orderLineId); + + if ($line === null || $line->variant_id === null) { + continue; + } + + $item = InventoryItem::query() + ->where('store_id', $order->store_id) + ->where('variant_id', $line->variant_id) + ->first(); + + if ($item !== null) { + $this->inventory->restock($item, $quantity); + } + } + } +} diff --git a/app/Services/RolePermissionMap.php b/app/Services/RolePermissionMap.php new file mode 100644 index 00000000..11061394 --- /dev/null +++ b/app/Services/RolePermissionMap.php @@ -0,0 +1,73 @@ + + */ + public static function for(StoreUserRole $role): array + { + return match ($role) { + StoreUserRole::Owner, StoreUserRole::Admin => [ + 'read-products', + 'write-products', + 'read-orders', + 'write-orders', + 'read-customers', + 'write-customers', + 'read-collections', + 'write-collections', + 'read-discounts', + 'write-discounts', + 'read-analytics', + 'read-settings', + 'write-settings', + 'read-themes', + 'write-themes', + 'read-content', + 'write-content', + ], + StoreUserRole::Staff => [ + 'read-products', + 'write-products', + 'read-orders', + 'write-orders', + 'read-customers', + 'write-customers', + 'read-collections', + 'write-collections', + 'read-discounts', + 'write-discounts', + 'read-analytics', + 'read-content', + 'write-content', + ], + StoreUserRole::Support => [ + 'read-products', + 'read-orders', + 'read-customers', + 'read-collections', + 'read-discounts', + ], + }; + } + + /** + * @return array + */ + public static function allAbilities(): array + { + return [ + ...self::for(StoreUserRole::Owner), + 'manage-platform', + ]; + } +} diff --git a/app/Services/SearchService.php b/app/Services/SearchService.php new file mode 100644 index 00000000..14803902 --- /dev/null +++ b/app/Services/SearchService.php @@ -0,0 +1,407 @@ + $filters + * @return array{results: LengthAwarePaginator, facets: array{vendors: array, tags: array, price_range: array{min: int, max: int}}} + */ + public function search(Store $store, string $query, array $filters = [], string $sort = 'relevance', int $perPage = 24, int $page = 1): array + { + $rankedIds = $this->matchProductIds($store->id, $query); + + $products = $this->loadSearchableProducts($store, $rankedIds); + $products = $this->applyFilters($products, $filters); + $products = $this->applySort($products, $sort, $rankedIds); + + SearchQuery::query()->create([ + 'store_id' => $store->id, + 'query' => $query, + 'filters_json' => $filters === [] ? null : $filters, + 'results_count' => $products->count(), + 'created_at' => now(), + ]); + + $facets = $this->facets($products); + $items = $products->map(fn (Product $product): array => $this->resultRow($product, $store))->all(); + + $paginator = new LengthAwarePaginator( + array_slice($items, ($page - 1) * $perPage, $perPage), + count($items), + $perPage, + $page, + ); + + return ['results' => $paginator, 'facets' => $facets]; + } + + /** + * @return SupportCollection> + */ + public function autocomplete(Store $store, string $prefix, int $limit = 5): SupportCollection + { + $sanitized = $this->sanitize($prefix); + + $products = collect(); + + if ($sanitized !== '') { + try { + $rows = DB::select( + 'SELECT product_id FROM products_fts WHERE products_fts MATCH ? AND store_id = ? ORDER BY rank LIMIT ?', + ["{$sanitized}*", $store->id, $limit * 2] + ); + $ids = array_map(fn ($row): int => (int) $row->product_id, $rows); + + $products = $this->loadSearchableProducts($store, $ids)->sortBy('title')->take($limit); + } catch (\Throwable) { + $products = collect(); + } + + if ($products->isEmpty()) { + $products = Product::query() + ->where('store_id', $store->id) + ->where('status', ProductStatus::Active) + ->whereNotNull('published_at') + ->where('title', 'like', "{$prefix}%") + ->with(['productVariants', 'productMedia']) + ->orderBy('title') + ->limit($limit) + ->get(); + } + } + + $collections = Collection::query() + ->where('store_id', $store->id) + ->where('title', 'like', "{$prefix}%") + ->orderBy('title') + ->limit($limit) + ->get(); + + $suggestions = $products->map(fn (Product $product): array => [ + 'type' => 'product', + 'title' => $product->title, + 'handle' => $product->handle, + 'image_url' => $this->primaryImageUrl($product), + 'price_amount' => $this->displayVariant($product)?->price_amount ?? 0, + 'currency' => $this->displayVariant($product)?->currency ?? $store->default_currency, + ])->concat($collections->map(fn (Collection $collection): array => [ + 'type' => 'collection', + 'title' => $collection->title, + 'handle' => $collection->handle, + 'image_url' => null, + ])); + + return $suggestions->take($limit)->values(); + } + + public function syncProduct(Product $product): void + { + DB::delete('DELETE FROM products_fts WHERE product_id = ?', [$product->getKey()]); + + DB::insert( + 'INSERT INTO products_fts (store_id, product_id, title, description, vendor, product_type, tags) VALUES (?, ?, ?, ?, ?, ?, ?)', + [ + $product->store_id, + $product->getKey(), + (string) $product->title, + trim(strip_tags((string) $product->description_html)), + (string) ($product->vendor ?? ''), + (string) ($product->product_type ?? ''), + implode(' ', $product->tags ?? []), + ] + ); + } + + public function removeProduct(int $productId): void + { + DB::delete('DELETE FROM products_fts WHERE product_id = ?', [$productId]); + } + + public function reindex(Store $store): int + { + DB::delete('DELETE FROM products_fts WHERE store_id = ?', [$store->id]); + + $count = 0; + + Product::query()->where('store_id', $store->id)->orderBy('id')->chunk(200, function ($products) use (&$count): void { + foreach ($products as $product) { + $this->syncProduct($product); + $count++; + } + }); + + $setting = SearchSetting::query()->firstOrNew(['store_id' => $store->id]); + $setting->forceFill(['updated_at' => now()])->save(); + + return $count; + } + + public function documentCount(Store $store): int + { + return (int) DB::selectOne('SELECT COUNT(*) AS total FROM products_fts WHERE store_id = ?', [$store->id])->total; + } + + /** + * @return array Product IDs ordered by FTS5 rank. + */ + protected function matchProductIds(int $storeId, string $query): array + { + $sanitized = $this->sanitize($query); + + if ($sanitized === '') { + return []; + } + + $tokens = preg_split('/\s+/', $sanitized) ?: []; + $tokens[count($tokens) - 1] .= '*'; + + try { + $rows = DB::select( + 'SELECT product_id FROM products_fts WHERE products_fts MATCH ? AND store_id = ? ORDER BY rank LIMIT 500', + [implode(' ', $tokens), $storeId] + ); + } catch (\Throwable) { + return []; + } + + return array_map(fn ($row): int => (int) $row->product_id, $rows); + } + + /** + * @param array $rankedIds + * @return SupportCollection + */ + protected function loadSearchableProducts(Store $store, array $rankedIds): SupportCollection + { + if ($rankedIds === []) { + return collect(); + } + + return Product::query() + ->where('store_id', $store->id) + ->whereIn('id', $rankedIds) + ->where('status', ProductStatus::Active) + ->whereNotNull('published_at') + ->with([ + 'productVariants' => fn ($query) => $query->orderBy('price_amount')->orderBy('position')->orderBy('id'), + 'productVariants.inventoryItem', + 'productMedia' => fn ($query) => $query->orderBy('position')->orderBy('id'), + 'collections', + ]) + ->get(); + } + + /** + * @param SupportCollection $products + * @param array $filters + * @return SupportCollection + */ + protected function applyFilters(SupportCollection $products, array $filters): SupportCollection + { + if (isset($filters['collection_id'])) { + $collectionId = (int) $filters['collection_id']; + $products = $products->filter( + fn (Product $product): bool => $product->collections->contains('id', $collectionId) + ); + } + + if (isset($filters['price_min'])) { + $min = (int) $filters['price_min']; + $products = $products->filter(fn (Product $product): bool => $this->minPrice($product) >= $min); + } + + if (isset($filters['price_max'])) { + $max = (int) $filters['price_max']; + $products = $products->filter(fn (Product $product): bool => $this->minPrice($product) <= $max); + } + + if (! empty($filters['in_stock'])) { + $products = $products->filter(fn (Product $product): bool => $this->inStock($product)); + } + + if (! empty($filters['tags'])) { + $wanted = array_map(strtolower(...), (array) $filters['tags']); + $products = $products->filter(function (Product $product) use ($wanted): bool { + $tags = array_map(strtolower(...), $product->tags ?? []); + + return array_intersect($tags, $wanted) !== []; + }); + } + + if (! empty($filters['vendor'])) { + $vendor = strtolower((string) $filters['vendor']); + $products = $products->filter( + fn (Product $product): bool => strtolower((string) $product->vendor) === $vendor + ); + } + + return $products->values(); + } + + /** + * @param SupportCollection $products + * @param array $rankedIds + * @return SupportCollection + */ + protected function applySort(SupportCollection $products, string $sort, array $rankedIds): SupportCollection + { + return match ($sort) { + 'price_asc' => $products->sortBy(fn (Product $p): array => [$this->minPrice($p), $p->id])->values(), + 'price_desc' => $products->sortBy(fn (Product $p): array => [-$this->minPrice($p), $p->id])->values(), + 'newest' => $products->sortByDesc(fn (Product $p): string => (string) $p->created_at)->values(), + 'best_selling' => $this->sortBySales($products), + default => $products->sortBy(fn (Product $p): int => array_search($p->id, $rankedIds, true) ?: PHP_INT_MAX)->values(), + }; + } + + /** + * @param SupportCollection $products + * @return SupportCollection + */ + protected function sortBySales(SupportCollection $products): SupportCollection + { + if ($products->isEmpty()) { + return $products; + } + + $sales = OrderLine::query() + ->whereIn('product_id', $products->pluck('id')->all()) + ->groupBy('product_id') + ->selectRaw('product_id, SUM(quantity) AS total') + ->pluck('total', 'product_id'); + + return $products + ->sortBy(fn (Product $p): array => [-((int) ($sales[$p->id] ?? 0)), $p->id]) + ->values(); + } + + /** + * @param SupportCollection $products + * @return array{vendors: array, tags: array, price_range: array{min: int, max: int}} + */ + protected function facets(SupportCollection $products): array + { + $vendors = []; + $tags = []; + $prices = []; + + foreach ($products as $product) { + if ($product->vendor !== null && $product->vendor !== '') { + $vendors[$product->vendor] = ($vendors[$product->vendor] ?? 0) + 1; + } + + foreach ($product->tags ?? [] as $tag) { + $tags[$tag] = ($tags[$tag] ?? 0) + 1; + } + + $prices[] = $this->minPrice($product); + } + + ksort($vendors); + ksort($tags); + + return [ + 'vendors' => collect($vendors)->map(fn (int $count, string $value): array => ['value' => $value, 'count' => $count])->values()->all(), + 'tags' => collect($tags)->map(fn (int $count, string $value): array => ['value' => $value, 'count' => $count])->values()->all(), + 'price_range' => [ + 'min' => $prices === [] ? 0 : min($prices), + 'max' => $prices === [] ? 0 : max($prices), + ], + ]; + } + + /** + * @return array + */ + protected function resultRow(Product $product, Store $store): array + { + $variant = $this->displayVariant($product); + + return [ + 'id' => $product->id, + 'title' => $product->title, + 'handle' => $product->handle, + 'vendor' => $product->vendor, + 'product_type' => $product->product_type, + 'price_amount' => $variant?->price_amount ?? 0, + 'compare_at_amount' => $variant?->compare_at_amount, + 'currency' => $variant?->currency ?? $store->default_currency, + 'image_url' => $this->primaryImageUrl($product), + 'in_stock' => $this->inStock($product), + 'tags' => $product->tags ?? [], + ]; + } + + protected function minPrice(Product $product): int + { + return $this->displayVariant($product)?->price_amount ?? 0; + } + + protected function displayVariant(Product $product): ?\App\Models\ProductVariant + { + $active = $product->productVariants->where('status', \App\Enums\VariantStatus::Active); + + return $active->firstWhere('is_default', true) ?? $active->sortBy(fn ($v): array => [$v->price_amount, $v->position, $v->id])->first(); + } + + protected function inStock(Product $product): bool + { + foreach ($product->productVariants as $variant) { + if ($variant->status !== \App\Enums\VariantStatus::Active) { + continue; + } + + $item = $variant->inventoryItem; + + if ($item === null) { + continue; + } + + if ($item->policy === \App\Enums\InventoryPolicy::Continue) { + return true; + } + + if (($item->quantity_on_hand - $item->quantity_reserved) > 0) { + return true; + } + } + + return false; + } + + protected function primaryImageUrl(Product $product): ?string + { + $media = $product->productMedia->first(); + + if ($media === null) { + return null; + } + + return Storage::disk('public')->url($media->storage_key); + } + + protected function sanitize(string $query): string + { + $cleaned = (string) preg_replace('/[^\p{L}\p{N}\s]+/u', ' ', $query); + $cleaned = (string) preg_replace('/\b(AND|OR|NOT|NEAR)\b/i', ' ', $cleaned); + + return trim((string) preg_replace('/\s+/', ' ', $cleaned)); + } +} diff --git a/app/Services/ShippingCalculator.php b/app/Services/ShippingCalculator.php new file mode 100644 index 00000000..89e3e35d --- /dev/null +++ b/app/Services/ShippingCalculator.php @@ -0,0 +1,196 @@ + $address + */ + public function getMatchingZone(Store $store, array $address): ?ShippingZone + { + $countryCode = strtoupper((string) ($address['country_code'] ?? $address['country'] ?? '')); + $provinceCode = strtoupper((string) ($address['province_code'] ?? '')); + + if ($countryCode === '') { + return null; + } + + $zones = ShippingZone::query()->where('store_id', $store->getKey())->orderBy('id')->get(); + + $bestMatch = null; + $bestSpecificity = -1; + + foreach ($zones as $zone) { + $countries = array_map('strtoupper', array_map('strval', (array) ($zone->countries_json ?? []))); + $regions = array_map('strtoupper', array_map('strval', (array) ($zone->regions_json ?? []))); + + $countryMatch = in_array($countryCode, $countries, true); + $regionMatch = $provinceCode !== '' && in_array($provinceCode, $regions, true); + + if ($countryMatch && $regionMatch) { + $specificity = 2; + } elseif ($countryMatch) { + $specificity = 1; + } else { + continue; + } + + if ($specificity > $bestSpecificity) { + $bestMatch = $zone; + $bestSpecificity = $specificity; + } elseif ($specificity === $bestSpecificity && $bestMatch !== null && $zone->getKey() < $bestMatch->getKey()) { + $bestMatch = $zone; + } + } + + return $bestMatch; + } + + /** + * @param array $address + * @return Collection + */ + public function getAvailableRates(Store $store, array $address, Cart $cart): Collection + { + if (! $this->requiresShipping($cart)) { + return collect(); + } + + $zone = $this->getMatchingZone($store, $address); + + if ($zone === null) { + return collect(); + } + + $rates = ShippingRate::query() + ->where('zone_id', $zone->getKey()) + ->where('is_active', true) + ->orderBy('id') + ->get(); + + $options = collect(); + + foreach ($rates as $rate) { + $amount = $this->calculate($rate, $cart); + + if ($amount !== null) { + $options->push(new ShippingRateOption( + id: $rate->getKey(), + name: $rate->name, + amount: $amount, + type: $rate->type->value, + )); + } + } + + return $options; + } + + public function calculate(ShippingRate $rate, Cart $cart): ?int + { + return match ($rate->type) { + ShippingRateType::Flat => $this->calculateFlat($rate), + ShippingRateType::Weight => $this->calculateWeight($rate, $cart), + ShippingRateType::Price => $this->calculatePrice($rate, $cart), + ShippingRateType::Carrier => $this->calculateCarrier($rate), + }; + } + + public function requiresShipping(Cart $cart): bool + { + return $cart->cartLines() + ->whereHas('variant', fn ($query) => $query->where('requires_shipping', true)) + ->exists(); + } + + public function cartWeightG(Cart $cart): int + { + $weight = 0; + + foreach ($cart->cartLines()->with('variant')->get() as $line) { + if ($line->variant->requires_shipping) { + $weight += ((int) ($line->variant->weight_g ?? 0)) * $line->quantity; + } + } + + return $weight; + } + + public function cartSubtotal(Cart $cart): int + { + return (int) $cart->cartLines()->sum('line_subtotal_amount'); + } + + protected function calculateFlat(ShippingRate $rate): int + { + $config = $rate->config_json ?? []; + + return (int) ($config['amount'] ?? $config['price_amount'] ?? 0); + } + + protected function calculateWeight(ShippingRate $rate, Cart $cart): ?int + { + $config = $rate->config_json ?? []; + $ranges = $config['ranges'] ?? $config['tiers'] ?? []; + $totalWeight = $this->cartWeightG($cart); + + foreach ($ranges as $range) { + $min = (int) ($range['min_g'] ?? $range['min_weight_g'] ?? 0); + $max = $range['max_g'] ?? $range['max_weight_g'] ?? null; + $amount = $range['amount'] ?? $range['price_amount'] ?? null; + + if ($amount === null) { + continue; + } + + if ($totalWeight >= $min && ($max === null || $totalWeight <= (int) $max)) { + return (int) $amount; + } + } + + return null; + } + + protected function calculatePrice(ShippingRate $rate, Cart $cart): ?int + { + $config = $rate->config_json ?? []; + $ranges = $config['ranges'] ?? $config['tiers'] ?? []; + $subtotal = $this->cartSubtotal($cart); + + foreach ($ranges as $range) { + $min = (int) ($range['min_amount'] ?? 0); + $max = $range['max_amount'] ?? null; + $amount = $range['amount'] ?? $range['price_amount'] ?? null; + + if ($amount === null) { + continue; + } + + if ($subtotal >= $min && ($max === null || $subtotal <= (int) $max)) { + return (int) $amount; + } + } + + return null; + } + + protected function calculateCarrier(ShippingRate $rate): ?int + { + $config = $rate->config_json ?? []; + $amount = $config['amount'] ?? $config['price_amount'] ?? null; + + return $amount === null ? null : (int) $amount; + } +} diff --git a/app/Services/StorefrontCatalog.php b/app/Services/StorefrontCatalog.php new file mode 100644 index 00000000..b447257b --- /dev/null +++ b/app/Services/StorefrontCatalog.php @@ -0,0 +1,137 @@ + + */ + public function visibleProducts(Store $store): Builder + { + return Product::query() + ->where('store_id', $store->getKey()) + ->where('status', ProductStatus::Active) + ->whereNotNull('published_at'); + } + + public function withStorefrontRelations(Builder $query): Builder + { + return $query->with([ + 'productVariants' => fn ($query) => $query->orderBy('position')->orderBy('id'), + 'productVariants.inventoryItem', + 'productVariants.optionValues', + 'productMedia' => fn ($query) => $query->orderBy('position')->orderBy('id'), + ]); + } + + public function displayVariant(Product $product): ?ProductVariant + { + $active = $product->productVariants->where('status', VariantStatus::Active); + + return $active->firstWhere('is_default', true) + ?? $active->sortBy(fn (ProductVariant $variant): array => [$variant->price_amount, $variant->position, $variant->getKey()])->first(); + } + + public function minPrice(Product $product): int + { + return $this->displayVariant($product)?->price_amount ?? 0; + } + + public function isInStock(Product $product): bool + { + foreach ($product->productVariants as $variant) { + if ($variant->status !== VariantStatus::Active) { + continue; + } + + $item = $variant->inventoryItem; + + if ($item === null) { + return true; + } + + if ($item->policy === InventoryPolicy::Continue) { + return true; + } + + if (($item->quantity_on_hand - $item->quantity_reserved) > 0) { + return true; + } + } + + return false; + } + + public function availableQuantity(ProductVariant $variant): ?int + { + $item = $variant->inventoryItem; + + if ($item === null) { + return null; + } + + if ($item->policy === InventoryPolicy::Continue) { + return null; + } + + return max(0, $item->quantity_on_hand - $item->quantity_reserved); + } + + public function primaryImageUrl(Product $product): ?string + { + $media = $product->productMedia->first(); + + if ($media === null) { + return null; + } + + // Relative URL: storefronts run on per-store hostnames while the + // storage URL is built from APP_URL, so absolute URLs leak a + // cross-host address into same-origin tags. + return parse_url(Storage::disk('public')->url($media->storage_key), PHP_URL_PATH) + ?: Storage::disk('public')->url($media->storage_key); + } + + public function primaryImageAlt(Product $product): string + { + return (string) ($product->productMedia->first()?->alt_text ?? $product->title); + } + + /** + * @param array $productIds + * @return array product_id => units sold + */ + public function salesCounts(array $productIds): array + { + if ($productIds === []) { + return []; + } + + return OrderLine::query() + ->whereIn('product_id', $productIds) + ->groupBy('product_id') + ->selectRaw('product_id, SUM(quantity) AS total') + ->pluck('total', 'product_id') + ->map(fn ($total): int => (int) $total) + ->all(); + } + + public function variantOptionLabels(ProductVariant $variant): string + { + return $variant->optionValues->pluck('value')->implode(' / '); + } +} diff --git a/app/Services/Tax/ManualTaxProvider.php b/app/Services/Tax/ManualTaxProvider.php new file mode 100644 index 00000000..87978f5c --- /dev/null +++ b/app/Services/Tax/ManualTaxProvider.php @@ -0,0 +1,58 @@ +lineItems as $item) { + $amount = $request->pricesIncludeTax + ? $this->extractInclusive((int) $item['amount'], $request->rate) + : $this->addExclusive((int) $item['amount'], $request->rate); + + $lines[] = new TaxLine($request->rateName, $request->rate, $amount); + $total += $amount; + } + + if ($request->shippingTaxed && $request->shippingAmount > 0) { + $shippingTax = $request->pricesIncludeTax + ? $this->extractInclusive($request->shippingAmount, $request->rate) + : $this->addExclusive($request->shippingAmount, $request->rate); + + $lines[] = new TaxLine($request->rateName, $request->rate, $shippingTax); + $total += $shippingTax; + } + + return new TaxCalculationResult($lines, $total); + } + + public function extractInclusive(int $grossAmount, int $rateBasisPoints): int + { + if ($grossAmount <= 0 || $rateBasisPoints <= 0) { + return 0; + } + + return $grossAmount - intdiv($grossAmount * 10000, 10000 + $rateBasisPoints); + } + + public function addExclusive(int $netAmount, int $rateBasisPoints): int + { + if ($netAmount <= 0 || $rateBasisPoints <= 0) { + return 0; + } + + return intdiv($netAmount * $rateBasisPoints, 10000); + } +} diff --git a/app/Services/Tax/StripeTaxProvider.php b/app/Services/Tax/StripeTaxProvider.php new file mode 100644 index 00000000..c637c72b --- /dev/null +++ b/app/Services/Tax/StripeTaxProvider.php @@ -0,0 +1,30 @@ +config['stripe_fallback'] ?? 'allow'; + + if ($fallback === 'block') { + throw new \RuntimeException('Stripe Tax provider is not configured and fallback is set to block checkout.'); + } + + Log::warning('Stripe Tax provider stub used; proceeding without tax.', [ + 'line_count' => count($request->lineItems), + ]); + + return new TaxCalculationResult([], 0); + } +} diff --git a/app/Services/TaxCalculator.php b/app/Services/TaxCalculator.php new file mode 100644 index 00000000..50275538 --- /dev/null +++ b/app/Services/TaxCalculator.php @@ -0,0 +1,92 @@ + $lineItems Discounted line amounts in cents. + * @param array $address + */ + public function calculate(Store $store, array $lineItems, int $shippingAmount, array $address): TaxCalculationResult + { + $settings = TaxSetting::query()->where('store_id', $store->getKey())->first(); + + if ($settings === null) { + return new TaxCalculationResult([], 0); + } + + $resolution = $this->resolveRate($settings, $address); + + $request = new TaxCalculationRequest( + lineItems: $lineItems, + shippingAmount: $shippingAmount, + address: $address, + pricesIncludeTax: (bool) $settings->prices_include_tax, + rate: $resolution['rate'], + rateName: $resolution['name'], + shippingTaxed: $resolution['shipping_taxed'], + config: $settings->config_json ?? [], + ); + + if ($settings->mode === TaxMode::Provider) { + return $this->stripe->calculate($request); + } + + return $this->manual->calculate($request); + } + + public function extractInclusive(int $grossAmount, int $rateBasisPoints): int + { + return $this->manual->extractInclusive($grossAmount, $rateBasisPoints); + } + + public function addExclusive(int $netAmount, int $rateBasisPoints): int + { + return $this->manual->addExclusive($netAmount, $rateBasisPoints); + } + + /** + * @param array $address + * @return array{rate: int, name: string, shipping_taxed: bool} + */ + public function resolveRate(TaxSetting $settings, array $address): array + { + $config = $settings->config_json ?? []; + $countryCode = strtoupper((string) ($address['country_code'] ?? $address['country'] ?? '')); + + $default = [ + 'rate' => (int) ($config['default_tax_rate'] ?? $config['default_rate'] ?? $config['rate'] ?? 0), + 'name' => (string) ($config['default_tax_name'] ?? 'Tax'), + 'shipping_taxed' => (bool) ($config['shipping_taxed'] ?? true), + ]; + + foreach ((array) ($config['tax_rates'] ?? []) as $entry) { + if (strtoupper((string) ($entry['country_code'] ?? '')) === $countryCode && $countryCode !== '') { + return [ + 'rate' => (int) ($entry['rate'] ?? 0), + 'name' => (string) ($entry['name'] ?? $default['name']), + 'shipping_taxed' => (bool) ($entry['shipping_taxed'] ?? true), + ]; + } + } + + return $default; + } +} diff --git a/app/Services/ThemeService.php b/app/Services/ThemeService.php new file mode 100644 index 00000000..86383b20 --- /dev/null +++ b/app/Services/ThemeService.php @@ -0,0 +1,82 @@ +getKey()}", + now()->addMinutes(5), + fn (): ?int => Theme::query() + ->where('store_id', $store->getKey()) + ->where('status', ThemeStatus::Published) + ->orderByDesc('published_at') + ->orderByDesc('id') + ->value('id') + ); + + if ($themeId === null) { + return null; + } + + return Theme::query()->whereKey($themeId)->with('themeSetting')->first(); + } + + /** + * @return array + */ + public function settings(Store $store): array + { + $theme = $this->activeTheme($store); + $stored = $theme?->themeSetting?->settings_json ?? []; + + return array_replace_recursive($this->defaults($store), is_array($stored) ? $stored : []); + } + + /** + * @return array + */ + protected function defaults(Store $store): array + { + return [ + 'announcement' => [ + 'show' => false, + 'text' => '', + 'link' => null, + ], + 'header' => [ + 'sticky' => false, + 'logo_url' => null, + ], + 'hero' => [ + 'show' => true, + 'heading' => 'Welcome to '.$store->name, + 'subheading' => '', + 'cta_text' => 'Shop now', + 'cta_url' => '/collections', + ], + 'home' => [ + 'featured_collections_count' => 4, + 'featured_products_count' => 8, + 'show_newsletter' => true, + 'rich_text_html' => null, + ], + 'footer' => [ + 'store_address' => null, + 'contact_email' => null, + ], + 'social' => [], + 'dark_mode' => 'system', + ]; + } +} diff --git a/app/Services/VariantMatrixService.php b/app/Services/VariantMatrixService.php new file mode 100644 index 00000000..63d2a491 --- /dev/null +++ b/app/Services/VariantMatrixService.php @@ -0,0 +1,156 @@ +productOptions()->with('productOptionValues')->orderBy('position')->get(); + + foreach ($options as $option) { + if ($option->productOptionValues->isEmpty()) { + return; + } + } + + $combos = $this->cartesian($options->map( + fn ($option) => $option->productOptionValues->sortBy('position')->pluck('id')->all() + )->all()); + + $existing = $product->productVariants()->with('optionValues')->get(); + $matchedIds = []; + + foreach ($combos as $combo) { + sort($combo); + + $match = $existing->first(function (ProductVariant $variant) use ($combo): bool { + $ids = $variant->optionValues->pluck('id')->all(); + sort($ids); + + return $ids === $combo; + }); + + if ($match !== null) { + $matchedIds[] = $match->getKey(); + + continue; + } + + $created = $this->createVariantForCombo($product, $existing); + $created->optionValues()->sync($combo); + $matchedIds[] = $created->getKey(); + $existing->push($created); + } + + foreach ($existing as $variant) { + if (in_array($variant->getKey(), $matchedIds, true)) { + continue; + } + + if ($this->hasOrderReferences($variant)) { + $variant->update(['status' => VariantStatus::Archived]); + } else { + $variant->delete(); + } + } + + $this->ensureSingleDefault($product); + }); + } + + /** + * @param array> $valueSets + * @return array> + */ + protected function cartesian(array $valueSets): array + { + if ($valueSets === []) { + return [[]]; + } + + $combos = [[]]; + + foreach ($valueSets as $set) { + $next = []; + + foreach ($combos as $combo) { + foreach ($set as $valueId) { + $next[] = [...$combo, $valueId]; + } + } + + $combos = $next; + } + + return $combos; + } + + /** + * @param \Illuminate\Support\Collection $existing + */ + protected function createVariantForCombo(Product $product, object $existing): ProductVariant + { + /** @var ProductVariant|null $template */ + $template = $existing->first(); + + $variant = $product->productVariants()->create([ + 'sku' => null, + 'barcode' => null, + 'price_amount' => $template?->price_amount ?? 0, + 'compare_at_amount' => $template?->compare_at_amount, + 'currency' => $template?->currency ?? $product->store?->default_currency ?? 'USD', + 'weight_g' => $template?->weight_g, + 'requires_shipping' => $template?->requires_shipping ?? true, + 'is_default' => $existing->isEmpty(), + 'position' => ($existing->max('position') ?? -1) + 1, + 'status' => VariantStatus::Active, + ]); + + InventoryItem::query()->create([ + 'store_id' => $product->store_id, + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny, + ]); + + return $variant; + } + + protected function hasOrderReferences(ProductVariant $variant): bool + { + return OrderLine::query()->where('variant_id', $variant->getKey())->exists(); + } + + protected function ensureSingleDefault(Product $product): void + { + $variants = $product->productVariants()->orderBy('position')->orderBy('id')->get(); + + if ($variants->isEmpty()) { + return; + } + + $default = $variants->firstWhere('is_default', true) ?? $variants->first(); + + foreach ($variants as $variant) { + $shouldBeDefault = $variant->getKey() === $default->getKey(); + + if ((bool) $variant->is_default !== $shouldBeDefault) { + $variant->update(['is_default' => $shouldBeDefault]); + } + } + } +} diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php new file mode 100644 index 00000000..3a17f3db --- /dev/null +++ b/app/Services/WebhookService.php @@ -0,0 +1,117 @@ +sign($payload, $secret), $signature); + } + + public function subscribe( + Store $store, + string $eventType, + string $targetUrl, + ?int $appInstallationId = null, + ): WebhookSubscription { + return WebhookSubscription::query()->create([ + 'store_id' => $store->getKey(), + 'app_installation_id' => $appInstallationId, + 'event_type' => $eventType, + 'target_url' => $targetUrl, + 'signing_secret_encrypted' => Str::random(48), + 'status' => WebhookSubscriptionStatus::Active, + ]); + } + + /** + * @param array $payload + */ + public function dispatch(Store $store, string $eventType, array $payload): void + { + $subscriptions = WebhookSubscription::query() + ->where('store_id', $store->getKey()) + ->where('event_type', $eventType) + ->where('status', WebhookSubscriptionStatus::Active) + ->get(); + + foreach ($subscriptions as $subscription) { + $delivery = WebhookDelivery::query()->create([ + 'subscription_id' => $subscription->getKey(), + 'event_id' => (string) Str::uuid(), + 'attempt_count' => 0, + 'status' => WebhookDeliveryStatus::Pending, + ]); + + DeliverWebhook::dispatch($delivery->getKey(), $eventType, $payload); + } + } + + public function recordSuccess(WebhookSubscription $subscription): void + { + if ($subscription->status === WebhookSubscriptionStatus::Paused) { + return; + } + + $subscription->status = WebhookSubscriptionStatus::Active; + $subscription->save(); + } + + /** + * Circuit breaker per spec 05 section 13.4: after 5 consecutive failed + * deliveries for a subscription, pause it until a merchant re-enables it. + */ + public function recordFailure(WebhookSubscription $subscription): void + { + $recent = WebhookDelivery::query() + ->where('subscription_id', $subscription->getKey()) + ->orderByDesc('id') + ->limit(5) + ->pluck('status'); + + if ($recent->count() < 5) { + return; + } + + $allFailed = $recent->every(fn ($status): bool => $status === WebhookDeliveryStatus::Failed + || $status === WebhookDeliveryStatus::Failed->value); + + if ($allFailed) { + $subscription->status = WebhookSubscriptionStatus::Paused; + $subscription->save(); + } + } +} diff --git a/app/Support/HandleGenerator.php b/app/Support/HandleGenerator.php new file mode 100644 index 00000000..bc70ff23 --- /dev/null +++ b/app/Support/HandleGenerator.php @@ -0,0 +1,42 @@ +exists($table, $storeId, $handle, $excludeId)) { + $suffix++; + $handle = "{$base}-{$suffix}"; + } + + return $handle; + } + + public function exists(string $table, int $storeId, string $handle, ?int $excludeId = null): 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/Support/Money.php b/app/Support/Money.php new file mode 100644 index 00000000..de6c5986 --- /dev/null +++ b/app/Support/Money.php @@ -0,0 +1,14 @@ +store_id.':'.$order->order_number, (string) config('app.key')); + } + + public static function verify(Order $order, ?string $token): bool + { + if ($token === null || $token === '') { + return false; + } + + return hash_equals(self::generate($order), $token); + } +} diff --git a/app/Tenancy/CurrentStore.php b/app/Tenancy/CurrentStore.php new file mode 100644 index 00000000..c1dc049d --- /dev/null +++ b/app/Tenancy/CurrentStore.php @@ -0,0 +1,64 @@ +instance('current_store', $store); + } + + public function get(): ?Store + { + if (! app()->bound('current_store')) { + return null; + } + + $store = app('current_store'); + + return $store instanceof Store ? $store : null; + } + + public function id(): ?int + { + return $this->get()?->getKey(); + } + + public function has(): bool + { + return $this->get() !== null; + } + + public function forget(): void + { + app()->forgetInstance('current_store'); + } + + /** + * Admin store id from the session. + * + * Fallback for Livewire update requests where the `store.resolve` + * middleware never runs. The session value is membership-verified when + * written (store selector / auto-select), and policies re-check + * membership, so this is safe to consult for authorization. + */ + public function adminIdFromSession(): ?int + { + if (! app()->bound('session')) { + return null; + } + + $storeId = session()->get('current_store_id'); + + return $storeId !== null ? (int) $storeId : null; + } +} diff --git a/app/Traits/ChecksStoreRole.php b/app/Traits/ChecksStoreRole.php new file mode 100644 index 00000000..d00fbfa5 --- /dev/null +++ b/app/Traits/ChecksStoreRole.php @@ -0,0 +1,51 @@ +stores()->where('stores.id', $storeId)->first()?->pivot->role; + + return $role instanceof StoreUserRole ? $role : null; + } + + /** + * @param array $roles + */ + public function hasRole(User $user, int $storeId, array $roles): bool + { + return in_array($this->getStoreRole($user, $storeId), $roles, true); + } + + public function isOwnerOrAdmin(User $user, int $storeId): bool + { + return $this->hasRole($user, $storeId, [StoreUserRole::Owner, StoreUserRole::Admin]); + } + + public function isOwnerAdminOrStaff(User $user, int $storeId): bool + { + return $this->hasRole($user, $storeId, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function isAnyRole(User $user, int $storeId): bool + { + return $this->getStoreRole($user, $storeId) !== null; + } + + protected function currentStoreId(): ?int + { + $current = app(CurrentStore::class); + + return $current->id() ?? $current->adminIdFromSession(); + } +} diff --git a/app/ValueObjects/Address.php b/app/ValueObjects/Address.php new file mode 100644 index 00000000..972b1548 --- /dev/null +++ b/app/ValueObjects/Address.php @@ -0,0 +1,63 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + firstName: $data['first_name'] ?? null, + lastName: $data['last_name'] ?? null, + company: $data['company'] ?? null, + address1: $data['address1'] ?? null, + address2: $data['address2'] ?? null, + city: $data['city'] ?? null, + province: $data['province'] ?? null, + provinceCode: $data['province_code'] ?? null, + country: $data['country'] ?? null, + countryCode: $data['country_code'] ?? $data['country'] ?? null, + postalCode: $data['postal_code'] ?? $data['zip'] ?? null, + phone: $data['phone'] ?? null, + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'first_name' => $this->firstName, + 'last_name' => $this->lastName, + 'company' => $this->company, + 'address1' => $this->address1, + 'address2' => $this->address2, + 'city' => $this->city, + 'province' => $this->province, + 'province_code' => $this->provinceCode, + 'country' => $this->country, + 'country_code' => $this->countryCode, + 'postal_code' => $this->postalCode, + 'phone' => $this->phone, + ]; + } +} diff --git a/app/ValueObjects/DiscountValidationResult.php b/app/ValueObjects/DiscountValidationResult.php new file mode 100644 index 00000000..14529647 --- /dev/null +++ b/app/ValueObjects/DiscountValidationResult.php @@ -0,0 +1,25 @@ + $taxLines + * @param array> $appliedDiscounts + */ + 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 $appliedDiscounts = [], + ) {} + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'subtotal' => $this->subtotal, + 'discount' => $this->discount, + 'shipping' => $this->shipping, + 'tax' => $this->taxTotal, + 'total' => $this->total, + 'currency' => $this->currency, + 'tax_lines' => array_map(fn (TaxLine $line): array => $line->toArray(), $this->taxLines), + 'applied_discounts' => $this->appliedDiscounts, + ]; + } +} diff --git a/app/ValueObjects/RefundResult.php b/app/ValueObjects/RefundResult.php new file mode 100644 index 00000000..fac41ca0 --- /dev/null +++ b/app/ValueObjects/RefundResult.php @@ -0,0 +1,12 @@ + $this->id, + 'name' => $this->name, + 'amount' => $this->amount, + 'type' => $this->type, + ]; + } +} diff --git a/app/ValueObjects/TaxCalculationRequest.php b/app/ValueObjects/TaxCalculationRequest.php new file mode 100644 index 00000000..6246c2ac --- /dev/null +++ b/app/ValueObjects/TaxCalculationRequest.php @@ -0,0 +1,22 @@ + $lineItems Discounted line amounts in cents. + * @param array $address + * @param array $config + */ + public function __construct( + public array $lineItems, + public int $shippingAmount, + public array $address, + public bool $pricesIncludeTax, + public int $rate, + public string $rateName, + public bool $shippingTaxed, + public array $config = [], + ) {} +} diff --git a/app/ValueObjects/TaxCalculationResult.php b/app/ValueObjects/TaxCalculationResult.php new file mode 100644 index 00000000..f7063889 --- /dev/null +++ b/app/ValueObjects/TaxCalculationResult.php @@ -0,0 +1,14 @@ + $taxLines + */ + public function __construct( + public array $taxLines, + public int $totalAmount, + ) {} +} diff --git a/app/ValueObjects/TaxLine.php b/app/ValueObjects/TaxLine.php new file mode 100644 index 00000000..b0b2ac64 --- /dev/null +++ b/app/ValueObjects/TaxLine.php @@ -0,0 +1,24 @@ + $this->name, + 'rate' => $this->rate, + 'amount' => $this->amount, + ]; + } +} diff --git a/boost.json b/boost.json new file mode 100644 index 00000000..b5049829 --- /dev/null +++ b/boost.json @@ -0,0 +1,19 @@ +{ + "agents": [ + "cursor" + ], + "cloud": false, + "guidelines": true, + "mcp": true, + "nightwatch": false, + "sail": false, + "skills": [ + "infer-conventions", + "developing-with-fortify", + "laravel-best-practices", + "testing-best-practices", + "fluxui-development", + "livewire-development", + "tailwindcss-development" + ] +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c1832766..136bcaae 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,17 +1,48 @@ 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([ + 'store.resolve' => ResolveStore::class, + 'role.check' => CheckStoreRole::class, + 'customer.auth' => CustomerAuthenticate::class, + 'customer.redirect' => RedirectCustomerToAccount::class, + 'abilities' => CheckAbilities::class, + 'ability' => CheckForAnyAbility::class, + ]); + + $middleware->redirectGuestsTo(function (Request $request): string { + if ($request->is('admin') || $request->is('admin/*')) { + return route('admin.login'); + } + + return route('login'); + }); + + $middleware->redirectUsersTo(function (Request $request): string { + if ($request->user('customer') !== null) { + return route('customer.account'); + } + + return '/dashboard'; + }); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/composer.json b/composer.json index 1f848aaf..29223fe8 100644 --- a/composer.json +++ b/composer.json @@ -10,15 +10,17 @@ "license": "MIT", "require": { "php": "^8.2", + "laravel-shift/blueprint": "^2.13", "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": { "fakerphp/faker": "^1.23", - "laravel/boost": "^1.0", + "laravel/boost": "^2.8", "laravel/pail": "^1.2.2", "laravel/pint": "^1.24", "laravel/sail": "^1.41", diff --git a/composer.lock b/composer.lock index e4255dbd..24f7b273 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": "d010ca69232a30d1ab45f66383e0d00c", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1157,6 +1157,107 @@ ], "time": "2025-08-22T14:27:06+00:00" }, + { + "name": "laravel-shift/blueprint", + "version": "v2.13.0", + "source": { + "type": "git", + "url": "https://github.com/laravel-shift/blueprint.git", + "reference": "a7c74809d2b769d62a549104d96d02b524173fe5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel-shift/blueprint/zipball/a7c74809d2b769d62a549104d96d02b524173fe5", + "reference": "a7c74809d2b769d62a549104d96d02b524173fe5", + "shasum": "" + }, + "require": { + "illuminate/console": ">=12.0", + "illuminate/database": ">=12.0", + "illuminate/filesystem": ">=12.0", + "illuminate/support": ">=12.0", + "laravel-shift/faker-registry": "^0.3.0", + "nunomaduro/termwind": "^2.3", + "symfony/yaml": ">=7.0" + }, + "require-dev": { + "laravel/pint": "~1.25.0", + "mockery/mockery": "^1.4.4", + "orchestra/testbench": ">=9.0", + "phpunit/phpunit": "^11.5.3" + }, + "suggest": { + "jasonmccreary/laravel-test-assertions": "Required to use additional assertions in generated tests (^2.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Blueprint\\BlueprintServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Blueprint\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "An expressive, human readable code generation tool.", + "keywords": [ + "code generation", + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel-shift/blueprint/issues", + "source": "https://github.com/laravel-shift/blueprint/tree/v2.13.0" + }, + "time": "2025-11-13T20:57:52+00:00" + }, + { + "name": "laravel-shift/faker-registry", + "version": "v0.3.0", + "source": { + "type": "git", + "url": "https://github.com/laravel-shift/faker-registry.git", + "reference": "968ab023b6c76c2f67cc474ba6d81fce8ff869a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel-shift/faker-registry/zipball/968ab023b6c76c2f67cc474ba6d81fce8ff869a9", + "reference": "968ab023b6c76c2f67cc474ba6d81fce8ff869a9", + "shasum": "" + }, + "require-dev": { + "phpunit/phpunit": "^8.0|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Shift\\Faker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jason McCreary", + "email": "jason@pureconcepts.net" + } + ], + "description": "A registry for generating Faker data from a name or data type.", + "support": { + "issues": "https://github.com/laravel-shift/faker-registry/issues", + "source": "https://github.com/laravel-shift/faker-registry/tree/v0.3.0" + }, + "time": "2023-11-24T15:54:02+00:00" + }, { "name": "laravel/fortify", "version": "v1.34.1", @@ -1501,6 +1602,69 @@ }, "time": "2026-02-06T12:17:10+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.3.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fee27a573d1a013af3721d86153a65e0b11927e6", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6", + "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-06-23T18:26:55+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.9", @@ -6213,6 +6377,81 @@ ], "time": "2026-01-01T22:13:48+00:00" }, + { + "name": "symfony/yaml", + "version": "v8.0.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14", + "reference": "7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "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": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/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-04T18:17:06+00:00" + }, { "name": "tijsverkoyen/css-to-inline-styles", "version": "v2.4.0", @@ -6521,6 +6760,83 @@ ], "time": "2026-02-05T09:14:44+00:00" }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, { "name": "doctrine/deprecations", "version": "1.1.6", @@ -6877,35 +7193,36 @@ }, { "name": "laravel/boost", - "version": "v1.0.18", + "version": "v2.8.1", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab" + "reference": "820bc93b7826c456ca591cfecca463b4dc794a72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", + "url": "https://api.github.com/repos/laravel/boost/zipball/820bc93b7826c456ca591cfecca463b4dc794a72", + "reference": "820bc93b7826c456ca591cfecca463b4dc794a72", "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" + "guzzlehttp/guzzle": "^7.9|^8.0", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "laravel/mcp": "^0.7.1|^0.8.0|^0.9.0", + "laravel/prompts": "^0.3.10", + "laravel/roster": "^1.0.0", + "php": "^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" + "laravel/pint": "^1.27.0", + "mockery/mockery": "^1.6.12", + "orchestra/testbench": "^9.15.0|^10.6|^11.0", + "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.1" }, "type": "library", "extra": { @@ -6927,7 +7244,7 @@ "license": [ "MIT" ], - "description": "Laravel Boost accelerates AI-assisted development to generate high-quality, Laravel-specific code.", + "description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.", "homepage": "https://github.com/laravel/boost", "keywords": [ "ai", @@ -6938,41 +7255,48 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2025-08-16T09:10:03+00:00" + "time": "2026-09-10T15:25:39+00:00" }, { "name": "laravel/mcp", - "version": "v0.1.1", + "version": "v0.9.5", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713" + "reference": "923d8d8cd9ed46766d1a8b5269f8b4afc4c91c53" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/6d6284a491f07c74d34f48dfd999ed52c567c713", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713", + "url": "https://api.github.com/repos/laravel/mcp/zipball/923d8d8cd9ed46766d1a8b5269f8b4afc4c91c53", + "reference": "923d8d8cd9ed46766d1a8b5269f8b4afc4c91c53", "shasum": "" }, "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/http": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "illuminate/validation": "^10.0|^11.0|^12.0", - "php": "^8.1|^8.2" + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2", + "symfony/process": "^7.4.5|^8.0.5" }, "require-dev": { - "laravel/pint": "^1.14", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "phpstan/phpstan": "^2.0" + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" }, "type": "library", "extra": { "laravel": { "aliases": { - "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + "Mcp": "Laravel\\Mcp\\Facades\\Mcp" }, "providers": [ "Laravel\\Mcp\\Server\\McpServiceProvider" @@ -6982,8 +7306,6 @@ "autoload": { "psr-4": { "Laravel\\Mcp\\": "src/", - "Workbench\\App\\": "workbench/app/", - "Laravel\\Mcp\\Tests\\": "tests/", "Laravel\\Mcp\\Server\\": "src/Server/" } }, @@ -6991,10 +7313,15 @@ "license": [ "MIT" ], - "description": "The easiest way to add MCP servers to your Laravel app.", + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", "homepage": "https://github.com/laravel/mcp", "keywords": [ - "dev", "laravel", "mcp" ], @@ -7002,7 +7329,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2025-08-16T09:50:43+00:00" + "time": "2026-09-09T12:56:57+00:00" }, { "name": "laravel/pail", @@ -7153,31 +7480,33 @@ }, { "name": "laravel/roster", - "version": "v0.2.2", + "version": "v1.0.0", "source": { "type": "git", "url": "https://github.com/laravel/roster.git", - "reference": "67a39bce557a6cb7e7205a2a9d6c464f0e72956f" + "reference": "89e518bd88ae98ff50f6082f6b517c8d8e8245fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/roster/zipball/67a39bce557a6cb7e7205a2a9d6c464f0e72956f", - "reference": "67a39bce557a6cb7e7205a2a9d6c464f0e72956f", + "url": "https://api.github.com/repos/laravel/roster/zipball/89e518bd88ae98ff50f6082f6b517c8d8e8245fa", + "reference": "89e518bd88ae98ff50f6082f6b517c8d8e8245fa", "shasum": "" }, "require": { - "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", - "php": "^8.1|^8.2" + "composer/semver": "^3.0", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/yaml": "^7.2|^8.0" }, "require-dev": { - "laravel/pint": "^1.14", + "laravel/pint": "^1.29", "mockery/mockery": "^1.6", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "pestphp/pest": "^2.0|^3.0", - "phpstan/phpstan": "^2.0" + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.1", + "phpstan/phpstan": "^2.0", + "rector/rector": "^2.0" }, "type": "library", "extra": { @@ -7209,7 +7538,7 @@ "issues": "https://github.com/laravel/roster/issues", "source": "https://github.com/laravel/roster" }, - "time": "2025-07-24T12:31:13+00:00" + "time": "2026-07-18T17:53:15+00:00" }, { "name": "laravel/sail", @@ -9718,81 +10047,6 @@ ], "time": "2024-10-20T05:08:20+00:00" }, - { - "name": "symfony/yaml", - "version": "v8.0.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/yaml.git", - "reference": "7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14", - "reference": "7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14", - "shasum": "" - }, - "require": { - "php": ">=8.4", - "symfony/polyfill-ctype": "^1.8" - }, - "conflict": { - "symfony/console": "<7.4" - }, - "require-dev": { - "symfony/console": "^7.4|^8.0" - }, - "bin": [ - "Resources/bin/yaml-lint" - ], - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Yaml\\": "" - }, - "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": "Loads and dumps YAML files", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/yaml/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-04T18:17:06+00:00" - }, { "name": "ta-tikoma/phpunit-architecture-test", "version": "0.8.6", @@ -9974,5 +10228,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/auth.php b/config/auth.php index 7d1eb0de..875427d3 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-eloquent', + 'model' => App\Models\Customer::class, + ], + // 'users' => [ // 'driver' => 'database', // 'table' => 'users', @@ -97,6 +107,13 @@ 'expire' => 60, 'throttle' => 60, ], + + 'customers' => [ + 'provider' => 'customers', + 'table' => 'customer_password_reset_tokens', + 'expire' => 60, + 'throttle' => 60, + ], ], /* diff --git a/config/cache.php b/config/cache.php index b32aead2..9289977f 100644 --- a/config/cache.php +++ b/config/cache.php @@ -15,7 +15,7 @@ | */ - 'default' => env('CACHE_STORE', 'database'), + 'default' => env('CACHE_STORE', 'file'), /* |-------------------------------------------------------------------------- diff --git a/config/cors.php b/config/cors.php new file mode 100644 index 00000000..b62b0311 --- /dev/null +++ b/config/cors.php @@ -0,0 +1,31 @@ + ['api/*', 'sanctum/csrf-cookie'], + + 'allowed_methods' => ['*'], + + 'allowed_origins' => [env('FRONTEND_URL', '*')], + + 'allowed_origins_patterns' => [], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'Retry-After'], + + 'max_age' => 0, + + 'supports_credentials' => true, + +]; diff --git a/config/database.php b/config/database.php index df933e7f..210e1eac 100644 --- a/config/database.php +++ b/config/database.php @@ -37,9 +37,9 @@ 'database' => env('DB_DATABASE', database_path('database.sqlite')), 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), - 'busy_timeout' => null, - 'journal_mode' => null, - 'synchronous' => null, + 'busy_timeout' => 5000, + 'journal_mode' => 'WAL', + 'synchronous' => 'NORMAL', 'transaction_mode' => 'DEFERRED', ], diff --git a/config/logging.php b/config/logging.php index 9e998a49..c720a6a5 100644 --- a/config/logging.php +++ b/config/logging.php @@ -127,6 +127,13 @@ 'path' => storage_path('logs/laravel.log'), ], + 'audit' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/audit.log'), + 'level' => 'info', + 'days' => 90, + ], + ], ]; diff --git a/config/queue.php b/config/queue.php index 79c2c0a2..d0e0f50e 100644 --- a/config/queue.php +++ b/config/queue.php @@ -13,7 +13,7 @@ | */ - 'default' => env('QUEUE_CONNECTION', 'database'), + 'default' => env('QUEUE_CONNECTION', 'sync'), /* |-------------------------------------------------------------------------- diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 00000000..128bcdf8 --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,87 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => 525600, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', 'shop_'), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, + ], + +]; diff --git a/config/session.php b/config/session.php index 5b541b75..3aeea55c 100644 --- a/config/session.php +++ b/config/session.php @@ -1,7 +1,5 @@ env('SESSION_DRIVER', 'database'), + 'driver' => env('SESSION_DRIVER', 'file'), /* |-------------------------------------------------------------------------- @@ -47,7 +45,7 @@ | */ - 'encrypt' => env('SESSION_ENCRYPT', false), + 'encrypt' => env('SESSION_ENCRYPT', true), /* |-------------------------------------------------------------------------- @@ -129,7 +127,7 @@ 'cookie' => env( 'SESSION_COOKIE', - Str::slug((string) env('APP_NAME', 'laravel')).'-session' + 'shop_session' ), /* @@ -169,7 +167,7 @@ | */ - 'secure' => env('SESSION_SECURE_COOKIE'), + 'secure' => env('SESSION_SECURE_COOKIE', true), /* |-------------------------------------------------------------------------- diff --git a/database/factories/AnalyticsDailyFactory.php b/database/factories/AnalyticsDailyFactory.php new file mode 100644 index 00000000..bbe407cb --- /dev/null +++ b/database/factories/AnalyticsDailyFactory.php @@ -0,0 +1,27 @@ + Store::factory(), + 'date' => fake()->date(), + 'orders_count' => fake()->numberBetween(0, 1000), + 'revenue_amount' => fake()->numberBetween(0, 1000000), + 'aov_amount' => fake()->numberBetween(0, 50000), + 'visits_count' => fake()->numberBetween(0, 10000), + 'add_to_cart_count' => fake()->numberBetween(0, 2000), + 'checkout_started_count' => fake()->numberBetween(0, 1000), + 'checkout_completed_count' => fake()->numberBetween(0, 1000), + ]; + } +} diff --git a/database/factories/AnalyticsEventFactory.php b/database/factories/AnalyticsEventFactory.php new file mode 100644 index 00000000..99227219 --- /dev/null +++ b/database/factories/AnalyticsEventFactory.php @@ -0,0 +1,25 @@ + Store::factory(), + 'type' => fake()->randomElement(['page_view', 'product_view', 'add_to_cart', 'remove_from_cart', 'checkout_started', 'checkout_completed', 'search']), + 'session_id' => fake()->uuid(), + 'customer_id' => null, + 'properties_json' => [], + 'client_event_id' => fake()->uuid(), + 'occurred_at' => fake()->dateTime(), + ]; + } +} diff --git a/database/factories/AppFactory.php b/database/factories/AppFactory.php new file mode 100644 index 00000000..e43680d8 --- /dev/null +++ b/database/factories/AppFactory.php @@ -0,0 +1,20 @@ + fake()->words(2, true), + 'status' => fake()->randomElement(AppStatus::cases()), + ]; + } +} diff --git a/database/factories/AppInstallationFactory.php b/database/factories/AppInstallationFactory.php new file mode 100644 index 00000000..b7e2748c --- /dev/null +++ b/database/factories/AppInstallationFactory.php @@ -0,0 +1,25 @@ + Store::factory(), + 'app_id' => App::factory(), + 'scopes_json' => ['read_products', 'read_orders'], + 'status' => fake()->randomElement(AppInstallationStatus::cases()), + 'installed_at' => fake()->dateTime(), + ]; + } +} diff --git a/database/factories/CartFactory.php b/database/factories/CartFactory.php new file mode 100644 index 00000000..4f8f6f08 --- /dev/null +++ b/database/factories/CartFactory.php @@ -0,0 +1,24 @@ + Store::factory(), + 'customer_id' => null, + 'currency' => 'USD', + 'cart_version' => 1, + 'status' => fake()->randomElement(CartStatus::cases()), + ]; + } +} diff --git a/database/factories/CartLineFactory.php b/database/factories/CartLineFactory.php new file mode 100644 index 00000000..a4b2ed2c --- /dev/null +++ b/database/factories/CartLineFactory.php @@ -0,0 +1,31 @@ +numberBetween(1, 5); + $unitPrice = fake()->numberBetween(100, 10000); + $subtotal = $quantity * $unitPrice; + $discount = fake()->numberBetween(0, (int) ($subtotal / 2)); + + return [ + 'cart_id' => Cart::factory(), + 'variant_id' => ProductVariant::factory(), + 'quantity' => $quantity, + 'unit_price_amount' => $unitPrice, + 'line_subtotal_amount' => $subtotal, + 'line_discount_amount' => $discount, + 'line_total_amount' => $subtotal - $discount, + ]; + } +} diff --git a/database/factories/CheckoutFactory.php b/database/factories/CheckoutFactory.php new file mode 100644 index 00000000..805e3453 --- /dev/null +++ b/database/factories/CheckoutFactory.php @@ -0,0 +1,49 @@ + fake()->firstName(), + 'last_name' => fake()->lastName(), + 'company' => null, + 'address1' => fake()->streetAddress(), + 'address2' => null, + 'city' => fake()->city(), + 'province' => fake()->state(), + 'province_code' => fake()->stateAbbr(), + 'country' => 'United States', + 'country_code' => 'US', + 'zip' => fake()->postcode(), + 'phone' => fake()->phoneNumber(), + ]; + + return [ + 'store_id' => Store::factory(), + 'cart_id' => Cart::factory(), + 'customer_id' => null, + 'status' => fake()->randomElement(CheckoutStatus::cases()), + 'payment_method' => fake()->randomElement(PaymentMethod::cases()), + 'email' => fake()->safeEmail(), + 'shipping_address_json' => $address, + 'billing_address_json' => $address, + 'shipping_method_id' => null, + 'discount_code' => null, + 'tax_provider_snapshot_json' => null, + 'totals_json' => ['subtotal' => 5000, 'discount' => 0, 'shipping' => 799, 'tax' => 430, 'total' => 6229], + 'expires_at' => fake()->dateTimeBetween('+30 minutes', '+2 hours'), + ]; + } +} diff --git a/database/factories/CollectionFactory.php b/database/factories/CollectionFactory.php new file mode 100644 index 00000000..337f0c99 --- /dev/null +++ b/database/factories/CollectionFactory.php @@ -0,0 +1,26 @@ + Store::factory(), + 'title' => fake()->sentence(3), + 'handle' => fake()->unique()->slug(), + 'description_html' => fake()->paragraph(), + 'type' => fake()->randomElement(CollectionType::cases()), + 'status' => fake()->randomElement(CollectionStatus::cases()), + ]; + } +} diff --git a/database/factories/CollectionProductFactory.php b/database/factories/CollectionProductFactory.php new file mode 100644 index 00000000..46e13b30 --- /dev/null +++ b/database/factories/CollectionProductFactory.php @@ -0,0 +1,22 @@ + Collection::factory(), + 'product_id' => Product::factory(), + 'position' => fake()->numberBetween(0, 100), + ]; + } +} diff --git a/database/factories/CustomerAddressFactory.php b/database/factories/CustomerAddressFactory.php new file mode 100644 index 00000000..ce8ce9b8 --- /dev/null +++ b/database/factories/CustomerAddressFactory.php @@ -0,0 +1,35 @@ + Customer::factory(), + 'label' => fake()->randomElement(['Home', 'Work']), + 'address_json' => [ + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), + 'company' => null, + 'address1' => fake()->streetAddress(), + 'address2' => null, + 'city' => fake()->city(), + 'province' => fake()->state(), + 'province_code' => fake()->stateAbbr(), + 'country' => 'United States', + 'country_code' => 'US', + 'zip' => fake()->postcode(), + 'phone' => fake()->phoneNumber(), + ], + 'is_default' => fake()->boolean(), + ]; + } +} diff --git a/database/factories/CustomerFactory.php b/database/factories/CustomerFactory.php new file mode 100644 index 00000000..7a3c1679 --- /dev/null +++ b/database/factories/CustomerFactory.php @@ -0,0 +1,23 @@ + Store::factory(), + 'email' => fake()->unique()->safeEmail(), + 'password_hash' => 'password', + 'name' => fake()->name(), + 'marketing_opt_in' => fake()->boolean(), + ]; + } +} diff --git a/database/factories/CustomerPasswordResetTokenFactory.php b/database/factories/CustomerPasswordResetTokenFactory.php new file mode 100644 index 00000000..0c95579c --- /dev/null +++ b/database/factories/CustomerPasswordResetTokenFactory.php @@ -0,0 +1,22 @@ + Store::factory(), + 'email' => fake()->unique()->safeEmail(), + 'token' => Str::random(64), + ]; + } +} diff --git a/database/factories/DiscountFactory.php b/database/factories/DiscountFactory.php new file mode 100644 index 00000000..3e61cd57 --- /dev/null +++ b/database/factories/DiscountFactory.php @@ -0,0 +1,36 @@ +randomElement(DiscountValueType::cases()); + + return [ + 'store_id' => Store::factory(), + 'type' => DiscountType::Code, + 'code' => fake()->unique()->regexify('[A-Z0-9]{8}'), + 'value_type' => $valueType, + 'value_amount' => $valueType === DiscountValueType::Percent + ? fake()->numberBetween(5, 50) + : fake()->numberBetween(100, 5000), + 'starts_at' => fake()->dateTimeBetween('-1 month', 'now'), + 'ends_at' => fake()->dateTimeBetween('+1 week', '+1 year'), + 'usage_limit' => fake()->numberBetween(10, 1000), + 'usage_count' => 0, + 'rules_json' => [], + 'status' => fake()->randomElement(DiscountStatus::cases()), + ]; + } +} diff --git a/database/factories/FulfillmentFactory.php b/database/factories/FulfillmentFactory.php new file mode 100644 index 00000000..bccc46b3 --- /dev/null +++ b/database/factories/FulfillmentFactory.php @@ -0,0 +1,25 @@ + Order::factory(), + 'status' => fake()->randomElement(FulfillmentShipmentStatus::cases()), + 'tracking_company' => fake()->randomElement(['UPS', 'FedEx', 'DHL', 'USPS']), + 'tracking_number' => fake()->regexify('[A-Z0-9]{12}'), + 'tracking_url' => fake()->url(), + 'shipped_at' => null, + ]; + } +} diff --git a/database/factories/FulfillmentLineFactory.php b/database/factories/FulfillmentLineFactory.php new file mode 100644 index 00000000..6f01745e --- /dev/null +++ b/database/factories/FulfillmentLineFactory.php @@ -0,0 +1,22 @@ + Fulfillment::factory(), + 'order_line_id' => OrderLine::factory(), + 'quantity' => fake()->numberBetween(1, 5), + ]; + } +} diff --git a/database/factories/InventoryItemFactory.php b/database/factories/InventoryItemFactory.php new file mode 100644 index 00000000..dbe27271 --- /dev/null +++ b/database/factories/InventoryItemFactory.php @@ -0,0 +1,25 @@ + Store::factory(), + 'variant_id' => ProductVariant::factory(), + 'quantity_on_hand' => fake()->numberBetween(0, 1000), + 'quantity_reserved' => fake()->numberBetween(0, 50), + 'policy' => fake()->randomElement(InventoryPolicy::cases()), + ]; + } +} diff --git a/database/factories/NavigationItemFactory.php b/database/factories/NavigationItemFactory.php new file mode 100644 index 00000000..de1961cb --- /dev/null +++ b/database/factories/NavigationItemFactory.php @@ -0,0 +1,25 @@ + NavigationMenu::factory(), + 'type' => NavigationItemType::Link, + 'label' => fake()->words(2, true), + 'url' => fake()->url(), + 'resource_id' => null, + 'position' => fake()->numberBetween(0, 20), + ]; + } +} diff --git a/database/factories/NavigationMenuFactory.php b/database/factories/NavigationMenuFactory.php new file mode 100644 index 00000000..e0e069ba --- /dev/null +++ b/database/factories/NavigationMenuFactory.php @@ -0,0 +1,21 @@ + Store::factory(), + 'handle' => fake()->unique()->slug(), + 'title' => fake()->sentence(3), + ]; + } +} diff --git a/database/factories/OauthClientFactory.php b/database/factories/OauthClientFactory.php new file mode 100644 index 00000000..0f5f01d8 --- /dev/null +++ b/database/factories/OauthClientFactory.php @@ -0,0 +1,23 @@ + App::factory(), + 'client_id' => fake()->unique()->uuid(), + 'client_secret_encrypted' => Str::random(64), + 'redirect_uris_json' => [fake()->url()], + ]; + } +} diff --git a/database/factories/OauthTokenFactory.php b/database/factories/OauthTokenFactory.php new file mode 100644 index 00000000..fbd42945 --- /dev/null +++ b/database/factories/OauthTokenFactory.php @@ -0,0 +1,22 @@ + AppInstallation::factory(), + 'access_token_hash' => hash('sha256', fake()->unique()->uuid()), + 'refresh_token_hash' => hash('sha256', fake()->uuid()), + 'expires_at' => fake()->dateTimeBetween('+1 hour', '+1 month'), + ]; + } +} diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php new file mode 100644 index 00000000..a0636695 --- /dev/null +++ b/database/factories/OrderFactory.php @@ -0,0 +1,58 @@ +numberBetween(1000, 50000); + $discount = fake()->numberBetween(0, (int) ($subtotal / 4)); + $shipping = fake()->numberBetween(0, 2000); + $tax = fake()->numberBetween(0, 5000); + $address = [ + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), + 'company' => null, + 'address1' => fake()->streetAddress(), + 'address2' => null, + 'city' => fake()->city(), + 'province' => fake()->state(), + 'province_code' => fake()->stateAbbr(), + 'country' => 'United States', + 'country_code' => 'US', + 'zip' => fake()->postcode(), + 'phone' => fake()->phoneNumber(), + ]; + + return [ + 'store_id' => Store::factory(), + 'customer_id' => null, + 'order_number' => '#'.fake()->unique()->numberBetween(1001, 999999), + 'payment_method' => fake()->randomElement(PaymentMethod::cases()), + 'status' => fake()->randomElement(OrderStatus::cases()), + 'financial_status' => fake()->randomElement(FinancialStatus::cases()), + 'fulfillment_status' => fake()->randomElement(FulfillmentStatus::cases()), + 'currency' => 'USD', + 'subtotal_amount' => $subtotal, + 'discount_amount' => $discount, + 'shipping_amount' => $shipping, + 'tax_amount' => $tax, + 'total_amount' => $subtotal - $discount + $shipping + $tax, + 'email' => fake()->safeEmail(), + 'billing_address_json' => $address, + 'shipping_address_json' => $address, + 'placed_at' => fake()->dateTime(), + ]; + } +} diff --git a/database/factories/OrderLineFactory.php b/database/factories/OrderLineFactory.php new file mode 100644 index 00000000..a9046017 --- /dev/null +++ b/database/factories/OrderLineFactory.php @@ -0,0 +1,33 @@ +numberBetween(1, 5); + $unitPrice = fake()->numberBetween(100, 10000); + + return [ + 'order_id' => Order::factory(), + 'product_id' => Product::factory(), + 'variant_id' => ProductVariant::factory(), + 'title_snapshot' => fake()->sentence(3), + 'sku_snapshot' => 'SKU-'.fake()->regexify('[A-Z0-9]{6}'), + 'quantity' => $quantity, + 'unit_price_amount' => $unitPrice, + 'total_amount' => $quantity * $unitPrice, + 'tax_lines_json' => [['title' => 'VAT', 'rate' => 1900, 'amount' => fake()->numberBetween(0, 1000)]], + 'discount_allocations_json' => [], + ]; + } +} diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php new file mode 100644 index 00000000..4bf2cb8f --- /dev/null +++ b/database/factories/OrganizationFactory.php @@ -0,0 +1,19 @@ + fake()->company(), + 'billing_email' => fake()->companyEmail(), + ]; + } +} diff --git a/database/factories/PageFactory.php b/database/factories/PageFactory.php new file mode 100644 index 00000000..573571c3 --- /dev/null +++ b/database/factories/PageFactory.php @@ -0,0 +1,25 @@ + Store::factory(), + 'title' => fake()->sentence(3), + 'handle' => fake()->unique()->slug(), + 'body_html' => fake()->paragraph(), + 'status' => fake()->randomElement(PageStatus::cases()), + 'published_at' => fake()->dateTime(), + ]; + } +} diff --git a/database/factories/PaymentFactory.php b/database/factories/PaymentFactory.php new file mode 100644 index 00000000..d560573c --- /dev/null +++ b/database/factories/PaymentFactory.php @@ -0,0 +1,30 @@ + Order::factory(), + 'provider' => PaymentProvider::Mock, + 'method' => fake()->randomElement(PaymentMethod::cases()), + 'provider_payment_id' => 'mock_'.Str::random(12), + 'status' => fake()->randomElement(PaymentStatus::cases()), + 'amount' => fake()->numberBetween(100, 50000), + 'currency' => 'USD', + 'raw_json_encrypted' => json_encode(['id' => fake()->uuid(), 'status' => 'succeeded']), + ]; + } +} diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php new file mode 100644 index 00000000..d4c31878 --- /dev/null +++ b/database/factories/ProductFactory.php @@ -0,0 +1,28 @@ + Store::factory(), + 'title' => fake()->sentence(3), + 'handle' => fake()->unique()->slug(), + 'status' => fake()->randomElement(ProductStatus::cases()), + 'description_html' => fake()->paragraph(), + 'vendor' => fake()->company(), + 'product_type' => fake()->word(), + 'tags' => [fake()->word()], + 'published_at' => fake()->dateTime(), + ]; + } +} diff --git a/database/factories/ProductMediaFactory.php b/database/factories/ProductMediaFactory.php new file mode 100644 index 00000000..68225aa1 --- /dev/null +++ b/database/factories/ProductMediaFactory.php @@ -0,0 +1,30 @@ + Product::factory(), + 'type' => fake()->randomElement(MediaType::cases()), + 'storage_key' => 'products/'.fake()->uuid().'.jpg', + 'alt_text' => fake()->sentence(4), + 'width' => fake()->numberBetween(100, 2000), + 'height' => fake()->numberBetween(100, 2000), + 'mime_type' => fake()->randomElement(['image/jpeg', 'image/png', 'video/mp4']), + 'byte_size' => fake()->numberBetween(1000, 5000000), + 'position' => fake()->numberBetween(0, 10), + 'status' => fake()->randomElement(MediaStatus::cases()), + ]; + } +} diff --git a/database/factories/ProductOptionFactory.php b/database/factories/ProductOptionFactory.php new file mode 100644 index 00000000..8f4a2c81 --- /dev/null +++ b/database/factories/ProductOptionFactory.php @@ -0,0 +1,21 @@ + Product::factory(), + 'name' => fake()->randomElement(['Size', 'Color', 'Material']), + 'position' => fake()->numberBetween(0, 5), + ]; + } +} diff --git a/database/factories/ProductOptionValueFactory.php b/database/factories/ProductOptionValueFactory.php new file mode 100644 index 00000000..5e1c73b2 --- /dev/null +++ b/database/factories/ProductOptionValueFactory.php @@ -0,0 +1,21 @@ + ProductOption::factory(), + 'value' => fake()->word(), + 'position' => fake()->numberBetween(0, 10), + ]; + } +} diff --git a/database/factories/ProductVariantFactory.php b/database/factories/ProductVariantFactory.php new file mode 100644 index 00000000..cf94da35 --- /dev/null +++ b/database/factories/ProductVariantFactory.php @@ -0,0 +1,30 @@ + Product::factory(), + 'sku' => 'SKU-'.fake()->unique()->regexify('[A-Z0-9]{6}'), + 'barcode' => fake()->ean13(), + 'price_amount' => fake()->numberBetween(100, 50000), + 'compare_at_amount' => fake()->numberBetween(100, 60000), + 'currency' => 'USD', + 'weight_g' => fake()->numberBetween(10, 5000), + 'requires_shipping' => true, + 'is_default' => false, + 'position' => fake()->numberBetween(0, 10), + 'status' => fake()->randomElement(VariantStatus::cases()), + ]; + } +} diff --git a/database/factories/RefundFactory.php b/database/factories/RefundFactory.php new file mode 100644 index 00000000..9cbe38bb --- /dev/null +++ b/database/factories/RefundFactory.php @@ -0,0 +1,27 @@ + Order::factory(), + 'payment_id' => Payment::factory(), + 'amount' => fake()->numberBetween(100, 10000), + 'reason' => fake()->sentence(), + 'status' => fake()->randomElement(RefundStatus::cases()), + 'provider_refund_id' => 'mock_rf_'.Str::random(12), + ]; + } +} diff --git a/database/factories/SearchQueryFactory.php b/database/factories/SearchQueryFactory.php new file mode 100644 index 00000000..d122826c --- /dev/null +++ b/database/factories/SearchQueryFactory.php @@ -0,0 +1,22 @@ + Store::factory(), + 'query' => fake()->words(2, true), + 'filters_json' => null, + 'results_count' => fake()->numberBetween(0, 500), + ]; + } +} diff --git a/database/factories/SearchSettingFactory.php b/database/factories/SearchSettingFactory.php new file mode 100644 index 00000000..bb4028fa --- /dev/null +++ b/database/factories/SearchSettingFactory.php @@ -0,0 +1,21 @@ + Store::factory(), + 'synonyms_json' => [['tv', 'television']], + 'stop_words_json' => ['the', 'and'], + ]; + } +} diff --git a/database/factories/ShippingRateFactory.php b/database/factories/ShippingRateFactory.php new file mode 100644 index 00000000..649d319b --- /dev/null +++ b/database/factories/ShippingRateFactory.php @@ -0,0 +1,24 @@ + ShippingZone::factory(), + 'name' => fake()->words(2, true), + 'type' => fake()->randomElement(ShippingRateType::cases()), + 'config_json' => ['amount' => 799], + 'is_active' => true, + ]; + } +} diff --git a/database/factories/ShippingZoneFactory.php b/database/factories/ShippingZoneFactory.php new file mode 100644 index 00000000..aeeca7b4 --- /dev/null +++ b/database/factories/ShippingZoneFactory.php @@ -0,0 +1,22 @@ + Store::factory(), + 'name' => fake()->words(2, true), + 'countries_json' => ['US', 'CA'], + 'regions_json' => [], + ]; + } +} diff --git a/database/factories/StoreDomainFactory.php b/database/factories/StoreDomainFactory.php new file mode 100644 index 00000000..b12558c2 --- /dev/null +++ b/database/factories/StoreDomainFactory.php @@ -0,0 +1,25 @@ + Store::factory(), + 'hostname' => fake()->unique()->domainName(), + 'type' => fake()->randomElement(StoreDomainType::cases()), + 'is_primary' => false, + 'tls_mode' => fake()->randomElement(TlsMode::cases()), + ]; + } +} diff --git a/database/factories/StoreFactory.php b/database/factories/StoreFactory.php new file mode 100644 index 00000000..40278eea --- /dev/null +++ b/database/factories/StoreFactory.php @@ -0,0 +1,26 @@ + Organization::factory(), + 'name' => fake()->company(), + 'handle' => fake()->unique()->slug(), + 'status' => fake()->randomElement(StoreStatus::cases()), + 'default_currency' => 'USD', + 'default_locale' => 'en', + 'timezone' => 'UTC', + ]; + } +} diff --git a/database/factories/StoreSettingFactory.php b/database/factories/StoreSettingFactory.php new file mode 100644 index 00000000..94c0c396 --- /dev/null +++ b/database/factories/StoreSettingFactory.php @@ -0,0 +1,20 @@ + Store::factory(), + 'settings_json' => [], + ]; + } +} diff --git a/database/factories/StoreUserFactory.php b/database/factories/StoreUserFactory.php new file mode 100644 index 00000000..659b02e3 --- /dev/null +++ b/database/factories/StoreUserFactory.php @@ -0,0 +1,23 @@ + Store::factory(), + 'user_id' => User::factory(), + 'role' => fake()->randomElement(StoreUserRole::cases()), + ]; + } +} diff --git a/database/factories/TaxSettingFactory.php b/database/factories/TaxSettingFactory.php new file mode 100644 index 00000000..00ec7c00 --- /dev/null +++ b/database/factories/TaxSettingFactory.php @@ -0,0 +1,25 @@ + Store::factory(), + 'mode' => fake()->randomElement(TaxMode::cases()), + 'provider' => fake()->randomElement(TaxProvider::cases()), + 'prices_include_tax' => false, + 'config_json' => [], + ]; + } +} diff --git a/database/factories/ThemeFactory.php b/database/factories/ThemeFactory.php new file mode 100644 index 00000000..f9568558 --- /dev/null +++ b/database/factories/ThemeFactory.php @@ -0,0 +1,24 @@ + Store::factory(), + 'name' => fake()->words(2, true), + 'version' => fake()->randomElement(['1.0.0', '1.1.0', '2.0.0']), + 'status' => fake()->randomElement(ThemeStatus::cases()), + 'published_at' => fake()->dateTime(), + ]; + } +} diff --git a/database/factories/ThemeFileFactory.php b/database/factories/ThemeFileFactory.php new file mode 100644 index 00000000..25413a91 --- /dev/null +++ b/database/factories/ThemeFileFactory.php @@ -0,0 +1,23 @@ + Theme::factory(), + 'path' => 'templates/'.fake()->unique()->slug().'.liquid', + 'storage_key' => 'themes/'.fake()->uuid().'.liquid', + 'sha256' => hash('sha256', fake()->uuid()), + 'byte_size' => fake()->numberBetween(100, 100000), + ]; + } +} diff --git a/database/factories/ThemeSettingFactory.php b/database/factories/ThemeSettingFactory.php new file mode 100644 index 00000000..d61e2a46 --- /dev/null +++ b/database/factories/ThemeSettingFactory.php @@ -0,0 +1,20 @@ + Theme::factory(), + 'settings_json' => ['color_scheme' => 'light'], + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 80da5ac7..9d7c14d0 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; @@ -26,6 +27,7 @@ public function definition(): array return [ 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), + 'status' => UserStatus::Active, 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), diff --git a/database/factories/VariantOptionValueFactory.php b/database/factories/VariantOptionValueFactory.php new file mode 100644 index 00000000..c01f9581 --- /dev/null +++ b/database/factories/VariantOptionValueFactory.php @@ -0,0 +1,21 @@ + ProductVariant::factory(), + 'product_option_value_id' => ProductOptionValue::factory(), + ]; + } +} diff --git a/database/factories/WebhookDeliveryFactory.php b/database/factories/WebhookDeliveryFactory.php new file mode 100644 index 00000000..c08eb198 --- /dev/null +++ b/database/factories/WebhookDeliveryFactory.php @@ -0,0 +1,26 @@ + WebhookSubscription::factory(), + 'event_id' => fake()->uuid(), + 'attempt_count' => 1, + 'status' => fake()->randomElement(WebhookDeliveryStatus::cases()), + 'last_attempt_at' => fake()->dateTime(), + 'response_code' => fake()->randomElement([200, 201, 400, 500]), + 'response_body_snippet' => fake()->sentence(), + ]; + } +} diff --git a/database/factories/WebhookSubscriptionFactory.php b/database/factories/WebhookSubscriptionFactory.php new file mode 100644 index 00000000..a1b288c5 --- /dev/null +++ b/database/factories/WebhookSubscriptionFactory.php @@ -0,0 +1,26 @@ + Store::factory(), + 'app_installation_id' => null, + 'event_type' => fake()->randomElement(['orders/create', 'orders/paid', 'products/update', 'refunds/create']), + 'target_url' => fake()->url(), + 'signing_secret_encrypted' => Str::random(64), + 'status' => fake()->randomElement(WebhookSubscriptionStatus::cases()), + ]; + } +} diff --git a/database/migrations/2026_09_13_000001_add_spec_columns_to_users_table.php b/database/migrations/2026_09_13_000001_add_spec_columns_to_users_table.php new file mode 100644 index 00000000..84b5b4ab --- /dev/null +++ b/database/migrations/2026_09_13_000001_add_spec_columns_to_users_table.php @@ -0,0 +1,36 @@ +string('status')->default('active')->after('name'); + $table->timestamp('last_login_at')->nullable()->after('email_verified_at'); + $table->index('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropIndex(['status']); + $table->dropColumn(['status', 'last_login_at']); + }); + } +}; diff --git a/database/migrations/2026_09_13_065919_create_organizations_table.php b/database/migrations/2026_09_13_065919_create_organizations_table.php new file mode 100644 index 00000000..431b64c4 --- /dev/null +++ b/database/migrations/2026_09_13_065919_create_organizations_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('name'); + $table->string('billing_email'); + $table->index('billing_email'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('organizations'); + } +}; diff --git a/database/migrations/2026_09_13_065920_create_apps_table.php b/database/migrations/2026_09_13_065920_create_apps_table.php new file mode 100644 index 00000000..904cc9ce --- /dev/null +++ b/database/migrations/2026_09_13_065920_create_apps_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('name'); + $table->string('status')->default('active'); + $table->timestamp('created_at')->nullable(); + $table->index('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('apps'); + } +}; diff --git a/database/migrations/2026_09_13_065921_create_stores_table.php b/database/migrations/2026_09_13_065921_create_stores_table.php new file mode 100644 index 00000000..39f30795 --- /dev/null +++ b/database/migrations/2026_09_13_065921_create_stores_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('organization_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('handle'); + $table->string('status')->default('active'); + $table->string('default_currency')->default('USD'); + $table->string('default_locale')->default('en'); + $table->string('timezone')->default('UTC'); + $table->unique('handle'); + $table->index('organization_id'); + $table->index('status'); + $table->timestamps(); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('stores'); + } +}; diff --git a/database/migrations/2026_09_13_065922_create_store_domains_table.php b/database/migrations/2026_09_13_065922_create_store_domains_table.php new file mode 100644 index 00000000..aa44e447 --- /dev/null +++ b/database/migrations/2026_09_13_065922_create_store_domains_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('hostname'); + $table->string('type')->default('storefront'); + $table->integer('is_primary')->default(0); + $table->string('tls_mode')->default('managed'); + $table->timestamp('created_at')->nullable(); + $table->unique('hostname'); + $table->index('store_id'); + $table->index(['store_id', 'is_primary']); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('store_domains'); + } +}; diff --git a/database/migrations/2026_09_13_065923_create_store_users_table.php b/database/migrations/2026_09_13_065923_create_store_users_table.php new file mode 100644 index 00000000..a933c923 --- /dev/null +++ b/database/migrations/2026_09_13_065923_create_store_users_table.php @@ -0,0 +1,36 @@ +foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('role')->default('staff'); + $table->timestamp('created_at')->nullable(); + $table->unique(['store_id', 'user_id']); + $table->index('user_id'); + $table->index(['store_id', 'role']); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('store_users'); + } +}; diff --git a/database/migrations/2026_09_13_065924_create_store_settings_table.php b/database/migrations/2026_09_13_065924_create_store_settings_table.php new file mode 100644 index 00000000..b3962d8e --- /dev/null +++ b/database/migrations/2026_09_13_065924_create_store_settings_table.php @@ -0,0 +1,32 @@ +foreignId('store_id')->constrained()->cascadeOnDelete()->primary(); + $table->json('settings_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('store_settings'); + } +}; diff --git a/database/migrations/2026_09_13_065925_create_customers_table.php b/database/migrations/2026_09_13_065925_create_customers_table.php new file mode 100644 index 00000000..76276763 --- /dev/null +++ b/database/migrations/2026_09_13_065925_create_customers_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('email'); + $table->string('password_hash')->nullable(); + $table->string('name')->nullable(); + $table->integer('marketing_opt_in')->default(0); + $table->unique(['store_id', 'email']); + $table->index('store_id'); + $table->timestamps(); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('customers'); + } +}; diff --git a/database/migrations/2026_09_13_065926_create_themes_table.php b/database/migrations/2026_09_13_065926_create_themes_table.php new file mode 100644 index 00000000..28c9de9c --- /dev/null +++ b/database/migrations/2026_09_13_065926_create_themes_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('version')->nullable(); + $table->string('status')->default('draft'); + $table->timestamp('published_at')->nullable(); + $table->index('store_id'); + $table->index(['store_id', 'status']); + $table->timestamps(); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('themes'); + } +}; diff --git a/database/migrations/2026_09_13_065927_create_pages_table.php b/database/migrations/2026_09_13_065927_create_pages_table.php new file mode 100644 index 00000000..472dd378 --- /dev/null +++ b/database/migrations/2026_09_13_065927_create_pages_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->text('body_html')->nullable(); + $table->string('status')->default('draft'); + $table->timestamp('published_at')->nullable(); + $table->unique(['store_id', 'handle']); + $table->index('store_id'); + $table->index(['store_id', 'status']); + $table->timestamps(); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('pages'); + } +}; diff --git a/database/migrations/2026_09_13_065928_create_navigation_menus_table.php b/database/migrations/2026_09_13_065928_create_navigation_menus_table.php new file mode 100644 index 00000000..9036090d --- /dev/null +++ b/database/migrations/2026_09_13_065928_create_navigation_menus_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('handle'); + $table->string('title'); + $table->unique(['store_id', 'handle']); + $table->index('store_id'); + $table->timestamps(); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('navigation_menus'); + } +}; diff --git a/database/migrations/2026_09_13_065929_create_search_settings_table.php b/database/migrations/2026_09_13_065929_create_search_settings_table.php new file mode 100644 index 00000000..1b67004f --- /dev/null +++ b/database/migrations/2026_09_13_065929_create_search_settings_table.php @@ -0,0 +1,33 @@ +foreignId('store_id')->constrained()->cascadeOnDelete()->primary(); + $table->json('synonyms_json')->default('[]'); + $table->json('stop_words_json')->default('[]'); + $table->timestamp('updated_at')->nullable(); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('search_settings'); + } +}; diff --git a/database/migrations/2026_09_13_065930_create_shipping_zones_table.php b/database/migrations/2026_09_13_065930_create_shipping_zones_table.php new file mode 100644 index 00000000..4af4b640 --- /dev/null +++ b/database/migrations/2026_09_13_065930_create_shipping_zones_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->json('countries_json')->default('[]'); + $table->json('regions_json')->default('[]'); + $table->index('store_id'); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shipping_zones'); + } +}; diff --git a/database/migrations/2026_09_13_065931_create_tax_settings_table.php b/database/migrations/2026_09_13_065931_create_tax_settings_table.php new file mode 100644 index 00000000..ac827a6f --- /dev/null +++ b/database/migrations/2026_09_13_065931_create_tax_settings_table.php @@ -0,0 +1,34 @@ +foreignId('store_id')->constrained()->cascadeOnDelete()->primary(); + $table->string('mode')->default('manual'); + $table->string('provider')->default('none'); + $table->integer('prices_include_tax')->default(0); + $table->json('config_json')->default('{}'); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tax_settings'); + } +}; diff --git a/database/migrations/2026_09_13_065932_create_discounts_table.php b/database/migrations/2026_09_13_065932_create_discounts_table.php new file mode 100644 index 00000000..20e7bee2 --- /dev/null +++ b/database/migrations/2026_09_13_065932_create_discounts_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('type')->default('code'); + $table->string('code')->nullable(); + $table->string('value_type'); + $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->json('rules_json')->default('{}'); + $table->string('status')->default('active'); + $table->unique(['store_id', 'code']); + $table->index('store_id'); + $table->index(['store_id', 'status']); + $table->index(['store_id', 'type']); + $table->timestamps(); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('discounts'); + } +}; diff --git a/database/migrations/2026_09_13_065933_create_products_table.php b/database/migrations/2026_09_13_065933_create_products_table.php new file mode 100644 index 00000000..bab5c2ae --- /dev/null +++ b/database/migrations/2026_09_13_065933_create_products_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->string('status')->default('draft'); + $table->text('description_html')->nullable(); + $table->string('vendor')->nullable(); + $table->string('product_type')->nullable(); + $table->json('tags')->default('[]'); + $table->timestamp('published_at')->nullable(); + $table->unique(['store_id', 'handle']); + $table->index('store_id'); + $table->index(['store_id', 'status']); + $table->index(['store_id', 'published_at']); + $table->index(['store_id', 'vendor']); + $table->index(['store_id', 'product_type']); + $table->timestamps(); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('products'); + } +}; diff --git a/database/migrations/2026_09_13_065934_create_collections_table.php b/database/migrations/2026_09_13_065934_create_collections_table.php new file mode 100644 index 00000000..889bfb01 --- /dev/null +++ b/database/migrations/2026_09_13_065934_create_collections_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->text('description_html')->nullable(); + $table->string('type')->default('manual'); + $table->string('status')->default('active'); + $table->unique(['store_id', 'handle']); + $table->index('store_id'); + $table->index(['store_id', 'status']); + $table->timestamps(); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('collections'); + } +}; diff --git a/database/migrations/2026_09_13_065935_create_app_installations_table.php b/database/migrations/2026_09_13_065935_create_app_installations_table.php new file mode 100644 index 00000000..e8c0ac10 --- /dev/null +++ b/database/migrations/2026_09_13_065935_create_app_installations_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('app_id')->constrained()->cascadeOnDelete(); + $table->json('scopes_json')->default('[]'); + $table->string('status')->default('active'); + $table->timestamp('installed_at')->nullable(); + $table->unique(['store_id', 'app_id']); + $table->index('store_id'); + $table->index('app_id'); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('app_installations'); + } +}; diff --git a/database/migrations/2026_09_13_065936_create_oauth_clients_table.php b/database/migrations/2026_09_13_065936_create_oauth_clients_table.php new file mode 100644 index 00000000..70e6a663 --- /dev/null +++ b/database/migrations/2026_09_13_065936_create_oauth_clients_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('app_id')->constrained()->cascadeOnDelete(); + $table->string('client_id'); + $table->text('client_secret_encrypted'); + $table->json('redirect_uris_json')->default('[]'); + $table->unique('client_id'); + $table->index('app_id'); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_clients'); + } +}; diff --git a/database/migrations/2026_09_13_065937_create_customer_password_reset_tokens_table.php b/database/migrations/2026_09_13_065937_create_customer_password_reset_tokens_table.php new file mode 100644 index 00000000..515afb8b --- /dev/null +++ b/database/migrations/2026_09_13_065937_create_customer_password_reset_tokens_table.php @@ -0,0 +1,34 @@ +foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('email'); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + $table->unique(['store_id', 'email']); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('customer_password_reset_tokens'); + } +}; diff --git a/database/migrations/2026_09_13_065938_create_product_options_table.php b/database/migrations/2026_09_13_065938_create_product_options_table.php new file mode 100644 index 00000000..d1f14fad --- /dev/null +++ b/database/migrations/2026_09_13_065938_create_product_options_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->integer('position')->default(0); + $table->index('product_id'); + $table->unique(['product_id', 'position']); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_options'); + } +}; diff --git a/database/migrations/2026_09_13_065939_create_product_variants_table.php b/database/migrations/2026_09_13_065939_create_product_variants_table.php new file mode 100644 index 00000000..0a7bdafe --- /dev/null +++ b/database/migrations/2026_09_13_065939_create_product_variants_table.php @@ -0,0 +1,47 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('sku')->nullable(); + $table->string('barcode')->nullable(); + $table->integer('price_amount')->default(0); + $table->integer('compare_at_amount')->nullable(); + $table->string('currency')->default('USD'); + $table->integer('weight_g')->nullable(); + $table->integer('requires_shipping')->default(1); + $table->integer('is_default')->default(0); + $table->integer('position')->default(0); + $table->string('status')->default('active'); + $table->index('product_id'); + $table->index('sku'); + $table->index('barcode'); + $table->index(['product_id', 'position']); + $table->index(['product_id', 'is_default']); + $table->timestamps(); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_variants'); + } +}; diff --git a/database/migrations/2026_09_13_065940_create_product_media_table.php b/database/migrations/2026_09_13_065940_create_product_media_table.php new file mode 100644 index 00000000..cb6bcbc5 --- /dev/null +++ b/database/migrations/2026_09_13_065940_create_product_media_table.php @@ -0,0 +1,44 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('type')->default('image'); + $table->string('storage_key'); + $table->string('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->string('status')->default('processing'); + $table->timestamp('created_at')->nullable(); + $table->index('product_id'); + $table->index(['product_id', 'position']); + $table->index('status'); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_media'); + } +}; diff --git a/database/migrations/2026_09_13_065941_create_collection_products_table.php b/database/migrations/2026_09_13_065941_create_collection_products_table.php new file mode 100644 index 00000000..bb0cda2e --- /dev/null +++ b/database/migrations/2026_09_13_065941_create_collection_products_table.php @@ -0,0 +1,35 @@ +foreignId('collection_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->integer('position')->default(0); + $table->unique(['collection_id', 'product_id']); + $table->index('product_id'); + $table->index(['collection_id', 'position']); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('collection_products'); + } +}; diff --git a/database/migrations/2026_09_13_065942_create_customer_addresses_table.php b/database/migrations/2026_09_13_065942_create_customer_addresses_table.php new file mode 100644 index 00000000..fea2d092 --- /dev/null +++ b/database/migrations/2026_09_13_065942_create_customer_addresses_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('customer_id')->constrained()->cascadeOnDelete(); + $table->string('label')->nullable(); + $table->json('address_json')->default('{}'); + $table->integer('is_default')->default(0); + $table->index('customer_id'); + $table->index(['customer_id', 'is_default']); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('customer_addresses'); + } +}; diff --git a/database/migrations/2026_09_13_065943_create_carts_table.php b/database/migrations/2026_09_13_065943_create_carts_table.php new file mode 100644 index 00000000..01ec8edb --- /dev/null +++ b/database/migrations/2026_09_13_065943_create_carts_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->onDelete('set null'); + $table->string('currency')->default('USD'); + $table->integer('cart_version')->default(1); + $table->string('status')->default('active'); + $table->index('store_id'); + $table->index('customer_id'); + $table->index(['store_id', 'status']); + $table->timestamps(); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('carts'); + } +}; diff --git a/database/migrations/2026_09_13_065944_create_navigation_items_table.php b/database/migrations/2026_09_13_065944_create_navigation_items_table.php new file mode 100644 index 00000000..e2e22814 --- /dev/null +++ b/database/migrations/2026_09_13_065944_create_navigation_items_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('menu_id')->constrained('navigation_menus')->cascadeOnDelete(); + $table->string('type')->default('link'); + $table->string('label'); + $table->string('url')->nullable(); + $table->integer('resource_id')->nullable(); + $table->integer('position')->default(0); + $table->index('menu_id'); + $table->index(['menu_id', 'position']); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('navigation_items'); + } +}; diff --git a/database/migrations/2026_09_13_065945_create_theme_files_table.php b/database/migrations/2026_09_13_065945_create_theme_files_table.php new file mode 100644 index 00000000..df685259 --- /dev/null +++ b/database/migrations/2026_09_13_065945_create_theme_files_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('theme_id')->constrained()->cascadeOnDelete(); + $table->string('path'); + $table->string('storage_key'); + $table->string('sha256'); + $table->integer('byte_size')->default(0); + $table->unique(['theme_id', 'path']); + $table->index('theme_id'); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('theme_files'); + } +}; diff --git a/database/migrations/2026_09_13_065946_create_theme_settings_table.php b/database/migrations/2026_09_13_065946_create_theme_settings_table.php new file mode 100644 index 00000000..af55a506 --- /dev/null +++ b/database/migrations/2026_09_13_065946_create_theme_settings_table.php @@ -0,0 +1,32 @@ +foreignId('theme_id')->constrained()->cascadeOnDelete()->primary(); + $table->json('settings_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('theme_settings'); + } +}; diff --git a/database/migrations/2026_09_13_065947_create_search_queries_table.php b/database/migrations/2026_09_13_065947_create_search_queries_table.php new file mode 100644 index 00000000..0b02beff --- /dev/null +++ b/database/migrations/2026_09_13_065947_create_search_queries_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('query'); + $table->json('filters_json')->nullable(); + $table->integer('results_count')->default(0); + $table->timestamp('created_at')->nullable(); + $table->index('store_id'); + $table->index(['store_id', 'created_at']); + $table->index(['store_id', 'query']); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('search_queries'); + } +}; diff --git a/database/migrations/2026_09_13_065948_create_shipping_rates_table.php b/database/migrations/2026_09_13_065948_create_shipping_rates_table.php new file mode 100644 index 00000000..3f371cba --- /dev/null +++ b/database/migrations/2026_09_13_065948_create_shipping_rates_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('zone_id')->constrained('shipping_zones')->cascadeOnDelete(); + $table->string('name'); + $table->string('type')->default('flat'); + $table->json('config_json')->default('{}'); + $table->integer('is_active')->default(1); + $table->index('zone_id'); + $table->index(['zone_id', 'is_active']); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('shipping_rates'); + } +}; diff --git a/database/migrations/2026_09_13_065949_create_oauth_tokens_table.php b/database/migrations/2026_09_13_065949_create_oauth_tokens_table.php new file mode 100644 index 00000000..f2a38d73 --- /dev/null +++ b/database/migrations/2026_09_13_065949_create_oauth_tokens_table.php @@ -0,0 +1,37 @@ +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'); + $table->unique('access_token_hash'); + $table->index('expires_at'); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_tokens'); + } +}; diff --git a/database/migrations/2026_09_13_065950_create_webhook_subscriptions_table.php b/database/migrations/2026_09_13_065950_create_webhook_subscriptions_table.php new file mode 100644 index 00000000..5d82a5a2 --- /dev/null +++ b/database/migrations/2026_09_13_065950_create_webhook_subscriptions_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('app_installation_id')->nullable()->constrained()->cascadeOnDelete(); + $table->string('event_type'); + $table->string('target_url'); + $table->text('signing_secret_encrypted'); + $table->string('status')->default('active'); + $table->index('store_id'); + $table->index(['store_id', 'event_type']); + $table->index('app_installation_id'); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_subscriptions'); + } +}; diff --git a/database/migrations/2026_09_13_065951_create_product_option_values_table.php b/database/migrations/2026_09_13_065951_create_product_option_values_table.php new file mode 100644 index 00000000..edad5c53 --- /dev/null +++ b/database/migrations/2026_09_13_065951_create_product_option_values_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('product_option_id')->constrained()->cascadeOnDelete(); + $table->string('value'); + $table->integer('position')->default(0); + $table->index('product_option_id'); + $table->unique(['product_option_id', 'position']); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_option_values'); + } +}; diff --git a/database/migrations/2026_09_13_065952_create_inventory_items_table.php b/database/migrations/2026_09_13_065952_create_inventory_items_table.php new file mode 100644 index 00000000..7218ac1b --- /dev/null +++ b/database/migrations/2026_09_13_065952_create_inventory_items_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->integer('quantity_on_hand')->default(0); + $table->integer('quantity_reserved')->default(0); + $table->string('policy')->default('deny'); + $table->unique('variant_id'); + $table->index('store_id'); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('inventory_items'); + } +}; diff --git a/database/migrations/2026_09_13_065953_create_cart_lines_table.php b/database/migrations/2026_09_13_065953_create_cart_lines_table.php new file mode 100644 index 00000000..1c29e799 --- /dev/null +++ b/database/migrations/2026_09_13_065953_create_cart_lines_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('cart_id')->constrained()->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'); + $table->unique(['cart_id', 'variant_id']); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cart_lines'); + } +}; diff --git a/database/migrations/2026_09_13_065954_create_checkouts_table.php b/database/migrations/2026_09_13_065954_create_checkouts_table.php new file mode 100644 index 00000000..bd91dfbe --- /dev/null +++ b/database/migrations/2026_09_13_065954_create_checkouts_table.php @@ -0,0 +1,49 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('cart_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->onDelete('set null'); + $table->string('status')->default('started'); + $table->string('payment_method')->nullable(); + $table->string('email')->nullable(); + $table->json('shipping_address_json')->nullable(); + $table->json('billing_address_json')->nullable(); + $table->integer('shipping_method_id')->nullable(); + $table->string('discount_code')->nullable(); + $table->json('tax_provider_snapshot_json')->nullable(); + $table->json('totals_json')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->index('store_id'); + $table->index('cart_id'); + $table->index('customer_id'); + $table->index(['store_id', 'status']); + $table->index('expires_at'); + $table->timestamps(); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('checkouts'); + } +}; diff --git a/database/migrations/2026_09_13_065955_create_orders_table.php b/database/migrations/2026_09_13_065955_create_orders_table.php new file mode 100644 index 00000000..05ae06db --- /dev/null +++ b/database/migrations/2026_09_13_065955_create_orders_table.php @@ -0,0 +1,55 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->onDelete('set null'); + $table->string('order_number'); + $table->string('payment_method'); + $table->string('status')->default('pending'); + $table->string('financial_status')->default('pending'); + $table->string('fulfillment_status')->default('unfulfilled'); + $table->string('currency')->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->json('billing_address_json')->nullable(); + $table->json('shipping_address_json')->nullable(); + $table->timestamp('placed_at')->nullable(); + $table->unique(['store_id', 'order_number']); + $table->index('store_id'); + $table->index('customer_id'); + $table->index(['store_id', 'status']); + $table->index(['store_id', 'financial_status']); + $table->index(['store_id', 'fulfillment_status']); + $table->index(['store_id', 'placed_at']); + $table->timestamps(); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('orders'); + } +}; diff --git a/database/migrations/2026_09_13_065956_create_analytics_events_table.php b/database/migrations/2026_09_13_065956_create_analytics_events_table.php new file mode 100644 index 00000000..2d7364b2 --- /dev/null +++ b/database/migrations/2026_09_13_065956_create_analytics_events_table.php @@ -0,0 +1,44 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('type'); + $table->string('session_id')->nullable(); + $table->foreignId('customer_id')->nullable()->constrained()->onDelete('set null'); + $table->json('properties_json')->default('{}'); + $table->string('client_event_id')->nullable(); + $table->timestamp('occurred_at')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->index('store_id'); + $table->index(['store_id', 'type']); + $table->index(['store_id', 'created_at']); + $table->index('session_id'); + $table->index('customer_id'); + $table->unique(['store_id', 'client_event_id']); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('analytics_events'); + } +}; diff --git a/database/migrations/2026_09_13_065957_create_analytics_daily_table.php b/database/migrations/2026_09_13_065957_create_analytics_daily_table.php new file mode 100644 index 00000000..ea1833b4 --- /dev/null +++ b/database/migrations/2026_09_13_065957_create_analytics_daily_table.php @@ -0,0 +1,39 @@ +foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('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->unique(['store_id', 'date']); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('analytics_daily'); + } +}; diff --git a/database/migrations/2026_09_13_065958_create_webhook_deliveries_table.php b/database/migrations/2026_09_13_065958_create_webhook_deliveries_table.php new file mode 100644 index 00000000..c01aedec --- /dev/null +++ b/database/migrations/2026_09_13_065958_create_webhook_deliveries_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('subscription_id')->constrained('webhook_subscriptions')->cascadeOnDelete(); + $table->string('event_id'); + $table->integer('attempt_count')->default(1); + $table->string('status')->default('pending'); + $table->timestamp('last_attempt_at')->nullable(); + $table->integer('response_code')->nullable(); + $table->text('response_body_snippet')->nullable(); + $table->index('subscription_id'); + $table->index('event_id'); + $table->index('status'); + $table->index('last_attempt_at'); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_deliveries'); + } +}; diff --git a/database/migrations/2026_09_13_065959_create_variant_option_values_table.php b/database/migrations/2026_09_13_065959_create_variant_option_values_table.php new file mode 100644 index 00000000..9cdfbb5c --- /dev/null +++ b/database/migrations/2026_09_13_065959_create_variant_option_values_table.php @@ -0,0 +1,33 @@ +foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->foreignId('product_option_value_id')->constrained()->cascadeOnDelete(); + $table->unique(['variant_id', 'product_option_value_id']); + $table->index('product_option_value_id'); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('variant_option_values'); + } +}; diff --git a/database/migrations/2026_09_13_070000_create_order_lines_table.php b/database/migrations/2026_09_13_070000_create_order_lines_table.php new file mode 100644 index 00000000..4e70ac64 --- /dev/null +++ b/database/migrations/2026_09_13_070000_create_order_lines_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->constrained()->onDelete('set null'); + $table->foreignId('variant_id')->nullable()->constrained('product_variants')->onDelete('set null'); + $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->json('tax_lines_json')->default('[]'); + $table->json('discount_allocations_json')->default('[]'); + $table->index('order_id'); + $table->index('product_id'); + $table->index('variant_id'); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('order_lines'); + } +}; diff --git a/database/migrations/2026_09_13_070001_create_payments_table.php b/database/migrations/2026_09_13_070001_create_payments_table.php new file mode 100644 index 00000000..4741e810 --- /dev/null +++ b/database/migrations/2026_09_13_070001_create_payments_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->string('provider')->default('mock'); + $table->string('method'); + $table->string('provider_payment_id')->nullable(); + $table->string('status')->default('pending'); + $table->integer('amount')->default(0); + $table->string('currency')->default('USD'); + $table->text('raw_json_encrypted')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->index('order_id'); + $table->index(['provider', 'provider_payment_id']); + $table->index('method'); + $table->index('status'); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('payments'); + } +}; diff --git a/database/migrations/2026_09_13_070002_create_fulfillments_table.php b/database/migrations/2026_09_13_070002_create_fulfillments_table.php new file mode 100644 index 00000000..625eb3bf --- /dev/null +++ b/database/migrations/2026_09_13_070002_create_fulfillments_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->string('status')->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'); + $table->index('status'); + $table->index(['tracking_company', 'tracking_number']); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('fulfillments'); + } +}; diff --git a/database/migrations/2026_09_13_070003_create_refunds_table.php b/database/migrations/2026_09_13_070003_create_refunds_table.php new file mode 100644 index 00000000..0a2cc1e0 --- /dev/null +++ b/database/migrations/2026_09_13_070003_create_refunds_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('payment_id')->constrained()->cascadeOnDelete(); + $table->integer('amount')->default(0); + $table->text('reason')->nullable(); + $table->string('status')->default('pending'); + $table->string('provider_refund_id')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->index('order_id'); + $table->index('payment_id'); + $table->index('status'); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('refunds'); + } +}; diff --git a/database/migrations/2026_09_13_070004_create_fulfillment_lines_table.php b/database/migrations/2026_09_13_070004_create_fulfillment_lines_table.php new file mode 100644 index 00000000..204b237f --- /dev/null +++ b/database/migrations/2026_09_13_070004_create_fulfillment_lines_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('fulfillment_id')->constrained()->cascadeOnDelete(); + $table->foreignId('order_line_id')->constrained()->cascadeOnDelete(); + $table->integer('quantity')->default(1); + $table->index('fulfillment_id'); + $table->unique(['fulfillment_id', 'order_line_id']); + }); + + Schema::enableForeignKeyConstraints(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('fulfillment_lines'); + } +}; diff --git a/database/migrations/2026_09_13_070005_create_personal_access_tokens_table.php b/database/migrations/2026_09_13_070005_create_personal_access_tokens_table.php new file mode 100644 index 00000000..40ff706e --- /dev/null +++ b/database/migrations/2026_09_13_070005_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/migrations/2026_09_13_080000_create_products_fts_table.php b/database/migrations/2026_09_13_080000_create_products_fts_table.php new file mode 100644 index 00000000..611cfc74 --- /dev/null +++ b/database/migrations/2026_09_13_080000_create_products_fts_table.php @@ -0,0 +1,26 @@ +foreignId('checkout_id')->nullable()->after('store_id')->constrained('checkouts')->nullOnDelete(); + $table->index('checkout_id', 'idx_orders_checkout_id'); + }); + } + + public function down(): void + { + Schema::table('orders', function (Blueprint $table): void { + $table->dropForeign(['checkout_id']); + $table->dropIndex('idx_orders_checkout_id'); + $table->dropColumn('checkout_id'); + }); + } +}; diff --git a/database/seeders/AnalyticsSeeder.php b/database/seeders/AnalyticsSeeder.php new file mode 100644 index 00000000..84024194 --- /dev/null +++ b/database/seeders/AnalyticsSeeder.php @@ -0,0 +1,145 @@ +where('handle', 'acme-fashion')->firstOrFail(); + + $this->seedDaily($store); + $this->seedEvents($store); + } + + protected function seedDaily(Store $store): void + { + for ($i = 30; $i >= 0; $i--) { + $dayFactor = 1 + (30 - $i) * 0.03; + $visits = (int) round(random_int(50, 100) * $dayFactor); + $addToCart = (int) round($visits * random_int(18, 25) / 100); + $checkoutStarted = (int) round($addToCart * random_int(40, 55) / 100); + $orders = max(2, (int) round($checkoutStarted * random_int(35, 55) / 100)); + $aov = random_int(4000, 9000); + + AnalyticsDaily::query()->updateOrCreate( + ['store_id' => $store->getKey(), 'date' => today()->subDays($i)->toDateString()], + [ + 'visits_count' => $visits, + 'add_to_cart_count' => $addToCart, + 'checkout_started_count' => $checkoutStarted, + 'checkout_completed_count' => $orders, + 'orders_count' => $orders, + 'revenue_amount' => $orders * $aov, + 'aov_amount' => $aov, + ] + ); + } + } + + protected function seedEvents(Store $store): void + { + if (AnalyticsEvent::query()->where('store_id', $store->getKey())->count() >= 200) { + return; + } + + $products = Product::query()->where('store_id', $store->getKey())->get(); + $variants = ProductVariant::query() + ->whereIn('product_id', $products->pluck('id')) + ->get(); + $orders = Order::query()->where('store_id', $store->getKey())->get(); + $customerIds = Customer::query()->where('store_id', $store->getKey())->pluck('id')->all(); + + $distribution = [ + 'page_view' => 88, + 'product_view' => 55, + 'add_to_cart' => 33, + 'checkout_started' => 22, + 'checkout_completed' => 11, + 'search' => 11, + ]; + + $types = []; + + foreach ($distribution as $type => $count) { + for ($i = 0; $i < $count; $i++) { + $types[] = $type; + } + } + + shuffle($types); + + $sessions = []; + + for ($i = 0; $i < 36; $i++) { + $sessions[] = (string) Str::uuid(); + } + + foreach ($types as $type) { + $occurredAt = fake()->dateTimeBetween('-7 days', 'now'); + + AnalyticsEvent::query()->create([ + 'store_id' => $store->getKey(), + 'type' => $type, + 'session_id' => $sessions[array_rand($sessions)], + 'customer_id' => random_int(1, 100) <= 30 ? $customerIds[array_rand($customerIds)] : null, + 'properties_json' => $this->propertiesFor($type, $products, $variants, $orders), + 'client_event_id' => (string) Str::uuid(), + 'occurred_at' => $occurredAt, + 'created_at' => $occurredAt, + ]); + } + } + + /** + * @param \Illuminate\Support\Collection $products + * @param \Illuminate\Support\Collection $variants + * @param \Illuminate\Support\Collection $orders + * @return array + */ + protected function propertiesFor(string $type, $products, $variants, $orders): array + { + $product = $products->isNotEmpty() ? $products->random() : null; + + return match ($type) { + 'page_view' => [ + 'url' => fake()->randomElement(['/', '/collections/t-shirts', '/collections/new-arrivals', '/collections/sale']), + 'referrer' => fake()->boolean(40) ? 'https://www.google.com' : null, + ], + 'product_view' => [ + 'product_id' => $product?->getKey(), + 'product_title' => $product?->title, + 'url' => $product ? '/products/'.$product->handle : '/', + ], + 'add_to_cart' => [ + 'product_id' => $product?->getKey(), + 'variant_id' => ($variant = $variants->isNotEmpty() ? $variants->random() : null)?->getKey(), + 'quantity' => random_int(1, 3), + 'price_amount' => $variant?->price_amount ?? 2499, + ], + 'checkout_started' => [ + 'cart_id' => random_int(1, 500), + 'item_count' => random_int(1, 4), + 'cart_total' => random_int(2499, 19999), + ], + 'checkout_completed' => ($order = $orders->isNotEmpty() ? $orders->random() : null) !== null + ? ['order_id' => $order->getKey(), 'order_number' => $order->order_number, 'total_amount' => $order->total_amount] + : ['order_id' => null, 'order_number' => null, 'total_amount' => 0], + 'search' => [ + 'query' => fake()->randomElement(['cotton t-shirt', 'jeans', 'gift card', 'hoodie', 'sneakers']), + 'results_count' => random_int(0, 12), + ], + default => [], + }; + } +} diff --git a/database/seeders/CollectionSeeder.php b/database/seeders/CollectionSeeder.php new file mode 100644 index 00000000..5c303765 --- /dev/null +++ b/database/seeders/CollectionSeeder.php @@ -0,0 +1,39 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + $this->seedCollection($fashion, 'New Arrivals', 'new-arrivals', '

Discover the latest additions to our store.

'); + $this->seedCollection($fashion, 'T-Shirts', 't-shirts', '

Premium cotton tees for every occasion.

'); + $this->seedCollection($fashion, 'Pants & Jeans', 'pants-jeans', '

Find the perfect fit from our denim and trouser range.

'); + $this->seedCollection($fashion, 'Sale', 'sale', '

Great deals on selected items.

'); + + $this->seedCollection($electronics, 'Featured', 'featured', '

Featured products.

'); + $this->seedCollection($electronics, 'Accessories', 'accessories', '

Accessories for your setup.

'); + } + + protected function seedCollection(Store $store, string $title, string $handle, string $description): void + { + Collection::query()->updateOrCreate( + ['store_id' => $store->getKey(), 'handle' => $handle], + [ + 'title' => $title, + 'description_html' => $description, + 'type' => CollectionType::Manual, + 'status' => CollectionStatus::Active, + ] + ); + } +} diff --git a/database/seeders/CustomerSeeder.php b/database/seeders/CustomerSeeder.php new file mode 100644 index 00000000..d5b60fbf --- /dev/null +++ b/database/seeders/CustomerSeeder.php @@ -0,0 +1,136 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + $fashionCustomers = [ + ['email' => 'customer@acme.test', 'name' => 'John Doe', 'marketing_opt_in' => true], + ['email' => 'jane@example.com', 'name' => 'Jane Smith', 'marketing_opt_in' => false], + ['email' => 'michael@example.com', 'name' => 'Michael Brown', 'marketing_opt_in' => true], + ['email' => 'sarah@example.com', 'name' => 'Sarah Wilson', 'marketing_opt_in' => false], + ['email' => 'david@example.com', 'name' => 'David Lee', 'marketing_opt_in' => true], + ['email' => 'emma@example.com', 'name' => 'Emma Garcia', 'marketing_opt_in' => false], + ['email' => 'james@example.com', 'name' => 'James Taylor', 'marketing_opt_in' => false], + ['email' => 'lisa@example.com', 'name' => 'Lisa Anderson', 'marketing_opt_in' => true], + ['email' => 'robert@example.com', 'name' => 'Robert Martinez', 'marketing_opt_in' => false], + ['email' => 'anna@example.com', 'name' => 'Anna Thomas', 'marketing_opt_in' => true], + ]; + + foreach ($fashionCustomers as $attributes) { + $this->seedCustomer($fashion, $attributes['email'], $attributes['name'], $attributes['marketing_opt_in']); + } + + $this->seedCustomer($electronics, 'techfan@example.com', 'Tech Fan', false); + $this->seedCustomer($electronics, 'gadgetlover@example.com', 'Gadget Lover', false); + + $this->seedAddress('customer@acme.test', 'Home', true, [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'company' => '', + 'address1' => 'Hauptstrasse 1', + 'address2' => '', + 'city' => 'Berlin', + 'province' => '', + 'province_code' => '', + 'country' => 'Germany', + 'country_code' => 'DE', + 'zip' => '10115', + 'phone' => '+49 30 12345678', + ]); + + $this->seedAddress('customer@acme.test', 'Work', false, [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'company' => 'Acme Corp', + 'address1' => 'Friedrichstrasse 100', + 'address2' => '3rd Floor', + 'city' => 'Berlin', + 'province' => '', + 'province_code' => '', + 'country' => 'Germany', + 'country_code' => 'DE', + 'zip' => '10117', + 'phone' => '+49 30 87654321', + ]); + + $this->seedAddress('jane@example.com', 'Home', true, [ + 'first_name' => 'Jane', + 'last_name' => 'Smith', + 'company' => '', + 'address1' => 'Schillerstrasse 45', + 'address2' => '', + 'city' => 'Munich', + 'province' => 'Bavaria', + 'province_code' => 'BY', + 'country' => 'Germany', + 'country_code' => 'DE', + 'zip' => '80336', + 'phone' => '', + ]); + + foreach (['michael@example.com', 'sarah@example.com', 'david@example.com', 'emma@example.com', 'james@example.com', 'lisa@example.com', 'robert@example.com', 'anna@example.com', 'techfan@example.com', 'gadgetlover@example.com'] as $email) { + $this->seedAddress($email, 'Home', true, $this->fakerAddress($email)); + } + } + + protected function seedCustomer(Store $store, string $email, string $name, bool $marketingOptIn): void + { + Customer::query()->updateOrCreate( + ['store_id' => $store->getKey(), 'email' => $email], + [ + 'name' => $name, + 'marketing_opt_in' => $marketingOptIn, + ] + (Customer::query()->where('store_id', $store->getKey())->where('email', $email)->exists() + ? [] + : ['password_hash' => 'password']) + ); + } + + /** + * @param array $address + */ + protected function seedAddress(string $email, string $label, bool $isDefault, array $address): void + { + $customer = Customer::query()->where('email', $email)->firstOrFail(); + + CustomerAddress::query()->updateOrCreate( + ['customer_id' => $customer->getKey(), 'label' => $label], + ['address_json' => $address, 'is_default' => $isDefault] + ); + } + + /** + * @return array + */ + protected function fakerAddress(string $email): array + { + $customer = Customer::query()->where('email', $email)->firstOrFail(); + $parts = explode(' ', (string) $customer->name, 2); + + return [ + 'first_name' => $parts[0], + 'last_name' => $parts[1] ?? '', + 'company' => '', + 'address1' => fake()->streetAddress(), + 'address2' => '', + 'city' => fake()->city(), + 'province' => '', + 'province_code' => '', + 'country' => 'Germany', + 'country_code' => 'DE', + 'zip' => fake()->postcode(), + 'phone' => '', + ]; + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef2..04d7fcef 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,22 +2,41 @@ namespace Database\Seeders; -use App\Models\User; -// use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\DB; class DatabaseSeeder extends Seeder { - /** - * Seed the application's database. - */ public function run(): void { - // User::factory(10)->create(); + DB::transaction(function (): void { + $this->call([ + OrganizationSeeder::class, + StoreSeeder::class, + StoreDomainSeeder::class, + UserSeeder::class, + StoreUserSeeder::class, + StoreSettingsSeeder::class, + TaxSettingsSeeder::class, + ShippingSeeder::class, + CollectionSeeder::class, + ProductSeeder::class, + ProductMediaSeeder::class, + DiscountSeeder::class, + CustomerSeeder::class, + OrderSeeder::class, + ThemeSeeder::class, + PageSeeder::class, + NavigationSeeder::class, + AnalyticsSeeder::class, + SearchSettingsSeeder::class, + ]); + }); - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); + $search = app(\App\Services\SearchService::class); + + foreach (\App\Models\Store::query()->get() as $store) { + $search->reindex($store); + } } } diff --git a/database/seeders/DiscountSeeder.php b/database/seeders/DiscountSeeder.php new file mode 100644 index 00000000..0a160130 --- /dev/null +++ b/database/seeders/DiscountSeeder.php @@ -0,0 +1,83 @@ +where('handle', 'acme-fashion')->firstOrFail(); + + $discounts = [ + [ + 'code' => 'WELCOME10', + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + 'starts_at' => '2025-01-01', + 'ends_at' => '2027-12-31', + 'usage_limit' => null, + 'usage_count' => 3, + 'rules_json' => ['min_purchase_amount' => 2000], + 'status' => DiscountStatus::Active, + ], + [ + 'code' => 'FLAT5', + 'value_type' => DiscountValueType::Fixed, + 'value_amount' => 500, + 'starts_at' => '2025-01-01', + 'ends_at' => '2027-12-31', + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => [], + 'status' => DiscountStatus::Active, + ], + [ + 'code' => 'FREESHIP', + 'value_type' => DiscountValueType::FreeShipping, + 'value_amount' => 0, + 'starts_at' => '2025-01-01', + 'ends_at' => '2027-12-31', + 'usage_limit' => null, + 'usage_count' => 1, + 'rules_json' => [], + 'status' => DiscountStatus::Active, + ], + [ + 'code' => 'EXPIRED20', + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 20, + 'starts_at' => '2024-01-01', + 'ends_at' => '2024-12-31', + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => [], + 'status' => DiscountStatus::Expired, + ], + [ + 'code' => 'MAXED', + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + 'starts_at' => '2025-01-01', + 'ends_at' => '2027-12-31', + 'usage_limit' => 5, + 'usage_count' => 5, + 'rules_json' => [], + 'status' => DiscountStatus::Active, + ], + ]; + + foreach ($discounts as $attributes) { + Discount::query()->updateOrCreate( + ['store_id' => $store->getKey(), 'code' => $attributes['code']], + [...$attributes, 'store_id' => $store->getKey(), 'type' => DiscountType::Code] + ); + } + } +} diff --git a/database/seeders/NavigationSeeder.php b/database/seeders/NavigationSeeder.php new file mode 100644 index 00000000..1046a50f --- /dev/null +++ b/database/seeders/NavigationSeeder.php @@ -0,0 +1,70 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + $fashionCollections = Collection::query()->where('store_id', $fashion->getKey())->pluck('id', 'handle'); + $fashionPages = Page::query()->where('store_id', $fashion->getKey())->pluck('id', 'handle'); + $electronicsCollections = Collection::query()->where('store_id', $electronics->getKey())->pluck('id', 'handle'); + + $this->seedMenu($fashion, 'main-menu', 'Main Menu', [ + ['label' => 'Home', 'type' => NavigationItemType::Link, 'url' => '/'], + ['label' => 'New Arrivals', 'type' => NavigationItemType::Collection, 'resource_id' => $fashionCollections['new-arrivals']], + ['label' => 'T-Shirts', 'type' => NavigationItemType::Collection, 'resource_id' => $fashionCollections['t-shirts']], + ['label' => 'Pants & Jeans', 'type' => NavigationItemType::Collection, 'resource_id' => $fashionCollections['pants-jeans']], + ['label' => 'Sale', 'type' => NavigationItemType::Collection, 'resource_id' => $fashionCollections['sale']], + ]); + + $this->seedMenu($fashion, 'footer-menu', 'Footer Menu', [ + ['label' => 'About Us', 'type' => NavigationItemType::Page, 'resource_id' => $fashionPages['about']], + ['label' => 'FAQ', 'type' => NavigationItemType::Page, 'resource_id' => $fashionPages['faq']], + ['label' => 'Shipping & Returns', 'type' => NavigationItemType::Page, 'resource_id' => $fashionPages['shipping-returns']], + ['label' => 'Privacy Policy', 'type' => NavigationItemType::Page, 'resource_id' => $fashionPages['privacy-policy']], + ['label' => 'Terms of Service', 'type' => NavigationItemType::Page, 'resource_id' => $fashionPages['terms']], + ]); + + $this->seedMenu($electronics, 'main-menu', 'Main Menu', [ + ['label' => 'Home', 'type' => NavigationItemType::Link, 'url' => '/'], + ['label' => 'Featured', 'type' => NavigationItemType::Collection, 'resource_id' => $electronicsCollections['featured']], + ['label' => 'Accessories', 'type' => NavigationItemType::Collection, 'resource_id' => $electronicsCollections['accessories']], + ]); + } + + /** + * @param array> $items + */ + protected function seedMenu(Store $store, string $handle, string $title, array $items): void + { + $menu = NavigationMenu::query()->updateOrCreate( + ['store_id' => $store->getKey(), 'handle' => $handle], + ['title' => $title] + ); + + NavigationItem::query()->where('menu_id', $menu->getKey())->delete(); + + foreach ($items as $position => $item) { + NavigationItem::query()->create([ + 'menu_id' => $menu->getKey(), + 'type' => $item['type'], + 'label' => $item['label'], + 'url' => $item['url'] ?? null, + 'resource_id' => $item['resource_id'] ?? null, + 'position' => $position, + ]); + } + } +} diff --git a/database/seeders/OrderSeeder.php b/database/seeders/OrderSeeder.php new file mode 100644 index 00000000..674dacb1 --- /dev/null +++ b/database/seeders/OrderSeeder.php @@ -0,0 +1,440 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + foreach ($this->fashionOrders() as $definition) { + $this->seedOrder($fashion, $definition); + } + + foreach ($this->electronicsOrders() as $definition) { + $this->seedOrder($electronics, $definition); + } + } + + /** + * @param array $definition + */ + protected function seedOrder(Store $store, array $definition): void + { + $customer = Customer::query() + ->where('store_id', $store->getKey()) + ->where('email', $definition['customer']) + ->firstOrFail(); + + $defaultAddress = $customer->customerAddresses()->where('is_default', true)->first() + ?? $customer->customerAddresses()->firstOrFail(); + + $order = Order::query()->updateOrCreate( + ['order_number' => $definition['number']], + [ + 'store_id' => $store->getKey(), + 'customer_id' => $customer->getKey(), + 'payment_method' => $definition['method'], + 'status' => $definition['status'], + 'financial_status' => $definition['financial'], + 'fulfillment_status' => $definition['fulfillment'], + 'currency' => 'EUR', + 'subtotal_amount' => $definition['subtotal'], + 'discount_amount' => $definition['discount'], + 'shipping_amount' => $definition['shipping'], + 'tax_amount' => $definition['tax'], + 'total_amount' => $definition['total'], + 'email' => $customer->email, + 'billing_address_json' => $defaultAddress->address_json, + 'shipping_address_json' => $defaultAddress->address_json, + 'placed_at' => $definition['placed_at'], + ] + ); + + foreach ($order->fulfillments()->pluck('id')->all() as $fulfillmentId) { + FulfillmentLine::query()->where('fulfillment_id', $fulfillmentId)->delete(); + } + + $order->fulfillments()->delete(); + $order->orderLines()->delete(); + + $lines = []; + + foreach ($definition['lines'] as $lineIndex => $line) { + $variant = $this->findVariant($store, $line['handle'], $line['values']); + $product = $variant->product; + + $lines[$lineIndex] = OrderLine::query()->create([ + 'order_id' => $order->getKey(), + 'product_id' => $product->getKey(), + 'variant_id' => $variant->getKey(), + 'title_snapshot' => $product->title, + 'sku_snapshot' => $variant->sku, + 'quantity' => $line['qty'], + 'unit_price_amount' => $line['unit'], + 'total_amount' => $line['qty'] * $line['unit'], + 'tax_lines_json' => [], + 'discount_allocations_json' => $this->allocations($store, $line['discount'] ?? null), + ]); + } + + $payment = Payment::query()->updateOrCreate( + ['provider_payment_id' => $definition['payment_id']], + [ + 'order_id' => $order->getKey(), + 'provider' => PaymentProvider::Mock, + 'method' => $definition['method'], + 'status' => $definition['payment_status'], + 'amount' => $definition['total'], + 'currency' => 'EUR', + ] + ); + + foreach ($definition['fulfillments'] as $fulfillmentData) { + $fulfillment = Fulfillment::query()->create([ + 'order_id' => $order->getKey(), + 'status' => $fulfillmentData['status'], + 'tracking_company' => $fulfillmentData['company'] ?? null, + 'tracking_number' => $fulfillmentData['number'] ?? null, + 'tracking_url' => isset($fulfillmentData['number']) + ? 'https://tracking.example.com/'.$fulfillmentData['number'] + : null, + 'shipped_at' => $fulfillmentData['shipped_at'] ?? $definition['placed_at'], + ]); + + foreach ($fulfillmentData['lines'] as $lineIndex => $quantity) { + FulfillmentLine::query()->create([ + 'fulfillment_id' => $fulfillment->getKey(), + 'order_line_id' => $lines[$lineIndex]->getKey(), + 'quantity' => $quantity, + ]); + } + } + + foreach ($definition['refunds'] as $refundData) { + Refund::query()->updateOrCreate( + ['provider_refund_id' => $refundData['provider_refund_id']], + [ + 'order_id' => $order->getKey(), + 'payment_id' => $payment->getKey(), + 'amount' => $refundData['amount'], + 'reason' => $refundData['reason'], + 'status' => RefundStatus::Processed, + ] + ); + } + } + + /** + * @param array $values + */ + protected function findVariant(Store $store, string $handle, array $values): ProductVariant + { + $product = Product::query() + ->where('store_id', $store->getKey()) + ->where('handle', $handle) + ->firstOrFail(); + + $query = $product->productVariants(); + + foreach ($values as $value) { + $query->whereHas('optionValues', fn ($q) => $q->where('value', $value)); + } + + return $query->firstOrFail(); + } + + /** + * @return array> + */ + protected function allocations(Store $store, ?array $discount): array + { + if ($discount === null) { + return []; + } + + $model = Discount::query() + ->where('store_id', $store->getKey()) + ->where('code', $discount['code']) + ->firstOrFail(); + + return [['discount_id' => $model->getKey(), 'amount' => $discount['amount']]]; + } + + /** + * @return array> + */ + protected function fashionOrders(): array + { + $card = PaymentMethod::CreditCard; + + return [ + [ + 'number' => '#1001', 'customer' => 'customer@acme.test', 'method' => $card, + 'status' => OrderStatus::Paid, 'financial' => FinancialStatus::Paid, 'fulfillment' => FulfillmentStatus::Unfulfilled, + 'placed_at' => now()->subDays(2), + 'lines' => [ + ['handle' => 'classic-cotton-t-shirt', 'values' => ['S', 'White'], 'qty' => 2, 'unit' => 2499], + ], + 'subtotal' => 4998, 'discount' => 0, 'shipping' => 499, 'tax' => 798, 'total' => 5497, + 'payment_id' => 'mock_test_order1001', 'payment_status' => PaymentStatus::Captured, + 'fulfillments' => [], 'refunds' => [], + ], + [ + 'number' => '#1002', 'customer' => 'customer@acme.test', 'method' => $card, + 'status' => OrderStatus::Fulfilled, 'financial' => FinancialStatus::Paid, 'fulfillment' => FulfillmentStatus::Fulfilled, + 'placed_at' => now()->subDays(10), + 'lines' => [ + ['handle' => 'organic-hoodie', 'values' => ['M'], 'qty' => 1, 'unit' => 5999], + ['handle' => 'classic-cotton-t-shirt', 'values' => ['L', 'Black'], 'qty' => 1, 'unit' => 2499], + ], + 'subtotal' => 8498, 'discount' => 0, 'shipping' => 499, 'tax' => 1357, 'total' => 8997, + 'payment_id' => 'mock_test_order1002', 'payment_status' => PaymentStatus::Captured, + 'fulfillments' => [ + ['status' => FulfillmentShipmentStatus::Delivered, 'company' => 'DHL', 'number' => 'DHL1234567890', 'shipped_at' => now()->subDays(8), 'lines' => [0 => 1, 1 => 1]], + ], + 'refunds' => [], + ], + [ + 'number' => '#1003', 'customer' => 'jane@example.com', 'method' => $card, + 'status' => OrderStatus::Paid, 'financial' => FinancialStatus::Paid, 'fulfillment' => FulfillmentStatus::Partial, + 'placed_at' => now()->subDays(5), + 'lines' => [ + ['handle' => 'premium-slim-fit-jeans', 'values' => ['32', 'Blue'], 'qty' => 1, 'unit' => 7999], + ['handle' => 'leather-belt', 'values' => ['L/XL', 'Brown'], 'qty' => 1, 'unit' => 3499], + ], + 'subtotal' => 11498, 'discount' => 0, 'shipping' => 499, 'tax' => 1836, 'total' => 11997, + 'payment_id' => 'mock_test_order1003', 'payment_status' => PaymentStatus::Captured, + 'fulfillments' => [ + ['status' => FulfillmentShipmentStatus::Shipped, 'company' => 'DHL', 'number' => 'DHL9876543210', 'shipped_at' => now()->subDays(3), 'lines' => [0 => 1]], + ], + 'refunds' => [], + ], + [ + 'number' => '#1004', 'customer' => 'customer@acme.test', 'method' => $card, + 'status' => OrderStatus::Cancelled, 'financial' => FinancialStatus::Refunded, 'fulfillment' => FulfillmentStatus::Unfulfilled, + 'placed_at' => now()->subDays(15), + 'lines' => [ + ['handle' => 'classic-cotton-t-shirt', 'values' => ['M', 'Navy'], 'qty' => 1, 'unit' => 2499], + ], + 'subtotal' => 2499, 'discount' => 0, 'shipping' => 499, 'tax' => 399, 'total' => 2998, + 'payment_id' => 'mock_test_order1004', 'payment_status' => PaymentStatus::Refunded, + 'fulfillments' => [], + 'refunds' => [ + ['amount' => 2998, 'reason' => 'Customer requested cancellation', 'provider_refund_id' => 'mock_re_test_order1004'], + ], + ], + [ + 'number' => '#1005', 'customer' => 'jane@example.com', 'method' => PaymentMethod::BankTransfer, + 'status' => OrderStatus::Pending, 'financial' => FinancialStatus::Pending, 'fulfillment' => FulfillmentStatus::Unfulfilled, + 'placed_at' => now()->subHours(2), + 'lines' => [ + ['handle' => 'leather-belt', 'values' => ['S/M', 'Black'], 'qty' => 1, 'unit' => 3499], + ], + 'subtotal' => 3499, 'discount' => 0, 'shipping' => 499, 'tax' => 559, 'total' => 3998, + 'payment_id' => 'mock_test_order1005', 'payment_status' => PaymentStatus::Pending, + 'fulfillments' => [], 'refunds' => [], + ], + [ + 'number' => '#1006', 'customer' => 'michael@example.com', 'method' => $card, + 'status' => OrderStatus::Paid, 'financial' => FinancialStatus::Paid, 'fulfillment' => FulfillmentStatus::Unfulfilled, + 'placed_at' => now()->subDay(), + 'lines' => [ + ['handle' => 'running-sneakers', 'values' => ['EU 42', 'Black'], 'qty' => 1, 'unit' => 11999], + ], + 'subtotal' => 11999, 'discount' => 0, 'shipping' => 499, 'tax' => 1916, 'total' => 12498, + 'payment_id' => 'mock_test_order1006', 'payment_status' => PaymentStatus::Captured, + 'fulfillments' => [], 'refunds' => [], + ], + [ + 'number' => '#1007', 'customer' => 'sarah@example.com', 'method' => PaymentMethod::Paypal, + 'status' => OrderStatus::Fulfilled, 'financial' => FinancialStatus::Paid, 'fulfillment' => FulfillmentStatus::Fulfilled, + 'placed_at' => now()->subDays(20), + 'lines' => [ + ['handle' => 'v-neck-linen-tee', 'values' => ['M', 'Beige'], 'qty' => 2, 'unit' => 3499], + ['handle' => 'wool-scarf', 'values' => ['Grey'], 'qty' => 1, 'unit' => 2999], + ], + 'subtotal' => 9997, 'discount' => 0, 'shipping' => 499, 'tax' => 1596, 'total' => 10496, + 'payment_id' => 'mock_test_order1007', 'payment_status' => PaymentStatus::Captured, + 'fulfillments' => [ + ['status' => FulfillmentShipmentStatus::Delivered, 'company' => 'DHL', 'number' => 'DHL1112223334', 'shipped_at' => now()->subDays(18), 'lines' => [0 => 2, 1 => 1]], + ], + 'refunds' => [], + ], + [ + 'number' => '#1008', 'customer' => 'david@example.com', 'method' => $card, + 'status' => OrderStatus::Paid, 'financial' => FinancialStatus::PartiallyRefunded, 'fulfillment' => FulfillmentStatus::Fulfilled, + 'placed_at' => now()->subDays(12), + 'lines' => [ + ['handle' => 'cargo-pants', 'values' => ['32', 'Khaki'], 'qty' => 1, 'unit' => 5499], + ['handle' => 'graphic-print-tee', 'values' => ['L'], 'qty' => 1, 'unit' => 2999], + ], + 'subtotal' => 8498, 'discount' => 0, 'shipping' => 499, 'tax' => 1357, 'total' => 8997, + 'payment_id' => 'mock_test_order1008', 'payment_status' => PaymentStatus::Captured, + 'fulfillments' => [ + ['status' => FulfillmentShipmentStatus::Delivered, 'company' => 'UPS', 'number' => 'UPS5556667778', 'shipped_at' => now()->subDays(10), 'lines' => [0 => 1, 1 => 1]], + ], + 'refunds' => [ + ['amount' => 2999, 'reason' => 'Item returned', 'provider_refund_id' => 'mock_re_test_order1008'], + ], + ], + [ + 'number' => '#1009', 'customer' => 'emma@example.com', 'method' => $card, + 'status' => OrderStatus::Paid, 'financial' => FinancialStatus::Paid, 'fulfillment' => FulfillmentStatus::Unfulfilled, + 'placed_at' => now()->subDays(3), + 'lines' => [ + ['handle' => 'canvas-tote-bag', 'values' => ['Natural'], 'qty' => 1, 'unit' => 1999], + ['handle' => 'bucket-hat', 'values' => ['S/M', 'Black'], 'qty' => 1, 'unit' => 2499], + ], + 'subtotal' => 4498, 'discount' => 0, 'shipping' => 499, 'tax' => 718, 'total' => 4997, + 'payment_id' => 'mock_test_order1009', 'payment_status' => PaymentStatus::Captured, + 'fulfillments' => [], 'refunds' => [], + ], + [ + 'number' => '#1010', 'customer' => 'customer@acme.test', 'method' => PaymentMethod::Paypal, + 'status' => OrderStatus::Paid, 'financial' => FinancialStatus::Paid, 'fulfillment' => FulfillmentStatus::Unfulfilled, + 'placed_at' => now()->subDay(), + 'lines' => [ + ['handle' => 'cashmere-overcoat', 'values' => ['M', 'Camel'], 'qty' => 1, 'unit' => 49999], + ], + 'subtotal' => 49999, 'discount' => 0, 'shipping' => 499, 'tax' => 7983, 'total' => 50498, + 'payment_id' => 'mock_test_order1010', 'payment_status' => PaymentStatus::Captured, + 'fulfillments' => [], 'refunds' => [], + ], + [ + 'number' => '#1011', 'customer' => 'james@example.com', 'method' => $card, + 'status' => OrderStatus::Paid, 'financial' => FinancialStatus::Paid, 'fulfillment' => FulfillmentStatus::Fulfilled, + 'placed_at' => now()->subDays(25), + 'lines' => [ + ['handle' => 'striped-polo-shirt', 'values' => ['XL'], 'qty' => 1, 'unit' => 2799], + ], + 'subtotal' => 2799, 'discount' => 0, 'shipping' => 499, 'tax' => 447, 'total' => 3298, + 'payment_id' => 'mock_test_order1011', 'payment_status' => PaymentStatus::Captured, + 'fulfillments' => [ + ['status' => FulfillmentShipmentStatus::Delivered, 'company' => 'FedEx', 'number' => 'FX9998887776', 'shipped_at' => now()->subDays(23), 'lines' => [0 => 1]], + ], + 'refunds' => [], + ], + [ + 'number' => '#1012', 'customer' => 'lisa@example.com', 'method' => $card, + 'status' => OrderStatus::Paid, 'financial' => FinancialStatus::Paid, 'fulfillment' => FulfillmentStatus::Unfulfilled, + 'placed_at' => now()->subDays(4), + 'lines' => [ + ['handle' => 'chino-shorts', 'values' => ['34', 'Navy'], 'qty' => 2, 'unit' => 3999], + ], + 'subtotal' => 7998, 'discount' => 0, 'shipping' => 499, 'tax' => 1277, 'total' => 8497, + 'payment_id' => 'mock_test_order1012', 'payment_status' => PaymentStatus::Captured, + 'fulfillments' => [], 'refunds' => [], + ], + [ + 'number' => '#1013', 'customer' => 'robert@example.com', 'method' => $card, + 'status' => OrderStatus::Paid, 'financial' => FinancialStatus::Paid, 'fulfillment' => FulfillmentStatus::Unfulfilled, + 'placed_at' => now()->subDay(), + 'lines' => [ + ['handle' => 'wide-leg-trousers', 'values' => ['M'], 'qty' => 1, 'unit' => 4999], + ['handle' => 'wool-scarf', 'values' => ['Burgundy'], 'qty' => 1, 'unit' => 2999], + ], + 'subtotal' => 7998, 'discount' => 0, 'shipping' => 499, 'tax' => 1277, 'total' => 8497, + 'payment_id' => 'mock_test_order1013', 'payment_status' => PaymentStatus::Captured, + 'fulfillments' => [], 'refunds' => [], + ], + [ + 'number' => '#1014', 'customer' => 'anna@example.com', 'method' => $card, + 'status' => OrderStatus::Paid, 'financial' => FinancialStatus::Paid, 'fulfillment' => FulfillmentStatus::Fulfilled, + 'placed_at' => now()->subDays(14), + 'lines' => [ + ['handle' => 'gift-card', 'values' => ['50 EUR'], 'qty' => 1, 'unit' => 5000], + ], + 'subtotal' => 5000, 'discount' => 0, 'shipping' => 0, 'tax' => 798, 'total' => 5000, + 'payment_id' => 'mock_test_order1014', 'payment_status' => PaymentStatus::Captured, + 'fulfillments' => [ + ['status' => FulfillmentShipmentStatus::Delivered, 'shipped_at' => now()->subDays(14), 'lines' => [0 => 1]], + ], + 'refunds' => [], + ], + [ + 'number' => '#1015', 'customer' => 'customer@acme.test', 'method' => PaymentMethod::BankTransfer, + 'status' => OrderStatus::Paid, 'financial' => FinancialStatus::Paid, 'fulfillment' => FulfillmentStatus::Unfulfilled, + 'placed_at' => now(), + 'lines' => [ + ['handle' => 'classic-cotton-t-shirt', 'values' => ['M', 'White'], 'qty' => 1, 'unit' => 2499, 'discount' => ['code' => 'WELCOME10', 'amount' => 250]], + ['handle' => 'graphic-print-tee', 'values' => ['M'], 'qty' => 1, 'unit' => 2999, 'discount' => ['code' => 'WELCOME10', 'amount' => 300]], + ], + 'subtotal' => 5498, 'discount' => 550, 'shipping' => 499, 'tax' => 790, 'total' => 5447, + 'payment_id' => 'mock_test_order1015', 'payment_status' => PaymentStatus::Captured, + 'fulfillments' => [], 'refunds' => [], + ], + ]; + } + + /** + * @return array> + */ + protected function electronicsOrders(): array + { + $card = PaymentMethod::CreditCard; + + return [ + [ + 'number' => '#5001', 'customer' => 'techfan@example.com', 'method' => $card, + 'status' => OrderStatus::Paid, 'financial' => FinancialStatus::Paid, 'fulfillment' => FulfillmentStatus::Fulfilled, + 'placed_at' => now()->subDays(6), + 'lines' => [ + ['handle' => 'pro-laptop-15', 'values' => ['512GB'], 'qty' => 1, 'unit' => 119999], + ['handle' => 'usb-c-cable-2m', 'values' => [], 'qty' => 1, 'unit' => 1299], + ], + 'subtotal' => 121298, 'discount' => 0, 'shipping' => 0, 'tax' => 0, 'total' => 121298, + 'payment_id' => 'mock_test_order5001', 'payment_status' => PaymentStatus::Captured, + 'fulfillments' => [ + ['status' => FulfillmentShipmentStatus::Delivered, 'company' => 'DHL', 'number' => 'DHL5001001001', 'shipped_at' => now()->subDays(4), 'lines' => [0 => 1, 1 => 1]], + ], + 'refunds' => [], + ], + [ + 'number' => '#5002', 'customer' => 'gadgetlover@example.com', 'method' => $card, + 'status' => OrderStatus::Paid, 'financial' => FinancialStatus::Paid, 'fulfillment' => FulfillmentStatus::Unfulfilled, + 'placed_at' => now()->subDays(3), + 'lines' => [ + ['handle' => 'wireless-headphones', 'values' => ['Black'], 'qty' => 1, 'unit' => 14999], + ], + 'subtotal' => 14999, 'discount' => 0, 'shipping' => 0, 'tax' => 0, 'total' => 14999, + 'payment_id' => 'mock_test_order5002', 'payment_status' => PaymentStatus::Captured, + 'fulfillments' => [], 'refunds' => [], + ], + [ + 'number' => '#5003', 'customer' => 'techfan@example.com', 'method' => PaymentMethod::BankTransfer, + 'status' => OrderStatus::Pending, 'financial' => FinancialStatus::Pending, 'fulfillment' => FulfillmentStatus::Unfulfilled, + 'placed_at' => now()->subHours(5), + 'lines' => [ + ['handle' => 'monitor-stand', 'values' => [], 'qty' => 1, 'unit' => 4999], + ], + 'subtotal' => 4999, 'discount' => 0, 'shipping' => 0, 'tax' => 0, 'total' => 4999, + 'payment_id' => 'mock_test_order5003', 'payment_status' => PaymentStatus::Pending, + 'fulfillments' => [], 'refunds' => [], + ], + ]; + } +} diff --git a/database/seeders/OrganizationSeeder.php b/database/seeders/OrganizationSeeder.php new file mode 100644 index 00000000..93018c55 --- /dev/null +++ b/database/seeders/OrganizationSeeder.php @@ -0,0 +1,17 @@ +updateOrCreate( + ['name' => 'Acme Corp'], + ['billing_email' => 'billing@acme.test'] + ); + } +} diff --git a/database/seeders/PageSeeder.php b/database/seeders/PageSeeder.php new file mode 100644 index 00000000..45c50e33 --- /dev/null +++ b/database/seeders/PageSeeder.php @@ -0,0 +1,51 @@ +where('handle', 'acme-fashion')->firstOrFail(); + + $pages = [ + 'about' => [ + 'title' => 'About Us', + 'body' => '

Our Story

Acme Fashion started in Berlin with a simple mission: timeless essentials made to last. We design every piece in-house and work directly with family-owned factories.

Our Values

We believe in ethical sourcing, sustainable materials, and fair labor. Every supplier in our chain is audited annually.

Our Team

Our Berlin-based team of designers, tailors, and customer advocates is passionate about quality clothing that respects people and the planet.

', + ], + 'faq' => [ + 'title' => 'FAQ', + 'body' => '

Frequently Asked Questions

How long does shipping take?

Germany: 2-4 business days standard, 1-2 days express. EU: 5-7 business days.

What is your return policy?

30 days from delivery. Items must be unworn and in original packaging.

Do you ship internationally?

Yes, we ship to the EU plus the US, UK, Canada, and Australia.

How do I track my order?

You will receive an email with a tracking number as soon as your order ships.

', + ], + 'shipping-returns' => [ + 'title' => 'Shipping & Returns', + 'body' => '

Shipping Rates

Germany

  • Standard (2-4 days): 4.99 EUR
  • Express (1-2 days): 9.99 EUR

European Union

  • EU Standard (5-7 days): 8.99 EUR

International

  • International (7-14 days): 14.99 EUR

Returns

Returns are accepted within 30 days of delivery. Customers pay return shipping unless the item is defective.

', + ], + 'privacy-policy' => [ + 'title' => 'Privacy Policy', + 'body' => '

Information We Collect

We collect your contact details, order history, and browsing behavior to operate the store.

How We Use Your Information

We use your data to fulfil orders, provide support, and improve our store.

Cookies

We use essential cookies for the cart and checkout, and optional analytics cookies with your consent.

Contact

Questions about privacy? Email privacy@acme-fashion.test.

', + ], + 'terms' => [ + 'title' => 'Terms of Service', + 'body' => '

Orders and Payments

All prices are in EUR and include VAT. Payment is due at checkout.

Product Descriptions

We describe products as accurately as possible; minor color variance may occur between screens.

Limitation of Liability

Our liability is limited to the purchase price of the goods.

Governing Law

These terms are governed by the laws of the Federal Republic of Germany.

', + ], + ]; + + foreach ($pages as $handle => $page) { + Page::query()->updateOrCreate( + ['store_id' => $store->getKey(), 'handle' => $handle], + [ + 'title' => $page['title'], + 'body_html' => $page['body'], + 'status' => PageStatus::Published, + 'published_at' => now()->subMonths(3), + ] + ); + } + } +} diff --git a/database/seeders/ProductMediaSeeder.php b/database/seeders/ProductMediaSeeder.php new file mode 100644 index 00000000..517e742c --- /dev/null +++ b/database/seeders/ProductMediaSeeder.php @@ -0,0 +1,59 @@ +orderBy('id')->get() as $product) { + $this->seedPlaceholder($product); + } + } + + protected function seedPlaceholder(Product $product): void + { + $storageKey = "products/placeholders/{$product->handle}.svg"; + + if (! Storage::disk('public')->exists($storageKey)) { + Storage::disk('public')->put($storageKey, $this->svgFor($product->title)); + } + + $byteSize = Storage::disk('public')->size($storageKey) ?: strlen($this->svgFor($product->title)); + + ProductMedia::query()->updateOrCreate( + ['product_id' => $product->getKey(), 'storage_key' => $storageKey], + [ + 'type' => MediaType::Image, + 'alt_text' => $product->title, + 'width' => 800, + 'height' => 800, + 'mime_type' => 'image/svg+xml', + 'byte_size' => $byteSize, + 'position' => 0, + 'status' => MediaStatus::Ready, + ] + ); + } + + protected function svgFor(string $title): string + { + $hue = abs(crc32($title)) % 360; + $escaped = htmlspecialchars(mb_substr($title, 0, 32), ENT_QUOTES); + + return << + + +{$escaped} + +SVG; + } +} diff --git a/database/seeders/ProductSeeder.php b/database/seeders/ProductSeeder.php new file mode 100644 index 00000000..c8a812a8 --- /dev/null +++ b/database/seeders/ProductSeeder.php @@ -0,0 +1,629 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + foreach ($this->fashionProducts() as $definition) { + $this->seedProduct($fashion, $definition); + } + + foreach ($this->electronicsProducts() as $definition) { + $this->seedProduct($electronics, $definition); + } + + $this->seedAssignments($fashion, [ + 'new-arrivals' => ['classic-cotton-t-shirt', 'premium-slim-fit-jeans', 'organic-hoodie', 'running-sneakers', 'chino-shorts', 'bucket-hat', 'cashmere-overcoat'], + 't-shirts' => ['classic-cotton-t-shirt', 'graphic-print-tee', 'v-neck-linen-tee', 'striped-polo-shirt'], + 'pants-jeans' => ['premium-slim-fit-jeans', 'cargo-pants', 'chino-shorts', 'wide-leg-trousers'], + 'sale' => ['premium-slim-fit-jeans', 'striped-polo-shirt', 'wide-leg-trousers'], + ]); + + $this->seedAssignments($electronics, [ + 'featured' => ['pro-laptop-15', 'wireless-headphones', 'mechanical-keyboard'], + 'accessories' => ['usb-c-cable-2m', 'monitor-stand'], + ]); + } + + /** + * @param array $definition + */ + protected function seedProduct(Store $store, array $definition): void + { + $product = Product::query()->updateOrCreate( + ['store_id' => $store->getKey(), 'handle' => $definition['handle']], + [ + 'title' => $definition['title'], + 'status' => $definition['status'], + 'description_html' => $definition['description_html'], + 'vendor' => $definition['vendor'], + 'product_type' => $definition['product_type'], + 'tags' => $definition['tags'], + 'published_at' => $definition['published_at'], + ] + ); + + $optionIds = []; + + foreach (array_values($definition['options']) as $position => $option) { + $productOption = ProductOption::query()->updateOrCreate( + ['product_id' => $product->getKey(), 'name' => $option['name']], + ['position' => $position] + ); + + $valueIds = []; + + foreach ($option['values'] as $valuePosition => $value) { + $optionValue = ProductOptionValue::query()->updateOrCreate( + ['product_option_id' => $productOption->getKey(), 'value' => $value], + ['position' => $valuePosition] + ); + + $valueIds[$value] = $optionValue->getKey(); + } + + $optionIds[$option['name']] = $valueIds; + } + + $combinations = $this->combinations($definition['options']); + + foreach ($combinations as $position => $combination) { + $sku = $this->skuFor($definition, $combination, $position); + + $variant = ProductVariant::query()->updateOrCreate( + ['product_id' => $product->getKey(), 'sku' => $sku], + [ + 'barcode' => fake()->unique()->ean13(), + 'price_amount' => $definition['prices'][$position] ?? $definition['price'], + 'compare_at_amount' => $definition['compare_at'], + 'currency' => 'EUR', + 'weight_g' => $definition['weight_g'], + 'requires_shipping' => $definition['requires_shipping'], + 'is_default' => $position === 0, + 'position' => $position, + 'status' => VariantStatus::Active, + ] + ); + + foreach ($combination as $optionName => $value) { + VariantOptionValue::query()->firstOrCreate([ + 'variant_id' => $variant->getKey(), + 'product_option_value_id' => $optionIds[$optionName][$value], + ]); + } + + InventoryItem::query()->updateOrCreate( + ['variant_id' => $variant->getKey()], + [ + 'store_id' => $store->getKey(), + 'quantity_on_hand' => $definition['inventory'], + 'quantity_reserved' => 0, + 'policy' => $definition['policy'], + ] + ); + } + } + + /** + * @param array}> $options + * @return array> + */ + protected function combinations(array $options): array + { + if ($options === []) { + return [[]]; + } + + $combinations = [[]]; + + foreach ($options as $option) { + $next = []; + + foreach ($combinations as $combination) { + foreach ($option['values'] as $value) { + $next[] = [...$combination, $option['name'] => $value]; + } + } + + $combinations = $next; + } + + return $combinations; + } + + /** + * @param array $definition + * @param array $combination + */ + protected function skuFor(array $definition, array $combination, int $position): string + { + if (isset($definition['skus'][$position])) { + return $definition['skus'][$position]; + } + + $suffix = array_map( + fn (string $value): string => $this->abbreviate($value), + array_values($combination) + ); + + return $definition['sku_prefix'].($suffix === [] ? '' : '-'.implode('-', $suffix)); + } + + protected function abbreviate(string $value): string + { + $map = [ + 'White' => 'WHT', 'Black' => 'BLK', 'Navy' => 'NVY', 'Blue' => 'BLU', + 'Brown' => 'BRN', 'Beige' => 'BEG', 'Olive' => 'OLV', 'Sky Blue' => 'SKY', + 'Khaki' => 'KHK', 'Sand' => 'SND', 'Grey' => 'GRY', 'Burgundy' => 'BRG', + 'Natural' => 'NAT', 'Camel' => 'CML', 'Charcoal' => 'CHR', 'Silver' => 'SLV', + 'Gray' => 'GRY', + ]; + + if (isset($map[$value])) { + return $map[$value]; + } + + $clean = strtoupper((string) preg_replace('/[^A-Za-z0-9]/', '', $value)); + + if (strlen($clean) <= 3 || preg_match('/[0-9]/', $clean) === 1) { + return $clean; + } + + return substr($clean, 0, 3); + } + + /** + * @param array> $assignments + */ + protected function seedAssignments(Store $store, array $assignments): void + { + foreach ($assignments as $handle => $productHandles) { + $collection = Collection::query() + ->where('store_id', $store->getKey()) + ->where('handle', $handle) + ->firstOrFail(); + + CollectionProduct::query() + ->where('collection_id', $collection->getKey()) + ->delete(); + + foreach ($productHandles as $position => $productHandle) { + $product = Product::query() + ->where('store_id', $store->getKey()) + ->where('handle', $productHandle) + ->firstOrFail(); + + CollectionProduct::query()->create([ + 'collection_id' => $collection->getKey(), + 'product_id' => $product->getKey(), + 'position' => $position, + ]); + } + } + } + + /** + * @return array> + */ + protected function fashionProducts(): array + { + $active = [ + 'status' => ProductStatus::Active, + 'published_at' => now(), + 'compare_at' => null, + 'requires_shipping' => true, + 'policy' => InventoryPolicy::Deny, + 'prices' => [], + 'skus' => [], + ]; + + return [ + [ + ...$active, + 'title' => 'Classic Cotton T-Shirt', + 'handle' => 'classic-cotton-t-shirt', + 'vendor' => 'Acme Basics', + 'product_type' => 'T-Shirts', + 'tags' => ['new', 'popular'], + 'description_html' => '

A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.

', + 'sku_prefix' => 'ACME-CTSH', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ['name' => 'Color', 'values' => ['White', 'Black', 'Navy']], + ], + 'price' => 2499, 'weight_g' => 200, 'inventory' => 15, + ], + [ + ...$active, + 'title' => 'Premium Slim Fit Jeans', + 'handle' => 'premium-slim-fit-jeans', + 'vendor' => 'Acme Denim', + 'product_type' => 'Pants', + 'tags' => ['new', 'sale'], + 'description_html' => '

Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.

', + 'sku_prefix' => 'ACME-JEAN', + 'options' => [ + ['name' => 'Size', 'values' => ['28', '30', '32', '34', '36']], + ['name' => 'Color', 'values' => ['Blue', 'Black']], + ], + 'price' => 7999, 'compare_at' => 9999, 'weight_g' => 800, 'inventory' => 8, + ], + [ + ...$active, + 'title' => 'Organic Hoodie', + 'handle' => 'organic-hoodie', + 'vendor' => 'Acme Basics', + 'product_type' => 'Hoodies', + 'tags' => ['new', 'trending'], + 'description_html' => '

Made from 100% organic cotton. Warm, soft, and sustainably produced.

', + 'sku_prefix' => 'ACME-HOOD', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ], + 'price' => 5999, 'weight_g' => 500, 'inventory' => 20, + ], + [ + ...$active, + 'title' => 'Leather Belt', + 'handle' => 'leather-belt', + 'vendor' => 'Acme Accessories', + 'product_type' => 'Accessories', + 'tags' => ['popular'], + 'description_html' => '

Genuine leather belt with brushed metal buckle. A wardrobe essential.

', + 'sku_prefix' => 'ACME-BELT', + 'options' => [ + ['name' => 'Size', 'values' => ['S/M', 'L/XL']], + ['name' => 'Color', 'values' => ['Brown', 'Black']], + ], + 'price' => 3499, 'weight_g' => 150, 'inventory' => 25, + ], + [ + ...$active, + 'title' => 'Running Sneakers', + 'handle' => 'running-sneakers', + 'vendor' => 'Acme Sport', + 'product_type' => 'Shoes', + 'tags' => ['trending'], + 'description_html' => '

Lightweight running sneakers with responsive cushioning and breathable mesh upper.

', + 'sku_prefix' => 'ACME-RUN', + 'options' => [ + ['name' => 'Size', 'values' => ['EU 38', 'EU 39', 'EU 40', 'EU 41', 'EU 42', 'EU 43', 'EU 44']], + ['name' => 'Color', 'values' => ['White', 'Black']], + ], + 'price' => 11999, 'weight_g' => 600, 'inventory' => 5, + ], + [ + ...$active, + 'title' => 'Graphic Print Tee', + 'handle' => 'graphic-print-tee', + 'vendor' => 'Acme Basics', + 'product_type' => 'T-Shirts', + 'tags' => ['new'], + 'description_html' => '

Bold graphic print on soft cotton. Express yourself with this statement piece.

', + 'sku_prefix' => 'ACME-GRAPH', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ], + 'price' => 2999, 'weight_g' => 210, 'inventory' => 18, + ], + [ + ...$active, + 'title' => 'V-Neck Linen Tee', + 'handle' => 'v-neck-linen-tee', + 'vendor' => 'Acme Basics', + 'product_type' => 'T-Shirts', + 'tags' => ['popular'], + 'description_html' => '

Lightweight linen blend v-neck. Perfect for warm summer days.

', + 'sku_prefix' => 'ACME-VNECK', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L']], + ['name' => 'Color', 'values' => ['Beige', 'Olive', 'Sky Blue']], + ], + 'price' => 3499, 'weight_g' => 180, 'inventory' => 12, + ], + [ + ...$active, + 'title' => 'Striped Polo Shirt', + 'handle' => 'striped-polo-shirt', + 'vendor' => 'Acme Basics', + 'product_type' => 'T-Shirts', + 'tags' => ['sale'], + 'description_html' => '

Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.

', + 'sku_prefix' => 'ACME-POLO', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ], + 'price' => 2799, 'compare_at' => 3999, 'weight_g' => 250, 'inventory' => 10, + ], + [ + ...$active, + 'title' => 'Cargo Pants', + 'handle' => 'cargo-pants', + 'vendor' => 'Acme Workwear', + 'product_type' => 'Pants', + 'tags' => ['popular'], + 'description_html' => '

Utility cargo pants with multiple pockets. Durable cotton twill construction.

', + 'sku_prefix' => 'ACME-CARGO', + 'options' => [ + ['name' => 'Size', 'values' => ['30', '32', '34', '36']], + ['name' => 'Color', 'values' => ['Khaki', 'Olive', 'Black']], + ], + 'price' => 5499, 'weight_g' => 700, 'inventory' => 14, + ], + [ + ...$active, + 'title' => 'Chino Shorts', + 'handle' => 'chino-shorts', + 'vendor' => 'Acme Basics', + 'product_type' => 'Pants', + 'tags' => ['new', 'trending'], + 'description_html' => '

Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.

', + 'sku_prefix' => 'ACME-CHINO', + 'options' => [ + ['name' => 'Size', 'values' => ['30', '32', '34', '36']], + ['name' => 'Color', 'values' => ['Navy', 'Sand']], + ], + 'price' => 3999, 'weight_g' => 350, 'inventory' => 16, + ], + [ + ...$active, + 'title' => 'Wide Leg Trousers', + 'handle' => 'wide-leg-trousers', + 'vendor' => 'Acme Denim', + 'product_type' => 'Pants', + 'tags' => ['sale'], + 'description_html' => '

Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.

', + 'sku_prefix' => 'ACME-TROUS', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L']], + ], + 'price' => 4999, 'compare_at' => 6999, 'weight_g' => 550, 'inventory' => 7, + ], + [ + ...$active, + 'title' => 'Wool Scarf', + 'handle' => 'wool-scarf', + 'vendor' => 'Acme Accessories', + 'product_type' => 'Accessories', + 'tags' => ['popular'], + 'description_html' => '

Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.

', + 'sku_prefix' => 'ACME-SCARF', + 'options' => [ + ['name' => 'Color', 'values' => ['Grey', 'Burgundy', 'Navy']], + ], + 'price' => 2999, 'weight_g' => 120, 'inventory' => 30, + ], + [ + ...$active, + 'title' => 'Canvas Tote Bag', + 'handle' => 'canvas-tote-bag', + 'vendor' => 'Acme Accessories', + 'product_type' => 'Accessories', + 'tags' => ['trending'], + 'description_html' => '

Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.

', + 'sku_prefix' => 'ACME-TOTE', + 'options' => [ + ['name' => 'Color', 'values' => ['Natural', 'Black']], + ], + 'price' => 1999, 'weight_g' => 300, 'inventory' => 40, + ], + [ + ...$active, + 'title' => 'Bucket Hat', + 'handle' => 'bucket-hat', + 'vendor' => 'Acme Accessories', + 'product_type' => 'Accessories', + 'tags' => ['new', 'trending'], + 'description_html' => '

Lightweight bucket hat for sun protection. Packable design, washed cotton twill.

', + 'sku_prefix' => 'ACME-HAT', + 'options' => [ + ['name' => 'Size', 'values' => ['S/M', 'L/XL']], + ['name' => 'Color', 'values' => ['Beige', 'Black', 'Olive']], + ], + 'price' => 2499, 'weight_g' => 80, 'inventory' => 22, + ], + [ + ...$active, + 'title' => 'Unreleased Winter Jacket', + 'handle' => 'unreleased-winter-jacket', + 'status' => ProductStatus::Draft, + 'published_at' => null, + 'vendor' => 'Acme Outerwear', + 'product_type' => 'Jackets', + 'tags' => ['limited'], + 'description_html' => '

Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.

', + 'sku_prefix' => 'ACME-JACKET', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ], + 'price' => 14999, 'weight_g' => 900, 'inventory' => 0, + ], + [ + ...$active, + 'title' => 'Discontinued Raincoat', + 'handle' => 'discontinued-raincoat', + 'status' => ProductStatus::Archived, + 'published_at' => now()->subMonths(6), + 'vendor' => 'Acme Outerwear', + 'product_type' => 'Jackets', + 'tags' => [], + 'description_html' => '

Lightweight waterproof raincoat. This product has been discontinued.

', + 'sku_prefix' => 'ACME-RAIN', + 'options' => [ + ['name' => 'Size', 'values' => ['M', 'L']], + ], + 'price' => 8999, 'weight_g' => 400, 'inventory' => 3, + ], + [ + ...$active, + 'title' => 'Limited Edition Sneakers', + 'handle' => 'limited-edition-sneakers', + 'vendor' => 'Acme Sport', + 'product_type' => 'Shoes', + 'tags' => ['limited'], + 'description_html' => '

Limited edition collaboration sneakers. Once they are gone, they are gone.

', + 'sku_prefix' => 'ACME-LESNK', + 'options' => [ + ['name' => 'Size', 'values' => ['EU 40', 'EU 42', 'EU 44']], + ], + 'price' => 15999, 'weight_g' => 650, 'inventory' => 0, + ], + [ + ...$active, + 'title' => 'Backorder Denim Jacket', + 'handle' => 'backorder-denim-jacket', + 'vendor' => 'Acme Denim', + 'product_type' => 'Jackets', + 'tags' => ['popular'], + 'description_html' => '

Classic denim jacket. Currently on backorder - ships within 2-3 weeks.

', + 'sku_prefix' => 'ACME-DJACK', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L', 'XL']], + ], + 'price' => 9999, 'weight_g' => 750, 'inventory' => 0, + 'policy' => InventoryPolicy::Continue, + ], + [ + ...$active, + 'title' => 'Gift Card', + 'handle' => 'gift-card', + 'vendor' => 'Acme Fashion', + 'product_type' => 'Gift Cards', + 'tags' => ['popular'], + 'description_html' => '

Digital gift card delivered via email. The perfect gift when you are not sure what to choose.

', + 'sku_prefix' => 'ACME-GIFT', + 'options' => [ + ['name' => 'Amount', 'values' => ['25 EUR', '50 EUR', '100 EUR']], + ], + 'price' => 2500, + 'prices' => [2500, 5000, 10000], + 'skus' => ['ACME-GIFT-25', 'ACME-GIFT-50', 'ACME-GIFT-100'], + 'weight_g' => 0, + 'requires_shipping' => false, + 'inventory' => 9999, + ], + [ + ...$active, + 'title' => 'Cashmere Overcoat', + 'handle' => 'cashmere-overcoat', + 'vendor' => 'Acme Premium', + 'product_type' => 'Jackets', + 'tags' => ['limited', 'new'], + 'description_html' => '

Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.

', + 'sku_prefix' => 'ACME-COAT', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L']], + ['name' => 'Color', 'values' => ['Camel', 'Charcoal']], + ], + 'price' => 49999, 'weight_g' => 1200, 'inventory' => 3, + ], + ]; + } + + /** + * @return array> + */ + protected function electronicsProducts(): array + { + $active = [ + 'status' => ProductStatus::Active, + 'published_at' => now(), + 'compare_at' => null, + 'requires_shipping' => true, + 'policy' => InventoryPolicy::Deny, + 'prices' => [], + 'skus' => [], + ]; + + return [ + [ + ...$active, + 'title' => 'Pro Laptop 15', + 'handle' => 'pro-laptop-15', + 'vendor' => 'TechCorp', + 'product_type' => 'Laptops', + 'tags' => ['new', 'popular'], + 'description_html' => '

High-performance 15-inch laptop for professionals.

', + 'sku_prefix' => 'TECH-LAP15', + 'options' => [ + ['name' => 'Storage', 'values' => ['256GB', '512GB', '1TB']], + ], + 'price' => 99999, + 'prices' => [99999, 119999, 149999], + 'weight_g' => 1800, + 'inventory' => 10, + ], + [ + ...$active, + 'title' => 'Wireless Headphones', + 'handle' => 'wireless-headphones', + 'vendor' => 'AudioMax', + 'product_type' => 'Audio', + 'tags' => ['popular'], + 'description_html' => '

Noise-cancelling wireless headphones with 30-hour battery life.

', + 'sku_prefix' => 'AUDIO-HP', + 'options' => [ + ['name' => 'Color', 'values' => ['Black', 'Silver']], + ], + 'price' => 14999, 'weight_g' => 250, 'inventory' => 25, + ], + [ + ...$active, + 'title' => 'USB-C Cable 2m', + 'handle' => 'usb-c-cable-2m', + 'vendor' => 'CablePro', + 'product_type' => 'Cables', + 'tags' => ['popular'], + 'description_html' => '

Durable 2-meter USB-C charging and data cable.

', + 'sku_prefix' => 'CABLE-USBC-2M', + 'options' => [], + 'price' => 1299, 'weight_g' => 50, 'inventory' => 200, + ], + [ + ...$active, + 'title' => 'Mechanical Keyboard', + 'handle' => 'mechanical-keyboard', + 'vendor' => 'KeyTech', + 'product_type' => 'Peripherals', + 'tags' => ['new'], + 'description_html' => '

Hot-swappable mechanical keyboard with RGB backlight.

', + 'sku_prefix' => 'KEY-MECH', + 'options' => [ + ['name' => 'Switch Type', 'values' => ['Red', 'Blue', 'Brown']], + ], + 'price' => 12999, 'weight_g' => 1100, 'inventory' => 15, + ], + [ + ...$active, + 'title' => 'Monitor Stand', + 'handle' => 'monitor-stand', + 'vendor' => 'DeskGear', + 'product_type' => 'Accessories', + 'tags' => ['popular'], + 'description_html' => '

Ergonomic aluminium monitor stand with cable management.

', + 'sku_prefix' => 'DESK-STAND', + 'options' => [], + 'price' => 4999, 'weight_g' => 2500, 'inventory' => 30, + ], + ]; + } +} diff --git a/database/seeders/SearchSettingsSeeder.php b/database/seeders/SearchSettingsSeeder.php new file mode 100644 index 00000000..65920b4d --- /dev/null +++ b/database/seeders/SearchSettingsSeeder.php @@ -0,0 +1,42 @@ + [ + 'synonyms_json' => [ + ['tee', 't-shirt', 'tshirt'], + ['pants', 'trousers', 'jeans'], + ['sneakers', 'trainers', 'shoes'], + ['hoodie', 'sweatshirt'], + ], + 'stop_words_json' => ['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'is'], + ], + 'acme-electronics' => [ + 'synonyms_json' => [ + ['laptop', 'notebook', 'computer'], + ['headphones', 'earphones', 'earbuds'], + ['cable', 'cord', 'wire'], + ], + 'stop_words_json' => ['the', 'a', 'an', 'and', 'or'], + ], + ]; + + foreach ($settings as $handle => $values) { + $store = Store::query()->where('handle', $handle)->firstOrFail(); + + SearchSetting::query()->updateOrCreate( + ['store_id' => $store->getKey()], + $values + ); + } + } +} diff --git a/database/seeders/ShippingSeeder.php b/database/seeders/ShippingSeeder.php new file mode 100644 index 00000000..19cbc7dc --- /dev/null +++ b/database/seeders/ShippingSeeder.php @@ -0,0 +1,59 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + $this->seedZone($fashion, 'Domestic', ['DE'], [], [ + ['name' => 'Standard Shipping', 'amount' => 499], + ['name' => 'Express Shipping', 'amount' => 999], + ]); + + $this->seedZone($fashion, 'EU', ['AT', 'FR', 'IT', 'ES', 'NL', 'BE', 'PL'], [], [ + ['name' => 'EU Standard', 'amount' => 899], + ]); + + $this->seedZone($fashion, 'Rest of World', ['US', 'GB', 'CA', 'AU'], [], [ + ['name' => 'International', 'amount' => 1499], + ]); + + $this->seedZone($electronics, 'Germany', ['DE'], [], [ + ['name' => 'Standard', 'amount' => 0], + ]); + } + + /** + * @param array $countries + * @param array $regions + * @param array $rates + */ + protected function seedZone(Store $store, string $name, array $countries, array $regions, array $rates): void + { + $zone = ShippingZone::query()->updateOrCreate( + ['store_id' => $store->getKey(), 'name' => $name], + ['countries_json' => $countries, 'regions_json' => $regions] + ); + + foreach ($rates as $rate) { + ShippingRate::query()->updateOrCreate( + ['zone_id' => $zone->getKey(), 'name' => $rate['name']], + [ + 'type' => ShippingRateType::Flat, + 'config_json' => ['amount' => $rate['amount']], + 'is_active' => true, + ] + ); + } + } +} diff --git a/database/seeders/StoreDomainSeeder.php b/database/seeders/StoreDomainSeeder.php new file mode 100644 index 00000000..efd2b50d --- /dev/null +++ b/database/seeders/StoreDomainSeeder.php @@ -0,0 +1,35 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + $this->seedDomain($fashion, 'acme-fashion.test', StoreDomainType::Storefront, true); + $this->seedDomain($fashion, 'admin.acme-fashion.test', StoreDomainType::Admin, false); + $this->seedDomain($electronics, 'acme-electronics.test', StoreDomainType::Storefront, true); + } + + protected function seedDomain(Store $store, string $hostname, StoreDomainType $type, bool $isPrimary): void + { + StoreDomain::query()->updateOrCreate( + ['hostname' => $hostname], + [ + 'store_id' => $store->getKey(), + 'type' => $type, + 'is_primary' => $isPrimary, + 'tls_mode' => TlsMode::Managed, + ] + ); + } +} diff --git a/database/seeders/StoreSeeder.php b/database/seeders/StoreSeeder.php new file mode 100644 index 00000000..cd0a4947 --- /dev/null +++ b/database/seeders/StoreSeeder.php @@ -0,0 +1,48 @@ +where('name', 'Acme Corp')->firstOrFail(); + + foreach ($this->stores() as $attributes) { + Store::query()->updateOrCreate( + ['handle' => $attributes['handle']], + [...$attributes, 'organization_id' => $organization->getKey()] + ); + } + } + + /** + * @return array> + */ + protected function stores(): array + { + return [ + [ + 'name' => 'Acme Fashion', + 'handle' => 'acme-fashion', + 'status' => StoreStatus::Active, + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ], + [ + 'name' => 'Acme Electronics', + 'handle' => 'acme-electronics', + 'status' => StoreStatus::Active, + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ], + ]; + } +} diff --git a/database/seeders/StoreSettingsSeeder.php b/database/seeders/StoreSettingsSeeder.php new file mode 100644 index 00000000..6b638aaf --- /dev/null +++ b/database/seeders/StoreSettingsSeeder.php @@ -0,0 +1,37 @@ + [ + 'store_name' => 'Acme Fashion', + 'contact_email' => 'hello@acme-fashion.test', + 'order_number_prefix' => '#', + 'order_number_start' => 1001, + ], + 'acme-electronics' => [ + 'store_name' => 'Acme Electronics', + 'contact_email' => 'hello@acme-electronics.test', + 'order_number_prefix' => '#', + 'order_number_start' => 5001, + ], + ]; + + foreach ($settings as $handle => $values) { + $store = Store::query()->where('handle', $handle)->firstOrFail(); + + StoreSetting::query()->updateOrCreate( + ['store_id' => $store->getKey()], + ['settings_json' => $values] + ); + } + } +} diff --git a/database/seeders/StoreUserSeeder.php b/database/seeders/StoreUserSeeder.php new file mode 100644 index 00000000..3e35d188 --- /dev/null +++ b/database/seeders/StoreUserSeeder.php @@ -0,0 +1,33 @@ + 'admin@acme.test', 'handle' => 'acme-fashion', 'role' => StoreUserRole::Owner], + ['email' => 'staff@acme.test', 'handle' => 'acme-fashion', 'role' => StoreUserRole::Staff], + ['email' => 'support@acme.test', 'handle' => 'acme-fashion', 'role' => StoreUserRole::Support], + ['email' => 'manager@acme.test', 'handle' => 'acme-fashion', 'role' => StoreUserRole::Admin], + ['email' => 'admin2@acme.test', 'handle' => 'acme-electronics', 'role' => StoreUserRole::Owner], + ]; + + foreach ($assignments as $assignment) { + $user = User::query()->where('email', $assignment['email'])->firstOrFail(); + $store = Store::query()->where('handle', $assignment['handle'])->firstOrFail(); + + StoreUser::query()->updateOrCreate( + ['store_id' => $store->getKey(), 'user_id' => $user->getKey()], + ['role' => $assignment['role']] + ); + } + } +} diff --git a/database/seeders/TaxSettingsSeeder.php b/database/seeders/TaxSettingsSeeder.php new file mode 100644 index 00000000..7e6c5818 --- /dev/null +++ b/database/seeders/TaxSettingsSeeder.php @@ -0,0 +1,32 @@ +where('handle', $handle)->firstOrFail(); + + TaxSetting::query()->updateOrCreate( + ['store_id' => $store->getKey()], + [ + 'mode' => TaxMode::Manual, + 'provider' => TaxProvider::None, + 'prices_include_tax' => true, + 'config_json' => [ + 'default_rate_bps' => 1900, + 'default_rate' => 1900, + ], + ] + ); + } + } +} diff --git a/database/seeders/ThemeSeeder.php b/database/seeders/ThemeSeeder.php new file mode 100644 index 00000000..e742c46f --- /dev/null +++ b/database/seeders/ThemeSeeder.php @@ -0,0 +1,67 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + $this->seedTheme($fashion, [ + 'primary_color' => '#1a1a2e', + 'secondary_color' => '#e94560', + 'font_family' => 'Inter, sans-serif', + 'hero_heading' => 'Welcome to Acme Fashion', + 'hero_subheading' => 'Discover our curated collection of modern essentials', + 'hero_cta_text' => 'Shop New Arrivals', + 'hero_cta_link' => '/collections/new-arrivals', + 'featured_collection_handles' => ['new-arrivals', 't-shirts', 'sale'], + 'footer_text' => '2025 Acme Fashion. All rights reserved.', + 'show_announcement_bar' => true, + 'announcement_text' => 'Free shipping on orders over 50 EUR - Use code FREESHIP', + 'products_per_page' => 12, + 'show_vendor' => true, + 'show_quantity_selector' => true, + ]); + + $this->seedTheme($electronics, [ + 'primary_color' => '#0f172a', + 'secondary_color' => '#3b82f6', + 'font_family' => 'Inter, sans-serif', + 'hero_heading' => 'Acme Electronics', + 'hero_subheading' => 'Premium tech for professionals', + 'hero_cta_text' => 'Shop Featured', + 'hero_cta_link' => '/collections/featured', + 'featured_collection_handles' => ['featured'], + 'footer_text' => '2025 Acme Electronics. All rights reserved.', + ]); + } + + /** + * @param array $settings + */ + protected function seedTheme(Store $store, array $settings): void + { + $theme = Theme::query()->updateOrCreate( + ['store_id' => $store->getKey(), 'name' => 'Default Theme'], + [ + 'version' => '1.0.0', + 'status' => ThemeStatus::Published, + 'published_at' => now(), + ] + ); + + ThemeSetting::query()->updateOrCreate( + ['theme_id' => $theme->getKey()], + ['settings_json' => $settings] + ); + } +} diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php new file mode 100644 index 00000000..fe749541 --- /dev/null +++ b/database/seeders/UserSeeder.php @@ -0,0 +1,39 @@ + 'admin@acme.test', 'name' => 'Admin User', 'last_login_at' => now()], + ['email' => 'staff@acme.test', 'name' => 'Staff User', 'last_login_at' => now()->subDays(2)], + ['email' => 'support@acme.test', 'name' => 'Support User', 'last_login_at' => now()->subDay()], + ['email' => 'manager@acme.test', 'name' => 'Store Manager', 'last_login_at' => now()->subDay()], + ['email' => 'admin2@acme.test', 'name' => 'Admin Two', 'last_login_at' => now()->subDay()], + ]; + + foreach ($users as $attributes) { + $user = User::query()->firstOrCreate( + ['email' => $attributes['email']], + [ + 'name' => $attributes['name'], + 'password' => 'password', + 'status' => UserStatus::Active, + ] + ); + + $user->forceFill([ + 'name' => $attributes['name'], + 'status' => UserStatus::Active, + 'email_verified_at' => now(), + 'last_login_at' => $attributes['last_login_at'], + ])->save(); + } + } +} diff --git a/draft.yaml b/draft.yaml new file mode 100644 index 00000000..182bc909 --- /dev/null +++ b/draft.yaml @@ -0,0 +1,630 @@ +# Shop e-commerce platform - Blueprint draft +# Source of truth: specs/01-DATABASE-SCHEMA.md +# Models are ordered by migration dependency batch (1-7) so generated +# migration filenames (sequential timestamps) satisfy FK dependencies. +# +# Deliberate deviations from spec 01 (Blueprint limitations): +# - Enum CHECK constraints are NOT generated; enums are string columns +# enforced via PHP Enums (app/Enums) + casts() + validation. +# - Composite primary keys are expressed as composite UNIQUE indexes +# (store_users, collection_products, variant_option_values, +# analytics_daily, customer_password_reset_tokens). Functionally +# equivalent for the app layer and Eloquent-friendly. +# - Index names are Laravel-generated (table_columns_type), not idx_*. + +models: + # ---- Batch 1: no dependencies ---- + Organization: + name: string + billing_email: string + relationships: + hasMany: Store + indexes: + - index: billing_email + + App: + timestamps: false + name: string + status: string default:active + created_at: timestamp nullable + relationships: + hasMany: AppInstallation, OauthClient + indexes: + - index: status + + # ---- Batch 2 ---- + Store: + organization_id: id foreign:organizations onDelete:cascade + name: string + handle: string + status: string default:active + default_currency: string default:USD + default_locale: string default:en + timezone: string default:UTC + relationships: + hasOne: StoreSetting, SearchSetting, TaxSetting + hasMany: StoreDomain, Customer, Theme, Page, NavigationMenu, Cart, Checkout, Order, Discount, ShippingZone, Product, Collection, SearchQuery, AnalyticsEvent, WebhookSubscription, InventoryItem, AppInstallation + indexes: + - unique: handle + - index: organization_id + - index: status + + # ---- Batch 3 ---- + StoreDomain: + timestamps: false + store_id: id foreign:stores onDelete:cascade + hostname: string + type: string default:storefront + is_primary: integer default:0 + tls_mode: string default:managed + created_at: timestamp nullable + indexes: + - unique: hostname + - index: store_id + - index: store_id, is_primary + + StoreUser: + meta: + pivot: true + id: false + timestamps: false + store_id: id foreign:stores onDelete:cascade + user_id: id foreign:users onDelete:cascade + role: string default:staff + created_at: timestamp nullable + indexes: + - unique: store_id, user_id + - index: user_id + - index: store_id, role + + StoreSetting: + id: false + timestamps: false + store_id: id foreign:stores onDelete:cascade primary + settings_json: json default:'{}' + updated_at: timestamp nullable + + Customer: + store_id: id foreign:stores onDelete:cascade + email: string + password_hash: string nullable + name: string nullable + marketing_opt_in: integer default:0 + relationships: + hasMany: CustomerAddress, Order, Cart, Checkout, AnalyticsEvent + indexes: + - unique: store_id, email + - index: store_id + + Theme: + store_id: id foreign:stores onDelete:cascade + name: string + version: string nullable + status: string default:draft + published_at: timestamp nullable + relationships: + hasOne: ThemeSetting + hasMany: ThemeFile + indexes: + - index: store_id + - index: store_id, status + + Page: + store_id: id foreign:stores onDelete:cascade + title: string + handle: string + body_html: text nullable + status: string default:draft + published_at: timestamp nullable + indexes: + - unique: store_id, handle + - index: store_id + - index: store_id, status + + NavigationMenu: + store_id: id foreign:stores onDelete:cascade + handle: string + title: string + relationships: + hasMany: NavigationItem + indexes: + - unique: store_id, handle + - index: store_id + + SearchSetting: + id: false + timestamps: false + store_id: id foreign:stores onDelete:cascade primary + synonyms_json: json default:'[]' + stop_words_json: json default:'[]' + updated_at: timestamp nullable + + ShippingZone: + timestamps: false + store_id: id foreign:stores onDelete:cascade + name: string + countries_json: json default:'[]' + regions_json: json default:'[]' + relationships: + hasMany: ShippingRate + indexes: + - index: store_id + + TaxSetting: + id: false + timestamps: false + store_id: id foreign:stores onDelete:cascade primary + mode: string default:manual + provider: string default:none + prices_include_tax: integer default:0 + config_json: json default:'{}' + + Discount: + store_id: id foreign:stores onDelete:cascade + type: string default:code + code: string nullable + value_type: string + value_amount: integer default:0 + starts_at: timestamp + ends_at: timestamp nullable + usage_limit: integer nullable + usage_count: integer default:0 + rules_json: json default:'{}' + status: string default:active + indexes: + - unique: store_id, code + - index: store_id + - index: store_id, status + - index: store_id, type + + Product: + store_id: id foreign:stores onDelete:cascade + title: string + handle: string + status: string default:draft + description_html: text nullable + vendor: string nullable + product_type: string nullable + tags: json default:'[]' + published_at: timestamp nullable + relationships: + hasMany: ProductOption, ProductVariant, ProductMedia + indexes: + - unique: store_id, handle + - index: store_id + - index: store_id, status + - index: store_id, published_at + - index: store_id, vendor + - index: store_id, product_type + + Collection: + store_id: id foreign:stores onDelete:cascade + title: string + handle: string + description_html: text nullable + type: string default:manual + status: string default:active + indexes: + - unique: store_id, handle + - index: store_id + - index: store_id, status + + AppInstallation: + timestamps: false + store_id: id foreign:stores onDelete:cascade + app_id: id foreign:apps onDelete:cascade + scopes_json: json default:'[]' + status: string default:active + installed_at: timestamp nullable + relationships: + hasMany: OauthToken, WebhookSubscription + indexes: + - unique: store_id, app_id + - index: store_id + - index: app_id + + OauthClient: + timestamps: false + app_id: id foreign:apps onDelete:cascade + client_id: string + client_secret_encrypted: text + redirect_uris_json: json default:'[]' + indexes: + - unique: client_id + - index: app_id + + CustomerPasswordResetToken: + id: false + timestamps: false + store_id: id foreign:stores onDelete:cascade + email: string + token: string + created_at: timestamp nullable + indexes: + - unique: store_id, email + + # ---- Batch 4 ---- + ProductOption: + timestamps: false + product_id: id foreign:products onDelete:cascade + name: string + position: integer default:0 + relationships: + hasMany: ProductOptionValue + indexes: + - index: product_id + - unique: product_id, position + + ProductVariant: + product_id: id foreign:products onDelete:cascade + sku: string nullable + barcode: string nullable + price_amount: integer default:0 + compare_at_amount: integer nullable + currency: string default:USD + weight_g: integer nullable + requires_shipping: integer default:1 + is_default: integer default:0 + position: integer default:0 + status: string default:active + relationships: + hasOne: InventoryItem + indexes: + - index: product_id + - index: sku + - index: barcode + - index: product_id, position + - index: product_id, is_default + + ProductMedia: + timestamps: false + product_id: id foreign:products onDelete:cascade + type: string default:image + storage_key: string + alt_text: string nullable + width: integer nullable + height: integer nullable + mime_type: string nullable + byte_size: integer nullable + position: integer default:0 + status: string default:processing + created_at: timestamp nullable + indexes: + - index: product_id + - index: product_id, position + - index: status + + CollectionProduct: + id: false + timestamps: false + collection_id: id foreign:collections onDelete:cascade + product_id: id foreign:products onDelete:cascade + position: integer default:0 + indexes: + - unique: collection_id, product_id + - index: product_id + - index: collection_id, position + + CustomerAddress: + timestamps: false + customer_id: id foreign:customers onDelete:cascade + label: string nullable + address_json: json default:'{}' + is_default: integer default:0 + indexes: + - index: customer_id + - index: customer_id, is_default + + Cart: + store_id: id foreign:stores onDelete:cascade + customer_id: id foreign:customers nullable onDelete:null + currency: string default:USD + cart_version: integer default:1 + status: string default:active + relationships: + hasMany: CartLine, Checkout + indexes: + - index: store_id + - index: customer_id + - index: store_id, status + + NavigationItem: + timestamps: false + menu_id: id foreign:navigation_menu onDelete:cascade + type: string default:link + label: string + url: string nullable + resource_id: integer nullable + position: integer default:0 + indexes: + - index: menu_id + - index: menu_id, position + + ThemeFile: + timestamps: false + theme_id: id foreign:themes onDelete:cascade + path: string + storage_key: string + sha256: string + byte_size: integer default:0 + indexes: + - unique: theme_id, path + - index: theme_id + + ThemeSetting: + id: false + timestamps: false + theme_id: id foreign:themes onDelete:cascade primary + settings_json: json default:'{}' + updated_at: timestamp nullable + + SearchQuery: + timestamps: false + store_id: id foreign:stores onDelete:cascade + query: string + filters_json: json nullable + results_count: integer default:0 + created_at: timestamp nullable + indexes: + - index: store_id + - index: store_id, created_at + - index: store_id, query + + ShippingRate: + timestamps: false + zone_id: id foreign:shipping_zones onDelete:cascade + name: string + type: string default:flat + config_json: json default:'{}' + is_active: integer default:1 + indexes: + - index: zone_id + - index: zone_id, is_active + + OauthToken: + timestamps: false + installation_id: id foreign:app_installations onDelete:cascade + access_token_hash: string + refresh_token_hash: string nullable + expires_at: timestamp + indexes: + - index: installation_id + - unique: access_token_hash + - index: expires_at + + WebhookSubscription: + timestamps: false + store_id: id foreign:stores onDelete:cascade + app_installation_id: id foreign:app_installations nullable onDelete:cascade + event_type: string + target_url: string + signing_secret_encrypted: text + status: string default:active + relationships: + hasMany: WebhookDelivery + indexes: + - index: store_id + - index: store_id, event_type + - index: app_installation_id + + # ---- Batch 5 ---- + ProductOptionValue: + timestamps: false + product_option_id: id foreign:product_options onDelete:cascade + value: string + position: integer default:0 + indexes: + - index: product_option_id + - unique: product_option_id, position + + InventoryItem: + timestamps: false + store_id: id foreign:stores onDelete:cascade + variant_id: id foreign:product_variants onDelete:cascade + quantity_on_hand: integer default:0 + quantity_reserved: integer default:0 + policy: string default:deny + indexes: + - unique: variant_id + - index: store_id + + CartLine: + timestamps: false + cart_id: id foreign:carts onDelete:cascade + variant_id: id foreign:product_variants onDelete:cascade + quantity: integer default:1 + unit_price_amount: integer default:0 + line_subtotal_amount: integer default:0 + line_discount_amount: integer default:0 + line_total_amount: integer default:0 + indexes: + - index: cart_id + - unique: cart_id, variant_id + + Checkout: + store_id: id foreign:stores onDelete:cascade + cart_id: id foreign:carts onDelete:cascade + customer_id: id foreign:customers nullable onDelete:null + status: string default:started + payment_method: string nullable + email: string nullable + shipping_address_json: json nullable + billing_address_json: json nullable + shipping_method_id: integer nullable + discount_code: string nullable + tax_provider_snapshot_json: json nullable + totals_json: json nullable + expires_at: timestamp nullable + indexes: + - index: store_id + - index: cart_id + - index: customer_id + - index: store_id, status + - index: expires_at + + Order: + store_id: id foreign:stores onDelete:cascade + customer_id: id foreign:customers nullable onDelete:null + order_number: string + payment_method: string + status: string default:pending + financial_status: string default:pending + fulfillment_status: string default:unfulfilled + currency: string default:USD + subtotal_amount: integer default:0 + discount_amount: integer default:0 + shipping_amount: integer default:0 + tax_amount: integer default:0 + total_amount: integer default:0 + email: string nullable + billing_address_json: json nullable + shipping_address_json: json nullable + placed_at: timestamp nullable + relationships: + hasMany: OrderLine, Payment, Refund, Fulfillment + indexes: + - unique: store_id, order_number + - index: store_id + - index: customer_id + - index: store_id, status + - index: store_id, financial_status + - index: store_id, fulfillment_status + - index: store_id, placed_at + + AnalyticsEvent: + timestamps: false + store_id: id foreign:stores onDelete:cascade + type: string + session_id: string nullable + customer_id: id foreign:customers nullable onDelete:null + properties_json: json default:'{}' + client_event_id: string nullable + occurred_at: timestamp nullable + created_at: timestamp nullable + indexes: + - index: store_id + - index: store_id, type + - index: store_id, created_at + - index: session_id + - index: customer_id + - unique: store_id, client_event_id + + AnalyticsDaily: + meta: + table: analytics_daily + id: false + timestamps: false + store_id: id foreign:stores onDelete:cascade + date: string + orders_count: integer default:0 + revenue_amount: integer default:0 + aov_amount: integer default:0 + visits_count: integer default:0 + add_to_cart_count: integer default:0 + checkout_started_count: integer default:0 + checkout_completed_count: integer default:0 + indexes: + - unique: store_id, date + + WebhookDelivery: + timestamps: false + subscription_id: id foreign:webhook_subscriptions onDelete:cascade + event_id: string + attempt_count: integer default:1 + status: string default:pending + last_attempt_at: timestamp nullable + response_code: integer nullable + response_body_snippet: text nullable + indexes: + - index: subscription_id + - index: event_id + - index: status + - index: last_attempt_at + + # ---- Batch 6 ---- + VariantOptionValue: + id: false + timestamps: false + variant_id: id foreign:product_variants onDelete:cascade + product_option_value_id: id foreign:product_option_values onDelete:cascade + indexes: + - unique: variant_id, product_option_value_id + - index: product_option_value_id + + OrderLine: + timestamps: false + order_id: id foreign:orders onDelete:cascade + product_id: id foreign:products nullable onDelete:null + variant_id: id foreign:product_variants nullable onDelete:null + title_snapshot: string + sku_snapshot: string nullable + quantity: integer default:1 + unit_price_amount: integer default:0 + total_amount: integer default:0 + tax_lines_json: json default:'[]' + discount_allocations_json: json default:'[]' + relationships: + hasMany: FulfillmentLine + indexes: + - index: order_id + - index: product_id + - index: variant_id + + Payment: + timestamps: false + order_id: id foreign:orders onDelete:cascade + provider: string default:mock + method: string + provider_payment_id: string nullable + status: string default:pending + amount: integer default:0 + currency: string default:USD + raw_json_encrypted: text nullable + created_at: timestamp nullable + relationships: + hasMany: Refund + indexes: + - index: order_id + - index: provider, provider_payment_id + - index: method + - index: status + + Fulfillment: + timestamps: false + order_id: id foreign:orders onDelete:cascade + status: string default:pending + tracking_company: string nullable + tracking_number: string nullable + tracking_url: string nullable + shipped_at: timestamp nullable + created_at: timestamp nullable + relationships: + hasMany: FulfillmentLine + indexes: + - index: order_id + - index: status + - index: tracking_company, tracking_number + + # ---- Batch 7 ---- + Refund: + timestamps: false + order_id: id foreign:orders onDelete:cascade + payment_id: id foreign:payments onDelete:cascade + amount: integer default:0 + reason: text nullable + status: string default:pending + provider_refund_id: string nullable + created_at: timestamp nullable + indexes: + - index: order_id + - index: payment_id + - index: status + + FulfillmentLine: + timestamps: false + fulfillment_id: id foreign:fulfillments onDelete:cascade + order_line_id: id foreign:order_lines onDelete:cascade + quantity: integer default:1 + indexes: + - index: fulfillment_id + - unique: fulfillment_id, order_line_id diff --git a/phpunit.xml b/phpunit.xml index d7032415..d5e8cb18 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,6 +18,7 @@ + diff --git a/resources/js/app.js b/resources/js/app.js index e69de29b..7d62edcf 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -0,0 +1,4 @@ +import axios from 'axios'; + +window.axios = axios; +window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; 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 00000000..39ca8f3d --- /dev/null +++ b/resources/views/admin/auth/forgot-password.blade.php @@ -0,0 +1,35 @@ + + + + + + Admin Forgot Password + + + +

Forgot Password

+

Enter your admin email to receive a password reset link.

+ + @if (session('status')) +

{{ session('status') }}

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

Back to login

+ + diff --git a/resources/views/admin/auth/login.blade.php b/resources/views/admin/auth/login.blade.php new file mode 100644 index 00000000..ba61b3c8 --- /dev/null +++ b/resources/views/admin/auth/login.blade.php @@ -0,0 +1,43 @@ + + + + + + Admin Login + + + +

Admin Login

+

Login to manage your store.

+ + @if (session('status')) +

{{ session('status') }}

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

Forgot your password?

+ + 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 00000000..c8ab5fc9 --- /dev/null +++ b/resources/views/admin/auth/reset-password.blade.php @@ -0,0 +1,40 @@ + + + + + + Admin Reset Password + + + +

Reset Password

+ + @if (session('status')) +

{{ session('status') }}

+ @endif + + @if ($errors->any()) +
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+ @endif + +
+ @csrf + + + + + + + + + + + + +
+ + diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php new file mode 100644 index 00000000..89941320 --- /dev/null +++ b/resources/views/admin/dashboard.blade.php @@ -0,0 +1,18 @@ + + + + + + Admin + + + {{-- Placeholder dashboard. Replaced by the Phase 7 admin panel (Livewire). --}} +

Admin Dashboard

+

Store: {{ $currentStore->name ?? 'none' }}

+ +
+ @csrf + +
+ + diff --git a/resources/views/admin/store-selector.blade.php b/resources/views/admin/store-selector.blade.php new file mode 100644 index 00000000..84067297 --- /dev/null +++ b/resources/views/admin/store-selector.blade.php @@ -0,0 +1,24 @@ + + + + + + Select Store + + + +

Select a store

+ +
    + @foreach ($stores as $store) +
  • +
    + @csrf + + +
    +
  • + @endforeach +
+ + diff --git a/resources/views/components/storefront/address-form.blade.php b/resources/views/components/storefront/address-form.blade.php new file mode 100644 index 00000000..34ac0686 --- /dev/null +++ b/resources/views/components/storefront/address-form.blade.php @@ -0,0 +1,48 @@ +@props(['prefix' => 'address', 'showPhone' => true]) + +
+
+ + @error($prefix.'.first_name')

{{ $message }}

@enderror +
+
+ + @error($prefix.'.last_name')

{{ $message }}

@enderror +
+
+ + @error($prefix.'.address1')

{{ $message }}

@enderror +
+
+ + @error($prefix.'.address2')

{{ $message }}

@enderror +
+
+ + @error($prefix.'.city')

{{ $message }}

@enderror +
+
+ + @error($prefix.'.province')

{{ $message }}

@enderror +
+
+ + @error($prefix.'.postal_code')

{{ $message }}

@enderror +
+
+ + + @error($prefix.'.country_code')

{{ $message }}

@enderror +
+ @if ($showPhone) +
+ + @error($prefix.'.phone')

{{ $message }}

@enderror +
+ @endif +
diff --git a/resources/views/components/storefront/badge.blade.php b/resources/views/components/storefront/badge.blade.php new file mode 100644 index 00000000..591fd592 --- /dev/null +++ b/resources/views/components/storefront/badge.blade.php @@ -0,0 +1,12 @@ +@props(['text', 'variant' => 'default']) + +@php + $styles = [ + 'sale' => 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', + 'sold-out' => 'bg-zinc-200 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300', + 'new' => 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', + 'default' => 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200', + ]; +@endphp + +merge(['class' => 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium '.($styles[$variant] ?? $styles['default'])]) }}>{{ $text }} diff --git a/resources/views/components/storefront/breadcrumbs.blade.php b/resources/views/components/storefront/breadcrumbs.blade.php new file mode 100644 index 00000000..711c565a --- /dev/null +++ b/resources/views/components/storefront/breadcrumbs.blade.php @@ -0,0 +1,18 @@ +@props(['items']) + + diff --git a/resources/views/components/storefront/order-summary.blade.php b/resources/views/components/storefront/order-summary.blade.php new file mode 100644 index 00000000..f584c339 --- /dev/null +++ b/resources/views/components/storefront/order-summary.blade.php @@ -0,0 +1,76 @@ +@props(['lines', 'totals', 'currency', 'discountCode' => null, 'showDiscountInput' => false]) + +@php + $money = fn (int $cents): string => \App\Support\Money::format($cents, (string) $currency); +@endphp + +
merge(['class' => 'rounded-lg bg-zinc-100 p-6 dark:bg-zinc-900']) }} aria-live="polite"> +

Order Summary

+ +
    + @foreach ($lines as $line) +
  • + @if (($line['image_url'] ?? null)) + + @endif +
    +

    {{ $line['title'] }}

    + @if (($line['variant'] ?? '') !== '') +

    {{ $line['variant'] }}

    + @endif +

    Qty: {{ $line['quantity'] }}

    +
    +

    {{ $money((int) $line['total']) }}

    +
  • + @endforeach +
+ + @if ($showDiscountInput) +
+ @if ($discountCode) +

+ {{ $discountCode }} applied + +

+ @else +
+ + Apply +
+ @error('discountCodeInput')

{{ $message }}

@enderror + @if (session()->has('discount_error')) +

{{ session('discount_error') }}

+ @endif + @endif +
+ @elseif ($discountCode) +

{{ $discountCode }} applied

+ @endif + +
+
+
Subtotal
+
{{ $money((int) ($totals['subtotal'] ?? 0)) }}
+
+ @if ((int) ($totals['discount'] ?? 0) > 0) +
+
Discount
+
-{{ $money((int) $totals['discount']) }}
+
+ @endif +
+
Shipping
+
{{ array_key_exists('shipping', $totals) ? $money((int) $totals['shipping']) : 'Calculated at next step' }}
+
+ @if (array_key_exists('tax', $totals)) +
+
Tax
+
{{ $money((int) $totals['tax']) }}
+
+ @endif +
+
Total
+
{{ $money((int) ($totals['total'] ?? 0)) }}
+
+
+
diff --git a/resources/views/components/storefront/pagination.blade.php b/resources/views/components/storefront/pagination.blade.php new file mode 100644 index 00000000..a637db74 --- /dev/null +++ b/resources/views/components/storefront/pagination.blade.php @@ -0,0 +1,12 @@ +@props(['paginator']) + +@if ($paginator->hasPages()) + +

Page {{ $paginator->currentPage() }} of {{ $paginator->lastPage() }}

+@endif diff --git a/resources/views/components/storefront/price.blade.php b/resources/views/components/storefront/price.blade.php new file mode 100644 index 00000000..842dd9fc --- /dev/null +++ b/resources/views/components/storefront/price.blade.php @@ -0,0 +1,14 @@ +@props(['amount', 'currency', 'compareAtAmount' => null]) + +@php + $formatted = \App\Support\Money::format((int) $amount, (string) $currency); + $onSale = $compareAtAmount !== null && (int) $compareAtAmount > (int) $amount; +@endphp + +merge(['class' => 'inline-flex items-baseline gap-2']) }}> + {{ $formatted }} + @if ($onSale) + {{ \App\Support\Money::format((int) $compareAtAmount, (string) $currency) }} + + @endif + diff --git a/resources/views/components/storefront/product-card.blade.php b/resources/views/components/storefront/product-card.blade.php new file mode 100644 index 00000000..7c7de738 --- /dev/null +++ b/resources/views/components/storefront/product-card.blade.php @@ -0,0 +1,47 @@ +@props(['product', 'headingLevel' => 'h3', 'showQuickAdd' => true]) + +@php + $catalog = app(\App\Services\StorefrontCatalog::class); + $variant = $catalog->displayVariant($product); + $inStock = $catalog->isInStock($product); + $image = $catalog->primaryImageUrl($product); + $currency = $variant?->currency ?? $product->store?->default_currency ?? 'USD'; + $url = route('storefront.products.show', ['handle' => $product->handle]); +@endphp + +
merge(['class' => 'group flex flex-col overflow-hidden rounded-lg border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900']) }}> + +
+ @if ($image) + {{ $catalog->primaryImageAlt($product) }} + @else +
+ +
+ @endif +
+ @if ($variant && $variant->compare_at_amount && $variant->compare_at_amount > $variant->price_amount) + + @endif + @if (! $inStock) + + @endif +
+
+
+
+ <{{ $headingLevel }} class="line-clamp-2 text-sm font-semibold"> + {{ $product->title }} + + @if ($variant) + + @endif + @if ($showQuickAdd) + @if ($product->productVariants->where('status', \App\Enums\VariantStatus::Active)->count() > 1) + Choose options + @elseif ($variant && $inStock) + + @endif + @endif +
+
diff --git a/resources/views/components/storefront/quantity-selector.blade.php b/resources/views/components/storefront/quantity-selector.blade.php new file mode 100644 index 00000000..951e8d04 --- /dev/null +++ b/resources/views/components/storefront/quantity-selector.blade.php @@ -0,0 +1,15 @@ +@props(['value' => 1, 'min' => 1, 'max' => null, 'model' => null, 'increment' => null, 'decrement' => null, 'compact' => false, 'label' => 'Quantity']) + +@php + $buttonClass = $compact ? 'h-8 w-8' : 'h-10 w-10'; +@endphp + +
merge(['class' => 'inline-flex items-center rounded border border-zinc-300 dark:border-zinc-700']) }}> + + + +
diff --git a/resources/views/livewire/admin/analytics/index.blade.php b/resources/views/livewire/admin/analytics/index.blade.php new file mode 100644 index 00000000..ae70d577 --- /dev/null +++ b/resources/views/livewire/admin/analytics/index.blade.php @@ -0,0 +1,165 @@ +
+ + Home + Analytics + + + Analytics + +
+ + Today + Last 7 days + Last 30 days + Custom range + + @if ($this->dateRange === 'custom') + + + @endif + + All channels + Storefront + API + + + All devices + Desktop + Mobile + Tablet + + + + {{ $this->isExporting ? 'Exporting...' : 'Export CSV' }} + + @if ($this->exportUrl !== null) + Export saved: {{ $this->exportUrl }} + @endif +
+ +
+ @php + $tiles = [ + ['label' => 'Revenue', 'value' => $this->money($summary['revenue_amount'])], + ['label' => 'Orders', 'value' => number_format($summary['orders_count'])], + ['label' => 'Avg Order Value', 'value' => $this->money($summary['aov_amount'])], + ['label' => 'Visits', 'value' => number_format($summary['visits_count'])], + ]; + @endphp + @foreach ($tiles as $tile) +
+ {{ $tile['label'] }} + {{ $tile['value'] }} +
+ @endforeach +
+ +
+ Sales over time + @if (count($daily) === 0) + No data for this period. + @else + +
+ {{ $from }} + {{ $to }} +
+ @endif +
+ +
+ Conversion funnel + @php + $steps = [ + ['label' => 'Visits', 'value' => $funnelData['visits'], 'class' => 'bg-zinc-300 dark:bg-zinc-600'], + ['label' => 'Add to Cart', 'value' => $funnelData['add_to_cart'], 'class' => 'bg-zinc-500 dark:bg-zinc-400'], + ['label' => 'Checkout Started', 'value' => $funnelData['checkout_started'], 'class' => 'bg-zinc-700 dark:bg-zinc-200'], + ['label' => 'Completed', 'value' => $funnelData['checkout_completed'], 'class' => 'bg-zinc-900 dark:bg-white'], + ]; + $funnelMax = max(1, $funnelData['visits']); + @endphp +
+ @foreach ($steps as $step) +
+ {{ $step['label'] }} +
+
+
+ {{ number_format($step['value']) }} +
+ @endforeach +
+
+ +
+ Top products + @if (count($topProducts) === 0) + No sales data for this period. + @else +
+ + + + + + + + + + + + @foreach ($topProducts as $product) + + + + + + + + @endforeach + +
RankProductUnits SoldRevenue% of Total
{{ $loop->iteration }}{{ $product['title'] }}{{ $product['units_sold'] }}{{ $this->money($product['revenue_amount']) }}{{ $totalRevenue > 0 ? number_format($product['revenue_amount'] / $totalRevenue * 100, 1) : 0 }}%
+
+ @endif +
+ +
+ Top referrers + @if (count($topReferrers) === 0) + No traffic data for this period. + @else +
+ + + + + + + + + + + @foreach ($topReferrers as $referrer) + + + + + + + @endforeach + +
SourceSessionsOrdersConversion Rate
{{ $referrer['source'] }}{{ $referrer['sessions'] }}{{ $referrer['orders'] }}{{ number_format($referrer['conversion_rate'] * 100, 2) }}%
+
+ @endif +
+
diff --git a/resources/views/livewire/admin/apps/index.blade.php b/resources/views/livewire/admin/apps/index.blade.php new file mode 100644 index 00000000..c3c0c379 --- /dev/null +++ b/resources/views/livewire/admin/apps/index.blade.php @@ -0,0 +1,40 @@ +
+ + Home + Apps + + + Apps + + @if ($installedApps->isEmpty()) +
+ No apps installed + Installed apps and integrations will appear here. +
+ @else +
+ @foreach ($installedApps as $installation) +
+
+ +
+
+ {{ $installation->app?->name ?? 'Unknown app' }} +

Installed {{ $installation->installed_at?->diffForHumans() }}

+
+ {{ ucfirst($installation->status->value) }} + Uninstall +
+ @endforeach +
+ @endif + + + Uninstall this app? + The app will lose access to your store data. Its webhook subscriptions will stop receiving events. +
+ Cancel + Uninstall +
+
+
diff --git a/resources/views/livewire/admin/apps/show.blade.php b/resources/views/livewire/admin/apps/show.blade.php new file mode 100644 index 00000000..5d862bfb --- /dev/null +++ b/resources/views/livewire/admin/apps/show.blade.php @@ -0,0 +1,66 @@ +
+ + Home + Apps + {{ $installation->app?->name ?? 'App' }} + + +
+ {{ $installation->app?->name ?? 'Unknown app' }} + {{ ucfirst($installation->status->value) }} +
+ Installed {{ $installation->installed_at?->format('M j, Y g:i A') }} + +
+
+ Scopes granted + @php $scopes = is_array($installation->scopes_json) ? $installation->scopes_json : []; @endphp + @if ($scopes === []) + No scopes granted. + @else +
    + @foreach ($scopes as $scope) +
  • {{ $scope }}
  • + @endforeach +
+ @endif +
+ +
+ Usage +
+

Webhook deliveries: {{ number_format($deliveryStats['count']) }}

+

Last delivery: {{ $deliveryStats['last_at'] ? \Carbon\Carbon::parse($deliveryStats['last_at'])->diffForHumans() : 'Never' }}

+

OAuth tokens: {{ $installation->oauthTokens->count() }}

+
+
+
+ +
+ Webhook subscriptions + @if ($installation->webhookSubscriptions->isEmpty()) + No webhook subscriptions for this app. + @else +
+ + + + + + + + + + @foreach ($installation->webhookSubscriptions as $subscription) + + + + + + @endforeach + +
Event typeURLStatus
{{ $subscription->event_type }}{{ $subscription->target_url }}{{ ucfirst($subscription->status->value) }}
+
+ @endif +
+
diff --git a/resources/views/livewire/admin/collections/form.blade.php b/resources/views/livewire/admin/collections/form.blade.php new file mode 100644 index 00000000..eb0804fd --- /dev/null +++ b/resources/views/livewire/admin/collections/form.blade.php @@ -0,0 +1,99 @@ +
+ + Home + Collections + {{ $isEditing ? $this->collection->title : 'Add collection' }} + + + {{ $isEditing ? $this->collection->title : 'Add collection' }} + +
+
+
+ + Title + + + + + Handle + + + + + Description + + + +
+ +
+ Products + + @if ($this->productSearch !== '') +
+ @forelse ($searchResults as $result) +
+ {{ $result->title }} + Add +
+ @empty +

No products found.

+ @endforelse +
+ @endif + +

Assigned products

+ @if ($assignedProducts->isEmpty()) + No products assigned yet. + @else +
+ @foreach ($assignedProducts as $product) +
+ @if ($product->productMedia->first()?->storage_key) + + @else +
+ +
+ @endif + {{ $product->title }} + + + + + + + + + +
+ @endforeach +
+ @endif +
+
+ +
+
+ + Status + + Active + Draft + Archived + + + +
+
+
+ +
+ Discard + + Save + Saving... + +
+
diff --git a/resources/views/livewire/admin/collections/index.blade.php b/resources/views/livewire/admin/collections/index.blade.php new file mode 100644 index 00000000..4555076c --- /dev/null +++ b/resources/views/livewire/admin/collections/index.blade.php @@ -0,0 +1,82 @@ +
+ + Home + Collections + + +
+ Collections + @can('create', App\Models\Collection::class) + Create collection + @endcan +
+ +
+ + + All statuses + Active + Draft + Archived + +
+ + @if ($collections->total() === 0 && $this->search === '' && $this->statusFilter === 'all') +
+ + Create your first collection + Group products into curated collections. + @can('create', App\Models\Collection::class) + Create collection + @endcan +
+ @else +
+ + + + + + + + + + + + @forelse ($collections as $collection) + + + + + + + + @empty + + + + @endforelse + +
TitleProductsStatusUpdatedActions
{{ $collection->title }}{{ $collection->products_count }}{{ ucfirst($collection->status->value) }}{{ $collection->updated_at?->diffForHumans() }} + @can('delete', $collection) + + + + @endcan +
No collections match your filters.
+
+ +
+ {{ $collections->links() }} +
+ @endif + + + Delete this collection? + The collection will be removed. Products in the collection will not be deleted. +
+ Cancel + Delete +
+
+
diff --git a/resources/views/livewire/admin/customers/index.blade.php b/resources/views/livewire/admin/customers/index.blade.php new file mode 100644 index 00000000..6ee86a67 --- /dev/null +++ b/resources/views/livewire/admin/customers/index.blade.php @@ -0,0 +1,45 @@ +
+ + Home + Customers + + + Customers + +
+ +
+ +
+ + + + + + + + + + + + @forelse ($customers as $customer) + + + + + + + + @empty + + + + @endforelse + +
NameEmailOrdersTotal spentCreated
{{ $customer->name }}{{ $customer->email }}{{ $customer->orders_count }}{{ $this->money((int) ($customer->orders_sum_total_amount ?? 0)) }}{{ $customer->created_at?->format('M j, Y') }}
No customers found.
+
+ +
+ {{ $customers->links() }} +
+
diff --git a/resources/views/livewire/admin/customers/show.blade.php b/resources/views/livewire/admin/customers/show.blade.php new file mode 100644 index 00000000..6922f2e4 --- /dev/null +++ b/resources/views/livewire/admin/customers/show.blade.php @@ -0,0 +1,115 @@ +
+ + Home + Customers + {{ $customer->name }} + + +
+
+
+ Customer info +
+

Name: {{ $customer->name }}

+

Email: {{ $customer->email }}

+

Created: {{ $customer->created_at?->format('M j, Y') }}

+

Marketing: + {{ $customer->marketing_opt_in ? 'Opted In' : 'Opted Out' }} +

+
+
+ +
+ Order history + @if ($orders->isEmpty()) + No orders yet. + @else +
+ + + + + + + + + + + @foreach ($orders as $order) + + + + + + + @endforeach + +
Order #DateStatusTotal
{{ $order->order_number }}{{ $order->placed_at?->format('M j, Y') }}{{ ucfirst($order->financial_status->value) }}{{ $this->money($order->total_amount, $order->currency) }}
+
+
{{ $orders->links() }}
+ @endif +
+
+ +
+
+
+ Addresses + @can('update', $customer) + Add address + @endcan +
+
+ @forelse ($customer->customerAddresses as $address) +
+
+

{{ $address->label }}

+ @if ($address->is_default) + Default + @endif +
+ @php $json = is_array($address->address_json) ? $address->address_json : []; @endphp +

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

+

{{ $json['city'] ?? '' }}{{ isset($json['zip']) ? ', '.$json['zip'] : '' }}

+ @can('update', $customer) +
+ Edit + Delete + @if (! $address->is_default) + Set default + @endif +
+ @endcan +
+ @empty + No addresses on file. + @endforelse +
+
+
+
+ + + {{ $this->editingAddressId ? 'Edit address' : 'Add address' }} +
+ + + + + +
+ + +
+
+ + +
+ +
+
+ Cancel + Save +
+
+
diff --git a/resources/views/livewire/admin/dashboard.blade.php b/resources/views/livewire/admin/dashboard.blade.php new file mode 100644 index 00000000..03d5e802 --- /dev/null +++ b/resources/views/livewire/admin/dashboard.blade.php @@ -0,0 +1,154 @@ +
+
+ Dashboard + +
+ + Today + Last 7 days + Last 30 days + Custom range + + @if ($this->dateRange === 'custom') + + + @endif +
+
+ +
+ @php + $tiles = [ + ['label' => 'Total Sales', 'value' => $this->money($totalSales), 'change' => $salesChange], + ['label' => 'Orders', 'value' => number_format($ordersCount), 'change' => $ordersChange], + ['label' => 'Avg Order Value', 'value' => $this->money($averageOrderValue), 'change' => $aovChange], + ['label' => 'Visitors', 'value' => number_format($visitorsCount), 'change' => $visitorsChange], + ]; + @endphp + @foreach ($tiles as $tile) +
+ {{ $tile['label'] }} + {{ $tile['value'] }} +
+ {{ ($tile['change'] >= 0 ? '+' : '').$tile['change'] }}% + +
+
+ @endforeach +
+ +
+ Orders over time +
+ @if (count($ordersChartData) === 0) + No orders in this period. + @else + +
+ {{ $from }} + {{ $to }} +
+ @endif +
+
+ +
+
+ Top products + @if (count($topProducts) === 0) + No sales data for this period. + @else +
+ + + + + + + + + + @foreach ($topProducts as $product) + + + + + + @endforeach + +
Product TitleUnits SoldRevenue
{{ $product['title'] }}{{ $product['units_sold'] }}{{ $this->money($product['revenue_amount']) }}
+
+ @endif +
+ +
+ Conversion funnel + @php + $steps = [ + ['label' => 'Visits', 'value' => $funnelData['visits'], 'class' => 'bg-zinc-300 dark:bg-zinc-600'], + ['label' => 'Add to Cart', 'value' => $funnelData['add_to_cart'], 'class' => 'bg-zinc-500 dark:bg-zinc-400'], + ['label' => 'Checkout Started', 'value' => $funnelData['checkout_started'], 'class' => 'bg-zinc-700 dark:bg-zinc-200'], + ['label' => 'Completed', 'value' => $funnelData['checkout_completed'], 'class' => 'bg-zinc-900 dark:bg-white'], + ]; + $funnelMax = max(1, $funnelData['visits']); + @endphp +
+ @foreach ($steps as $step) +
+ {{ $step['label'] }} +
+
+
+ {{ number_format($step['value']) }} +
+ @endforeach +
+
+
+ +
+
+ Recent orders + View all +
+ @if ($recentOrders->isEmpty()) + No orders yet. + @else +
+ + + + + + + + + + + @foreach ($recentOrders as $order) + + + + + + + @endforeach + +
OrderCustomerStatusTotal
{{ $order->order_number }}{{ $order->customer?->name ?? 'Guest' }}{{ ucfirst($order->financial_status->value) }}{{ $this->money($order->total_amount, $order->currency) }}
+
+ @endif +
+
diff --git a/resources/views/livewire/admin/developers/index.blade.php b/resources/views/livewire/admin/developers/index.blade.php new file mode 100644 index 00000000..5e5a85ab --- /dev/null +++ b/resources/views/livewire/admin/developers/index.blade.php @@ -0,0 +1,131 @@ +
+ + Home + Developers + + + Developers + +
+ API tokens + Manage personal access tokens for the Admin API. + +
+ + + + + + + + + + + @forelse ($tokens as $token) + + + + + + + @empty + + + + @endforelse + +
NameLast usedCreatedActions
{{ $token->name }}{{ $token->last_used_at?->diffForHumans() ?? 'Never' }}{{ $token->created_at?->format('M j, Y') }} + Revoke +
No API tokens yet.
+
+ + Generate new token + + @if ($this->generatedToken !== null) + + {{ $this->generatedToken }} +
+ Dismiss +
+
+ @endif +
+ + + +
+ Webhooks + Manage webhook subscriptions for real-time event notifications. + +
+ + + + + + + + + + + @forelse ($webhooks as $webhook) + + + + + + + @empty + + + + @endforelse + +
Event typeURLStatusActions
{{ $webhook->event_type }}{{ $webhook->target_url }} + {{ ucfirst($webhook->status->value) }} + + {{ $webhook->status->value === 'active' ? 'Pause' : 'Resume' }} + + + + + + +
No webhook subscriptions yet.
+
+ + Add webhook +
+ + + Generate API token + +

Abilities

+
+ @foreach ($abilities as $ability) + + @endforeach +
+
+ Cancel + Generate +
+
+ + + {{ $this->editingWebhookId ? 'Edit webhook' : 'Add webhook' }} + + @foreach ($eventTypes as $eventType) + {{ $eventType }} + @endforeach + + +
+ Cancel + Save +
+
+
diff --git a/resources/views/livewire/admin/discounts/form.blade.php b/resources/views/livewire/admin/discounts/form.blade.php new file mode 100644 index 00000000..56d36f86 --- /dev/null +++ b/resources/views/livewire/admin/discounts/form.blade.php @@ -0,0 +1,138 @@ +
+ + Home + Discounts + {{ $isEditing ? ($this->discount->code ?? 'Automatic') : 'Create discount' }} + + + {{ $isEditing ? 'Edit discount' : 'Create discount' }} + +
+
+ + + + +
+ + @if ($this->type === 'code') +
+ + Discount code +
+ + Generate +
+ +
+
+ @endif + +
+ + + + + + @if ($this->valueType !== 'free_shipping') + + {{ $this->valueType === 'percent' ? 'Percentage' : 'Amount' }} + + + + @endif +
+ +
+ Conditions + + Minimum purchase amount + + Leave empty for no minimum + + +

Specific products

+ + @if ($this->productSearch !== '') +
+ @forelse ($productResults as $result) +
+ {{ $result->title }} + Add +
+ @empty +

No products found.

+ @endforelse +
+ @endif + @if ($selectedProducts->isNotEmpty()) +
+ @foreach ($selectedProducts as $product) + + {{ $product->title }} + + + @endforeach +
+ @endif + +

Specific collections

+ + @if ($this->collectionSearch !== '') +
+ @forelse ($collectionResults as $result) +
+ {{ $result->title }} + Add +
+ @empty +

No collections found.

+ @endforelse +
+ @endif + @if ($selectedCollections->isNotEmpty()) +
+ @foreach ($selectedCollections as $collection) + + {{ $collection->title }} + + + @endforeach +
+ @endif +
+ +
+ Usage limits + + +
+ +
+ Active dates +
+ + + End date + + Leave empty for no end date + +
+
+ +
+ +
+
+ +
+ Discard + + Save + Saving... + +
+
diff --git a/resources/views/livewire/admin/discounts/index.blade.php b/resources/views/livewire/admin/discounts/index.blade.php new file mode 100644 index 00000000..8b8fd25c --- /dev/null +++ b/resources/views/livewire/admin/discounts/index.blade.php @@ -0,0 +1,94 @@ +
+ + Home + Discounts + + +
+ Discounts + @can('create', App\Models\Discount::class) + Create discount + @endcan +
+ +
+ + + All statuses + Active + Scheduled + Expired + Disabled + +
+ +
+ + + + + + + + + + + + + + @forelse ($discounts as $discount) + + + + + + + + + + @empty + + + + @endforelse + +
CodeTypeValueUsageStatusDatesActions
+ {{ $discount->code ?? 'Automatic' }} + {{ ucfirst($discount->type->value) }} + @if ($discount->value_type->value === 'percent') + {{ $discount->value_amount }}% + @elseif ($discount->value_type->value === 'fixed') + {{ $this->money($discount->value_amount) }} + @else + Free shipping + @endif + {{ $discount->usage_count }} / {{ $discount->usage_limit ?? 'unlimited' }} + @php + $exhausted = $discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit; + $derived = $exhausted + ? 'exhausted' + : ($discount->starts_at && $discount->starts_at->isFuture() ? 'scheduled' : $discount->status->value); + @endphp + {{ ucfirst($derived) }} + {{ $discount->starts_at?->format('M j, Y') }} - {{ $discount->ends_at?->format('M j, Y') ?? 'No end' }} + @can('delete', $discount) + + + + @endcan +
No discounts found.
+
+ +
+ {{ $discounts->links() }} +
+ + + Delete this discount? + The discount will be permanently removed and can no longer be applied. +
+ Cancel + Delete +
+
+
diff --git a/resources/views/livewire/admin/inventory/index.blade.php b/resources/views/livewire/admin/inventory/index.blade.php new file mode 100644 index 00000000..0bea0852 --- /dev/null +++ b/resources/views/livewire/admin/inventory/index.blade.php @@ -0,0 +1,67 @@ +
+ + Home + Inventory + + + Inventory + +
+ + + All stock + In stock + Low stock + Out of stock + +
+ +
+ + + + + + + + + + + + + @forelse ($inventoryItems as $item) + + + + + + + + + @empty + + + + @endforelse + +
ProductVariantSKUOn HandReservedPolicy
{{ $item->variant?->product?->title ?? '-' }}{{ $item->variant?->optionValues->pluck('value')->implode(' / ') ?: 'Default' }}{{ $item->variant?->sku ?? '-' }} + + {{ $item->quantity_reserved }} + + Deny + Continue + +
No inventory items found.
+
+ +
+ {{ $inventoryItems->links() }} +
+
diff --git a/resources/views/livewire/admin/layout/app.blade.php b/resources/views/livewire/admin/layout/app.blade.php new file mode 100644 index 00000000..925f5442 --- /dev/null +++ b/resources/views/livewire/admin/layout/app.blade.php @@ -0,0 +1,63 @@ + + + + + + {{ $title ?? 'Admin' }} - {{ $currentStore?->name ?? config('app.name') }} + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + @livewireStyles + @fluxAppearance + + + Skip to main content + + + +
+ + +
+ {{ $slot }} +
+
+ +
+ + @if (session('toast')) +
+

{{ session('toast')['message'] }}

+
+ @endif +
+ + @livewireScripts + @fluxScripts + + diff --git a/resources/views/livewire/admin/layout/sidebar.blade.php b/resources/views/livewire/admin/layout/sidebar.blade.php new file mode 100644 index 00000000..b0258ede --- /dev/null +++ b/resources/views/livewire/admin/layout/sidebar.blade.php @@ -0,0 +1,131 @@ +
+
+ + + +
+
diff --git a/resources/views/livewire/admin/layout/top-bar.blade.php b/resources/views/livewire/admin/layout/top-bar.blade.php new file mode 100644 index 00000000..a5be41bd --- /dev/null +++ b/resources/views/livewire/admin/layout/top-bar.blade.php @@ -0,0 +1,34 @@ +
+
+ + + + + + + {{ $currentStore?->name ?? 'Select store' }} + + + @foreach ($stores as $store) + + {{ $store->name }} + + @endforeach + + +
+ +
+ + + + Settings + +
+ @csrf + Log out +
+
+
+
+
diff --git a/resources/views/livewire/admin/navigation/index.blade.php b/resources/views/livewire/admin/navigation/index.blade.php new file mode 100644 index 00000000..ed4aa919 --- /dev/null +++ b/resources/views/livewire/admin/navigation/index.blade.php @@ -0,0 +1,93 @@ +
+ + Home + Navigation + + + Navigation + + Menus +
+ @foreach ($menus as $menu) +
+
+

{{ $menu->title }}

+

{{ $menu->handle }}

+
+ Edit +
+ @endforeach +
+ + @if ($editingMenu !== null) +
+
+ {{ $editingMenu->title }} + @can('manage', App\Models\NavigationMenu::class) + + Add item + + @endcan +
+ +
+ @forelse ($this->menuItems as $index => $item) +
+ +
+

{{ $item['label'] }}

+

{{ $item['type'] }}: {{ $item['type'] === 'link' ? $item['url'] : '#'.$item['resource_id'] }}

+
+ @can('manage', App\Models\NavigationMenu::class) + + + + + + + + + + + + + @endcan +
+ @empty + No items in this menu yet. + @endforelse +
+ + @can('manage', App\Models\NavigationMenu::class) +
+ Save menu +
+ @endcan +
+ @endif + + + {{ $this->editingItemIndex !== null ? 'Edit menu item' : 'Add menu item' }} + + + Custom link + Page + Collection + Product + + @if ($this->itemType === 'link') + + @else + + Select... + @foreach ($resources as $resource) + {{ $resource->title }} + @endforeach + + @endif +
+ Cancel + Save item +
+
+
diff --git a/resources/views/livewire/admin/orders/index.blade.php b/resources/views/livewire/admin/orders/index.blade.php new file mode 100644 index 00000000..01227da1 --- /dev/null +++ b/resources/views/livewire/admin/orders/index.blade.php @@ -0,0 +1,67 @@ +
+ + Home + Orders + + + Orders + +
+ +
+ +
+ @php + $tabs = ['all' => 'All', 'pending' => 'Pending', 'paid' => 'Paid', 'fulfilled' => 'Fulfilled', 'cancelled' => 'Cancelled', 'refunded' => 'Refunded']; + @endphp + @foreach ($tabs as $value => $label) + + @endforeach +
+ +
+ + + + + + + + + + + + + @forelse ($orders as $order) + + + + + + + + + @empty + + + + @endforelse + +
CustomerPaymentFulfillment
{{ $order->order_number }}{{ $order->placed_at?->format('M j, Y g:i A') }}{{ $order->customer?->name ?? 'Guest' }} + {{ ucfirst(str_replace('_', ' ', $order->financial_status->value)) }} + + {{ ucfirst($order->fulfillment_status->value) }} + {{ $this->money($order->total_amount, $order->currency) }}
No orders found.
+
+ +
+ {{ $orders->links() }} +
+
diff --git a/resources/views/livewire/admin/orders/show.blade.php b/resources/views/livewire/admin/orders/show.blade.php new file mode 100644 index 00000000..83f61141 --- /dev/null +++ b/resources/views/livewire/admin/orders/show.blade.php @@ -0,0 +1,243 @@ +
+ + Home + Orders + {{ $order->order_number }} + + +
+
+
+
+ {{ $order->order_number }} + {{ ucfirst(str_replace('_', ' ', $order->financial_status->value)) }} + {{ ucfirst($order->fulfillment_status->value) }} +
+ {{ $order->placed_at?->format('M j, Y g:i A') }} + +
+ @if ($showConfirmPayment) + @can('confirmPayment', $order) + Confirm payment + @endcan + @endif + @if ($order->fulfillment_status->value !== 'fulfilled') + @can('createFulfillment', $order) + Create fulfillment + @endcan + @endif + @if ($showRefund) + @can('createRefund', $order) + Refund + @endcan + @endif +
+ + @if (! $canFulfill && $order->fulfillment_status->value !== 'fulfilled') + + Payment must be confirmed before items can be fulfilled. Current financial status: {{ $order->financial_status->value }}. + + @endif +
+ +
+ Timeline +
    +
  1. + +

    Order placed

    +

    {{ $order->placed_at?->format('M j, Y g:i A') }}

    +
  2. + @foreach ($order->payments->filter(fn ($p) => $p->status->value === 'captured') as $paymentEvent) +
  3. + +

    Payment received

    +

    {{ $this->money($paymentEvent->amount, $paymentEvent->currency) }} via {{ str_replace('_', ' ', $paymentEvent->method->value) }}

    +
  4. + @endforeach + @foreach ($order->fulfillments as $timelineFulfillment) +
  5. + +

    Fulfillment {{ $timelineFulfillment->status->value }}

    +

    {{ $timelineFulfillment->shipped_at?->format('M j, Y g:i A') ?? 'Pending' }}

    +
  6. + @endforeach + @foreach ($order->refunds as $timelineRefund) +
  7. + +

    Refund {{ $timelineRefund->status->value }}

    +

    {{ $this->money($timelineRefund->amount, $order->currency) }}

    +
  8. + @endforeach +
+
+ +
+ Order lines +
+ + + + + + + + + + + + @foreach ($order->orderLines as $line) + + + + + + + + @endforeach + +
ImageProductQtyUnit PriceTotal
+ @if ($line->variant?->product?->productMedia->first()?->storage_key) + + @else +
+ +
+ @endif +
{{ $line->title_snapshot }}
{{ $line->sku_snapshot }}
{{ $line->quantity }}{{ $this->money($line->unit_price_amount, $order->currency) }}{{ $this->money($line->total_amount, $order->currency) }}
+
+
+
Subtotal{{ $this->money($order->subtotal_amount, $order->currency) }}
+
Discount-{{ $this->money($order->discount_amount, $order->currency) }}
+
Shipping{{ $this->money($order->shipping_amount, $order->currency) }}
+
Tax{{ $this->money($order->tax_amount, $order->currency) }}
+
Total{{ $this->money($order->total_amount, $order->currency) }}
+
+
+ +
+ Payment details + @if ($payment) +
+
+ {{ ucwords(str_replace('_', ' ', $order->payment_method->value)) }} + {{ ucfirst($payment->status->value) }} +
+

Amount: {{ $this->money($payment->amount, $payment->currency) }}

+

Ref: {{ $payment->provider_payment_id }}

+ @if ($showConfirmPayment) + @can('confirmPayment', $order) +
Confirm payment
+ @endcan + @endif +
+ @else + No payment recorded. + @endif +
+ + @foreach ($order->fulfillments as $fulfillment) +
+
+ {{ ucfirst($fulfillment->status->value) }} +
+ @if ($fulfillment->status->value === 'pending') + Mark as shipped + @elseif ($fulfillment->status->value === 'shipped') + Mark as delivered + @endif +
+
+ @if ($fulfillment->tracking_company || $fulfillment->tracking_number) +

{{ $fulfillment->tracking_company }} {{ $fulfillment->tracking_number }}

+ @if ($fulfillment->tracking_url) + Track shipment + @endif + @endif +
    + @foreach ($fulfillment->fulfillmentLines as $fulfillmentLine) +
  • {{ $fulfillmentLine->orderLine?->title_snapshot }} x {{ $fulfillmentLine->quantity }}
  • + @endforeach +
+
+ @endforeach +
+ +
+
+ Customer + +

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

+

{{ $order->email }}

+ @if ($order->customer) + View customer + @endif +
+ +
+ Shipping address + + @php $shipping = $order->shipping_address_json ?? []; @endphp +

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

+

{{ $shipping['city'] ?? '' }}{{ isset($shipping['province']) && $shipping['province'] !== '' ? ', '.$shipping['province'] : '' }} {{ $shipping['zip'] ?? $shipping['postal_code'] ?? '' }}

+

{{ $shipping['country'] ?? '' }}

+
+ +
+ Billing address + + @php $billing = $order->billing_address_json ?? []; @endphp +

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

+

{{ $billing['city'] ?? '' }}{{ isset($billing['province']) && $billing['province'] !== '' ? ', '.$billing['province'] : '' }} {{ $billing['zip'] ?? $billing['postal_code'] ?? '' }}

+

{{ $billing['country'] ?? '' }}

+
+
+
+ + + Create fulfillment +
+ @foreach ($order->orderLines as $line) + @php $unfulfilled = $line->quantity - $line->fulfillmentLines->sum('quantity'); @endphp + @if ($unfulfilled > 0) +
+ {{ $line->title_snapshot }} ({{ $unfulfilled }} unfulfilled) + +
+ @endif + @endforeach +
+ + + + +
+ Cancel + Create fulfillment +
+
+ + + Refund order +
+ @foreach ($order->orderLines as $line) +
+ + {{ $line->title_snapshot }} + +
+ @endforeach +
+ + + + +
+ Cancel + Create refund +
+
+
diff --git a/resources/views/livewire/admin/pages/form.blade.php b/resources/views/livewire/admin/pages/form.blade.php new file mode 100644 index 00000000..ee18486e --- /dev/null +++ b/resources/views/livewire/admin/pages/form.blade.php @@ -0,0 +1,70 @@ +
+ + Home + Pages + {{ $isEditing ? $this->page->title : 'Add page' }} + + +
+ {{ $isEditing ? $this->page->title : 'Add page' }} + @if ($isEditing) + @can('delete', $this->page) + Delete + @endcan + @endif +
+ +
+
+ + Title + + + + + Handle + + + + + Body + + + +
+ +
+ + Status + + Draft + Published + Archived + + + + + Published at + + + +
+
+ +
+ Discard + + Save + Saving... + +
+ + + Delete this page? + The page will be permanently removed from your store. +
+ Cancel + Delete +
+
+
diff --git a/resources/views/livewire/admin/pages/index.blade.php b/resources/views/livewire/admin/pages/index.blade.php new file mode 100644 index 00000000..228637d8 --- /dev/null +++ b/resources/views/livewire/admin/pages/index.blade.php @@ -0,0 +1,65 @@ +
+ + Home + Pages + + +
+ Pages + @can('create', App\Models\Page::class) + Create page + @endcan +
+ +
+ +
+ +
+ + + + + + + + + + + + @forelse ($pages as $page) + + + + + + + + @empty + + + + @endforelse + +
TitleHandleStatusUpdatedActions
{{ $page->title }}{{ $page->handle }}{{ ucfirst($page->status->value) }}{{ $page->updated_at?->diffForHumans() }} + @can('delete', $page) + + + + @endcan +
No pages found.
+
+ +
+ {{ $pages->links() }} +
+ + + Delete this page? + The page will be permanently removed from your store. +
+ Cancel + Delete +
+
+
diff --git a/resources/views/livewire/admin/products/form.blade.php b/resources/views/livewire/admin/products/form.blade.php new file mode 100644 index 00000000..6d854e53 --- /dev/null +++ b/resources/views/livewire/admin/products/form.blade.php @@ -0,0 +1,179 @@ +
+ + Home + Products + {{ $isEditing ? $this->product->title : 'Add product' }} + + +
+ {{ $isEditing ? $this->product->title : 'Add product' }} + @if ($isEditing) + @can('delete', $this->product) + Delete + @endcan + @endif +
+ +
+
+
+ + Title + + + + + Description + + + +
+ +
+ Media + +
+
+
+ @if (count($this->media) > 0) +
+ @foreach ($this->media as $item) +
+ {{ $item['alt_text'] }} +
+ + + +
+ +
+ @endforeach +
+ @endif +
+ +
+ Variants + @foreach ($this->options as $index => $option) +
+ + + + + +
+ @endforeach + + Add another option + + + @if (count($this->variants) > 0) +
+ + + + + + + + + + + + + @foreach ($this->variants as $index => $variant) + + + + + + + + + @error("variants.{$index}.price") @enderror + @endforeach + +
VariantSKUPriceCompare atQtyShips
{{ $variant['label'] }}
{{ $message }}
+
+ @endif +
+ +
+ + @if ($this->seoOpen) + + URL handle + + + + @endif +
+
+ +
+
+ + Status + + Draft + Active + Archived + + + + + Published at + + + +
+ +
+ Organization + + + + Tags + + Separate tags with commas + +
+ +
+ Collections +
+ @foreach ($availableCollections as $collection) + + @endforeach +
+
+
+
+ +
+ Discard + + Save + Saving... + +
+ + + Delete this product? + This product will be archived. Products with existing orders cannot be permanently removed. +
+ Cancel + Delete +
+
+
diff --git a/resources/views/livewire/admin/products/index.blade.php b/resources/views/livewire/admin/products/index.blade.php new file mode 100644 index 00000000..51e5f48f --- /dev/null +++ b/resources/views/livewire/admin/products/index.blade.php @@ -0,0 +1,107 @@ +
+ + Home + Products + + +
+ Products + @can('create', App\Models\Product::class) + Add product + @endcan +
+ +
+ + + All statuses + Draft + Active + Archived + + + All types + @foreach ($productTypes as $type) + {{ $type }} + @endforeach + +
+ + @if (count($this->selectedIds) > 0) +
+ {{ count($this->selectedIds) }} products selected + Set Active + Archive + Delete +
+ @endif + + @if ($products->total() === 0 && $this->search === '' && $this->statusFilter === 'all' && $this->typeFilter === 'all') +
+ + Add your first product + Start building your catalog by adding products. + @can('create', App\Models\Product::class) + Add product + @endcan +
+ @else +
+ + + + + + + + + + + + + + + @forelse ($products as $product) + + + + + + + + + + + @empty + + + + @endforelse + +
ImageStatusInventoryTypeVendor
+ @if ($product->productMedia->first()?->storage_key) + + @else +
+ +
+ @endif +
{{ $product->title }} + {{ ucfirst($product->status->value) }} + {{ $product->productVariants->sum(fn ($v) => $v->inventoryItem?->quantity_on_hand ?? 0) }}{{ $product->product_type ?? '-' }}{{ $product->vendor ?? '-' }}{{ $product->updated_at?->diffForHumans() }}
No products match your filters.
+
+ +
+ {{ $products->links() }} +
+ @endif + + + Delete products? + This will archive {{ count($this->selectedIds) }} product(s). Products with orders cannot be permanently deleted. +
+ Cancel + Delete +
+
+
diff --git a/resources/views/livewire/admin/search/settings.blade.php b/resources/views/livewire/admin/search/settings.blade.php new file mode 100644 index 00000000..eafadbf2 --- /dev/null +++ b/resources/views/livewire/admin/search/settings.blade.php @@ -0,0 +1,63 @@ +
+ + Home + Search Settings + + + Search Settings + +
+
+ Synonyms + Define groups of words that should be treated as equivalent. +
+ @foreach ($this->synonymGroups as $index => $group) +
+ + + + +
+ @endforeach +
+ + Add synonym group + +
+ +
+ + Stop words + Words that are excluded from search indexing. + + Separate words with commas. +
+ +
isReindexing) wire:poll.2s="pollReindexStatus" @endif + > + + Search index +
+ + {{ $this->isReindexing ? 'Reindexing...' : 'Reindex now' }} + + {{ $documentCount }} documents indexed +
+ @if ($this->isReindexing) +
+
+
+ {{ $this->reindexProgress ?? 0 }}% complete + @endif +
+ +
+ + Save + Saving... + +
+
+
diff --git a/resources/views/livewire/admin/settings/domains.blade.php b/resources/views/livewire/admin/settings/domains.blade.php new file mode 100644 index 00000000..eb291cf0 --- /dev/null +++ b/resources/views/livewire/admin/settings/domains.blade.php @@ -0,0 +1,56 @@ +
+
+ Domains + Add domain +
+ +
+ + + + + + + + + + + + @forelse ($domains as $domain) + + + + + + + + @empty + + + + @endforelse + +
HostnameTypePrimaryTLSActions
{{ $domain->hostname }}{{ $domain->type->value }}@if ($domain->is_primary)Primary@endif{{ $domain->tls_mode->value }} + @if (! $domain->is_primary) + Set Primary + + + + @endif +
No domains configured.
+
+ + + Add domain + + + Storefront + Admin + API + +
+ Cancel + Add domain +
+
+
diff --git a/resources/views/livewire/admin/settings/general.blade.php b/resources/views/livewire/admin/settings/general.blade.php new file mode 100644 index 00000000..6d1adb9d --- /dev/null +++ b/resources/views/livewire/admin/settings/general.blade.php @@ -0,0 +1,72 @@ +
+
+
+ Store details + Basic information about your store. +
+
+ + Store name + + + + + Store handle + + The store handle cannot be changed after creation. + + + Contact email + + + +
+
+ + + +
+
+ Defaults + Currency, language, and timezone settings. +
+
+ + Default currency + + @foreach (['EUR', 'USD', 'GBP', 'CHF', 'JPY', 'CAD', 'AUD'] as $currency) + {{ $currency }} + @endforeach + + + + + Default locale + + English + German + French + Spanish + Italian + + + + + Timezone + + @foreach ($timezones as $timezone) + {{ $timezone }} + @endforeach + + + +
+
+ +
+ + Save + Saving... + +
+
diff --git a/resources/views/livewire/admin/settings/index.blade.php b/resources/views/livewire/admin/settings/index.blade.php new file mode 100644 index 00000000..a9521349 --- /dev/null +++ b/resources/views/livewire/admin/settings/index.blade.php @@ -0,0 +1,21 @@ +
+ + Home + Settings + + + Store Settings + +
+ + + Shipping + Taxes +
+ + @if ($this->tab === 'general') + + @else + + @endif +
diff --git a/resources/views/livewire/admin/settings/shipping.blade.php b/resources/views/livewire/admin/settings/shipping.blade.php new file mode 100644 index 00000000..356ac098 --- /dev/null +++ b/resources/views/livewire/admin/settings/shipping.blade.php @@ -0,0 +1,153 @@ +
+ + Home + Settings + Shipping + + +
+ Shipping + Add zone +
+ +
+ @forelse ($zones as $zone) +
+
+ Zone: {{ $zone->name }} +
+ Edit + Delete +
+
+ Countries: {{ implode(', ', (array) ($zone->countries_json ?? [])) }} + +
+ + + + + + + + + + + + @forelse ($zone->shippingRates as $rate) + + + + + + + + @empty + + + + @endforelse + +
NameTypeConfigActiveActions
{{ $rate->name }}{{ $rate->type->value }}{{ $this->money((int) (is_array($rate->config_json) ? ($rate->config_json['amount'] ?? 0) : 0)) }} + + + Edit + Delete +
No rates configured.
+
+ Add rate +
+ @empty +
+ No shipping zones configured yet. +
+ @endforelse +
+ +
+ Test shipping address + Enter an address to see which shipping zone and rates match. +
+ + @foreach ($countries as $code => $name) + {{ $name }} + @endforeach + + + + +
+ Test + @if ($this->testResult !== null) +
+ @if ($this->testResult['matched']) +

Matched zone: {{ $this->testResult['zone_name'] }}

+
    + @foreach ($this->testResult['rates'] as $rate) +
  • {{ $rate['name'] }} - {{ $rate['price'] }}
  • + @endforeach +
+ @else + + No shipping zone matches this address. + + @endif +
+ @endif +
+ + + {{ $this->editingZoneId ? 'Edit shipping zone' : 'Add shipping zone' }} + +

Countries

+
+ @foreach ($countries as $code => $name) + + @endforeach +
+
+ Cancel + Save zone +
+
+ + + {{ $this->editingRateId ? 'Edit shipping rate' : 'Add shipping rate' }} + + + Flat rate + Weight-based + Price-based + Carrier-calculated + + @if ($this->rateType === 'flat') + + @elseif ($this->rateType === 'weight') +
+ + + +
+ @elseif ($this->rateType === 'price') +
+ + + +
+ @else + + Carrier-calculated rates require a carrier integration to be configured. + + @endif +
+ +
+
+ Cancel + Save rate +
+
+
diff --git a/resources/views/livewire/admin/settings/taxes.blade.php b/resources/views/livewire/admin/settings/taxes.blade.php new file mode 100644 index 00000000..f331a584 --- /dev/null +++ b/resources/views/livewire/admin/settings/taxes.blade.php @@ -0,0 +1,70 @@ +
+ + Home + Settings + Taxes + + + Tax Settings + +
+
+ + + + +
+ + @if ($this->mode === 'manual') +
+ Manual rates + + + + + + + + + + @foreach ($this->manualRates as $index => $rate) + + + + + + @endforeach + +
Zone nameRate (%)Actions
+ + + +
+ + Add rate + +
+ @else +
+ Provider config + + None + Stripe Tax + + +
+ @endif + +
+ + +
+ +
+ + Save + Saving... + +
+
+
diff --git a/resources/views/livewire/admin/themes/editor.blade.php b/resources/views/livewire/admin/themes/editor.blade.php new file mode 100644 index 00000000..8960469c --- /dev/null +++ b/resources/views/livewire/admin/themes/editor.blade.php @@ -0,0 +1,72 @@ +
+
+ + Back to themes + +
+ Save + Save and publish +
+
+ +
+
+ Sections +
+ @foreach ($sections as $key => $section) + + @endforeach +
+
+ +
+
+ +
+
+ +
+ @if ($this->selectedSection === null) +

Select a section to edit its settings.

+ @else + {{ $sections[$this->selectedSection]['label'] }} settings + +
+ @foreach ($sections[$this->selectedSection]['fields'] as $field) +
+ @if ($field['type'] === 'text') + + @elseif ($field['type'] === 'textarea') + + @elseif ($field['type'] === 'color') + + {{ $field['label'] }} + + + @elseif ($field['type'] === 'select') + + {{ $field['label'] }} + + @foreach ($field['options'] as $value => $label) + {{ $label }} + @endforeach + + + @elseif ($field['type'] === 'checkbox') + + @endif +
+ @endforeach +
+ @endif +
+
+
diff --git a/resources/views/livewire/admin/themes/index.blade.php b/resources/views/livewire/admin/themes/index.blade.php new file mode 100644 index 00000000..2bcf0762 --- /dev/null +++ b/resources/views/livewire/admin/themes/index.blade.php @@ -0,0 +1,61 @@ +
+ + Home + Themes + + + Themes + + @if ($themes->isEmpty()) +
+ No themes yet. +
+ @else +
+ @foreach ($themes as $theme) +
+
+ +
+
+
+ {{ $theme->name }} + v{{ $theme->version }} +
+
+ {{ ucfirst($theme->status->value) }} +
+
+ Customize + + + + + + @if ($theme->status->value !== 'published') + Publish + @endif + Duplicate + + Delete + + +
+
+
+ @endforeach +
+ @endif + + + Delete this theme? + The theme and its settings will be permanently removed. +
+ Cancel + Delete +
+
+
diff --git a/resources/views/livewire/storefront/account/addresses/index.blade.php b/resources/views/livewire/storefront/account/addresses/index.blade.php new file mode 100644 index 00000000..15d6a71f --- /dev/null +++ b/resources/views/livewire/storefront/account/addresses/index.blade.php @@ -0,0 +1,52 @@ +
+ +
+

Your Addresses

+ Add new address +
+ + @if ($addresses->isEmpty()) +

You have no saved addresses yet.

+ @else +
+ @foreach ($addresses as $record) + @php $a = (array) $record->address_json; @endphp +
+ @if ($record->is_default) + + @endif +

{{ $record->label ?? 'Address' }}

+

+ {{ $a['first_name'] ?? '' }} {{ $a['last_name'] ?? '' }}
+ {{ $a['address1'] ?? '' }}
+ {{ $a['city'] ?? '' }} {{ $a['postal_code'] ?? '' }}
+ {{ $a['country'] ?? $a['country_code'] ?? '' }} +

+
+ + @if ($confirmingDelete === $record->getKey()) + + @else + + @endif + @if (! $record->is_default) + + @endif +
+
+ @endforeach +
+ @endif + + + {{ $editingId ? 'Edit address' : 'Add new address' }} +
+ + +
+ Cancel + Save address +
+
+
+
diff --git a/resources/views/livewire/storefront/account/dashboard.blade.php b/resources/views/livewire/storefront/account/dashboard.blade.php new file mode 100644 index 00000000..1380b240 --- /dev/null +++ b/resources/views/livewire/storefront/account/dashboard.blade.php @@ -0,0 +1,56 @@ +
+

Welcome back, {{ $customer->name ?? 'friend' }}!

+ + + +

Recent Orders

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

You have not placed any orders yet.

+ @else +
+ + + + + + + + + + + + @foreach ($orders as $order) + + + + + + + + @endforeach + +
OrderDateStatusTotalAction
{{ $order->order_number }}{{ $order->placed_at?->format('M d, Y') }}{{ \App\Support\Money::format($order->total_amount, $order->currency) }}View
+
+ @endif + +
+ @csrf + Log out +
+
diff --git a/resources/views/livewire/storefront/account/orders/index.blade.php b/resources/views/livewire/storefront/account/orders/index.blade.php new file mode 100644 index 00000000..4b43f91d --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/index.blade.php @@ -0,0 +1,44 @@ +
+ +

Order History

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

You have not placed any orders yet.

+ @else + + + + @endif +
diff --git a/resources/views/livewire/storefront/account/orders/show.blade.php b/resources/views/livewire/storefront/account/orders/show.blade.php new file mode 100644 index 00000000..8a13bb57 --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/show.blade.php @@ -0,0 +1,72 @@ +
+ + +
+

Order {{ $order->order_number }}

+ + +
+

Placed on {{ $order->placed_at?->format('F j, Y') }}

+ +
+

Items

+
    + @foreach ($order->orderLines as $line) +
  • + {{ $line->title_snapshot }} x{{ $line->quantity }} + {{ \App\Support\Money::format($line->total_amount, $order->currency) }} +
  • + @endforeach +
+
+ +
+
+

Shipping Address

+ @php $a = (array) $order->shipping_address_json; @endphp +

+ {{ $a['first_name'] ?? '' }} {{ $a['last_name'] ?? '' }}
+ {{ $a['address1'] ?? '' }}
+ {{ $a['city'] ?? '' }} {{ $a['postal_code'] ?? '' }} +

+
+
+

Billing Address

+ @php $b = (array) $order->billing_address_json; @endphp +

+ {{ $b['first_name'] ?? '' }} {{ $b['last_name'] ?? '' }}
+ {{ $b['address1'] ?? '' }}
+ {{ $b['city'] ?? '' }} {{ $b['postal_code'] ?? '' }} +

+
+
+

Payment

+

{{ ucfirst(str_replace('_', ' ', $order->payment_method?->value ?? 'unknown')) }}

+
+
+ +
+
Subtotal
{{ \App\Support\Money::format($order->subtotal_amount, $order->currency) }}
+
Shipping
{{ \App\Support\Money::format($order->shipping_amount, $order->currency) }}
+
Tax
{{ \App\Support\Money::format($order->tax_amount, $order->currency) }}
+
Discount
-{{ \App\Support\Money::format($order->discount_amount, $order->currency) }}
+
Total
{{ \App\Support\Money::format($order->total_amount, $order->currency) }}
+
+ + @if ($order->fulfillments->isNotEmpty()) +
+

Fulfillment

+ @foreach ($order->fulfillments as $fulfillment) +
+

Status: {{ ucfirst($fulfillment->status->value) }}

+ @if ($fulfillment->tracking_number) +

Shipped via {{ $fulfillment->tracking_company ?? 'carrier' }} - {{ $fulfillment->tracking_number }}

+ @endif + @if ($fulfillment->tracking_url) + Track shipment + @endif +
+ @endforeach +
+ @endif +
diff --git a/resources/views/livewire/storefront/account/profile.blade.php b/resources/views/livewire/storefront/account/profile.blade.php new file mode 100644 index 00000000..c3742bf6 --- /dev/null +++ b/resources/views/livewire/storefront/account/profile.blade.php @@ -0,0 +1,24 @@ +
+ +

Profile

+ + @if ($status) +

{{ $status }}

+ @endif + +
+
+ + @error('name')

{{ $message }}

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

{{ $message }}

@enderror +
+ + Save changes +
+
diff --git a/resources/views/livewire/storefront/cart-count.blade.php b/resources/views/livewire/storefront/cart-count.blade.php new file mode 100644 index 00000000..c6293d8f --- /dev/null +++ b/resources/views/livewire/storefront/cart-count.blade.php @@ -0,0 +1,5 @@ +
+ @if ($count > 0) + {{ $count }} + @endif +
diff --git a/resources/views/livewire/storefront/cart-drawer.blade.php b/resources/views/livewire/storefront/cart-drawer.blade.php new file mode 100644 index 00000000..2c20bf1e --- /dev/null +++ b/resources/views/livewire/storefront/cart-drawer.blade.php @@ -0,0 +1,93 @@ +
+ @if ($open) + + @endif +
diff --git a/resources/views/livewire/storefront/cart/show.blade.php b/resources/views/livewire/storefront/cart/show.blade.php new file mode 100644 index 00000000..330d5389 --- /dev/null +++ b/resources/views/livewire/storefront/cart/show.blade.php @@ -0,0 +1,110 @@ +
+

Your Cart

+ + @if ($cartNotice) +

{{ $cartNotice }}

+ @endif + + @if ($lines === []) +
+ +

Your cart is empty

+ Continue shopping +
+ @else + + +
    + @foreach ($lines as $line) +
  • + @if ($line['image_url']) + + @endif +
    +

    {{ $line['title'] }}

    +

    {{ \App\Support\Money::format($line['total'], $line['currency']) }}

    + @if ($line['is_backorder'] ?? false) +

    Available on backorder

    + @endif +
    +
    + + {{ $line['quantity'] }} + +
    + +
    +
    +
  • + @endforeach +
+ +
+ @if ($appliedCode) +

+ {{ $appliedCode }} applied + +

+ @else +
+ + Apply +
+ @error('discountCodeInput')

{{ $message }}

@enderror + @endif +

Subtotal: {{ \App\Support\Money::format($totals['subtotal'], $totals['currency']) }}

+ @if ($totals['discount'] > 0) +

Discount: -{{ \App\Support\Money::format($totals['discount'], $totals['currency']) }}

+ @endif +

Total: {{ \App\Support\Money::format($totals['total'], $totals['currency']) }}

+ Checkout + Continue shopping +
+ @endif +
diff --git a/resources/views/livewire/storefront/checkout/confirmation.blade.php b/resources/views/livewire/storefront/checkout/confirmation.blade.php new file mode 100644 index 00000000..043efd56 --- /dev/null +++ b/resources/views/livewire/storefront/checkout/confirmation.blade.php @@ -0,0 +1,69 @@ +
+
+ +
+

Thank you for your order!

+

Order {{ $order->order_number }}

+

We've sent a confirmation to {{ $order->email }}

+ +
+

Order Summary

+
    + @foreach ($order->orderLines as $line) +
  • + {{ $line->title_snapshot }} x{{ $line->quantity }} + {{ \App\Support\Money::format($line->total_amount, $order->currency) }} +
  • + @endforeach +
+ +
+
+

Shipping Address

+ @php $a = (array) $order->shipping_address_json; @endphp +

+ {{ $a['first_name'] ?? '' }} {{ $a['last_name'] ?? '' }}
+ {{ $a['address1'] ?? '' }}
+ {{ $a['city'] ?? '' }} {{ $a['postal_code'] ?? '' }}
+ {{ $a['country'] ?? $a['country_code'] ?? '' }} +

+
+
+

Payment Method

+

{{ $paymentLabel }}

+
+
+ + @if ($bankInstructions) +
+

Bank Transfer Instructions

+

Please transfer the total amount to the following account:

+
+
Bank:
{{ $bankInstructions['bank_name'] }}
+
IBAN:
{{ $bankInstructions['iban'] }}
+
BIC:
{{ $bankInstructions['bic'] }}
+
Amount:
{{ $bankInstructions['amount_formatted'] }}
+
Reference:
{{ $bankInstructions['reference'] }}
+
+

Please complete your transfer within 7 days. Your order will be processed once payment is confirmed by our team.

+
+ @endif + +
+
Subtotal
{{ \App\Support\Money::format($order->subtotal_amount, $order->currency) }}
+
Shipping
{{ \App\Support\Money::format($order->shipping_amount, $order->currency) }}
+
Tax
{{ \App\Support\Money::format($order->tax_amount, $order->currency) }}
+ @if ($order->discount_amount > 0) +
Discount
-{{ \App\Support\Money::format($order->discount_amount, $order->currency) }}
+ @endif +
Total
{{ \App\Support\Money::format($order->total_amount, $order->currency) }}
+
+
+ +
+ Continue shopping + @if ($ownsOrder) + View order + @endif +
+
diff --git a/resources/views/livewire/storefront/checkout/show.blade.php b/resources/views/livewire/storefront/checkout/show.blade.php new file mode 100644 index 00000000..1c56375d --- /dev/null +++ b/resources/views/livewire/storefront/checkout/show.blade.php @@ -0,0 +1,154 @@ +
+

Checkout

+ +
+
+
+

1. Contact information

+ @if ($step > 1) +

{{ $checkout->email }}

+ @else +
+ + @error('email')

{{ $message }}

@enderror + @if ($isGuest) +

Already have an account? Log in

+ @endif + Continue +
+ @endif +
+ +
+

2. Shipping address

+ @if ($step > 1) + @php $a = (array) $checkout->shipping_address_json; @endphp +

{{ $a['first_name'] ?? '' }} {{ $a['last_name'] ?? '' }}, {{ $a['address1'] ?? '' }}, {{ $a['city'] ?? '' }} {{ $a['postal_code'] ?? '' }}, {{ $a['country_code'] ?? '' }}

+ @else +
+ @if ($savedAddresses->isNotEmpty()) +
+ + +
+ @endif + + + @if (! $useShippingAsBilling) +

Billing address

+ + @endif + Continue +
+ @endif +
+ +
+

3. Shipping method

+ @if ($step < 3) +

Complete the previous steps first.

+ @elseif (! $requiresShipping) +

No shipping required for this order.

+ @elseif ($shippingOptions->isEmpty()) +

No shipping methods are available for your address. Please verify your address or contact us.

+ @else +
+ Shipping method + @foreach ($shippingOptions as $option) + + @endforeach +
+ @error('shippingRateId')

{{ $message }}

@enderror + @if ($step === 3) + Continue + @endif + @endif +
+ +
+

4. Payment method

+ @if ($step < 4) +

Complete the previous steps first.

+ @else +
+ Payment method + + + +
+ + @if ($paymentMethod === 'credit_card') +
+
+ + @error('cardNumber')

{{ $message }}

@enderror +
+
+ + @error('cardHolder')

{{ $message }}

@enderror +
+
+ + @error('cardExpiry')

{{ $message }}

@enderror +
+
+ + @error('cardCvc')

{{ $message }}

@enderror +
+
+ @elseif ($paymentMethod === 'paypal') +

Your PayPal payment will be processed securely.

+ @else +

After placing your order, you will receive bank transfer instructions. Your order will be held for 7 days while we await your payment.

+ @endif + + @if ($payError) + + @endif + + + + @if ($paymentMethod === 'paypal') + Pay with PayPal - {{ \App\Support\Money::format($summaryTotals['total'], $currency) }} + @elseif ($paymentMethod === 'bank_transfer') + Place order - {{ \App\Support\Money::format($summaryTotals['total'], $currency) }} + @else + Pay now - {{ \App\Support\Money::format($summaryTotals['total'], $currency) }} + @endif + + Processing... + + @endif +
+
+ +
+
+ +
+
+
+
diff --git a/resources/views/livewire/storefront/collections/filters.blade.php b/resources/views/livewire/storefront/collections/filters.blade.php new file mode 100644 index 00000000..08ac2009 --- /dev/null +++ b/resources/views/livewire/storefront/collections/filters.blade.php @@ -0,0 +1,50 @@ +
+ @if ($hasActiveFilters) + + @endif + +
+ Availability + +
+ +
+ Price Range +
+ + - + +
+
+ + @if ($availableTypes !== []) +
+ Product Type +
+ @foreach ($availableTypes as $type) + + @endforeach +
+
+ @endif + + @if ($availableVendors !== []) +
+ Vendor +
+ @foreach ($availableVendors as $vendor) + + @endforeach +
+
+ @endif +
diff --git a/resources/views/livewire/storefront/collections/index.blade.php b/resources/views/livewire/storefront/collections/index.blade.php new file mode 100644 index 00000000..c5755e39 --- /dev/null +++ b/resources/views/livewire/storefront/collections/index.blade.php @@ -0,0 +1,22 @@ +
+ +

Collections

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

No collections yet.

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

{{ $collection->title }}

+

{{ $collection->products_count }} products

+
+
+
+ @endforeach +
+ + @endif +
diff --git a/resources/views/livewire/storefront/collections/show.blade.php b/resources/views/livewire/storefront/collections/show.blade.php new file mode 100644 index 00000000..6db015b1 --- /dev/null +++ b/resources/views/livewire/storefront/collections/show.blade.php @@ -0,0 +1,80 @@ +
+ + +

{{ $collection->title }}

+ @if ($collection->description_html) +
{!! $collection->description_html !!}
+ @endif + +
+
+ Filter +

{{ $products->total() }} products

+
+
+ + +
+
+ + @if ($this->hasActiveFilters()) +
+ @if ($inStock) + In stock + @endif + @foreach ($vendors as $vendor) + {{ $vendor }} + @endforeach + @foreach ($types as $type) + {{ $type }} + @endforeach + +
+ @endif + +
+ + + @if ($showMobileFilters) + + @endif + +
+ @if ($products->isEmpty()) +
+ +

No products found

+

Try adjusting your filters or browse our full collection.

+ Clear filters +
+ @else +
+ @foreach ($products as $product) + + @endforeach +
+ + @endif +
+
+
diff --git a/resources/views/livewire/storefront/featured-products.blade.php b/resources/views/livewire/storefront/featured-products.blade.php new file mode 100644 index 00000000..66164bf5 --- /dev/null +++ b/resources/views/livewire/storefront/featured-products.blade.php @@ -0,0 +1,12 @@ +
+

Featured Products

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

No products yet. Check back soon.

+ @else +
+ @foreach ($products as $product) + + @endforeach +
+ @endif +
diff --git a/resources/views/livewire/storefront/home.blade.php b/resources/views/livewire/storefront/home.blade.php new file mode 100644 index 00000000..a8be6e05 --- /dev/null +++ b/resources/views/livewire/storefront/home.blade.php @@ -0,0 +1,45 @@ +
+ @if (($settings['hero']['show'] ?? true)) +
+
+

{{ $settings['hero']['heading'] ?? $store->name }}

+ @if (($settings['hero']['subheading'] ?? '') !== '') +

{{ $settings['hero']['subheading'] }}

+ @endif + {{ $settings['hero']['cta_text'] ?? 'Shop now' }} +
+
+ @endif + + @if ($collections->isNotEmpty()) +
+

Featured Collections

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

{{ $collection->title }}

+

Shop now

+
+
+
+ @endforeach +
+
+ @endif + +
+ +
+ + @if (($settings['home']['show_newsletter'] ?? true)) + + @endif + + @if (($settings['home']['rich_text_html'] ?? null)) +
+
{!! $settings['home']['rich_text_html'] !!}
+
+ @endif +
diff --git a/resources/views/livewire/storefront/newsletter-signup.blade.php b/resources/views/livewire/storefront/newsletter-signup.blade.php new file mode 100644 index 00000000..447f66fd --- /dev/null +++ b/resources/views/livewire/storefront/newsletter-signup.blade.php @@ -0,0 +1,18 @@ +
+
+

Stay in the loop

+

Subscribe for exclusive offers and updates.

+ @if ($subscribed) +

Thanks for subscribing!

+ @else +
+ + + Subscribe + Subscribing... + + + @error('email') @enderror + @endif +
+
diff --git a/resources/views/livewire/storefront/pages/show.blade.php b/resources/views/livewire/storefront/pages/show.blade.php new file mode 100644 index 00000000..96649fe2 --- /dev/null +++ b/resources/views/livewire/storefront/pages/show.blade.php @@ -0,0 +1,5 @@ +
+ +

{{ $page->title }}

+
{!! $page->body_html !!}
+
diff --git a/resources/views/livewire/storefront/products/show.blade.php b/resources/views/livewire/storefront/products/show.blade.php new file mode 100644 index 00000000..59b55554 --- /dev/null +++ b/resources/views/livewire/storefront/products/show.blade.php @@ -0,0 +1,120 @@ +
+ + +
+
+ @if ($product->productMedia->isNotEmpty()) + @php $main = $product->productMedia->get($selectedImage) ?? $product->productMedia->first(); @endphp +
+ {{ $main->alt_text ?? $product->title }} +
+ @if ($product->productMedia->count() > 1) +
+ @foreach ($product->productMedia as $index => $media) + + @endforeach +
+ @endif + @else +
+ +
+ @endif +
+ +
+

{{ $product->title }}

+ +
+ @if ($variant) + + @else +

Select options to see the price

+ @endif +
+ + @foreach ($product->productOptions as $option) +
+ {{ $option->name }} + @if ($option->productOptionValues->count() > 6) + + @elseif (strtolower($option->name) === 'color') +
+ @foreach ($option->productOptionValues as $value) + + @endforeach +
+ @else +
+ @foreach ($option->productOptionValues as $value) + + @endforeach +
+ @endif +
+ @endforeach + +
+ @if ($variant === null) +

Select all options to check availability.

+ @elseif ($isBackorder) +

Available on backorder

+ @elseif ($available === null) +

In stock

+ @elseif ($available > 10) +

In stock

+ @elseif ($available > 0) +

Only {{ $available }} left in stock

+ @else +

Out of stock

+ @endif +
+ + @if ($variant !== null) +
+ +
+ + @if ($available === 0) + Sold out + @else + + Add to cart + Adding... + + @endif + @endif + + @if ($notice) +

{{ $notice }}

+ @endif + + @if ($product->description_html) +
+
{!! $product->description_html !!}
+ @endif + + @if (($product->tags ?? []) !== []) +
+ @foreach ($product->tags as $tag) + {{ $tag }} + @endforeach +
+ @endif +
+
+
diff --git a/resources/views/livewire/storefront/quick-add.blade.php b/resources/views/livewire/storefront/quick-add.blade.php new file mode 100644 index 00000000..5c089be6 --- /dev/null +++ b/resources/views/livewire/storefront/quick-add.blade.php @@ -0,0 +1,9 @@ +
+ + Add to cart + Adding... + + @if ($message) +

{{ $message }}

+ @endif +
diff --git a/resources/views/livewire/storefront/search-modal.blade.php b/resources/views/livewire/storefront/search-modal.blade.php new file mode 100644 index 00000000..05fd27c4 --- /dev/null +++ b/resources/views/livewire/storefront/search-modal.blade.php @@ -0,0 +1,35 @@ +
+ @if ($open) + + @endif +
diff --git a/resources/views/livewire/storefront/search/index.blade.php b/resources/views/livewire/storefront/search/index.blade.php new file mode 100644 index 00000000..3285b3b6 --- /dev/null +++ b/resources/views/livewire/storefront/search/index.blade.php @@ -0,0 +1,60 @@ +
+ + + @if (trim($query) === '') +

Search

+ @elseif ($paginator) +

{{ $paginator->total() }} results for '{{ $query }}'

+ @endif + +
+ + Search + + + @if (trim($query) !== '' && $paginator) +
+ +
+ + +
+
+ + @if ($paginator->isEmpty()) +

No results found for '{{ $query }}'. Try a different search term.

+ @else +
+ @foreach ($paginator as $row) + +
+ @if ($row['image_url']) + {{ $row['title'] }} + @endif +
+
+

{{ $row['title'] }}

+ +
+
+ @endforeach +
+ @if ($paginator->hasPages()) + + @endif + @endif + @endif +
diff --git a/resources/views/partials/head.blade.php b/resources/views/partials/head.blade.php index dce80588..9f5d9ec7 100644 --- a/resources/views/partials/head.blade.php +++ b/resources/views/partials/head.blade.php @@ -3,7 +3,6 @@ {{ $title ?? config('app.name') }} - diff --git a/resources/views/storefront/account/dashboard.blade.php b/resources/views/storefront/account/dashboard.blade.php new file mode 100644 index 00000000..a4cce7db --- /dev/null +++ b/resources/views/storefront/account/dashboard.blade.php @@ -0,0 +1,18 @@ + + + + + + My Account + + + {{-- Placeholder account page. Replaced by the Phase 6 customer account UI. --}} +

My Account

+

Store: {{ $currentStore->name ?? 'none' }}

+ +
+ @csrf + +
+ + diff --git a/resources/views/storefront/auth/forgot-password.blade.php b/resources/views/storefront/auth/forgot-password.blade.php new file mode 100644 index 00000000..2b46c096 --- /dev/null +++ b/resources/views/storefront/auth/forgot-password.blade.php @@ -0,0 +1,32 @@ + + + + + + Forgot Password + + + +

Forgot Password

+ + @if (session('status')) +

{{ session('status') }}

+ @endif + + @if ($errors->any()) +
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+ @endif + +
+ @csrf + + + + +
+ + diff --git a/resources/views/storefront/auth/login.blade.php b/resources/views/storefront/auth/login.blade.php new file mode 100644 index 00000000..474a0921 --- /dev/null +++ b/resources/views/storefront/auth/login.blade.php @@ -0,0 +1,38 @@ + + + + + + Customer Login + + + +

Customer Login

+ + @if (session('status')) +

{{ session('status') }}

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

Create an account

+

Forgot your password?

+ + diff --git a/resources/views/storefront/auth/register.blade.php b/resources/views/storefront/auth/register.blade.php new file mode 100644 index 00000000..9ef7006d --- /dev/null +++ b/resources/views/storefront/auth/register.blade.php @@ -0,0 +1,44 @@ + + + + + + Create Account + + + +

Create Account

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

Already have an account? Log in

+ + diff --git a/resources/views/storefront/auth/reset-password.blade.php b/resources/views/storefront/auth/reset-password.blade.php new file mode 100644 index 00000000..1e5d9903 --- /dev/null +++ b/resources/views/storefront/auth/reset-password.blade.php @@ -0,0 +1,36 @@ + + + + + + Reset Password + + + +

Reset Password

+ + @if ($errors->any()) +
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+ @endif + +
+ @csrf + + + + + + + + + + + + +
+ + diff --git a/resources/views/storefront/layouts/app.blade.php b/resources/views/storefront/layouts/app.blade.php new file mode 100644 index 00000000..499e75bc --- /dev/null +++ b/resources/views/storefront/layouts/app.blade.php @@ -0,0 +1,126 @@ + + + + + + + {{ $title ?? config('app.name') }} + + @stack('meta') + @vite(['resources/css/app.css', 'resources/js/app.js']) + @livewireStyles + @fluxAppearance + +@php + $store = app(\App\Tenancy\CurrentStore::class)->get() ?? ($currentStore ?? null); + $themeSettings = $store ? app(\App\Services\ThemeService::class)->settings($store) : []; + $navigation = $store ? app(\App\Services\NavigationService::class) : null; + $mainMenu = ($store && $navigation) ? $navigation->menu($store, 'main-menu') : null; + $mainNav = ($mainMenu && $navigation) ? $navigation->buildTree($mainMenu) : []; + $footerMenu = ($store && $navigation) ? $navigation->menu($store, 'footer-menu') : null; + $footerNav = ($footerMenu && $navigation) ? $navigation->buildTree($footerMenu) : []; + $customer = auth('customer')->user(); +@endphp + + Skip to main content + + @if (($themeSettings['announcement']['show'] ?? false) && ($themeSettings['announcement']['text'] ?? '') !== '') +
+ @if (($themeSettings['announcement']['link'] ?? null)) + {{ $themeSettings['announcement']['text'] }} + @else + {{ $themeSettings['announcement']['text'] }} + @endif +
+ @endif + + + +
+ {{ $slot }} +
+ +
+
+
+ +
+

Store

+

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

+ @if (($themeSettings['footer']['contact_email'] ?? null)) +

{{ $themeSettings['footer']['contact_email'] }}

+ @endif +
+
+

© {{ now()->year }} {{ $store?->name ?? config('app.name') }}. All rights reserved.

+
+
+ + + + + @livewireScripts + @fluxScripts + + diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php index a808a399..827943ca 100644 --- a/resources/views/welcome.blade.php +++ b/resources/views/welcome.blade.php @@ -6,7 +6,6 @@ Laravel - diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 00000000..3ad1e84b --- /dev/null +++ b/routes/api.php @@ -0,0 +1,242 @@ +name('api.admin.')->group(function (): void { + Route::middleware(['auth:sanctum', 'store.resolve', 'throttle:api.admin'])->group(function (): void { + Route::post('platform/organizations', [OrganizationController::class, 'store']) + ->middleware('abilities:manage-platform') + ->name('platform.organizations.store'); + + Route::post('platform/stores', [PlatformStoreController::class, 'store']) + ->middleware('abilities:manage-platform') + ->name('platform.stores.store'); + + Route::post('stores/{storeId}/invites', [StoreInviteController::class, 'store']) + ->middleware('abilities:manage-platform') + ->name('stores.invites.store'); + + Route::get('stores/{storeId}/me', [MeController::class, 'show'])->name('stores.me'); + + Route::get('stores/{storeId}/products', [ProductController::class, 'index']) + ->middleware('abilities:read-products') + ->name('stores.products.index'); + + Route::post('stores/{storeId}/products', [ProductController::class, 'store']) + ->middleware('abilities:write-products') + ->name('stores.products.store'); + + Route::get('stores/{storeId}/products/{productId}', [ProductController::class, 'show']) + ->middleware('abilities:read-products') + ->name('stores.products.show'); + + Route::put('stores/{storeId}/products/{productId}', [ProductController::class, 'update']) + ->middleware('abilities:write-products') + ->name('stores.products.update'); + + Route::delete('stores/{storeId}/products/{productId}', [ProductController::class, 'destroy']) + ->middleware('abilities:write-products') + ->name('stores.products.destroy'); + + Route::get('stores/{storeId}/products/{productId}/variants/{variantId}/inventory', [VariantInventoryController::class, 'show']) + ->middleware('abilities:read-products') + ->name('stores.products.variants.inventory.show'); + + Route::put('stores/{storeId}/products/{productId}/variants/{variantId}/inventory', [VariantInventoryController::class, 'update']) + ->middleware('abilities:write-products') + ->name('stores.products.variants.inventory.update'); + + Route::post('stores/{storeId}/products/{productId}/media', [ProductMediaController::class, 'store']) + ->middleware('abilities:write-products') + ->name('stores.products.media.store'); + + Route::put('stores/{storeId}/products/{productId}/media/{mediaId}', [ProductMediaController::class, 'update']) + ->middleware('abilities:write-products') + ->name('stores.products.media.update'); + + Route::delete('stores/{storeId}/products/{productId}/media/{mediaId}', [ProductMediaController::class, 'destroy']) + ->middleware('abilities:write-products') + ->name('stores.products.media.destroy'); + + Route::get('stores/{storeId}/collections', [CollectionController::class, 'index']) + ->middleware('abilities:read-collections') + ->name('stores.collections.index'); + + Route::post('stores/{storeId}/collections', [CollectionController::class, 'store']) + ->middleware('abilities:write-collections') + ->name('stores.collections.store'); + + Route::get('stores/{storeId}/collections/{collectionId}', [CollectionController::class, 'show']) + ->middleware('abilities:read-collections') + ->name('stores.collections.show'); + + Route::put('stores/{storeId}/collections/{collectionId}', [CollectionController::class, 'update']) + ->middleware('abilities:write-collections') + ->name('stores.collections.update'); + + Route::delete('stores/{storeId}/collections/{collectionId}', [CollectionController::class, 'destroy']) + ->middleware('abilities:write-collections') + ->name('stores.collections.destroy'); + + Route::get('stores/{storeId}/orders', [OrderController::class, 'index']) + ->middleware('abilities:read-orders') + ->name('stores.orders.index'); + + Route::get('stores/{storeId}/orders/{orderId}', [OrderController::class, 'show']) + ->middleware('abilities:read-orders') + ->name('stores.orders.show'); + + Route::post('stores/{storeId}/orders/{orderId}/confirm-payment', [OrderController::class, 'confirmPayment']) + ->middleware('abilities:write-orders') + ->name('stores.orders.confirm-payment'); + + Route::post('stores/{storeId}/orders/{orderId}/fulfillments', [FulfillmentController::class, 'store']) + ->middleware('abilities:write-orders') + ->name('stores.orders.fulfillments.store'); + + Route::post('stores/{storeId}/orders/{orderId}/fulfillments/{fulfillmentId}/ship', [FulfillmentController::class, 'ship']) + ->middleware('abilities:write-orders') + ->name('stores.orders.fulfillments.ship'); + + Route::post('stores/{storeId}/orders/{orderId}/fulfillments/{fulfillmentId}/deliver', [FulfillmentController::class, 'deliver']) + ->middleware('abilities:write-orders') + ->name('stores.orders.fulfillments.deliver'); + + Route::get('stores/{storeId}/orders/{orderId}/refunds', [RefundController::class, 'index']) + ->middleware('abilities:read-orders') + ->name('stores.orders.refunds.index'); + + Route::post('stores/{storeId}/orders/{orderId}/refunds', [RefundController::class, 'store']) + ->middleware('abilities:write-orders') + ->name('stores.orders.refunds.store'); + + Route::get('stores/{storeId}/discounts', [DiscountController::class, 'index']) + ->middleware('abilities:read-discounts') + ->name('stores.discounts.index'); + + Route::post('stores/{storeId}/discounts', [DiscountController::class, 'store']) + ->middleware('abilities:write-discounts') + ->name('stores.discounts.store'); + + Route::get('stores/{storeId}/discounts/{discountId}', [DiscountController::class, 'show']) + ->middleware('abilities:read-discounts') + ->name('stores.discounts.show'); + + Route::put('stores/{storeId}/discounts/{discountId}', [DiscountController::class, 'update']) + ->middleware('abilities:write-discounts') + ->name('stores.discounts.update'); + + Route::delete('stores/{storeId}/discounts/{discountId}', [DiscountController::class, 'destroy']) + ->middleware('abilities:write-discounts') + ->name('stores.discounts.destroy'); + + Route::get('stores/{storeId}/shipping/zones', [ShippingZoneController::class, 'index']) + ->middleware('abilities:read-settings') + ->name('stores.shipping.zones.index'); + + Route::post('stores/{storeId}/shipping/zones', [ShippingZoneController::class, 'store']) + ->middleware('abilities:write-settings') + ->name('stores.shipping.zones.store'); + + Route::put('stores/{storeId}/shipping/zones/{zoneId}', [ShippingZoneController::class, 'update']) + ->middleware('abilities:write-settings') + ->name('stores.shipping.zones.update'); + + Route::delete('stores/{storeId}/shipping/zones/{zoneId}', [ShippingZoneController::class, 'destroy']) + ->middleware('abilities:write-settings') + ->name('stores.shipping.zones.destroy'); + + Route::post('stores/{storeId}/shipping/zones/{zoneId}/rates', [ShippingRateController::class, 'store']) + ->middleware('abilities:write-settings') + ->name('stores.shipping.zones.rates.store'); + + Route::delete('stores/{storeId}/shipping/zones/{zoneId}/rates/{rateId}', [ShippingRateController::class, 'destroy']) + ->middleware('abilities:write-settings') + ->name('stores.shipping.zones.rates.destroy'); + + Route::get('stores/{storeId}/tax/settings', [TaxSettingController::class, 'show']) + ->middleware('abilities:read-settings') + ->name('stores.tax.settings.show'); + + Route::put('stores/{storeId}/tax/settings', [TaxSettingController::class, 'update']) + ->middleware('abilities:write-settings') + ->name('stores.tax.settings.update'); + + Route::post('stores/{storeId}/search/reindex', [SearchAdminController::class, 'reindex']) + ->middleware('abilities:write-settings') + ->name('stores.search.reindex'); + + Route::get('stores/{storeId}/search/status', [SearchAdminController::class, 'status']) + ->middleware('abilities:read-settings') + ->name('stores.search.status'); + + Route::get('stores/{storeId}/analytics/summary', [AnalyticsController::class, 'summary']) + ->middleware('abilities:read-analytics') + ->name('stores.analytics.summary'); + }); + + Route::middleware(['web', 'auth', 'throttle:api.admin'])->group(function (): void { + Route::post('tokens', [TokenController::class, 'store'])->name('tokens.store'); + Route::delete('tokens/{tokenId}', [TokenController::class, 'destroy'])->name('tokens.destroy'); + }); +}); + +Route::prefix('storefront/v1')->name('api.storefront.')->middleware(['store.resolve', 'throttle:search'])->group(function (): void { + Route::get('search', [SearchController::class, 'index'])->name('search.index'); + Route::get('search/suggest', [SearchController::class, 'suggest'])->name('search.suggest'); +}); + +Route::prefix('storefront/v1')->name('api.storefront.')->middleware(['store.resolve', 'throttle:analytics'])->group(function (): void { + Route::post('analytics/events', [AnalyticsEventController::class, 'store'])->name('analytics.events.store'); +}); + +Route::prefix('storefront/v1')->name('api.storefront.')->middleware(['store.resolve', 'throttle:api.storefront'])->group(function (): void { + Route::post('carts', [CartController::class, 'store'])->name('carts.store'); + Route::get('carts/{cartId}', [CartController::class, 'show'])->name('carts.show'); + Route::post('carts/{cartId}/lines', [CartController::class, 'addLine'])->name('carts.lines.store'); + Route::put('carts/{cartId}/lines/{lineId}', [CartController::class, 'updateLine'])->name('carts.lines.update'); + Route::delete('carts/{cartId}/lines/{lineId}', [CartController::class, 'removeLine'])->name('carts.lines.destroy'); + + Route::get('orders/{orderNumber}', [StorefrontOrderController::class, 'show'])->name('orders.show'); +}); + +Route::prefix('storefront/v1')->name('api.storefront.')->middleware(['store.resolve', 'throttle:checkout'])->group(function (): void { + Route::post('checkouts', [CheckoutController::class, 'store'])->name('checkouts.store'); + Route::get('checkouts/{checkoutId}', [CheckoutController::class, 'show'])->name('checkouts.show'); + Route::put('checkouts/{checkoutId}/address', [CheckoutController::class, 'setAddress'])->name('checkouts.address'); + Route::put('checkouts/{checkoutId}/shipping-method', [CheckoutController::class, 'setShippingMethod'])->name('checkouts.shipping-method'); + Route::put('checkouts/{checkoutId}/payment-method', [CheckoutController::class, 'selectPaymentMethod'])->name('checkouts.payment-method'); + Route::post('checkouts/{checkoutId}/apply-discount', [CheckoutController::class, 'applyDiscount'])->name('checkouts.apply-discount'); + Route::delete('checkouts/{checkoutId}/discount', [CheckoutController::class, 'removeDiscount'])->name('checkouts.remove-discount'); + Route::post('checkouts/{checkoutId}/pay', [CheckoutController::class, 'pay'])->name('checkouts.pay'); +}); + +Route::prefix('apps/v1')->name('api.apps.')->group(function (): void { + Route::get('stores/{storeId}/products', [OauthStubController::class, 'appResource'])->name('stores.products.index'); + Route::get('stores/{storeId}/orders', [OauthStubController::class, 'appResource'])->name('stores.orders.index'); + Route::get('stores/{storeId}/customers', [OauthStubController::class, 'appResource'])->name('stores.customers.index'); +}); diff --git a/routes/console.php b/routes/console.php index 3c9adf1a..540c3d02 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,18 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::job(ExpireAbandonedCheckouts::class)->everyFifteenMinutes(); +Schedule::job(CleanupAbandonedCarts::class)->daily(); +Schedule::job(CancelUnpaidBankTransferOrders::class)->daily(); +Schedule::job(AggregateAnalytics::class)->dailyAt('01:00'); diff --git a/routes/settings.php b/routes/settings.php index 2019a287..f7c31432 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -7,13 +7,13 @@ use Illuminate\Support\Facades\Route; use Laravel\Fortify\Features; -Route::middleware(['auth'])->group(function () { +Route::middleware(['customer.redirect', 'auth'])->group(function () { Route::redirect('settings', 'settings/profile'); Route::livewire('settings/profile', Profile::class)->name('profile.edit'); }); -Route::middleware(['auth', 'verified'])->group(function () { +Route::middleware(['customer.redirect', 'auth', 'verified'])->group(function () { Route::livewire('settings/password', Password::class)->name('user-password.edit'); Route::livewire('settings/appearance', Appearance::class)->name('appearance.edit'); diff --git a/routes/web.php b/routes/web.php index f755f111..5e4e80d9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,13 +1,168 @@ name('home'); +Route::livewire('/', StorefrontHome::class)->name('home')->middleware('store.resolve'); Route::view('dashboard', 'dashboard') - ->middleware(['auth', 'verified']) + ->middleware(['customer.redirect', 'auth', 'verified']) ->name('dashboard'); +Route::prefix('admin')->name('admin.')->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('password.request'); + Route::post('forgot-password', [AdminForgotPasswordController::class, 'store'])->name('password.email'); + Route::get('reset-password/{token}', [AdminResetPasswordController::class, 'create'])->name('password.reset'); + Route::post('reset-password', [AdminResetPasswordController::class, 'store'])->name('password.update'); + }); + + Route::post('logout', [AdminLoginController::class, 'destroy']) + ->middleware('auth') + ->name('logout'); + + Route::middleware('auth')->group(function (): void { + Route::get('stores/select', [StoreSelectorController::class, 'index'])->name('store-selector'); + Route::post('stores/select', [StoreSelectorController::class, 'store'])->name('store-selector.store'); + }); + + Route::middleware(['auth', 'verified', 'store.resolve', 'role.check:owner,admin,staff,support'])->group(function (): void { + Route::livewire('/', Dashboard::class)->name('dashboard'); + + Route::livewire('products', ProductsIndex::class)->name('products.index'); + Route::livewire('products/create', ProductsForm::class)->name('products.create'); + Route::livewire('products/{product}/edit', ProductsForm::class)->name('products.edit'); + + Route::livewire('inventory', InventoryIndex::class)->name('inventory.index'); + + Route::livewire('collections', AdminCollectionsIndex::class)->name('collections.index'); + Route::livewire('collections/create', AdminCollectionsForm::class)->name('collections.create'); + Route::livewire('collections/{collection}/edit', AdminCollectionsForm::class)->name('collections.edit'); + + Route::livewire('orders', OrdersIndex::class)->name('orders.index'); + Route::livewire('orders/{order}', OrdersShow::class)->name('orders.show'); + + Route::livewire('customers', CustomersIndex::class)->name('customers.index'); + Route::livewire('customers/{customer}', CustomersShow::class)->name('customers.show'); + + Route::livewire('discounts', DiscountsIndex::class)->name('discounts.index'); + Route::livewire('discounts/create', DiscountsForm::class)->name('discounts.create'); + Route::livewire('discounts/{discount}/edit', DiscountsForm::class)->name('discounts.edit'); + + Route::livewire('settings', SettingsIndex::class)->name('settings.index'); + Route::livewire('settings/shipping', SettingsShipping::class)->name('settings.shipping'); + Route::livewire('settings/taxes', SettingsTaxes::class)->name('settings.taxes'); + + Route::livewire('themes', ThemesIndex::class)->name('themes.index'); + Route::livewire('themes/{theme}/editor', ThemesEditor::class)->name('themes.editor'); + + Route::livewire('pages', PagesIndex::class)->name('pages.index'); + Route::livewire('pages/create', PagesForm::class)->name('pages.create'); + Route::livewire('pages/{page}/edit', PagesForm::class)->name('pages.edit'); + + Route::livewire('navigation', NavigationIndex::class)->name('navigation.index'); + + Route::livewire('apps', AppsIndex::class)->name('apps.index'); + Route::livewire('apps/{installation}', AppsShow::class)->name('apps.show'); + + Route::livewire('developers', DevelopersIndex::class)->name('developers.index'); + + Route::livewire('analytics', AnalyticsIndex::class)->name('analytics.index'); + + Route::livewire('search/settings', SearchSettings::class)->name('search.settings'); + }); +}); + +Route::middleware('store.resolve')->group(function (): void { + Route::livewire('collections', CollectionsIndex::class)->name('storefront.collections.index'); + Route::livewire('collections/{handle}', CollectionsShow::class)->name('storefront.collections.show'); + Route::livewire('products/{handle}', ProductsShow::class)->name('storefront.products.show'); + Route::livewire('cart', CartShow::class)->name('storefront.cart'); + Route::livewire('search', SearchIndex::class)->name('storefront.search'); + Route::livewire('pages/{handle}', PagesShow::class)->name('storefront.pages.show'); + + Route::livewire('checkout/{checkoutId}', CheckoutShow::class)->name('storefront.checkout.show'); + Route::livewire('checkout/{checkoutId}/confirmation', CheckoutConfirmation::class)->name('storefront.checkout.confirmation'); + + Route::middleware('guest:customer')->group(function (): void { + Route::get('account/login', [CustomerLoginController::class, 'create'])->name('customer.login'); + Route::post('account/login', [CustomerLoginController::class, 'store']) + ->middleware('throttle:login') + ->name('customer.login.store'); + Route::get('account/register', [CustomerRegisterController::class, 'create'])->name('customer.register'); + Route::post('account/register', [CustomerRegisterController::class, 'store'])->name('customer.register.store'); + Route::get('account/forgot-password', [CustomerForgotPasswordController::class, 'create'])->name('customer.password.request'); + Route::post('account/forgot-password', [CustomerForgotPasswordController::class, 'store'])->name('customer.password.email'); + Route::get('account/reset-password/{token}', [CustomerResetPasswordController::class, 'create'])->name('customer.password.reset'); + Route::post('account/reset-password', [CustomerResetPasswordController::class, 'store'])->name('customer.password.update'); + }); + + Route::post('account/logout', [CustomerLoginController::class, 'destroy'])->name('customer.logout'); + + Route::middleware('customer.auth')->group(function (): void { + Route::livewire('account', AccountDashboard::class)->name('customer.account'); + Route::livewire('account/profile', AccountProfile::class)->name('customer.profile'); + Route::livewire('account/orders', AccountOrdersIndex::class)->name('customer.orders.index'); + Route::livewire('account/orders/{orderNumber}', AccountOrdersShow::class)->name('customer.orders.show'); + Route::livewire('account/addresses', AccountAddressesIndex::class)->name('customer.addresses'); + }); +}); + require __DIR__.'/settings.php'; + +Route::middleware(['web', 'auth'])->group(function (): void { + Route::get('oauth/authorize', [OauthStubController::class, 'authorize'])->name('oauth.authorize'); +}); + +Route::post('oauth/token', [OauthStubController::class, 'token'])->name('oauth.token'); diff --git a/specs/progress.md b/specs/progress.md new file mode 100644 index 00000000..83c7f308 --- /dev/null +++ b/specs/progress.md @@ -0,0 +1,39 @@ +# Shop Build Progress + +Mission: implement entire shop system from `specs/*.md` from scratch (branch `2026-09-13-cursor-muse-spark-1-3`). +Stack: PHP 8.4 / Laravel 12 / Livewire v4 / Flux UI Free v2 / Tailwind v4 / SQLite / Pest v4. +Schema: managed ONLY in `draft.yaml` via Laravel Blueprint (`php artisan blueprint:erase && php artisan blueprint:build && php artisan migrate:fresh --seed`). + +## Status Legend +- [ ] pending, [~] in progress, [x] done, [!] blocked/bug + +## Phases +- [x] Setup: progress.md, install Sanctum+Blueprint, base config +- [x] Agent A: Full `draft.yaml` schema (spec 01) + models + enums + factories (55 tables, 47 models, 35 enums, 85 tests green) +- [x] Agent B: Foundation - tenant resolution, middleware, BelongsToStore, admin/customer auth, RBAC (156 tests green) +- [x] Agent C: Catalog - products, variants, inventory, collections, media, search FTS5 (248 tests green) +- [x] Agent D: Cart, pricing engine, discounts, shipping, tax, checkout state machine, mock PSP payments, orders, refunds, fulfillment (447 tests green) +- [x] Agent E: Storefront UI - layout, themes, pages, navigation, cart/checkout UI, customer accounts (507 tests green) +- [x] Agent F: Admin panel (spec 03), analytics, webhooks/apps, seeders (624 tests green) +- [x] Agent G (independent 2nd agent): verified via Pest (624 green) + Playwright MCP - found 3 P0, 7 P1, 8 P2 (see log) +- [x] Agent H: fix ALL P0/P1/P2 bugs + regression tests (644 tests green) +- [x] Agent I: re-verify via Pest + Playwright (round 1: 3 fails remaining, round 2 sign-off: ALL PASS - APPROVED FOR REVIEW MEETING) +- [x] Bugfix + final review meeting (customer- + admin-side showcase) + +## Log +- 2026-09-13 08:53 UTC+2: Mission started. Branch is fresh Livewire starter kit (Fortify auth, no shop code) - building from scratch confirmed. +- 2026-09-13: Agent A: schema (55 tables, 47 models, 35 enums) - 85 tests green. +- 2026-09-13: Agent B: tenancy/auth/RBAC - 156 tests green. +- 2026-09-13: Agent C: catalog/inventory/media/search - 248 tests green. +- 2026-09-13: Agent D: cart/pricing/discounts/shipping/tax/checkout/payments/orders - 447 tests green. +- 2026-09-13: Agent E: storefront UI + customer accounts - 507 tests green. +- 2026-09-13: Agent F: admin panel + analytics + webhooks + seeders - 624 tests green. +- 2026-09-13: Agent G verification (Pest + Playwright MCP): 3 P0, 7 P1, 8 P2 found. +- 2026-09-13: Agent H bugfix round 1: all 18 fixed - 644 tests green. +- 2026-09-13: Agent I re-verification: 3 fails remaining (admin save 500, admin create 403, favicon). +- 2026-09-13: Agent H bugfix round 2: all fixed - 651 tests green. +- 2026-09-13: Agent I2 final sign-off: ALL PASS, 651/651 green, zero console errors. APPROVED FOR REVIEW MEETING. +- [ ] Bugfix + final review meeting (customer- + admin-side showcase) + +## Log +- 2026-09-13 08:53 UTC+2: Mission started. Branch is fresh Livewire starter kit (Fortify auth, no shop code) - building from scratch confirmed. diff --git a/tests/Feature/Admin/AdminAccessTest.php b/tests/Feature/Admin/AdminAccessTest.php new file mode 100644 index 00000000..97c4316d --- /dev/null +++ b/tests/Feature/Admin/AdminAccessTest.php @@ -0,0 +1,69 @@ +get(route($route))->assertRedirect(route('admin.login')); + } +}); + +it('denies access to users without a store membership', function () { + ['store' => $store] = $this->createStoreContext(); + $stranger = User::factory()->create(); + $this->actingAsAdmin($stranger, $store); + + $this->get(route('admin.dashboard'))->assertForbidden(); +}); + +it('denies support users access to owner-only sections', function () { + ['store' => $store] = $this->createStoreContext(); + $support = User::factory()->create(); + StoreUser::factory()->create([ + 'store_id' => $store->getKey(), + 'user_id' => $support->getKey(), + 'role' => StoreUserRole::Support, + ]); + $this->actingAsAdmin($support, $store); + + $this->get(route('admin.developers.index'))->assertForbidden(); + $this->get(route('admin.apps.index'))->assertForbidden(); + $this->get(route('admin.search.settings'))->assertForbidden(); +}); + +it('allows support users read access to catalog and order sections', function () { + ['store' => $store] = $this->createStoreContext(); + $support = User::factory()->create(); + StoreUser::factory()->create([ + 'store_id' => $store->getKey(), + 'user_id' => $support->getKey(), + 'role' => StoreUserRole::Support, + ]); + $this->actingAsAdmin($support, $store); + + $this->get(route('admin.products.index'))->assertOk(); + $this->get(route('admin.orders.index'))->assertOk(); + $this->get(route('admin.customers.index'))->assertOk(); +}); diff --git a/tests/Feature/Admin/AnalyticsPageTest.php b/tests/Feature/Admin/AnalyticsPageTest.php new file mode 100644 index 00000000..22637457 --- /dev/null +++ b/tests/Feature/Admin/AnalyticsPageTest.php @@ -0,0 +1,71 @@ + $this->store, 'user' => $this->user] = $this->createStoreContext(); + $this->actingAsAdmin($this->user, $this->store); +}); + +it('renders the analytics page', function () { + $this->get(route('admin.analytics.index'))->assertOk(); +}); + +it('shows summary tiles from daily metrics', function () { + AnalyticsDaily::factory()->create([ + 'store_id' => $this->store->getKey(), + 'date' => today()->toDateString(), + 'orders_count' => 4, + 'revenue_amount' => 20000, + 'aov_amount' => 5000, + 'visits_count' => 100, + 'add_to_cart_count' => 20, + 'checkout_started_count' => 10, + ]); + \App\Models\Order::factory()->count(4)->create([ + 'store_id' => $this->store->getKey(), + 'total_amount' => 5000, + 'placed_at' => now(), + ]); + + Livewire::test(Index::class) + ->assertViewHas('summary', fn ($summary) => $summary['orders_count'] === 4 && $summary['revenue_amount'] === 20000); +}); + +it('shows top referrers from page view events', function () { + AnalyticsEvent::factory()->create([ + 'store_id' => $this->store->getKey(), + 'type' => 'page_view', + 'session_id' => 'session-1', + 'properties_json' => ['referrer' => 'https://www.google.com/search'], + 'occurred_at' => now(), + 'created_at' => now(), + ]); + + Livewire::test(Index::class) + ->assertViewHas('topReferrers', fn ($referrers) => $referrers[0]['source'] === 'www.google.com'); +}); + +it('exports a csv of daily metrics', function () { + Storage::fake('local'); + AnalyticsDaily::factory()->create([ + 'store_id' => $this->store->getKey(), + 'date' => today()->toDateString(), + ]); + + $component = Livewire::test(Index::class) + ->call('exportCsv') + ->assertDispatched('toast'); + + $path = $component->get('exportUrl'); + + expect($path)->toEndWith('.csv'); + + Storage::disk('local')->assertExists($path); +}); diff --git a/tests/Feature/Admin/AppsTest.php b/tests/Feature/Admin/AppsTest.php new file mode 100644 index 00000000..6ef29d70 --- /dev/null +++ b/tests/Feature/Admin/AppsTest.php @@ -0,0 +1,62 @@ + $this->store, 'user' => $this->user] = $this->createStoreContext(); + $this->actingAsAdmin($this->user, $this->store); +}); + +it('lists installed apps scoped to the current store', function () { + $app = App::factory()->create(['name' => 'Reviews Pro']); + AppInstallation::factory()->create([ + 'store_id' => $this->store->getKey(), + 'app_id' => $app->getKey(), + 'status' => AppInstallationStatus::Active, + ]); + + $this->get(route('admin.apps.index')) + ->assertOk() + ->assertSee('Reviews Pro'); +}); + +it('shows app detail with scopes', function () { + $app = App::factory()->create(['name' => 'Reviews Pro']); + $installation = AppInstallation::factory()->create([ + 'store_id' => $this->store->getKey(), + 'app_id' => $app->getKey(), + 'status' => AppInstallationStatus::Active, + 'scopes_json' => ['read-products', 'read-orders'], + ]); + + $this->get(route('admin.apps.show', $installation)) + ->assertOk() + ->assertSee('read-products'); +}); + +it('uninstalls an app', function () { + $installation = AppInstallation::factory()->create([ + 'store_id' => $this->store->getKey(), + 'status' => AppInstallationStatus::Active, + ]); + + Livewire::test(Index::class) + ->call('confirmUninstall', $installation->getKey()) + ->call('uninstallApp') + ->assertDispatched('toast'); + + expect($installation->refresh()->status)->toBe(AppInstallationStatus::Uninstalled); +}); + +it('returns 404 for an installation from another store', function () { + ['store' => $otherStore] = $this->createStoreContext(); + $installation = AppInstallation::factory()->create(['store_id' => $otherStore->getKey()]); + + $this->get(route('admin.apps.show', $installation))->assertNotFound(); +}); diff --git a/tests/Feature/Admin/CollectionsTest.php b/tests/Feature/Admin/CollectionsTest.php new file mode 100644 index 00000000..0eb6f14b --- /dev/null +++ b/tests/Feature/Admin/CollectionsTest.php @@ -0,0 +1,88 @@ + $this->store, 'user' => $this->user] = $this->createStoreContext(); + $this->actingAsAdmin($this->user, $this->store); +}); + +it('lists collections scoped to the current store', function () { + Collection::factory()->create(['store_id' => $this->store->getKey(), 'title' => 'Summer Sale']); + + $this->get(route('admin.collections.index')) + ->assertOk() + ->assertSee('Summer Sale'); +}); + +it('creates a collection with assigned products', function () { + $variant = CommerceTestHelper::purchasableVariant($this->store); + $productId = $variant->product->getKey(); + + Livewire::test(Form::class) + ->set('title', 'New Arrivals') + ->set('status', 'active') + ->call('addProduct', $productId) + ->call('save') + ->assertRedirect(route('admin.collections.index')); + + $collection = Collection::query() + ->where('store_id', $this->store->getKey()) + ->where('handle', 'new-arrivals') + ->firstOrFail(); + + expect($collection->products()->pluck('products.id')->all())->toBe([$productId]); +}); + +it('edits a collection and reorders products', function () { + $collection = Collection::factory()->create(['store_id' => $this->store->getKey()]); + $first = CommerceTestHelper::purchasableVariant($this->store)->product; + $second = CommerceTestHelper::purchasableVariant($this->store)->product; + $collection->products()->sync([ + $first->getKey() => ['position' => 0], + $second->getKey() => ['position' => 1], + ]); + + Livewire::test(Form::class, ['collection' => $collection]) + ->assertSet('assignedProductIds', [$first->getKey(), $second->getKey()]) + ->call('moveProductUp', $second->getKey()) + ->call('save') + ->assertDispatched('toast'); + + expect($collection->products()->orderByPivot('position')->pluck('products.id')->all()) + ->toBe([$second->getKey(), $first->getKey()]); +}); + +it('deletes a collection with confirmation', function () { + $collection = Collection::factory()->create(['store_id' => $this->store->getKey()]); + + Livewire::test(Index::class) + ->call('confirmDelete', $collection->getKey()) + ->call('deleteCollection') + ->assertDispatched('toast'); + + expect(Collection::query()->whereKey($collection->getKey())->exists())->toBeFalse(); +}); + +it('validates duplicate handles', function () { + Collection::factory()->create(['store_id' => $this->store->getKey(), 'handle' => 'sale']); + + Livewire::test(Form::class) + ->set('title', 'Sale') + ->set('handle', 'sale') + ->call('save') + ->assertHasErrors('handle'); +}); + +it('returns 404 when editing a collection from another store', function () { + ['store' => $otherStore] = $this->createStoreContext(); + $collection = Collection::factory()->create(['store_id' => $otherStore->getKey()]); + + $this->get(route('admin.collections.edit', $collection))->assertNotFound(); +}); diff --git a/tests/Feature/Admin/CustomersTest.php b/tests/Feature/Admin/CustomersTest.php new file mode 100644 index 00000000..5b396b85 --- /dev/null +++ b/tests/Feature/Admin/CustomersTest.php @@ -0,0 +1,85 @@ + $this->store, 'user' => $this->user] = $this->createStoreContext(); + $this->actingAsAdmin($this->user, $this->store); +}); + +it('lists customers scoped to the current store', function () { + Customer::factory()->create(['store_id' => $this->store->getKey(), 'name' => 'Jane Doe']); + ['store' => $otherStore] = $this->createStoreContext(); + Customer::factory()->create(['store_id' => $otherStore->getKey(), 'name' => 'Other Store Customer']); + + $this->get(route('admin.customers.index')) + ->assertOk() + ->assertSee('Jane Doe') + ->assertDontSee('Other Store Customer'); +}); + +it('searches customers by name and email', function () { + Customer::factory()->create(['store_id' => $this->store->getKey(), 'name' => 'Alice', 'email' => 'alice@example.com']); + Customer::factory()->create(['store_id' => $this->store->getKey(), 'name' => 'Bob', 'email' => 'bob@example.com']); + + Livewire::test(Index::class) + ->set('search', 'alice') + ->assertSee('Alice') + ->assertDontSee('Bob'); +}); + +it('shows customer detail with addresses', function () { + $customer = Customer::factory()->create(['store_id' => $this->store->getKey()]); + CustomerAddress::factory()->create(['customer_id' => $customer->getKey(), 'label' => 'Home', 'is_default' => true]); + + $this->get(route('admin.customers.show', $customer)) + ->assertOk() + ->assertSee('Home'); +}); + +it('adds an address to a customer', function () { + $customer = Customer::factory()->create(['store_id' => $this->store->getKey()]); + + Livewire::test(Show::class, ['customer' => $customer]) + ->call('openAddressForm') + ->set('addressLabel', 'Work') + ->set('addressForm.first_name', 'Jane') + ->set('addressForm.last_name', 'Doe') + ->set('addressForm.address1', 'Main Street 1') + ->set('addressForm.city', 'Berlin') + ->set('addressForm.zip', '10115') + ->set('addressForm.country_code', 'DE') + ->call('saveAddress') + ->assertDispatched('toast'); + + $address = CustomerAddress::query()->where('customer_id', $customer->getKey())->where('label', 'Work')->firstOrFail(); + + expect($address->address_json['city'])->toBe('Berlin') + ->and((bool) $address->is_default)->toBeTrue(); +}); + +it('deletes an address and promotes another to default', function () { + $customer = Customer::factory()->create(['store_id' => $this->store->getKey()]); + $default = CustomerAddress::factory()->create(['customer_id' => $customer->getKey(), 'is_default' => true]); + $other = CustomerAddress::factory()->create(['customer_id' => $customer->getKey(), 'is_default' => false]); + + Livewire::test(Show::class, ['customer' => $customer]) + ->call('deleteAddress', $default->getKey()) + ->assertDispatched('toast'); + + expect(CustomerAddress::query()->whereKey($default->getKey())->exists())->toBeFalse() + ->and((bool) $other->refresh()->is_default)->toBeTrue(); +}); + +it('returns 404 for a customer from another store', function () { + ['store' => $otherStore] = $this->createStoreContext(); + $customer = Customer::factory()->create(['store_id' => $otherStore->getKey()]); + + $this->get(route('admin.customers.show', $customer))->assertNotFound(); +}); diff --git a/tests/Feature/Admin/DashboardTest.php b/tests/Feature/Admin/DashboardTest.php new file mode 100644 index 00000000..f047a88d --- /dev/null +++ b/tests/Feature/Admin/DashboardTest.php @@ -0,0 +1,65 @@ + $this->store, 'user' => $this->user] = $this->createStoreContext(); + $this->actingAsAdmin($this->user, $this->store); +}); + +it('renders the dashboard for an authenticated store member', function () { + $this->get(route('admin.dashboard')) + ->assertOk() + ->assertSeeLivewire(Dashboard::class); +}); + +it('redirects guests to the admin login', function () { + auth()->logout(); + + $this->get(route('admin.dashboard'))->assertRedirect(route('admin.login')); +}); + +it('shows kpis computed from orders and daily metrics', function () { + Order::factory()->create([ + 'store_id' => $this->store->getKey(), + 'total_amount' => 5000, + 'placed_at' => now(), + ]); + AnalyticsDaily::factory()->create([ + 'store_id' => $this->store->getKey(), + 'date' => today()->toDateString(), + 'visits_count' => 100, + ]); + + Livewire::test(Dashboard::class) + ->assertViewHas('ordersCount', 1) + ->assertViewHas('totalSales', 5000) + ->assertViewHas('visitorsCount', 100); +}); + +it('filters kpis by date range', function () { + Order::factory()->create([ + 'store_id' => $this->store->getKey(), + 'total_amount' => 5000, + 'placed_at' => now()->subDays(20), + ]); + + Livewire::test(Dashboard::class) + ->set('dateRange', 'today') + ->assertViewHas('ordersCount', 0) + ->set('dateRange', 'last_30_days') + ->assertViewHas('ordersCount', 1); +}); + +it('only counts orders from the current store', function () { + ['store' => $otherStore] = $this->createStoreContext(); + Order::factory()->create(['store_id' => $otherStore->getKey(), 'placed_at' => now()]); + $this->actingAsAdmin($this->user, $this->store); + + Livewire::test(Dashboard::class)->assertViewHas('ordersCount', 0); +}); diff --git a/tests/Feature/Admin/DevelopersTest.php b/tests/Feature/Admin/DevelopersTest.php new file mode 100644 index 00000000..967186cd --- /dev/null +++ b/tests/Feature/Admin/DevelopersTest.php @@ -0,0 +1,97 @@ + $this->store, 'user' => $this->user] = $this->createStoreContext(); + $this->actingAsAdmin($this->user, $this->store); +}); + +it('renders the developers page', function () { + $this->get(route('admin.developers.index'))->assertOk(); +}); + +it('generates and revokes api tokens', function () { + Livewire::test(Index::class) + ->set('newTokenName', 'CI Token') + ->set('newTokenAbilities', ['read-products']) + ->call('generateToken') + ->assertSet('generatedToken', fn ($value) => is_string($value) && $value !== ''); + + $token = $this->user->tokens()->where('name', 'CI Token')->firstOrFail(); + + expect($token->abilities)->toBe(['read-products']); + + Livewire::test(Index::class) + ->call('revokeToken', $token->getKey()) + ->assertDispatched('toast'); + + expect($this->user->tokens()->whereKey($token->getKey())->exists())->toBeFalse(); +}); + +it('creates a webhook subscription', function () { + Livewire::test(Index::class) + ->call('openWebhookModal') + ->set('webhookEventType', 'order.created') + ->set('webhookUrl', 'https://example.com/hooks') + ->call('saveWebhook') + ->assertDispatched('toast'); + + $subscription = WebhookSubscription::query() + ->where('store_id', $this->store->getKey()) + ->where('event_type', 'order.created') + ->firstOrFail(); + + expect($subscription->target_url)->toBe('https://example.com/hooks') + ->and($subscription->status)->toBe(WebhookSubscriptionStatus::Active); +}); + +it('pauses and deletes webhook subscriptions', function () { + $subscription = WebhookSubscription::factory()->create([ + 'store_id' => $this->store->getKey(), + 'status' => WebhookSubscriptionStatus::Active, + ]); + + Livewire::test(Index::class) + ->call('toggleWebhookStatus', $subscription->getKey()) + ->assertDispatched('toast'); + + expect($subscription->refresh()->status)->toBe(WebhookSubscriptionStatus::Paused); + + Livewire::test(Index::class) + ->call('deleteWebhook', $subscription->getKey()) + ->assertDispatched('toast'); + + expect(WebhookSubscription::query()->whereKey($subscription->getKey())->exists())->toBeFalse(); +}); + +it('only lists webhooks from the current store', function () { + ['store' => $otherStore] = $this->createStoreContext(); + WebhookSubscription::factory()->create([ + 'store_id' => $otherStore->getKey(), + 'target_url' => 'https://other-store.example.com/hooks', + ]); + $this->actingAsAdmin($this->user, $this->store); + + Livewire::test(Index::class)->assertDontSee('other-store.example.com'); +}); + +it('denies staff access to the developers page', function () { + $staff = User::factory()->create(); + StoreUser::factory()->create([ + 'store_id' => $this->store->getKey(), + 'user_id' => $staff->getKey(), + 'role' => StoreUserRole::Staff, + ]); + $this->actingAsAdmin($staff, $this->store); + + $this->get(route('admin.developers.index'))->assertForbidden(); +}); diff --git a/tests/Feature/Admin/DiscountsTest.php b/tests/Feature/Admin/DiscountsTest.php new file mode 100644 index 00000000..c71551bf --- /dev/null +++ b/tests/Feature/Admin/DiscountsTest.php @@ -0,0 +1,87 @@ + $this->store, 'user' => $this->user] = $this->createStoreContext(); + $this->actingAsAdmin($this->user, $this->store); +}); + +it('lists discounts scoped to the current store', function () { + Discount::factory()->create(['store_id' => $this->store->getKey(), 'code' => 'WELCOME10']); + + $this->get(route('admin.discounts.index')) + ->assertOk() + ->assertSee('WELCOME10'); +}); + +it('creates a percent discount with a minimum purchase rule', function () { + Livewire::test(Form::class) + ->set('type', 'code') + ->set('code', 'SAVE20') + ->set('valueType', 'percent') + ->set('valueAmount', '20') + ->set('minimumPurchaseAmount', '50.00') + ->set('startsAt', now()->format('Y-m-d\TH:i')) + ->call('save') + ->assertRedirect(route('admin.discounts.index')); + + $discount = Discount::query()->where('store_id', $this->store->getKey())->where('code', 'SAVE20')->firstOrFail(); + + expect($discount->value_type)->toBe(DiscountValueType::Percent) + ->and($discount->value_amount)->toBe(20) + ->and($discount->rules_json['min_purchase_amount'])->toBe(5000); +}); + +it('creates a fixed-amount discount converting euros to cents', function () { + Livewire::test(Form::class) + ->set('type', 'code') + ->set('code', 'FLAT5') + ->set('valueType', 'fixed') + ->set('valueAmount', '5.00') + ->set('startsAt', now()->format('Y-m-d\TH:i')) + ->call('save') + ->assertRedirect(route('admin.discounts.index')); + + expect(Discount::query()->where('code', 'FLAT5')->firstOrFail()->value_amount)->toBe(500); +}); + +it('edits a discount', function () { + $discount = Discount::factory()->create([ + 'store_id' => $this->store->getKey(), + 'code' => 'OLD', + 'status' => DiscountStatus::Active, + ]); + + Livewire::test(Form::class, ['discount' => $discount]) + ->set('code', 'NEWCODE') + ->call('save') + ->assertDispatched('toast'); + + expect($discount->refresh()->code)->toBe('NEWCODE'); +}); + +it('deletes a discount with confirmation', function () { + $discount = Discount::factory()->create(['store_id' => $this->store->getKey()]); + + Livewire::test(Index::class) + ->call('confirmDelete', $discount->getKey()) + ->call('deleteDiscount') + ->assertDispatched('toast'); + + expect(Discount::query()->whereKey($discount->getKey())->exists())->toBeFalse(); +}); + +it('returns 404 when editing a discount from another store', function () { + ['store' => $otherStore] = $this->createStoreContext(); + $discount = Discount::factory()->create(['store_id' => $otherStore->getKey()]); + + $this->get(route('admin.discounts.edit', $discount))->assertNotFound(); +}); diff --git a/tests/Feature/Admin/InventoryTest.php b/tests/Feature/Admin/InventoryTest.php new file mode 100644 index 00000000..3b55ef98 --- /dev/null +++ b/tests/Feature/Admin/InventoryTest.php @@ -0,0 +1,71 @@ + $this->store, 'user' => $this->user] = $this->createStoreContext(); + $this->actingAsAdmin($this->user, $this->store); +}); + +it('lists inventory scoped to the current store', function () { + $variant = CommerceTestHelper::purchasableVariant($this->store, ['inventory' => ['quantity_on_hand' => 3, 'policy' => 'deny']]); + + $this->get(route('admin.inventory.index')) + ->assertOk() + ->assertSee($variant->product->title); +}); + +it('updates quantity through the inventory service', function () { + $variant = CommerceTestHelper::purchasableVariant($this->store); + $item = $variant->inventoryItem; + + Livewire::test(Index::class) + ->call('updateQuantity', $item->getKey(), 25) + ->assertDispatched('toast'); + + expect($item->refresh()->quantity_on_hand)->toBe(25); +}); + +it('updates the inventory policy', function () { + $variant = CommerceTestHelper::purchasableVariant($this->store); + $item = $variant->inventoryItem; + + Livewire::test(Index::class) + ->call('updatePolicy', $item->getKey(), 'continue') + ->assertDispatched('toast'); + + expect($item->refresh()->policy)->toBe(InventoryPolicy::Continue); +}); + +it('filters by stock level', function () { + $low = CommerceTestHelper::purchasableVariant($this->store, [], ['quantity_on_hand' => 2]); + CommerceTestHelper::purchasableVariant($this->store, [], ['quantity_on_hand' => 50]); + + Livewire::test(Index::class) + ->set('stockFilter', 'low_stock') + ->assertSee($low->product->title); +}); + +it('rejects updates for items from another store', function () { + ['store' => $otherStore] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($otherStore); + $itemId = $variant->inventoryItem->getKey(); + $this->actingAsAdmin($this->user, $this->store); + + try { + Livewire::test(Index::class)->call('updateQuantity', $itemId, 5); + $this->fail('Expected cross-store inventory update to fail.'); + } catch (ModelNotFoundException) { + // Expected: item is scoped to the other store. + } + + expect(InventoryItem::query()->withoutGlobalScope(StoreScope::class)->find($itemId)->quantity_on_hand)->toBe(50); +}); diff --git a/tests/Feature/Admin/NavigationTest.php b/tests/Feature/Admin/NavigationTest.php new file mode 100644 index 00000000..208a1299 --- /dev/null +++ b/tests/Feature/Admin/NavigationTest.php @@ -0,0 +1,66 @@ + $this->store, 'user' => $this->user] = $this->createStoreContext(); + $this->actingAsAdmin($this->user, $this->store); +}); + +it('lists navigation menus scoped to the current store', function () { + NavigationMenu::factory()->create(['store_id' => $this->store->getKey(), 'handle' => 'main-menu', 'title' => 'Main Menu']); + + $this->get(route('admin.navigation.index')) + ->assertOk() + ->assertSee('Main Menu'); +}); + +it('adds and saves menu items', function () { + $menu = NavigationMenu::factory()->create(['store_id' => $this->store->getKey()]); + + Livewire::test(Index::class) + ->call('selectMenu', $menu->getKey()) + ->call('addItem') + ->set('itemLabel', 'Home') + ->set('itemType', 'link') + ->set('itemUrl', '/') + ->call('saveItem') + ->call('saveMenu') + ->assertDispatched('toast'); + + $item = NavigationItem::query()->where('menu_id', $menu->getKey())->firstOrFail(); + + expect($item->label)->toBe('Home') + ->and($item->type)->toBe(NavigationItemType::Link) + ->and($item->position)->toBe(0); +}); + +it('reorders and removes menu items', function () { + $menu = NavigationMenu::factory()->create(['store_id' => $this->store->getKey()]); + NavigationItem::factory()->create(['menu_id' => $menu->getKey(), 'label' => 'First', 'position' => 0]); + NavigationItem::factory()->create(['menu_id' => $menu->getKey(), 'label' => 'Second', 'position' => 1]); + + Livewire::test(Index::class) + ->call('selectMenu', $menu->getKey()) + ->call('moveItemDown', 0) + ->call('removeItem', 1) + ->call('saveMenu') + ->assertDispatched('toast'); + + expect(NavigationItem::query()->where('menu_id', $menu->getKey())->pluck('label')->all())->toBe(['Second']); +}); + +it('does not show menus from another store', function () { + ['store' => $otherStore] = $this->createStoreContext(); + NavigationMenu::factory()->create(['store_id' => $otherStore->getKey(), 'title' => 'Other Store Menu']); + + $this->get(route('admin.navigation.index')) + ->assertOk() + ->assertDontSee('Other Store Menu'); +}); diff --git a/tests/Feature/Admin/OrdersTest.php b/tests/Feature/Admin/OrdersTest.php new file mode 100644 index 00000000..af706f30 --- /dev/null +++ b/tests/Feature/Admin/OrdersTest.php @@ -0,0 +1,166 @@ + $this->store, 'user' => $this->user] = $this->createStoreContext(); + $this->actingAsAdmin($this->user, $this->store); +}); + +function createAdminOrder($store, array $overrides = []): Order +{ + $customer = Customer::factory()->create(['store_id' => $store->getKey()]); + $variant = CommerceTestHelper::purchasableVariant($store); + + $order = Order::factory()->create(array_merge([ + 'store_id' => $store->getKey(), + 'customer_id' => $customer->getKey(), + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Paid, + 'financial_status' => FinancialStatus::Paid, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled, + 'subtotal_amount' => 5000, + 'discount_amount' => 0, + 'shipping_amount' => 499, + 'tax_amount' => 800, + 'total_amount' => 6299, + 'placed_at' => now(), + ], $overrides)); + + OrderLine::factory()->create([ + 'order_id' => $order->getKey(), + 'product_id' => $variant->product_id, + 'variant_id' => $variant->getKey(), + 'quantity' => 2, + 'unit_price_amount' => 2500, + 'total_amount' => 5000, + ]); + + return $order; +} + +it('lists orders scoped to the current store', function () { + $order = createAdminOrder($this->store); + + $this->get(route('admin.orders.index')) + ->assertOk() + ->assertSee($order->order_number); +}); + +it('searches orders by number and email', function () { + $order = createAdminOrder($this->store, ['email' => 'buyer@example.com']); + createAdminOrder($this->store, ['email' => 'other@example.com']); + + Livewire::test(Index::class) + ->set('search', 'buyer@example.com') + ->assertSee($order->order_number) + ->assertDontSee('other@example.com'); +}); + +it('shows order detail with lines and payments', function () { + $order = createAdminOrder($this->store); + Payment::factory()->create([ + 'order_id' => $order->getKey(), + 'method' => PaymentMethod::CreditCard, + 'status' => PaymentStatus::Captured, + 'amount' => $order->total_amount, + ]); + + $this->get(route('admin.orders.show', $order)) + ->assertOk() + ->assertSee($order->order_number); +}); + +it('confirms bank transfer payments', function () { + $order = createAdminOrder($this->store, [ + 'payment_method' => PaymentMethod::BankTransfer, + 'status' => OrderStatus::Pending, + 'financial_status' => FinancialStatus::Pending, + ]); + Payment::factory()->create([ + 'order_id' => $order->getKey(), + 'method' => PaymentMethod::BankTransfer, + 'status' => PaymentStatus::Pending, + 'amount' => $order->total_amount, + ]); + + Livewire::test(Show::class, ['order' => $order]) + ->call('confirmPayment') + ->assertDispatched('toast'); + + expect($order->refresh()->financial_status)->toBe(FinancialStatus::Paid); +}); + +it('creates a fulfillment for paid orders', function () { + $order = createAdminOrder($this->store); + $lineId = $order->orderLines()->first()->getKey(); + + Livewire::test(Show::class, ['order' => $order]) + ->call('openFulfillmentModal') + ->assertSet('fulfillmentLines.'.$lineId, 2) + ->set('trackingCompany', 'DHL') + ->set('trackingNumber', 'DHL123') + ->call('createFulfillment') + ->assertDispatched('toast'); + + expect($order->fulfillments()->count())->toBe(1) + ->and($order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled); +}); + +it('blocks fulfillment for unpaid bank transfer orders', function () { + $order = createAdminOrder($this->store, [ + 'payment_method' => PaymentMethod::BankTransfer, + 'status' => OrderStatus::Pending, + 'financial_status' => FinancialStatus::Pending, + ]); + + Livewire::test(Show::class, ['order' => $order]) + ->call('openFulfillmentModal') + ->call('createFulfillment') + ->assertDispatched('toast'); + + expect($order->fulfillments()->count())->toBe(0); +}); + +it('creates a partial refund', function () { + $order = createAdminOrder($this->store); + Payment::factory()->create([ + 'order_id' => $order->getKey(), + 'method' => PaymentMethod::CreditCard, + 'status' => PaymentStatus::Captured, + 'amount' => $order->total_amount, + ]); + $lineId = $order->orderLines()->first()->getKey(); + + Livewire::test(Show::class, ['order' => $order]) + ->call('openRefundModal') + ->set('refundSelected.'.$lineId, true) + ->set('refundLines.'.$lineId, 1) + ->set('refundReason', 'Item returned') + ->call('createRefund') + ->assertDispatched('toast'); + + expect($order->refunds()->count())->toBe(1) + ->and($order->refresh()->financial_status)->toBe(FinancialStatus::PartiallyRefunded); +}); + +it('returns 404 for an order from another store', function () { + ['store' => $otherStore] = $this->createStoreContext(); + $order = createAdminOrder($otherStore); + + $this->get(route('admin.orders.show', $order))->assertNotFound(); +}); diff --git a/tests/Feature/Admin/PagesTest.php b/tests/Feature/Admin/PagesTest.php new file mode 100644 index 00000000..7b1a68e0 --- /dev/null +++ b/tests/Feature/Admin/PagesTest.php @@ -0,0 +1,74 @@ + $this->store, 'user' => $this->user] = $this->createStoreContext(); + $this->actingAsAdmin($this->user, $this->store); +}); + +it('lists pages scoped to the current store', function () { + Page::factory()->create(['store_id' => $this->store->getKey(), 'title' => 'About Us']); + + $this->get(route('admin.pages.index')) + ->assertOk() + ->assertSee('About Us'); +}); + +it('creates a page', function () { + Livewire::test(Form::class) + ->set('title', 'Shipping Info') + ->set('bodyHtml', '

We ship worldwide.

') + ->set('status', 'published') + ->call('save') + ->assertRedirect(route('admin.pages.index')); + + $page = Page::query()->where('store_id', $this->store->getKey())->where('handle', 'shipping-info')->firstOrFail(); + + expect($page->status)->toBe(PageStatus::Published) + ->and($page->published_at)->not->toBeNull(); +}); + +it('edits a page', function () { + $page = Page::factory()->create(['store_id' => $this->store->getKey()]); + + Livewire::test(Form::class, ['page' => $page]) + ->set('title', 'Updated Title') + ->call('save') + ->assertDispatched('toast'); + + expect($page->refresh()->title)->toBe('Updated Title'); +}); + +it('deletes a page', function () { + $page = Page::factory()->create(['store_id' => $this->store->getKey()]); + + Livewire::test(Form::class, ['page' => $page]) + ->call('deletePage') + ->assertRedirect(route('admin.pages.index')); + + expect(Page::query()->whereKey($page->getKey())->exists())->toBeFalse(); +}); + +it('validates duplicate handles', function () { + Page::factory()->create(['store_id' => $this->store->getKey(), 'handle' => 'about']); + + Livewire::test(Form::class) + ->set('title', 'About') + ->set('handle', 'about') + ->set('status', 'draft') + ->call('save') + ->assertHasErrors('handle'); +}); + +it('returns 404 for a page from another store', function () { + ['store' => $otherStore] = $this->createStoreContext(); + $page = Page::factory()->create(['store_id' => $otherStore->getKey()]); + + $this->get(route('admin.pages.edit', $page))->assertNotFound(); +}); diff --git a/tests/Feature/Admin/ProductsTest.php b/tests/Feature/Admin/ProductsTest.php new file mode 100644 index 00000000..bc79fa24 --- /dev/null +++ b/tests/Feature/Admin/ProductsTest.php @@ -0,0 +1,124 @@ + $this->store, 'user' => $this->user] = $this->createStoreContext(); + $this->actingAsAdmin($this->user, $this->store); +}); + +it('lists products scoped to the current store', function () { + $product = Product::factory()->create(['store_id' => $this->store->getKey(), 'title' => 'Store One Tee']); + ['store' => $otherStore] = $this->createStoreContext(); + Product::factory()->create(['store_id' => $otherStore->getKey(), 'title' => 'Other Store Tee']); + + $this->get(route('admin.products.index')) + ->assertOk() + ->assertSee('Store One Tee') + ->assertDontSee('Other Store Tee'); + + expect($product->store_id)->toBe($this->store->getKey()); +}); + +it('filters products by search and status', function () { + Product::factory()->create(['store_id' => $this->store->getKey(), 'title' => 'Red Hoodie', 'status' => ProductStatus::Active]); + Product::factory()->create(['store_id' => $this->store->getKey(), 'title' => 'Blue Hoodie', 'status' => ProductStatus::Draft]); + + Livewire::test(Index::class) + ->set('search', 'Red') + ->assertSee('Red Hoodie') + ->assertDontSee('Blue Hoodie') + ->set('search', '') + ->set('statusFilter', 'draft') + ->assertSee('Blue Hoodie') + ->assertDontSee('Red Hoodie'); +}); + +it('creates a product through the shared form', function () { + Livewire::test(Form::class) + ->set('title', 'Fresh Sneakers') + ->set('status', 'active') + ->set('vendor', 'Acme') + ->set('variants.0.sku', 'TEST-SNK-01') + ->set('variants.0.price', '79.99') + ->set('variants.0.quantity', 10) + ->call('save') + ->assertRedirect(route('admin.products.index')); + + $product = Product::query()->where('store_id', $this->store->getKey())->where('title', 'Fresh Sneakers')->firstOrFail(); + + expect($product->status)->toBe(ProductStatus::Active) + ->and($product->productVariants()->count())->toBe(1) + ->and($product->productVariants()->first()->price_amount)->toBe(7999) + ->and($product->productVariants()->first()->inventoryItem->quantity_on_hand)->toBe(10); +}); + +it('edits a product through the shared form', function () { + $variant = CommerceTestHelper::purchasableVariant($this->store); + $product = $variant->product; + + Livewire::test(Form::class, ['product' => $product]) + ->set('title', 'New Title') + ->call('save') + ->assertDispatched('toast'); + + expect($product->refresh()->title)->toBe('New Title'); +}); + +it('returns 404 when editing a product from another store', function () { + ['store' => $otherStore] = $this->createStoreContext(); + $product = Product::factory()->create(['store_id' => $otherStore->getKey()]); + + $this->get(route('admin.products.edit', $product))->assertNotFound(); +}); + +it('archives products via bulk action', function () { + $product = Product::factory()->create(['store_id' => $this->store->getKey(), 'status' => ProductStatus::Active]); + + Livewire::test(Index::class) + ->set('selectedIds', [$product->getKey()]) + ->call('bulkArchive') + ->assertDispatched('toast'); + + expect($product->refresh()->status)->toBe(ProductStatus::Archived); +}); + +it('prevents support users from mutating products', function () { + $support = User::factory()->create(); + StoreUser::factory()->create([ + 'store_id' => $this->store->getKey(), + 'user_id' => $support->getKey(), + 'role' => StoreUserRole::Support, + ]); + $this->actingAsAdmin($support, $this->store); + $product = Product::factory()->create(['store_id' => $this->store->getKey(), 'status' => ProductStatus::Active]); + + Livewire::test(Index::class) + ->set('selectedIds', [$product->getKey()]) + ->call('bulkArchive') + ->assertForbidden(); + + expect($product->refresh()->status)->toBe(ProductStatus::Active); +}); + +it('allows support users to view the product list', function () { + $support = User::factory()->create(); + StoreUser::factory()->create([ + 'store_id' => $this->store->getKey(), + 'user_id' => $support->getKey(), + 'role' => StoreUserRole::Support, + ]); + $this->actingAsAdmin($support, $this->store); + + $this->get(route('admin.products.index'))->assertOk(); +}); diff --git a/tests/Feature/Admin/SearchSettingsTest.php b/tests/Feature/Admin/SearchSettingsTest.php new file mode 100644 index 00000000..9a4b2fc1 --- /dev/null +++ b/tests/Feature/Admin/SearchSettingsTest.php @@ -0,0 +1,40 @@ + $this->store, 'user' => $this->user] = $this->createStoreContext(); + $this->actingAsAdmin($this->user, $this->store); +}); + +it('renders the search settings page', function () { + $this->get(route('admin.search.settings'))->assertOk(); +}); + +it('saves synonyms and stop words', function () { + Livewire::test(Settings::class) + ->call('addSynonymGroup') + ->set('synonymGroups.0', 'tee, t-shirt, tshirt') + ->set('stopWords', 'the, a, an') + ->call('save') + ->assertDispatched('toast'); + + $settings = $this->store->refresh()->searchSetting; + + expect($settings->synonyms_json)->toBe([['tee', 't-shirt', 'tshirt']]) + ->and($settings->stop_words_json)->toBe(['the', 'a', 'an']); +}); + +it('dispatches a reindex job', function () { + Queue::fake([ReindexSearch::class]); + + Livewire::test(Settings::class) + ->call('triggerReindex'); + + Queue::assertPushed(ReindexSearch::class, fn ($job) => $job->storeId === $this->store->getKey()); +}); diff --git a/tests/Feature/Admin/SettingsTest.php b/tests/Feature/Admin/SettingsTest.php new file mode 100644 index 00000000..c4fe8b6c --- /dev/null +++ b/tests/Feature/Admin/SettingsTest.php @@ -0,0 +1,123 @@ + $this->store, 'user' => $this->user] = $this->createStoreContext(); + $this->actingAsAdmin($this->user, $this->store); +}); + +it('renders the settings page', function () { + $this->get(route('admin.settings.index'))->assertOk(); +}); + +it('saves general settings', function () { + Livewire::test(General::class) + ->set('storeName', 'Renamed Store') + ->set('contactEmail', 'hello@example.com') + ->call('save') + ->assertDispatched('toast'); + + expect($this->store->refresh()->name)->toBe('Renamed Store') + ->and($this->store->storeSetting->settings_json['contact_email'])->toBe('hello@example.com'); +}); + +it('adds and removes domains', function () { + Livewire::test(Domains::class) + ->set('newHostname', 'shop.example.com') + ->set('newType', 'storefront') + ->call('addDomain') + ->assertDispatched('toast'); + + $domain = StoreDomain::query()->where('hostname', 'shop.example.com')->firstOrFail(); + + Livewire::test(Domains::class) + ->call('setPrimary', $domain->getKey()) + ->assertDispatched('toast'); + + expect($domain->refresh()->is_primary)->toBeTrue(); + + $oldPrimary = StoreDomain::query() + ->where('store_id', $this->store->getKey()) + ->whereKeyNot($domain->getKey()) + ->firstOrFail(); + + Livewire::test(Domains::class)->call('removeDomain', $oldPrimary->getKey()); + + expect(StoreDomain::query()->whereKey($oldPrimary->getKey())->exists())->toBeFalse(); +}); + +it('refuses to remove the primary domain', function () { + $primary = $this->store->storeDomains()->firstOrFail(); + $primary->update(['is_primary' => true]); + + Livewire::test(Domains::class) + ->call('removeDomain', $primary->getKey()) + ->assertDispatched('toast'); + + expect(StoreDomain::query()->whereKey($primary->getKey())->exists())->toBeTrue(); +}); + +it('creates shipping zones and rates', function () { + Livewire::test(Shipping::class) + ->call('openZoneModal') + ->set('zoneName', 'Domestic') + ->set('zoneCountries', ['DE']) + ->call('saveZone') + ->assertDispatched('toast'); + + $zone = ShippingZone::query()->where('store_id', $this->store->getKey())->where('name', 'Domestic')->firstOrFail(); + + Livewire::test(Shipping::class) + ->call('openRateModal', $zone->getKey()) + ->set('rateName', 'Standard') + ->set('rateType', 'flat') + ->set('ratePrice', '4.99') + ->call('saveRate') + ->assertDispatched('toast'); + + expect(ShippingRate::query()->where('zone_id', $zone->getKey())->firstOrFail()->config_json['amount'])->toBe(499); +}); + +it('deletes shipping zones', function () { + $rate = CommerceTestHelper::flatShippingRate($this->store); + $zoneId = $rate->zone_id; + + Livewire::test(Shipping::class) + ->call('deleteZone', $zoneId) + ->assertDispatched('toast'); + + expect(ShippingZone::query()->whereKey($zoneId)->exists())->toBeFalse(); +}); + +it('tests a shipping address against zones', function () { + CommerceTestHelper::flatShippingRate($this->store, ['DE'], 499); + + Livewire::test(Shipping::class) + ->set('testCountry', 'DE') + ->call('testShippingAddress') + ->assertSet('testResult.matched', true); +}); + +it('saves tax settings with manual rates', function () { + Livewire::test(Taxes::class) + ->call('addManualRate') + ->set('manualRates.0.zone_name', 'DE') + ->set('manualRates.0.rate_percentage', '19.00') + ->call('save') + ->assertDispatched('toast'); + + $settings = $this->store->refresh()->taxSetting; + + expect($settings->config_json['tax_rates'][0]['rate'])->toBe(1900); +}); diff --git a/tests/Feature/Admin/ThemesTest.php b/tests/Feature/Admin/ThemesTest.php new file mode 100644 index 00000000..cc940ce3 --- /dev/null +++ b/tests/Feature/Admin/ThemesTest.php @@ -0,0 +1,80 @@ + $this->store, 'user' => $this->user] = $this->createStoreContext(); + $this->actingAsAdmin($this->user, $this->store); +}); + +it('lists themes scoped to the current store', function () { + Theme::factory()->create(['store_id' => $this->store->getKey(), 'name' => 'Default Theme']); + + $this->get(route('admin.themes.index')) + ->assertOk() + ->assertSee('Default Theme'); +}); + +it('publishes a theme and demotes the previous one', function () { + Theme::factory()->create(['store_id' => $this->store->getKey(), 'status' => ThemeStatus::Published]); + $draft = Theme::factory()->create(['store_id' => $this->store->getKey(), 'status' => ThemeStatus::Draft]); + + Livewire::test(Index::class) + ->call('publishTheme', $draft->getKey()) + ->assertDispatched('toast'); + + expect($draft->refresh()->status)->toBe(ThemeStatus::Published) + ->and(Theme::query()->where('store_id', $this->store->getKey())->where('status', ThemeStatus::Published)->count())->toBe(1); +}); + +it('duplicates a theme with its settings', function () { + $theme = Theme::factory()->create(['store_id' => $this->store->getKey(), 'status' => ThemeStatus::Draft]); + $theme->themeSetting()->create(['settings_json' => ['primary_color' => '#000000']]); + + Livewire::test(Index::class) + ->call('duplicateTheme', $theme->getKey()) + ->assertDispatched('toast'); + + $copy = Theme::query()->where('store_id', $this->store->getKey())->where('name', $theme->name.' (copy)')->firstOrFail(); + + expect($copy->status)->toBe(ThemeStatus::Draft) + ->and($copy->themeSetting->settings_json['primary_color'])->toBe('#000000'); +}); + +it('refuses to delete the published theme', function () { + $theme = Theme::factory()->create(['store_id' => $this->store->getKey(), 'status' => ThemeStatus::Published]); + + Livewire::test(Index::class) + ->call('confirmDelete', $theme->getKey()) + ->call('deleteTheme') + ->assertDispatched('toast'); + + expect(Theme::query()->whereKey($theme->getKey())->exists())->toBeTrue(); +}); + +it('edits section settings in the theme editor', function () { + $theme = Theme::factory()->create(['store_id' => $this->store->getKey(), 'status' => ThemeStatus::Draft]); + + $this->get(route('admin.themes.editor', $theme))->assertOk(); + + Livewire::test(Editor::class, ['theme' => $theme]) + ->call('selectSection', 'hero') + ->call('updateSetting', 'heading', 'Hello World') + ->call('save') + ->assertDispatched('toast'); + + expect($theme->themeSetting()->first()->settings_json['hero']['heading'])->toBe('Hello World'); +}); + +it('returns 404 for a theme from another store', function () { + ['store' => $otherStore] = $this->createStoreContext(); + $theme = Theme::factory()->create(['store_id' => $otherStore->getKey()]); + + $this->get(route('admin.themes.editor', $theme))->assertNotFound(); +}); diff --git a/tests/Feature/Analytics/AnalyticsTest.php b/tests/Feature/Analytics/AnalyticsTest.php new file mode 100644 index 00000000..9d43fe20 --- /dev/null +++ b/tests/Feature/Analytics/AnalyticsTest.php @@ -0,0 +1,184 @@ +withoutMiddleware(Illuminate\Routing\Middleware\ThrottleRequests::class); +}); + +it('accepts a batch of analytics events from the storefront', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + + $response = $this->postJson( + CommerceTestHelper::storefrontUrl($domain, '/api/storefront/v1/analytics/events'), + ['events' => [ + [ + 'type' => 'page_view', + 'session_id' => 'session-1', + 'client_event_id' => 'evt-1', + 'properties' => ['url' => '/', 'referrer' => null], + 'occurred_at' => now()->toIso8601String(), + ], + [ + 'type' => 'add_to_cart', + 'session_id' => 'session-1', + 'client_event_id' => 'evt-2', + 'properties' => ['variant_id' => 1, 'quantity' => 2], + 'occurred_at' => now()->toIso8601String(), + ], + ]] + ); + + $response->assertAccepted(); + $response->assertJsonPath('accepted', 2); + expect(AnalyticsEvent::query()->where('store_id', $store->getKey())->count())->toBe(2); +}); + +it('rejects invalid event types', function () { + ['domain' => $domain] = $this->createStoreContext(); + + $response = $this->postJson( + CommerceTestHelper::storefrontUrl($domain, '/api/storefront/v1/analytics/events'), + ['events' => [[ + 'type' => 'not_a_real_event', + 'session_id' => 'session-1', + 'client_event_id' => 'evt-1', + 'occurred_at' => now()->toIso8601String(), + ]]] + ); + + $response->assertUnprocessable(); +}); + +it('rejects events with stale timestamps', function () { + ['domain' => $domain] = $this->createStoreContext(); + + $response = $this->postJson( + CommerceTestHelper::storefrontUrl($domain, '/api/storefront/v1/analytics/events'), + ['events' => [[ + 'type' => 'page_view', + 'session_id' => 'session-1', + 'client_event_id' => 'evt-1', + 'occurred_at' => now()->subDays(2)->toIso8601String(), + ]]] + ); + + $response->assertUnprocessable(); +}); + +it('deduplicates events by client event id', function () { + ['store' => $store] = $this->createStoreContext(); + $analytics = app(AnalyticsService::class); + + $first = $analytics->track($store, 'page_view', [], 'session-1', null, 'evt-dedupe'); + $second = $analytics->track($store, 'page_view', [], 'session-1', null, 'evt-dedupe'); + + expect($first)->not->toBeNull() + ->and($second)->toBeNull(); +}); + +it('aggregates raw events into daily rows', function () { + ['store' => $store] = $this->createStoreContext(); + $day = today()->subDay(); + + AnalyticsEvent::factory()->create([ + 'store_id' => $store->getKey(), + 'type' => 'page_view', + 'session_id' => 'session-1', + 'occurred_at' => $day->copy()->setTime(10, 0), + 'created_at' => $day->copy()->setTime(10, 0), + ]); + AnalyticsEvent::factory()->create([ + 'store_id' => $store->getKey(), + 'type' => 'page_view', + 'session_id' => 'session-1', + 'occurred_at' => $day->copy()->setTime(11, 0), + 'created_at' => $day->copy()->setTime(11, 0), + ]); + AnalyticsEvent::factory()->create([ + 'store_id' => $store->getKey(), + 'type' => 'add_to_cart', + 'session_id' => 'session-1', + 'occurred_at' => $day->copy()->setTime(12, 0), + 'created_at' => $day->copy()->setTime(12, 0), + ]); + AnalyticsEvent::factory()->create([ + 'store_id' => $store->getKey(), + 'type' => 'checkout_completed', + 'session_id' => 'session-1', + 'properties_json' => ['total_amount' => 5000], + 'occurred_at' => $day->copy()->setTime(13, 0), + 'created_at' => $day->copy()->setTime(13, 0), + ]); + + (new AggregateAnalytics($day->toDateString()))->handle(); + + $daily = AnalyticsDaily::query() + ->where('store_id', $store->getKey()) + ->where('date', $day->toDateString()) + ->firstOrFail(); + + expect($daily->visits_count)->toBe(1) + ->and($daily->add_to_cart_count)->toBe(1) + ->and($daily->orders_count)->toBe(1) + ->and($daily->revenue_amount)->toBe(5000) + ->and($daily->aov_amount)->toBe(5000); +}); + +it('serves the analytics summary over the admin api', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + AnalyticsDaily::factory()->create([ + 'store_id' => $store->getKey(), + 'date' => today()->toDateString(), + 'orders_count' => 3, + 'revenue_amount' => 15000, + 'aov_amount' => 5000, + 'visits_count' => 60, + 'add_to_cart_count' => 12, + 'checkout_started_count' => 6, + ]); + \App\Models\Order::factory()->count(3)->create([ + 'store_id' => $store->getKey(), + 'total_amount' => 5000, + 'placed_at' => now(), + ]); + + $response = $this->getJson( + "/api/admin/v1/stores/{$store->getKey()}/analytics/summary?from=".today()->toDateString().'&to='.today()->toDateString(), + CatalogTestHelper::adminHeaders($user, ['read-analytics']) + ); + + $response->assertOk(); + $response->assertJsonPath('data.summary.orders_count', 3); + $response->assertJsonPath('data.summary.revenue_amount', 15000); + $response->assertJsonPath('data.summary.conversion_rate', 0.05); +}); + +it('rejects summary ranges over 365 days', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + + $response = $this->getJson( + "/api/admin/v1/stores/{$store->getKey()}/analytics/summary?from=2024-01-01&to=2026-01-01", + CatalogTestHelper::adminHeaders($user, ['read-analytics']) + ); + + $response->assertUnprocessable(); +}); + +it('requires the read-analytics ability for the summary', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + + $response = $this->getJson( + "/api/admin/v1/stores/{$store->getKey()}/analytics/summary?from=".today()->toDateString().'&to='.today()->toDateString(), + CatalogTestHelper::adminHeaders($user, ['read-products']) + ); + + $response->assertForbidden(); +}); diff --git a/tests/Feature/Api/AdminCommerceApiTest.php b/tests/Feature/Api/AdminCommerceApiTest.php new file mode 100644 index 00000000..1545f888 --- /dev/null +++ b/tests/Feature/Api/AdminCommerceApiTest.php @@ -0,0 +1,244 @@ +withoutMiddleware(Illuminate\Routing\Middleware\ThrottleRequests::class); +}); + +it('lists discounts', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + Discount::factory()->count(3)->create(['store_id' => $store->id]); + + $response = $this->getJson( + "/api/admin/v1/stores/{$store->id}/discounts", + CatalogTestHelper::adminHeaders($user, ['read-discounts']) + ); + + $response->assertOk(); + expect($response->json('data'))->toHaveCount(3); +}); + +it('creates a percent discount', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + + $response = $this->postJson( + "/api/admin/v1/stores/{$store->id}/discounts", + [ + 'type' => 'code', + 'code' => 'SAVE10', + 'value_type' => 'percent', + 'value_amount' => 10, + 'rules_json' => ['minimum_purchase_amount' => 2000], + ], + CatalogTestHelper::adminHeaders($user, ['write-discounts']) + ); + + $response->assertCreated(); + $response->assertJsonPath('data.code', 'SAVE10'); + $response->assertJsonPath('data.value_amount', 10); +}); + +it('validates discount code uniqueness within store', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + Discount::factory()->create(['store_id' => $store->id, 'code' => 'SAVE10']); + + $response = $this->postJson( + "/api/admin/v1/stores/{$store->id}/discounts", + ['type' => 'code', 'code' => 'SAVE10', 'value_type' => 'percent', 'value_amount' => 10], + CatalogTestHelper::adminHeaders($user, ['write-discounts']) + ); + + $response->assertUnprocessable(); +}); + +it('edits a discount', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $discount = Discount::factory()->create([ + 'store_id' => $store->id, + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + ]); + + $response = $this->putJson( + "/api/admin/v1/stores/{$store->id}/discounts/{$discount->id}", + ['value_amount' => 15], + CatalogTestHelper::adminHeaders($user, ['write-discounts']) + ); + + $response->assertOk(); + $response->assertJsonPath('data.value_amount', 15); +}); + +it('disables a discount', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $discount = Discount::factory()->create(['store_id' => $store->id, 'status' => DiscountStatus::Active]); + + $response = $this->putJson( + "/api/admin/v1/stores/{$store->id}/discounts/{$discount->id}", + ['status' => 'disabled'], + CatalogTestHelper::adminHeaders($user, ['write-discounts']) + ); + + $response->assertOk(); + $response->assertJsonPath('data.status', 'disabled'); +}); + +it('deletes a discount', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $discount = Discount::factory()->create(['store_id' => $store->id]); + + $response = $this->deleteJson( + "/api/admin/v1/stores/{$store->id}/discounts/{$discount->id}", + [], + CatalogTestHelper::adminHeaders($user, ['write-discounts']) + ); + + $response->assertOk(); + expect(Discount::query()->whereKey($discount->id)->exists())->toBeFalse(); +}); + +it('prevents staff from deleting discounts', function () { + ['store' => $store] = $this->createStoreContext(); + $discount = Discount::factory()->create(['store_id' => $store->id]); + + $staff = App\Models\User::factory()->create(); + StoreUser::factory()->create(['store_id' => $store->id, 'user_id' => $staff->id, 'role' => StoreUserRole::Staff]); + + $response = $this->deleteJson( + "/api/admin/v1/stores/{$store->id}/discounts/{$discount->id}", + [], + CatalogTestHelper::adminHeaders($staff, ['write-discounts']) + ); + + $response->assertForbidden(); +}); + +it('lists shipping zones with rates', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + CommerceTestHelper::flatShippingRate($store, ['DE'], 499); + + $response = $this->getJson( + "/api/admin/v1/stores/{$store->id}/shipping/zones", + CatalogTestHelper::adminHeaders($user, ['read-settings']) + ); + + $response->assertOk(); + expect($response->json('data'))->toHaveCount(1); + expect($response->json('data.0.rates'))->toHaveCount(1); +}); + +it('creates a shipping zone', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + + $response = $this->postJson( + "/api/admin/v1/stores/{$store->id}/shipping/zones", + ['name' => 'Europe', 'countries_json' => ['DE', 'FR'], 'regions_json' => []], + CatalogTestHelper::adminHeaders($user, ['write-settings']) + ); + + $response->assertCreated(); + $response->assertJsonPath('data.countries_json', ['DE', 'FR']); +}); + +it('updates a shipping zone', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $rate = CommerceTestHelper::flatShippingRate($store); + + $response = $this->putJson( + "/api/admin/v1/stores/{$store->id}/shipping/zones/{$rate->zone_id}", + ['name' => 'DACH'], + CatalogTestHelper::adminHeaders($user, ['write-settings']) + ); + + $response->assertOk(); + $response->assertJsonPath('data.name', 'DACH'); +}); + +it('adds a shipping rate to a zone', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $rate = CommerceTestHelper::flatShippingRate($store); + + $response = $this->postJson( + "/api/admin/v1/stores/{$store->id}/shipping/zones/{$rate->zone_id}/rates", + [ + 'name' => 'Express', + 'type' => 'flat', + 'config_json' => ['amount' => 1200], + 'is_active' => true, + ], + CatalogTestHelper::adminHeaders($user, ['write-settings']) + ); + + $response->assertCreated(); + $response->assertJsonPath('data.name', 'Express'); +}); + +it('restricts shipping management to owner and admin', function () { + ['store' => $store] = $this->createStoreContext(); + + $staff = App\Models\User::factory()->create(); + StoreUser::factory()->create(['store_id' => $store->id, 'user_id' => $staff->id, 'role' => StoreUserRole::Staff]); + + $response = $this->getJson( + "/api/admin/v1/stores/{$store->id}/shipping/zones", + CatalogTestHelper::adminHeaders($staff, ['read-settings']) + ); + + $response->assertForbidden(); +}); + +it('gets tax settings', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + + $response = $this->getJson( + "/api/admin/v1/stores/{$store->id}/tax/settings", + CatalogTestHelper::adminHeaders($user, ['read-settings']) + ); + + $response->assertOk(); + $response->assertJsonPath('data.store_id', $store->id); + $response->assertJsonPath('data.mode', 'manual'); +}); + +it('updates tax settings', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + + $response = $this->putJson( + "/api/admin/v1/stores/{$store->id}/tax/settings", + [ + 'mode' => 'manual', + 'provider' => 'none', + 'prices_include_tax' => true, + 'config_json' => ['default_tax_rate' => 1900], + ], + CatalogTestHelper::adminHeaders($user, ['write-settings']) + ); + + $response->assertOk(); + $response->assertJsonPath('data.prices_include_tax', true); + $response->assertJsonPath('data.config_json.default_tax_rate', 1900); +}); + +it('restricts tax management to owner and admin', function () { + ['store' => $store] = $this->createStoreContext(); + + $staff = App\Models\User::factory()->create(); + StoreUser::factory()->create(['store_id' => $store->id, 'user_id' => $staff->id, 'role' => StoreUserRole::Staff]); + + $response = $this->putJson( + "/api/admin/v1/stores/{$store->id}/tax/settings", + ['mode' => 'manual', 'prices_include_tax' => false, 'config_json' => ['default_tax_rate' => 0]], + CatalogTestHelper::adminHeaders($staff, ['write-settings']) + ); + + $response->assertForbidden(); +}); diff --git a/tests/Feature/Api/AdminOrderApiTest.php b/tests/Feature/Api/AdminOrderApiTest.php new file mode 100644 index 00000000..894ce8e9 --- /dev/null +++ b/tests/Feature/Api/AdminOrderApiTest.php @@ -0,0 +1,202 @@ +withoutMiddleware(Illuminate\Routing\Middleware\ThrottleRequests::class); +}); + +function adminOrderContext(string $method = 'credit_card'): array +{ + ['store' => $store, 'user' => $user] = test()->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + $rate = CommerceTestHelper::flatShippingRate($store, ['DE'], 0); + + $paymentMethod = App\Enums\PaymentMethod::from($method); + + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->createFromCart($cart, 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $rate->id); + $checkout = $checkouts->selectPaymentMethod($checkout, $paymentMethod); + $order = $checkouts->pay($checkout, $paymentMethod, ['card_number' => '4242424242424242']); + + return ['store' => $store, 'user' => $user, 'order' => $order]; +} + +it('lists orders with authentication', function () { + $ctx = adminOrderContext(); + + $response = $this->getJson( + "/api/admin/v1/stores/{$ctx['store']->id}/orders", + CatalogTestHelper::adminHeaders($ctx['user'], ['read-orders']) + ); + + $response->assertOk(); + expect($response->json('data'))->toHaveCount(1); + $response->assertJsonPath('data.0.order_number', '#1001'); +}); + +it('retrieves a single order', function () { + $ctx = adminOrderContext(); + + $response = $this->getJson( + "/api/admin/v1/stores/{$ctx['store']->id}/orders/{$ctx['order']->id}", + CatalogTestHelper::adminHeaders($ctx['user'], ['read-orders']) + ); + + $response->assertOk(); + $response->assertJsonPath('data.order_number', '#1001'); + expect($response->json('data.lines'))->toHaveCount(1); + expect($response->json('data.payments'))->toHaveCount(1); + expect($response->json('data.fulfillments'))->toBeArray(); +}); + +it('filters orders by status', function () { + $ctx = adminOrderContext(); + + $cart = CommerceTestHelper::cartWithLines($ctx['store']); + $rate = App\Models\ShippingRate::query()->first(); + + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->createFromCart($cart, 'other@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $rate->id); + $checkout = $checkouts->selectPaymentMethod($checkout, PaymentMethod::BankTransfer); + $checkouts->pay($checkout, PaymentMethod::BankTransfer, []); + + $response = $this->getJson( + "/api/admin/v1/stores/{$ctx['store']->id}/orders?status=paid", + CatalogTestHelper::adminHeaders($ctx['user'], ['read-orders']) + ); + + $response->assertOk(); + expect($response->json('data'))->toHaveCount(1); + $response->assertJsonPath('data.0.status', 'paid'); +}); + +it('creates a fulfillment via API', function () { + $ctx = adminOrderContext(); + $line = $ctx['order']->orderLines()->first(); + + $response = $this->postJson( + "/api/admin/v1/stores/{$ctx['store']->id}/orders/{$ctx['order']->id}/fulfillments", + [ + 'tracking_company' => 'DHL', + 'tracking_number' => '1234567890', + 'line_items' => [['order_line_id' => $line->id, 'quantity' => 1]], + ], + CatalogTestHelper::adminHeaders($ctx['user'], ['write-orders']) + ); + + $response->assertCreated(); + $response->assertJsonPath('data.status', 'pending'); + $response->assertJsonPath('data.tracking_company', 'DHL'); +}); + +it('creates a refund via API', function () { + $ctx = adminOrderContext(); + + $response = $this->postJson( + "/api/admin/v1/stores/{$ctx['store']->id}/orders/{$ctx['order']->id}/refunds", + ['amount' => 1000, 'reason' => 'Customer request'], + CatalogTestHelper::adminHeaders($ctx['user'], ['write-orders']) + ); + + $response->assertCreated(); + $response->assertJsonPath('data.amount', 1000); + $response->assertJsonPath('data.status', 'processed'); +}); + +it('requires write-orders ability for mutations', function () { + $ctx = adminOrderContext(); + $line = $ctx['order']->orderLines()->first(); + + $response = $this->postJson( + "/api/admin/v1/stores/{$ctx['store']->id}/orders/{$ctx['order']->id}/fulfillments", + ['line_items' => [['order_line_id' => $line->id, 'quantity' => 1]]], + CatalogTestHelper::adminHeaders($ctx['user'], ['read-orders']) + ); + + $response->assertForbidden(); +}); + +it('returns 401 without token', function () { + $ctx = adminOrderContext(); + + $this->getJson("/api/admin/v1/stores/{$ctx['store']->id}/orders")->assertUnauthorized(); +}); + +it('confirms bank transfer payment via API', function () { + $ctx = adminOrderContext('bank_transfer'); + + $response = $this->postJson( + "/api/admin/v1/stores/{$ctx['store']->id}/orders/{$ctx['order']->id}/confirm-payment", + [], + CatalogTestHelper::adminHeaders($ctx['user'], ['write-orders']) + ); + + $response->assertOk(); + $response->assertJsonPath('data.financial_status', 'paid'); + $response->assertJsonPath('data.status', 'paid'); +}); + +it('ships and delivers a fulfillment via API', function () { + $ctx = adminOrderContext(); + $line = $ctx['order']->orderLines()->first(); + $headers = CatalogTestHelper::adminHeaders($ctx['user'], ['write-orders']); + + $created = $this->postJson( + "/api/admin/v1/stores/{$ctx['store']->id}/orders/{$ctx['order']->id}/fulfillments", + ['line_items' => [['order_line_id' => $line->id, 'quantity' => $line->quantity]]], + $headers + ); + $created->assertCreated(); + $fulfillmentId = $created->json('data.id'); + + $shipped = $this->postJson( + "/api/admin/v1/stores/{$ctx['store']->id}/orders/{$ctx['order']->id}/fulfillments/{$fulfillmentId}/ship", + [], + $headers + ); + $shipped->assertOk(); + $shipped->assertJsonPath('data.status', 'shipped'); + + $delivered = $this->postJson( + "/api/admin/v1/stores/{$ctx['store']->id}/orders/{$ctx['order']->id}/fulfillments/{$fulfillmentId}/deliver", + [], + $headers + ); + $delivered->assertOk(); + $delivered->assertJsonPath('data.status', 'delivered'); +}); + +it('lists refunds for an order', function () { + $ctx = adminOrderContext(); + app(App\Services\RefundService::class)->create($ctx['order'], 1000); + + $response = $this->getJson( + "/api/admin/v1/stores/{$ctx['store']->id}/orders/{$ctx['order']->id}/refunds", + CatalogTestHelper::adminHeaders($ctx['user'], ['read-orders']) + ); + + $response->assertOk(); + expect($response->json('data'))->toHaveCount(1); +}); + +it('isolates orders across stores', function () { + $ctxA = adminOrderContext(); + $ctxB = adminOrderContext(); + + $response = $this->getJson( + "/api/admin/v1/stores/{$ctxB['store']->id}/orders/{$ctxA['order']->id}", + CatalogTestHelper::adminHeaders($ctxB['user'], ['read-orders']) + ); + + $response->assertNotFound(); +}); diff --git a/tests/Feature/Api/StorefrontCartApiTest.php b/tests/Feature/Api/StorefrontCartApiTest.php new file mode 100644 index 00000000..5226b26d --- /dev/null +++ b/tests/Feature/Api/StorefrontCartApiTest.php @@ -0,0 +1,130 @@ +withoutMiddleware(ThrottleRequests::class); + ['domain' => $domain] = $this->createStoreContext(); + + $response = $this->postJson(CommerceTestHelper::storefrontUrl($domain, '/api/storefront/v1/carts'), []); + + $response->assertCreated(); + expect($response->json('id'))->not->toBeNull(); + $response->assertJsonPath('cart_version', 1); +}); + +it('retrieves a cart with lines and totals', function () { + $this->withoutMiddleware(ThrottleRequests::class); + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store, [ + ['price' => 2500, 'qty' => 1], + ['price' => 1000, 'qty' => 2], + ]); + + $response = $this->getJson(CommerceTestHelper::storefrontUrl($domain, "/api/storefront/v1/carts/{$cart->id}")); + + $response->assertOk(); + expect($response->json('lines'))->toHaveCount(2); + $response->assertJsonPath('totals.subtotal', 4500); + $response->assertJsonPath('totals.item_count', 3); +}); + +it('adds a line to the cart', function () { + $this->withoutMiddleware(ThrottleRequests::class); + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store); + $cart = app(App\Services\CartService::class)->create($store); + + $response = $this->postJson( + CommerceTestHelper::storefrontUrl($domain, "/api/storefront/v1/carts/{$cart->id}/lines"), + ['variant_id' => $variant->id, 'quantity' => 1] + ); + + $response->assertCreated(); + expect($response->json('lines'))->toHaveCount(1); +}); + +it('updates a line quantity', function () { + $this->withoutMiddleware(ThrottleRequests::class); + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + $line = $cart->cartLines()->first(); + + $response = $this->putJson( + CommerceTestHelper::storefrontUrl($domain, "/api/storefront/v1/carts/{$cart->id}/lines/{$line->id}"), + ['quantity' => 5, 'cart_version' => $cart->cart_version] + ); + + $response->assertOk(); + $response->assertJsonPath('lines.0.quantity', 5); +}); + +it('removes a line', function () { + $this->withoutMiddleware(ThrottleRequests::class); + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + $line = $cart->cartLines()->first(); + + $response = $this->deleteJson( + CommerceTestHelper::storefrontUrl($domain, "/api/storefront/v1/carts/{$cart->id}/lines/{$line->id}"), + ['cart_version' => $cart->cart_version] + ); + + $response->assertOk(); + expect($response->json('lines'))->toHaveCount(0); +}); + +it('validates variant exists on add', function () { + $this->withoutMiddleware(ThrottleRequests::class); + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $cart = app(App\Services\CartService::class)->create($store); + + $response = $this->postJson( + CommerceTestHelper::storefrontUrl($domain, "/api/storefront/v1/carts/{$cart->id}/lines"), + ['variant_id' => 999999, 'quantity' => 1] + ); + + $response->assertUnprocessable(); +}); + +it('validates quantity is positive', function () { + $this->withoutMiddleware(ThrottleRequests::class); + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store); + $cart = app(App\Services\CartService::class)->create($store); + + $response = $this->postJson( + CommerceTestHelper::storefrontUrl($domain, "/api/storefront/v1/carts/{$cart->id}/lines"), + ['variant_id' => $variant->id, 'quantity' => 0] + ); + + $response->assertUnprocessable(); +}); + +it('returns 404 for nonexistent cart', function () { + $this->withoutMiddleware(ThrottleRequests::class); + ['domain' => $domain] = $this->createStoreContext(); + + $response = $this->getJson(CommerceTestHelper::storefrontUrl($domain, '/api/storefront/v1/carts/999999')); + + $response->assertNotFound(); +}); + +it('respects storefront rate limiting', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $cart = app(App\Services\CartService::class)->create($store); + $url = CommerceTestHelper::storefrontUrl($domain, "/api/storefront/v1/carts/{$cart->id}"); + + for ($i = 0; $i < 120; $i++) { + $this->getJson($url)->assertOk(); + } + + $this->getJson($url)->assertStatus(429); +}); diff --git a/tests/Feature/Api/StorefrontCheckoutApiTest.php b/tests/Feature/Api/StorefrontCheckoutApiTest.php new file mode 100644 index 00000000..e9bc1aaf --- /dev/null +++ b/tests/Feature/Api/StorefrontCheckoutApiTest.php @@ -0,0 +1,297 @@ +withoutMiddleware(ThrottleRequests::class); + Illuminate\Support\Facades\Cache::flush(); +}); + +function checkoutApiContext(): array +{ + ['store' => $store, 'domain' => $domain] = test()->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store, [['price' => 2500, 'qty' => 2]]); + $rate = CommerceTestHelper::flatShippingRate($store, ['DE'], 499); + + return ['store' => $store, 'domain' => $domain, 'cart' => $cart, 'rate' => $rate]; +} + +function apiUrl($domain, string $path): string +{ + return CommerceTestHelper::storefrontUrl($domain, $path); +} + +it('creates a checkout from a cart', function () { + $ctx = checkoutApiContext(); + + $response = $this->postJson(apiUrl($ctx['domain'], '/api/storefront/v1/checkouts'), [ + 'cart_id' => $ctx['cart']->id, + 'email' => 'customer@example.com', + ]); + + $response->assertCreated(); + $response->assertJsonPath('status', 'started'); + $response->assertJsonPath('totals.subtotal', 5000); +}); + +it('sets checkout address', function () { + $ctx = checkoutApiContext(); + $checkout = app(App\Services\CheckoutService::class)->createFromCart($ctx['cart'], 'customer@example.com'); + + $response = $this->putJson( + apiUrl($ctx['domain'], "/api/storefront/v1/checkouts/{$checkout->id}/address"), + ['shipping_address' => CommerceTestHelper::address()] + ); + + $response->assertOk(); + $response->assertJsonPath('status', 'addressed'); + expect($response->json('available_shipping_methods'))->toHaveCount(1); +}); + +it('selects a shipping method', function () { + $ctx = checkoutApiContext(); + $checkouts = app(App\Services\CheckoutService::class); + $checkout = $checkouts->createFromCart($ctx['cart'], 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + + $response = $this->putJson( + apiUrl($ctx['domain'], "/api/storefront/v1/checkouts/{$checkout->id}/shipping-method"), + ['shipping_method_id' => $ctx['rate']->id] + ); + + $response->assertOk(); + $response->assertJsonPath('status', 'shipping_selected'); + $response->assertJsonPath('totals.shipping', 499); +}); + +it('applies a discount code', function () { + $ctx = checkoutApiContext(); + Discount::factory()->create([ + 'store_id' => $ctx['store']->id, + 'type' => DiscountType::Code, + 'code' => 'SAVE10', + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + 'starts_at' => now()->subDay(), + 'ends_at' => now()->addMonth(), + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => [], + 'status' => DiscountStatus::Active, + ]); + + $checkouts = app(App\Services\CheckoutService::class); + $checkout = $checkouts->createFromCart($ctx['cart'], 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $ctx['rate']->id); + + $response = $this->postJson( + apiUrl($ctx['domain'], "/api/storefront/v1/checkouts/{$checkout->id}/apply-discount"), + ['code' => 'SAVE10'] + ); + + $response->assertOk(); + $response->assertJsonPath('discount_code', 'SAVE10'); + $response->assertJsonPath('totals.discount', 500); +}); + +it('retrieves checkout with totals', function () { + $ctx = checkoutApiContext(); + $checkout = app(App\Services\CheckoutService::class)->createFromCart($ctx['cart'], 'customer@example.com'); + + $response = $this->getJson(apiUrl($ctx['domain'], "/api/storefront/v1/checkouts/{$checkout->id}")); + + $response->assertOk(); + $response->assertJsonPath('totals.subtotal', 5000); + $response->assertJsonPath('totals.total', 5000); + expect($response->json('totals'))->toHaveKeys(['subtotal', 'discount', 'shipping', 'tax', 'total', 'currency']); +}); + +it('selects a payment method', function () { + $ctx = checkoutApiContext(); + $checkouts = app(App\Services\CheckoutService::class); + $checkout = $checkouts->createFromCart($ctx['cart'], 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $ctx['rate']->id); + + $response = $this->putJson( + apiUrl($ctx['domain'], "/api/storefront/v1/checkouts/{$checkout->id}/payment-method"), + ['payment_method' => 'credit_card'] + ); + + $response->assertOk(); + $response->assertJsonPath('status', 'payment_selected'); + $response->assertJsonPath('payment_method', 'credit_card'); +}); + +it('completes checkout with credit card payment', function () { + $ctx = checkoutApiContext(); + $checkouts = app(App\Services\CheckoutService::class); + $checkout = $checkouts->createFromCart($ctx['cart'], 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $ctx['rate']->id); + $checkout = $checkouts->selectPaymentMethod($checkout, App\Enums\PaymentMethod::CreditCard); + + $response = $this->postJson( + apiUrl($ctx['domain'], "/api/storefront/v1/checkouts/{$checkout->id}/pay"), + [ + 'payment_method' => 'credit_card', + 'card_number' => '4242424242424242', + 'card_expiry' => '12/28', + 'card_cvc' => '123', + 'card_holder' => 'Jane Doe', + ] + ); + + $response->assertOk(); + $response->assertJsonPath('status', 'completed'); + $response->assertJsonPath('order.status', 'paid'); + $response->assertJsonPath('order.order_number', '#1001'); +}); + +it('rejects payment with declined card', function () { + $ctx = checkoutApiContext(); + $checkouts = app(App\Services\CheckoutService::class); + $checkout = $checkouts->createFromCart($ctx['cart'], 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $ctx['rate']->id); + $checkout = $checkouts->selectPaymentMethod($checkout, App\Enums\PaymentMethod::CreditCard); + + $response = $this->postJson( + apiUrl($ctx['domain'], "/api/storefront/v1/checkouts/{$checkout->id}/pay"), + [ + 'payment_method' => 'credit_card', + 'card_number' => '4000000000000002', + 'card_expiry' => '12/28', + 'card_cvc' => '123', + 'card_holder' => 'Jane Doe', + ] + ); + + $response->assertUnprocessable(); + $response->assertJsonPath('error_code', 'card_declined'); + expect($checkout->refresh()->status)->toBe(App\Enums\CheckoutStatus::ShippingSelected); +}); + +it('validates required address fields', function () { + $ctx = checkoutApiContext(); + $checkout = app(App\Services\CheckoutService::class)->createFromCart($ctx['cart'], 'customer@example.com'); + + $address = CommerceTestHelper::address(); + unset($address['city'], $address['postal_code']); + + $response = $this->putJson( + apiUrl($ctx['domain'], "/api/storefront/v1/checkouts/{$checkout->id}/address"), + ['shipping_address' => $address] + ); + + $response->assertUnprocessable(); +}); + +it('returns discount error codes for invalid codes', function () { + $ctx = checkoutApiContext(); + Discount::factory()->create([ + 'store_id' => $ctx['store']->id, + 'type' => DiscountType::Code, + 'code' => 'OLD', + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + 'starts_at' => now()->subMonth(), + 'ends_at' => now()->subDay(), + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => [], + 'status' => DiscountStatus::Active, + ]); + + $checkout = app(App\Services\CheckoutService::class)->createFromCart($ctx['cart'], 'customer@example.com'); + + $expired = $this->postJson( + apiUrl($ctx['domain'], "/api/storefront/v1/checkouts/{$checkout->id}/apply-discount"), + ['code' => 'OLD'] + ); + $expired->assertStatus(400); + $expired->assertJsonPath('error_code', 'discount_expired'); + + $missing = $this->postJson( + apiUrl($ctx['domain'], "/api/storefront/v1/checkouts/{$checkout->id}/apply-discount"), + ['code' => 'NOPE'] + ); + $missing->assertUnprocessable(); + $missing->assertJsonPath('error_code', 'discount_not_found'); +}); + +it('processes bank transfer payment with instructions', function () { + $ctx = checkoutApiContext(); + $checkouts = app(App\Services\CheckoutService::class); + $checkout = $checkouts->createFromCart($ctx['cart'], 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $ctx['rate']->id); + $checkout = $checkouts->selectPaymentMethod($checkout, App\Enums\PaymentMethod::BankTransfer); + + $response = $this->postJson( + apiUrl($ctx['domain'], "/api/storefront/v1/checkouts/{$checkout->id}/pay"), + ['payment_method' => 'bank_transfer'] + ); + + $response->assertOk(); + $response->assertJsonPath('order.financial_status', 'pending'); + $response->assertJsonPath('bank_transfer_instructions.bank_name', 'Mock Bank AG'); + $response->assertJsonPath('bank_transfer_instructions.reference', '#1001'); +}); + +it('rejects pay when checkout is not ready', function () { + $ctx = checkoutApiContext(); + $checkout = app(App\Services\CheckoutService::class)->createFromCart($ctx['cart'], 'customer@example.com'); + + $response = $this->postJson( + apiUrl($ctx['domain'], "/api/storefront/v1/checkouts/{$checkout->id}/pay"), + ['payment_method' => 'paypal'] + ); + + $response->assertConflict(); +}); + +it('retrieves an order with a valid token', function () { + $ctx = checkoutApiContext(); + $checkouts = app(App\Services\CheckoutService::class); + $checkout = $checkouts->createFromCart($ctx['cart'], 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $ctx['rate']->id); + $checkout = $checkouts->selectPaymentMethod($checkout, App\Enums\PaymentMethod::CreditCard); + $order = $checkouts->pay($checkout, App\Enums\PaymentMethod::CreditCard, ['card_number' => '4242424242424242']); + + $token = App\Support\OrderAccessToken::generate($order); + $orderNumber = urlencode($order->order_number); + + $response = $this->getJson(apiUrl($ctx['domain'], "/api/storefront/v1/orders/{$orderNumber}?token={$token}")); + + $response->assertOk(); + $response->assertJsonPath('order_number', '#1001'); + expect($response->json('lines'))->toHaveCount(1); +}); + +it('rejects order lookup with invalid token', function () { + $ctx = checkoutApiContext(); + $checkouts = app(App\Services\CheckoutService::class); + $checkout = $checkouts->createFromCart($ctx['cart'], 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $ctx['rate']->id); + $checkout = $checkouts->selectPaymentMethod($checkout, App\Enums\PaymentMethod::CreditCard); + $order = $checkouts->pay($checkout, App\Enums\PaymentMethod::CreditCard, ['card_number' => '4242424242424242']); + + $orderNumber = urlencode($order->order_number); + + $this->getJson(apiUrl($ctx['domain'], "/api/storefront/v1/orders/{$orderNumber}?token=invalid")) + ->assertUnauthorized(); + + $this->getJson(apiUrl($ctx['domain'], "/api/storefront/v1/orders/{$orderNumber}")) + ->assertUnauthorized(); +}); diff --git a/tests/Feature/Auth/AdminAuthTest.php b/tests/Feature/Auth/AdminAuthTest.php new file mode 100644 index 00000000..6ec67ee3 --- /dev/null +++ b/tests/Feature/Auth/AdminAuthTest.php @@ -0,0 +1,124 @@ +get('/admin/login'); + + $response->assertOk(); + $response->assertSee('Login'); +}); + +it('authenticates an admin user with valid credentials', function () { + ['user' => $user] = $this->createStoreContext(); + + $response = $this->post('/admin/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $response->assertRedirect('/admin'); + $this->assertAuthenticatedAs($user); +}); + +it('rejects invalid credentials', function () { + ['user' => $user] = $this->createStoreContext(); + + $response = $this->from('/admin/login')->post('/admin/login', [ + 'email' => $user->email, + 'password' => 'wrong-password', + ]); + + $response->assertSessionHasErrors('email'); + $this->assertGuest(); + $this->followRedirects($response)->assertSee('Invalid credentials'); +}); + +it('does not reveal whether email or password is incorrect', function () { + $response = $this->from('/admin/login')->post('/admin/login', [ + 'email' => 'nobody@example.com', + 'password' => 'wrong-password', + ]); + + $response->assertSessionHasErrors('email'); + $this->followRedirects($response)->assertSee('Invalid credentials'); +}); + +it('rate limits login attempts', function () { + ['user' => $user] = $this->createStoreContext(); + + for ($i = 0; $i < 5; $i++) { + $this->post('/admin/login', ['email' => $user->email, 'password' => 'wrong-password']); + } + + $this->post('/admin/login', ['email' => $user->email, 'password' => 'wrong-password']) + ->assertStatus(429); +}); + +it('regenerates session on successful login', function () { + ['user' => $user] = $this->createStoreContext(); + $this->get('/admin/login'); + $before = session()->getId(); + + $this->post('/admin/login', ['email' => $user->email, 'password' => 'password']); + + expect(session()->getId())->not->toBe($before); +}); + +it('logs out and invalidates session', function () { + ['user' => $user] = $this->createStoreContext(); + + $response = $this->actingAs($user)->post('/admin/logout'); + + $response->assertRedirect('/admin/login'); + $this->assertGuest(); +}); + +it('redirects unauthenticated users to login', function () { + $response = $this->get('/admin'); + + $response->assertRedirect('/admin/login'); +}); + +it('supports remember me functionality', function () { + ['user' => $user] = $this->createStoreContext(); + + $response = $this->post('/admin/login', [ + 'email' => $user->email, + 'password' => 'password', + 'remember' => true, + ]); + + $response->assertRedirect('/admin'); + + $rememberCookies = collect($response->headers->getCookies()) + ->map(fn ($cookie): string => $cookie->getName()) + ->filter(fn (string $name): bool => str_starts_with($name, 'remember_web_')); + + expect($rememberCookies)->not->toBeEmpty(); +}); + +it('records last_login_at on successful login', function () { + ['user' => $user] = $this->createStoreContext(); + expect($user->last_login_at)->toBeNull(); + + $this->post('/admin/login', ['email' => $user->email, 'password' => 'password']); + + expect($user->fresh()->last_login_at)->not->toBeNull(); +}); + +it('selects the only store into the session on login', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + + $response = $this->post('/admin/login', ['email' => $user->email, 'password' => 'password']); + + $response->assertRedirect('/admin'); + $response->assertSessionHas('current_store_id', $store->id); +}); + +it('requires email and password', function () { + $response = $this->post('/admin/login', ['email' => '', 'password' => '']); + + $response->assertSessionHasErrors(['email', 'password']); + $this->assertGuest(); +}); diff --git a/tests/Feature/Auth/AuthorizationTest.php b/tests/Feature/Auth/AuthorizationTest.php new file mode 100644 index 00000000..893c4cbf --- /dev/null +++ b/tests/Feature/Auth/AuthorizationTest.php @@ -0,0 +1,134 @@ + $storeA, 'user' => $user] = $this->createStoreContext(); + ['store' => $storeB] = $this->createStoreContext(); + StoreUser::factory()->create([ + 'store_id' => $storeB->id, 'user_id' => $user->id, 'role' => StoreUserRole::Staff, + ]); + + expect($user->roleForStore($storeA))->toBe(StoreUserRole::Owner); + expect($user->roleForStore($storeB))->toBe(StoreUserRole::Staff); + expect(User::factory()->create()->roleForStore($storeA))->toBeNull(); +}); + +it('grants owners full product access', function () { + ['store' => $store, 'user' => $owner] = $this->createStoreContext(); + $product = Product::factory()->create(['store_id' => $store->id]); + + expect($owner->can('viewAny', Product::class))->toBeTrue(); + expect($owner->can('view', $product))->toBeTrue(); + expect($owner->can('create', Product::class))->toBeTrue(); + expect($owner->can('update', $product))->toBeTrue(); + expect($owner->can('delete', $product))->toBeTrue(); + expect($owner->can('archive', $product))->toBeTrue(); + expect($owner->can('restore', $product))->toBeTrue(); +}); + +it('lets staff manage but not delete products', function () { + ['store' => $store, 'user' => $staff] = $this->createStoreContext(role: StoreUserRole::Staff); + $product = Product::factory()->create(['store_id' => $store->id]); + + expect($staff->can('view', $product))->toBeTrue(); + expect($staff->can('create', Product::class))->toBeTrue(); + expect($staff->can('update', $product))->toBeTrue(); + expect($staff->can('delete', $product))->toBeFalse(); + expect($staff->can('archive', $product))->toBeFalse(); +}); + +it('restricts support to read-only product access', function () { + ['store' => $store, 'user' => $support] = $this->createStoreContext(role: StoreUserRole::Support); + $product = Product::factory()->create(['store_id' => $store->id]); + + expect($support->can('view', $product))->toBeTrue(); + expect($support->can('create', Product::class))->toBeFalse(); + expect($support->can('update', $product))->toBeFalse(); + expect($support->can('delete', $product))->toBeFalse(); +}); + +it('denies policy access across stores', function () { + ['store' => $storeA, 'user' => $ownerA] = $this->createStoreContext(); + ['store' => $storeB] = $this->createStoreContext(); + $productB = Product::factory()->create(['store_id' => $storeB->id]); + + app(CurrentStore::class)->set($storeA); + + expect($ownerA->can('view', $productB))->toBeFalse(); + expect($ownerA->can('update', $productB))->toBeFalse(); +}); + +it('enforces store deletion as owner-only', function () { + ['store' => $store, 'user' => $owner] = $this->createStoreContext(); + $admin = User::factory()->create(); + StoreUser::factory()->create([ + 'store_id' => $store->id, 'user_id' => $admin->id, 'role' => StoreUserRole::Admin, + ]); + + expect($owner->can('delete', $store))->toBeTrue(); + expect($admin->can('delete', $store))->toBeFalse(); + expect($owner->can('updateSettings', $store))->toBeTrue(); + expect($admin->can('updateSettings', $store))->toBeTrue(); +}); + +it('checks management gates by role', function () { + ['store' => $store] = $this->createStoreContext(); + $admin = User::factory()->create(); + $staff = User::factory()->create(); + StoreUser::factory()->create([ + 'store_id' => $store->id, 'user_id' => $admin->id, 'role' => StoreUserRole::Admin, + ]); + StoreUser::factory()->create([ + 'store_id' => $store->id, 'user_id' => $staff->id, 'role' => StoreUserRole::Staff, + ]); + app(CurrentStore::class)->set($store); + + expect(Gate::forUser($admin)->allows('manage-store-settings'))->toBeTrue(); + expect(Gate::forUser($admin)->allows('manage-staff'))->toBeTrue(); + expect(Gate::forUser($admin)->allows('manage-shipping'))->toBeTrue(); + expect(Gate::forUser($staff)->allows('manage-store-settings'))->toBeFalse(); + expect(Gate::forUser($staff)->allows('manage-staff'))->toBeFalse(); + expect(Gate::forUser($staff)->allows('view-analytics'))->toBeTrue(); +}); + +it('denies gates without a resolved store', function () { + ['user' => $owner] = $this->createStoreContext(); + app(CurrentStore::class)->forget(); + + expect(Gate::forUser($owner)->allows('manage-store-settings'))->toBeFalse(); +}); + +it('grants the platform gate to owners only', function () { + ['user' => $owner] = $this->createStoreContext(); + ['user' => $admin] = $this->createStoreContext(role: StoreUserRole::Admin); + + expect(Gate::forUser($owner)->allows('manage-platform'))->toBeTrue(); + expect(Gate::forUser($admin)->allows('manage-platform'))->toBeFalse(); +}); + +it('enforces role middleware on admin routes', function () { + ['store' => $store, 'user' => $support] = $this->createStoreContext(role: StoreUserRole::Support); + + $this->actingAsAdmin($support, $store)->get('/admin')->assertOk(); + + Route::middleware(['web', 'auth', 'store.resolve', 'role.check:owner,admin']) + ->get('/admin/settings-probe', fn () => 'settings'); + + $this->actingAsAdmin($support, $store)->get('/admin/settings-probe')->assertForbidden(); + + $owner = User::factory()->create(); + StoreUser::factory()->create([ + 'store_id' => $store->id, 'user_id' => $owner->id, 'role' => StoreUserRole::Owner, + ]); + + $this->actingAsAdmin($owner, $store)->get('/admin/settings-probe')->assertOk(); +}); diff --git a/tests/Feature/Auth/CustomerAuthTest.php b/tests/Feature/Auth/CustomerAuthTest.php new file mode 100644 index 00000000..7c471d0a --- /dev/null +++ b/tests/Feature/Auth/CustomerAuthTest.php @@ -0,0 +1,277 @@ + $domain] = $this->createStoreContext(); + + $response = $this->get(storefrontUrl($domain->hostname, '/account/login')); + + $response->assertOk(); + $response->assertSee('Login'); +}); + +it('authenticates a customer with valid credentials', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $customer = Customer::factory()->create(['store_id' => $store->id]); + + $response = $this->post(storefrontUrl($domain->hostname, '/account/login'), [ + 'email' => $customer->email, + 'password' => 'password', + ]); + + $response->assertRedirect('/account'); + $this->assertAuthenticatedAs($customer, 'customer'); +}); + +it('rejects invalid customer credentials', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $customer = Customer::factory()->create(['store_id' => $store->id]); + + $response = $this->post(storefrontUrl($domain->hostname, '/account/login'), [ + 'email' => $customer->email, + 'password' => 'wrong-password', + ]); + + $response->assertSessionHasErrors('email'); + $this->assertGuest('customer'); +}); + +it('scopes customer login to the current store', function () { + ['store' => $storeA] = $this->createStoreContext(); + ['domain' => $domainB] = $this->createStoreContext(); + $customer = Customer::factory()->create(['store_id' => $storeA->id]); + + $response = $this->post(storefrontUrl($domainB->hostname, '/account/login'), [ + 'email' => $customer->email, + 'password' => 'password', + ]); + + $response->assertSessionHasErrors('email'); + $this->assertGuest('customer'); +}); + +it('rate limits customer login attempts', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $customer = Customer::factory()->create(['store_id' => $store->id]); + + for ($i = 0; $i < 5; $i++) { + $this->post(storefrontUrl($domain->hostname, '/account/login'), [ + 'email' => $customer->email, + 'password' => 'wrong-password', + ]); + } + + $this->post(storefrontUrl($domain->hostname, '/account/login'), [ + 'email' => $customer->email, + 'password' => 'wrong-password', + ])->assertStatus(429); +}); + +it('registers a new customer', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + + $response = $this->post(storefrontUrl($domain->hostname, '/account/register'), [ + 'name' => 'Sam Shopper', + 'email' => 'sam@example.com', + 'password' => 'secret-password', + 'password_confirmation' => 'secret-password', + 'marketing_opt_in' => true, + ]); + + $response->assertRedirect('/account'); + $customer = Customer::query()->where('email', 'sam@example.com')->first(); + expect($customer)->not->toBeNull(); + expect($customer->store_id)->toBe($store->id); + expect($customer->marketing_opt_in)->toBeTrue(); + $this->assertAuthenticatedAs($customer, 'customer'); +}); + +it('rejects duplicate email registration in the same store', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + Customer::factory()->create(['store_id' => $store->id, 'email' => 'sam@example.com']); + + $response = $this->post(storefrontUrl($domain->hostname, '/account/register'), [ + 'name' => 'Sam Shopper', + 'email' => 'sam@example.com', + 'password' => 'secret-password', + 'password_confirmation' => 'secret-password', + ]); + + $response->assertSessionHasErrors('email'); + $this->assertGuest('customer'); +}); + +it('allows same email in different stores', function () { + ['store' => $storeA] = $this->createStoreContext(); + ['store' => $storeB, 'domain' => $domainB] = $this->createStoreContext(); + Customer::factory()->create(['store_id' => $storeA->id, 'email' => 'sam@example.com']); + + $response = $this->post(storefrontUrl($domainB->hostname, '/account/register'), [ + 'name' => 'Sam Shopper', + 'email' => 'sam@example.com', + 'password' => 'secret-password', + 'password_confirmation' => 'secret-password', + ]); + + $response->assertRedirect('/account'); + expect(Customer::query()->where('email', 'sam@example.com')->count())->toBe(1); + expect(Customer::withoutGlobalScopes()->where('email', 'sam@example.com')->count())->toBe(2); + expect(Customer::query()->first()->store_id)->toBe($storeB->id); +}); + +it('validates registration input', function () { + ['domain' => $domain] = $this->createStoreContext(); + + $response = $this->post(storefrontUrl($domain->hostname, '/account/register'), [ + 'name' => '', + 'email' => 'not-an-email', + 'password' => 'short', + 'password_confirmation' => 'different', + ]); + + $response->assertSessionHasErrors(['name', 'email', 'password']); + $this->assertGuest('customer'); +}); + +it('logs out customer and redirects to login', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $customer = Customer::factory()->create(['store_id' => $store->id]); + + $response = $this->actingAsCustomer($customer) + ->post(storefrontUrl($domain->hostname, '/account/logout')); + + $response->assertRedirect('/account/login'); + $this->assertGuest('customer'); +}); + +it('does not treat admin session as customer authentication', function () { + ['store' => $store, 'domain' => $domain, 'user' => $user] = $this->createStoreContext(); + + $response = $this->actingAsAdmin($user, $store)->get(storefrontUrl($domain->hostname, '/account')); + + $response->assertRedirect(storefrontUrl($domain->hostname, '/account/login')); +}); + +it('merges guest cart into customer cart on login', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $customer = Customer::factory()->create(['store_id' => $store->id]); + $product = Product::factory()->create(['store_id' => $store->id]); + $variantA = ProductVariant::factory()->create(['product_id' => $product->id]); + $variantB = ProductVariant::factory()->create(['product_id' => $product->id]); + + $guestCart = Cart::factory()->create([ + 'store_id' => $store->id, 'customer_id' => null, 'status' => CartStatus::Active, + ]); + CartLine::factory()->create([ + 'cart_id' => $guestCart->id, 'variant_id' => $variantA->id, 'quantity' => 2, + 'unit_price_amount' => 1000, 'line_subtotal_amount' => 2000, + 'line_discount_amount' => 0, 'line_total_amount' => 2000, + ]); + + $customerCart = Cart::factory()->create([ + 'store_id' => $store->id, 'customer_id' => $customer->id, 'status' => CartStatus::Active, + ]); + CartLine::factory()->create([ + 'cart_id' => $customerCart->id, 'variant_id' => $variantA->id, 'quantity' => 1, + 'unit_price_amount' => 1000, 'line_subtotal_amount' => 1000, + 'line_discount_amount' => 0, 'line_total_amount' => 1000, + ]); + CartLine::factory()->create([ + 'cart_id' => $customerCart->id, 'variant_id' => $variantB->id, 'quantity' => 3, + 'unit_price_amount' => 500, 'line_subtotal_amount' => 1500, + 'line_discount_amount' => 0, 'line_total_amount' => 1500, + ]); + + $this->withSession(['cart_id' => $guestCart->id]) + ->post(storefrontUrl($domain->hostname, '/account/login'), [ + 'email' => $customer->email, + 'password' => 'password', + ]) + ->assertRedirect('/account'); + + expect($guestCart->fresh()->status)->toBe(CartStatus::Abandoned); + expect($customerCart->cartLines()->where('variant_id', $variantA->id)->first()->quantity)->toBe(2); + expect($customerCart->cartLines()->where('variant_id', $variantB->id)->first()->quantity)->toBe(3); +}); + +it('sends a password reset link with a generic response', function () { + Notification::fake(); + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $customer = Customer::factory()->create(['store_id' => $store->id]); + + $response = $this->post(storefrontUrl($domain->hostname, '/account/forgot-password'), [ + 'email' => $customer->email, + ]); + + $response->assertSessionHas('status', 'If that email exists, we sent a reset link.'); + Notification::assertSentTo($customer, CustomerResetPasswordNotification::class); +}); + +it('returns the same response for unknown reset emails', function () { + Notification::fake(); + ['domain' => $domain] = $this->createStoreContext(); + + $response = $this->post(storefrontUrl($domain->hostname, '/account/forgot-password'), [ + 'email' => 'nobody@example.com', + ]); + + $response->assertSessionHas('status', 'If that email exists, we sent a reset link.'); + Notification::assertNothingSent(); +}); + +it('resets the password with a valid token', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $customer = Customer::factory()->create(['store_id' => $store->id]); + CustomerPasswordResetToken::query()->create([ + 'store_id' => $store->id, + 'email' => $customer->email, + 'token' => Hash::make('plain-token'), + 'created_at' => now(), + ]); + + $response = $this->post(storefrontUrl($domain->hostname, '/account/reset-password'), [ + 'token' => 'plain-token', + 'email' => $customer->email, + 'password' => 'new-secret-password', + 'password_confirmation' => 'new-secret-password', + ]); + + $response->assertRedirect('/account/login'); + + $this->post(storefrontUrl($domain->hostname, '/account/login'), [ + 'email' => $customer->email, + 'password' => 'new-secret-password', + ])->assertRedirect('/account'); +}); + +it('rejects expired reset tokens', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $customer = Customer::factory()->create(['store_id' => $store->id]); + CustomerPasswordResetToken::query()->create([ + 'store_id' => $store->id, + 'email' => $customer->email, + 'token' => Hash::make('plain-token'), + 'created_at' => now()->subHours(2), + ]); + + $response = $this->post(storefrontUrl($domain->hostname, '/account/reset-password'), [ + 'token' => 'plain-token', + 'email' => $customer->email, + 'password' => 'new-secret-password', + 'password_confirmation' => 'new-secret-password', + ]); + + $response->assertSessionHasErrors('email'); +}); diff --git a/tests/Feature/Auth/SanctumTokenTest.php b/tests/Feature/Auth/SanctumTokenTest.php new file mode 100644 index 00000000..7dfe0449 --- /dev/null +++ b/tests/Feature/Auth/SanctumTokenTest.php @@ -0,0 +1,219 @@ + $user] = $this->createStoreContext(); + + $response = $this->actingAs($user)->postJson('/api/admin/v1/tokens', [ + 'name' => 'cli', + 'abilities' => ['read-products', 'write-products'], + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.name', 'cli'); + $response->assertJsonPath('data.abilities', ['read-products', 'write-products']); + expect($response->json('data.token'))->toMatch('/^\d+\|shop_/'); + + $token = PersonalAccessToken::query()->where('tokenable_id', $user->id)->first(); + expect($token)->not->toBeNull(); + expect($token->abilities)->toBe(['read-products', 'write-products']); +}); + +it('authenticates api requests with a valid token', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $plainText = $user->createToken('cli', ['read-products'])->plainTextToken; + + $response = $this->getJson("/api/admin/v1/stores/{$store->id}/me", [ + 'Authorization' => "Bearer {$plainText}", + ]); + + $response->assertOk(); + $response->assertJsonPath('data.role', 'owner'); + $response->assertJsonPath('data.store_id', $store->id); + $response->assertJsonPath('data.user_id', $user->id); +}); + +it('rejects api requests with an invalid token', function () { + ['store' => $store] = $this->createStoreContext(); + + $response = $this->getJson("/api/admin/v1/stores/{$store->id}/me", [ + 'Authorization' => 'Bearer shop_invalidtokenvalue1234567890', + ]); + + $response->assertUnauthorized(); +}); + +it('enforces token abilities', function () { + ['user' => $user] = $this->createStoreContext(); + $plainText = $user->createToken('readonly', ['read-products'])->plainTextToken; + + $response = $this->postJson('/api/admin/v1/platform/organizations', [ + 'name' => 'Acme Corp', + 'billing_email' => 'billing@acme.com', + ], ['Authorization' => "Bearer {$plainText}"]); + + $response->assertForbidden(); +}); + +it('revokes a token', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $plainText = $user->createToken('cli')->plainTextToken; + $tokenId = $user->tokens()->first()->id; + + $this->getJson("/api/admin/v1/stores/{$store->id}/me", [ + 'Authorization' => "Bearer {$plainText}", + ])->assertOk(); + + $this->actingAs($user)->deleteJson("/api/admin/v1/tokens/{$tokenId}")->assertNoContent(); + + $this->app['auth']->forgetGuards(); + + $this->getJson("/api/admin/v1/stores/{$store->id}/me", [ + 'Authorization' => "Bearer {$plainText}", + ])->assertUnauthorized(); +}); + +it('creates an organization via the platform api', function () { + ['user' => $user] = $this->createStoreContext(); + $plainText = $user->createToken('platform', ['manage-platform'])->plainTextToken; + + $response = $this->postJson('/api/admin/v1/platform/organizations', [ + 'name' => 'Acme Corp', + 'billing_email' => 'billing@acme.com', + ], ['Authorization' => "Bearer {$plainText}"]); + + $response->assertCreated(); + $response->assertJsonPath('data.name', 'Acme Corp'); + $response->assertJsonPath('data.billing_email', 'billing@acme.com'); + expect(Organization::query()->where('name', 'Acme Corp')->exists())->toBeTrue(); +}); + +it('creates a store via the platform api', function () { + ['user' => $user] = $this->createStoreContext(); + $organization = Organization::factory()->create(); + $plainText = $user->createToken('platform', ['manage-platform'])->plainTextToken; + + $response = $this->postJson('/api/admin/v1/platform/stores', [ + 'organization_id' => $organization->id, + 'name' => 'Acme Store', + 'handle' => 'acme', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ], ['Authorization' => "Bearer {$plainText}"]); + + $response->assertCreated(); + $response->assertJsonPath('data.handle', 'acme'); + $response->assertJsonPath('data.status', 'active'); +}); + +it('validates platform store creation', function () { + ['user' => $user] = $this->createStoreContext(); + $plainText = $user->createToken('platform', ['manage-platform'])->plainTextToken; + + $response = $this->postJson('/api/admin/v1/platform/stores', [ + 'organization_id' => 999999, + 'name' => '', + 'handle' => 'NOT VALID!', + 'default_currency' => 'EURO', + 'default_locale' => 'en', + 'timezone' => 'Mars/Olympus', + ], ['Authorization' => "Bearer {$plainText}"]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['organization_id', 'name', 'handle', 'default_currency', 'timezone']); +}); + +it('invites a user to a store', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $invitee = User::factory()->create(); + $plainText = $user->createToken('platform', ['manage-platform'])->plainTextToken; + + $response = $this->postJson("/api/admin/v1/stores/{$store->id}/invites", [ + 'email' => $invitee->email, + 'role' => 'staff', + ], ['Authorization' => "Bearer {$plainText}"]); + + $response->assertCreated(); + $response->assertJsonPath('data.email', $invitee->email); + $response->assertJsonPath('data.role', 'staff'); + expect($invitee->roleForStore($store))->toBe(StoreUserRole::Staff); +}); + +it('rejects duplicate invites with conflict', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $member = User::factory()->create(); + StoreUser::factory()->create([ + 'store_id' => $store->id, 'user_id' => $member->id, 'role' => StoreUserRole::Staff, + ]); + $plainText = $user->createToken('platform', ['manage-platform'])->plainTextToken; + + $response = $this->postJson("/api/admin/v1/stores/{$store->id}/invites", [ + 'email' => $member->email, + 'role' => 'staff', + ], ['Authorization' => "Bearer {$plainText}"]); + + $response->assertConflict(); +}); + +it('rejects invites for unknown users', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $plainText = $user->createToken('platform', ['manage-platform'])->plainTextToken; + + $response = $this->postJson("/api/admin/v1/stores/{$store->id}/invites", [ + 'email' => 'ghost@example.com', + 'role' => 'staff', + ], ['Authorization' => "Bearer {$plainText}"]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['email']); +}); + +it('denies membership details to non-members', function () { + ['store' => $store] = $this->createStoreContext(); + $stranger = User::factory()->create(); + $plainText = $stranger->createToken('cli')->plainTextToken; + + $this->getJson("/api/admin/v1/stores/{$store->id}/me", [ + 'Authorization' => "Bearer {$plainText}", + ])->assertForbidden(); +}); + +it('returns 404 for membership details of unknown stores', function () { + ['user' => $user] = $this->createStoreContext(); + $plainText = $user->createToken('cli')->plainTextToken; + + $this->getJson('/api/admin/v1/stores/999999/me', [ + 'Authorization' => "Bearer {$plainText}", + ])->assertNotFound(); +}); + +it('prevents non-owners from minting manage-platform tokens', function () { + ['user' => $user] = $this->createStoreContext(role: StoreUserRole::Staff); + + $response = $this->actingAs($user)->postJson('/api/admin/v1/tokens', [ + 'name' => 'escalation', + 'abilities' => ['manage-platform'], + ]); + + $response->assertForbidden(); +}); + +it('rejects unknown token abilities', function () { + ['user' => $user] = $this->createStoreContext(); + + $response = $this->actingAs($user)->postJson('/api/admin/v1/tokens', [ + 'name' => 'cli', + 'abilities' => ['read-products', 'hack-everything'], + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['abilities.1']); +}); diff --git a/tests/Feature/Bugfix/AgentHRegressionTest.php b/tests/Feature/Bugfix/AgentHRegressionTest.php new file mode 100644 index 00000000..21786a1c --- /dev/null +++ b/tests/Feature/Bugfix/AgentHRegressionTest.php @@ -0,0 +1,337 @@ + $store, 'domain' => $domain] = $this->createStoreContext(); + + $this->get(storefrontUrl($domain->hostname, '/'))->assertOk(); + + app(CurrentStore::class)->forget(); + expect(app(CurrentStore::class)->get())->toBeNull(); + + Livewire::test(Home::class)->assertOk()->assertSee($store->name); +}); + +it('P0-1 resolves the admin store on Livewire updates without pre-bound CurrentStore', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + + $this->actingAsAdmin($user, $store)->get('/admin')->assertOk(); + + app(CurrentStore::class)->forget(); + expect(app(CurrentStore::class)->get())->toBeNull(); + + Livewire::test(Dashboard::class)->assertOk(); +}); + +it('P0-2 serves stateless checkout endpoints without a session store', function () { + ['domain' => $domain] = $this->createStoreContext(); + + $this->postJson(storefrontUrl($domain->hostname, '/api/storefront/v1/checkouts'), []) + ->assertUnprocessable(); + + $this->getJson(storefrontUrl($domain->hostname, '/api/storefront/v1/checkouts/999999')) + ->assertNotFound(); +}); + +it('P0-3 renders storefront pages with compiled Vite assets', function () { + ['domain' => $domain] = $this->createStoreContext(); + + $manifest = json_decode(file_get_contents(public_path('build/manifest.json')), true); + + expect($manifest)->toHaveKeys(['resources/css/app.css', 'resources/js/app.js']); + + $this->get(storefrontUrl($domain->hostname, '/')) + ->assertOk() + ->assertSee('build/assets', false); +}); + +it('P1-1 finds seeded products via search after migrate fresh seed', function () { + Storage::fake('public'); + $this->seed(); + + $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $total = (int) DB::selectOne('SELECT COUNT(*) AS total FROM products_fts WHERE store_id = ?', [$store->id])->total; + + expect($total)->toBeGreaterThan(0); + + $response = $this->getJson('http://acme-fashion.test/api/storefront/v1/search?q=cotton'); + + $response->assertOk(); + expect($response->json('pagination.total'))->toBeGreaterThan(0); + expect(collect($response->json('results'))->pluck('title')->all())->toContain('Classic Cotton T-Shirt'); +}); + +it('P1-2 caches navigation urls without leaking across hosts', function () { + ['store' => $storeA] = $this->createStoreContext(); + ['store' => $storeB, 'domain' => $domainB] = $this->createStoreContext(); + + $menu = NavigationMenu::factory()->create(['store_id' => $storeA->id, 'handle' => 'main-menu']); + $page = Page::factory()->create(['store_id' => $storeA->id, 'handle' => 'about', 'status' => 'published']); + NavigationItem::factory()->create(['menu_id' => $menu->id, 'type' => 'page', 'label' => 'About', 'resource_id' => $page->id]); + + app(CurrentStore::class)->set($storeA); + + URL::forceRootUrl('http://'.$storeA->storeDomains()->first()->hostname); + $first = app(NavigationService::class)->buildTree($menu->refresh()); + + URL::forceRootUrl('http://'.$domainB->hostname); + $second = app(NavigationService::class)->buildTree($menu->refresh()); + + foreach ([$first, $second] as $tree) { + expect($tree[0]['url'])->toStartWith('/') + ->and($tree[0]['url'])->not->toContain($domainB->hostname); + } + + URL::forceRootUrl(config('app.url')); +}); + +it('P1-3 encodes order numbers in account order links', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $customer = Customer::factory()->create(['store_id' => $store->id]); + Order::factory()->create(['store_id' => $store->id, 'customer_id' => $customer->id, 'order_number' => '#1001']); + + $response = $this->actingAsCustomer($customer)->get(storefrontUrl($domain->hostname, '/account/orders')); + + $response->assertOk(); + $response->assertSee('%231001', false); + expect($response->getContent())->not->toContain('/account/orders/#1001'); + + $this->actingAsCustomer($customer) + ->get(storefrontUrl($domain->hostname, '/account/orders/%231001')) + ->assertOk(); +}); + +it('P1-4 shows backorder messaging while keeping add-to-cart enabled', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store, [], ['quantity_on_hand' => 0, 'policy' => 'continue']); + + $this->get(storefrontUrl($domain->hostname, '/products/'.$variant->product->handle)) + ->assertOk() + ->assertSee('Available on backorder') + ->assertSee('Add to cart') + ->assertDontSee('Sold out'); + + Livewire::test(ProductShow::class, ['handle' => $variant->product->handle]) + ->call('addToCart') + ->assertDispatched('cart-updated'); +}); + +it('P1-5 seeds placeholder product media with local image files', function () { + Storage::fake('public'); + $this->seed(); + + expect(ProductMedia::query()->count())->toBe(25); + + $media = ProductMedia::query() + ->whereHas('product', fn ($query) => $query->where('status', 'active')->whereNotNull('published_at')) + ->firstOrFail(); + + expect(Storage::disk('public')->exists($media->storage_key))->toBeTrue(); + + $product = $media->product; + $hostname = $product->store->storeDomains()->where('type', 'storefront')->firstOrFail()->hostname; + + $this->get(storefrontUrl($hostname, '/products/'.$product->handle)) + ->assertOk() + ->assertSee('/storage/', false); +}); + +it('P1-6 serves the admin forgot and reset password flow', function () { + Notification::fake(); + ['user' => $user] = $this->createStoreContext(); + + $this->get('/admin/forgot-password')->assertOk()->assertSee('Email password reset link'); + + $this->post('/admin/forgot-password', ['email' => $user->email]) + ->assertRedirect() + ->assertSessionHas('status'); + + Notification::assertSentTo($user, \Illuminate\Auth\Notifications\ResetPassword::class); + + $token = Password::broker('users')->createToken($user); + + $this->get('/admin/reset-password/'.$token.'?email='.urlencode($user->email))->assertOk(); + + $this->post('/admin/reset-password', [ + 'token' => $token, + 'email' => $user->email, + 'password' => 'new-secure-password', + 'password_confirmation' => 'new-secure-password', + ])->assertRedirect(route('admin.login')); + + $this->post('/admin/login', ['email' => $user->email, 'password' => 'new-secure-password']) + ->assertRedirect('/admin'); +}); + +it('P1-7 counts real orders in the analytics summary, not events', function () { + ['store' => $store] = $this->createStoreContext(); + + AnalyticsDaily::factory()->create([ + 'store_id' => $store->id, + 'date' => today()->toDateString(), + 'orders_count' => 99, + 'revenue_amount' => 99900, + 'aov_amount' => 1009, + 'visits_count' => 200, + ]); + AnalyticsEvent::factory()->count(5)->create([ + 'store_id' => $store->id, + 'type' => 'checkout_completed', + 'occurred_at' => now(), + 'created_at' => now(), + ]); + Order::factory()->count(2)->create([ + 'store_id' => $store->id, + 'total_amount' => 5000, + 'placed_at' => now(), + ]); + + $summary = app(AnalyticsService::class)->summary($store, today()->toDateString(), today()->toDateString()); + + expect($summary['summary']['orders_count'])->toBe(2) + ->and($summary['summary']['revenue_amount'])->toBe(10000) + ->and($summary['daily'][0]['orders_count'])->toBe(2); +}); + +it('P2-1 links favicons from layouts and ships the files', function () { + ['domain' => $domain] = $this->createStoreContext(); + + expect(file_exists(public_path('favicon.svg')))->toBeTrue() + ->and(file_exists(public_path('favicon.ico')))->toBeTrue(); + + $this->get(storefrontUrl($domain->hostname, '/')) + ->assertOk() + ->assertSee('favicon.svg', false); +}); + +it('P2-2 aligns user-visible labels with the spec contract', function () { + ['store' => $store, 'domain' => $domain, 'user' => $user] = $this->createStoreContext(); + + $this->get('/admin/login')->assertSee('Sign in'); + $this->get(storefrontUrl($domain->hostname, '/account/login'))->assertSee('Log in'); + + $this->actingAsAdmin($user, $store); + + Livewire::test(CollectionsIndex::class)->assertSee('Create collection'); + $this->get(route('admin.pages.index'))->assertSee('Create page'); + $this->get(route('admin.settings.index'))->assertSee('Store Settings'); + $this->get(route('admin.settings.taxes'))->assertSee('Tax Settings'); + + Livewire::test(AnalyticsIndex::class) + ->assertSee('Revenue') + ->assertSee('Visits') + ->assertSee('Conversion funnel'); +}); + +it('P2-2 features the earliest products on the homepage', function () { + ['domain' => $domain] = $this->createStoreContext(); + $store = app(CurrentStore::class)->get(); + $first = CommerceTestHelper::purchasableVariant($store, productOverrides: ['title' => 'Homepage First Product']); + + foreach (range(1, 8) as $index) { + CommerceTestHelper::purchasableVariant($store, productOverrides: ['title' => "Homepage Filler {$index}"]); + } + + $this->get(storefrontUrl($domain->hostname, '/')) + ->assertOk() + ->assertSee($first->product->title); +}); + +it('P2-3 keeps the admin session when the customer logs out', function () { + ['store' => $store, 'domain' => $domain, 'user' => $user] = $this->createStoreContext(); + $customer = Customer::factory()->create(['store_id' => $store->id]); + + $this->actingAs($user) + ->actingAs($customer, 'customer') + ->withSession(['current_store_id' => $store->id]) + ->post(storefrontUrl($domain->hostname, '/account/logout')) + ->assertRedirect('/account/login'); + + $this->assertGuest('customer'); + $this->assertAuthenticatedAs($user, 'web'); + expect(session()->get('current_store_id'))->toBe($store->id); +}); + +it('P2-4 redirects customers away from guest auth pages, dashboard, and settings', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $customer = Customer::factory()->create(['store_id' => $store->id]); + + $this->actingAsCustomer($customer); + + $this->get(storefrontUrl($domain->hostname, '/account/login'))->assertRedirectToRoute('customer.account'); + $this->get(storefrontUrl($domain->hostname, '/account/register'))->assertRedirectToRoute('customer.account'); + $this->get('/dashboard')->assertRedirectToRoute('customer.account'); + $this->get('/settings/profile')->assertRedirectToRoute('customer.account'); +}); + +it('P2-5 shows top product titles on the dashboard', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store); + $order = Order::factory()->create(['store_id' => $store->id, 'total_amount' => 2500, 'placed_at' => now()]); + \App\Models\OrderLine::factory()->create([ + 'order_id' => $order->id, + 'product_id' => $variant->product_id, + 'variant_id' => $variant->id, + 'title_snapshot' => $variant->product->title, + 'quantity' => 1, + 'unit_price_amount' => 2500, + 'total_amount' => 2500, + ]); + + $this->actingAsAdmin($user, $store) + ->get(route('admin.dashboard')) + ->assertOk() + ->assertSee($variant->product->title); +}); + +it('P2-6 documents the production debug default in the env example', function () { + $example = file_get_contents(base_path('.env.example')); + + expect($example)->toContain('APP_DEBUG=false'); +}); + +it('P2-7 returns 404 for unregistered storefront hostnames', function () { + $this->get('http://shop.test/')->assertNotFound(); +}); + +it('P2-8 badges exhausted discounts instead of active', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + Discount::factory()->create([ + 'store_id' => $store->id, + 'code' => 'ALMOSTGONE', + 'status' => 'active', + 'usage_limit' => 5, + 'usage_count' => 5, + ]); + $this->actingAsAdmin($user, $store); + + Livewire::test(DiscountsIndex::class)->assertSee('Exhausted'); +}); diff --git a/tests/Feature/Bugfix/AgentHRound2Test.php b/tests/Feature/Bugfix/AgentHRound2Test.php new file mode 100644 index 00000000..9832fd9e --- /dev/null +++ b/tests/Feature/Bugfix/AgentHRound2Test.php @@ -0,0 +1,199 @@ + $store] = $this->createStoreContext(); + + $product = app(ProductService::class)->create($store, CatalogTestHelper::productPayload([ + 'options' => [ + ['name' => 'Size', 'position' => 1], + ['name' => 'Color', 'position' => 2], + ], + ])); + + // Mimic the seeders, which store options 0-based while form/API payloads + // are 1-based. Naive in-place re-positioning collides mid-update with + // UNIQUE(product_id, position) (SQLSTATE 23000, ProductService syncOptions). + $product->productOptions()->where('name', 'Size')->update(['position' => 0]); + $product->productOptions()->where('name', 'Color')->update(['position' => 1]); + + $updated = app(ProductService::class)->update($product->fresh(), [ + 'options' => [ + ['name' => 'Size', 'position' => 1, 'values' => ['XS', 'Small']], + ['name' => 'Color', 'position' => 2, 'values' => ['Red']], + ], + ]); + + expect($updated->productOptions()->orderBy('position')->pluck('position')->all())->toBe([1, 2]) + ->and($updated->productOptions()->orderBy('position')->pluck('name')->all())->toBe(['Size', 'Color']); + + $size = $updated->productOptions()->where('name', 'Size')->firstOrFail(); + + expect($size->productOptionValues()->orderBy('position')->pluck('value')->all())->toBe(['XS', 'Small']); +}); + +it('BUG1 saves the admin product form for seeded options with bound and unbound CurrentStore', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $this->actingAsAdmin($user, $store); + + $product = app(ProductService::class)->create($store, CatalogTestHelper::productPayload([ + 'options' => [ + ['name' => 'Size', 'position' => 1, 'values' => ['S', 'M']], + ['name' => 'Color', 'position' => 2, 'values' => ['Red', 'Blue']], + ], + ])); + $product->productOptions()->where('name', 'Size')->update(['position' => 0]); + $product->productOptions()->where('name', 'Color')->update(['position' => 1]); + + Livewire::test(ProductForm::class, ['product' => $product->fresh()]) + ->set('title', 'Bound Save Title') + ->call('save') + ->assertDispatched('toast'); + + expect($product->refresh()->title)->toBe('Bound Save Title') + ->and($product->productOptions()->orderBy('position')->pluck('name')->all())->toBe(['Size', 'Color']); + + app(CurrentStore::class)->forget(); + expect(app(CurrentStore::class)->get())->toBeNull(); + + Livewire::test(ProductForm::class, ['product' => $product->fresh()]) + ->set('title', 'Unbound Save Title') + ->call('save') + ->assertDispatched('toast'); + + expect($product->refresh()->title)->toBe('Unbound Save Title') + ->and($product->productOptions()->orderBy('position')->pluck('position')->all())->toBe([1, 2]); +}); + +it('BUG2 creates products and discounts via Livewire without a pre-bound CurrentStore', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $this->actingAsAdmin($user, $store); + + app(CurrentStore::class)->forget(); + expect(app(CurrentStore::class)->get())->toBeNull(); + + Livewire::test(ProductForm::class) + ->set('title', 'Unbound Product') + ->set('status', 'draft') + ->set('variants.0.sku', 'UNBOUND-01') + ->set('variants.0.price', '10.00') + ->set('variants.0.quantity', 5) + ->call('save') + ->assertRedirect(route('admin.products.index')); + + expect(Product::query()->where('store_id', $store->getKey())->where('title', 'Unbound Product')->exists())->toBeTrue(); + + app(CurrentStore::class)->forget(); + expect(app(CurrentStore::class)->get())->toBeNull(); + + Livewire::test(DiscountForm::class) + ->set('type', 'code') + ->set('code', 'UNBOUND10') + ->set('valueType', 'percent') + ->set('valueAmount', '10') + ->set('startsAt', now()->format('Y-m-d\TH:i')) + ->call('save') + ->assertRedirect(route('admin.discounts.index')); + + expect(Discount::query()->where('store_id', $store->getKey())->where('code', 'UNBOUND10')->exists())->toBeTrue(); +}); + +it('BUG3 links the svg favicon and never the ico file that Herd serves as 404', function () { + ['store' => $store, 'domain' => $domain, 'user' => $user] = $this->createStoreContext(); + + expect(file_exists(public_path('favicon.svg')))->toBeTrue() + ->and(file_exists(public_path('favicon.ico')))->toBeTrue(); + + $this->get(storefrontUrl($domain->hostname, '/')) + ->assertOk() + ->assertSee('favicon.svg', false) + ->assertDontSee('favicon.ico', false); + + $this->actingAsAdmin($user, $store) + ->get(route('admin.dashboard')) + ->assertOk() + ->assertSee('favicon.svg', false) + ->assertDontSee('favicon.ico', false); +}); + +it('L1 keeps the cart drawer closed for cart-page updates but opens it elsewhere', function () { + ['domain' => $domain] = $this->createStoreContext(); + + $drawer = new CartDrawer; + request()->headers->set('referer', storefrontUrl($domain->hostname, '/cart')); + $drawer->onCartUpdated(); + + expect($drawer->open)->toBeFalse(); + + $drawer = new CartDrawer; + request()->headers->set('referer', storefrontUrl($domain->hostname, '/')); + $drawer->onCartUpdated(); + + expect($drawer->open)->toBeTrue(); + + $drawer = new CartDrawer; + $drawer->open = true; + request()->headers->set('referer', storefrontUrl($domain->hostname, '/cart')); + $drawer->onCartUpdated(); + + expect($drawer->open)->toBeTrue(); +}); + +it('L2 renders gallery images with relative storage urls', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store); + $product = $variant->product; + $product->productMedia()->create([ + 'storage_key' => 'products/demo.jpg', + 'alt_text' => null, + 'position' => 0, + ]); + + $response = $this->get(storefrontUrl($domain->hostname, '/products/'.$product->handle)); + + $response->assertOk(); + $response->assertSee('src="/storage/products/demo.jpg"', false); + expect($response->getContent())->not->toContain('http://'.$domain->hostname.'/storage/') + ->and($response->getContent())->not->toContain('http://shop.test/storage/'); +}); + +it('L3 groups top products by product when snapshots carry variant suffixes', function () { + ['store' => $store] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store, productOverrides: ['title' => 'Grouped Tee']); + + $order = Order::factory()->create(['store_id' => $store->id, 'total_amount' => 5000, 'placed_at' => now()]); + + foreach (['Grouped Tee', 'Grouped Tee (M / White)'] as $snapshot) { + OrderLine::factory()->create([ + 'order_id' => $order->id, + 'product_id' => $variant->product_id, + 'variant_id' => $variant->id, + 'title_snapshot' => $snapshot, + 'quantity' => 1, + 'unit_price_amount' => 2500, + 'total_amount' => 2500, + ]); + } + + $top = app(AnalyticsService::class)->topProductsByRevenue($store, today()->toDateString(), today()->toDateString()); + + expect($top)->toHaveCount(1) + ->and($top[0]['title'])->toBe('Grouped Tee') + ->and($top[0]['units_sold'])->toBe(2) + ->and($top[0]['revenue_amount'])->toBe(5000); +}); diff --git a/tests/Feature/Cart/CartApiTest.php b/tests/Feature/Cart/CartApiTest.php new file mode 100644 index 00000000..b6be3db4 --- /dev/null +++ b/tests/Feature/Cart/CartApiTest.php @@ -0,0 +1,124 @@ +withoutMiddleware(ThrottleRequests::class); + Illuminate\Support\Facades\Cache::flush(); +}); + +it('creates a cart via API', function () { + ['domain' => $domain] = $this->createStoreContext(); + + $response = $this->postJson(CommerceTestHelper::storefrontUrl($domain, '/api/storefront/v1/carts'), []); + + $response->assertCreated(); + $response->assertJsonPath('cart_version', 1); + $response->assertJsonPath('status', 'active'); + expect($response->json('id'))->not->toBeNull(); +}); + +it('retrieves a cart via API', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store, ['price_amount' => 2500]); + + $cart = app(App\Services\CartService::class)->create($store); + app(App\Services\CartService::class)->addLine($cart, $variant->id, 2); + + $response = $this->getJson(CommerceTestHelper::storefrontUrl($domain, "/api/storefront/v1/carts/{$cart->id}")); + + $response->assertOk(); + $response->assertJsonPath('totals.subtotal', 5000); + $response->assertJsonPath('totals.item_count', 2); + expect($response->json('lines'))->toHaveCount(1); +}); + +it('adds a line via API', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store); + + $cart = app(App\Services\CartService::class)->create($store); + + $response = $this->postJson( + CommerceTestHelper::storefrontUrl($domain, "/api/storefront/v1/carts/{$cart->id}/lines"), + ['variant_id' => $variant->id, 'quantity' => 2] + ); + + $response->assertCreated(); + $response->assertJsonPath('cart_version', 2); + expect($response->json('lines'))->toHaveCount(1); +}); + +it('updates line quantity via API', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store); + + $cart = app(App\Services\CartService::class)->create($store); + $line = app(App\Services\CartService::class)->addLine($cart, $variant->id, 1); + + $response = $this->putJson( + CommerceTestHelper::storefrontUrl($domain, "/api/storefront/v1/carts/{$cart->id}/lines/{$line->id}"), + ['quantity' => 4, 'cart_version' => 2] + ); + + $response->assertOk(); + $response->assertJsonPath('lines.0.quantity', 4); + $response->assertJsonPath('cart_version', 3); +}); + +it('removes a line via API', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store); + + $cart = app(App\Services\CartService::class)->create($store); + $line = app(App\Services\CartService::class)->addLine($cart, $variant->id, 1); + + $response = $this->deleteJson( + CommerceTestHelper::storefrontUrl($domain, "/api/storefront/v1/carts/{$cart->id}/lines/{$line->id}"), + ['cart_version' => 2] + ); + + $response->assertOk(); + expect($response->json('lines'))->toHaveCount(0); +}); + +it('returns 404 for nonexistent cart', function () { + ['domain' => $domain] = $this->createStoreContext(); + + $response = $this->getJson(CommerceTestHelper::storefrontUrl($domain, '/api/storefront/v1/carts/999999')); + + $response->assertNotFound(); +}); + +it('returns 409 on version mismatch', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store); + + $cart = app(App\Services\CartService::class)->create($store); + $line = app(App\Services\CartService::class)->addLine($cart, $variant->id, 1); + + $response = $this->putJson( + CommerceTestHelper::storefrontUrl($domain, "/api/storefront/v1/carts/{$cart->id}/lines/{$line->id}"), + ['quantity' => 2, 'cart_version' => 1] + ); + + $response->assertConflict(); + $response->assertJsonPath('error_code', 'version_conflict'); + $response->assertJsonPath('current_version', 2); +}); + +it('isolates carts across stores', function () { + ['store' => $storeA] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($storeA); + $cart = app(App\Services\CartService::class)->create($storeA); + app(App\Services\CartService::class)->addLine($cart, $variant->id, 1); + + ['domain' => $domainB] = $this->createStoreContext(); + + $response = $this->getJson(CommerceTestHelper::storefrontUrl($domainB, "/api/storefront/v1/carts/{$cart->id}")); + + $response->assertNotFound(); +}); diff --git a/tests/Feature/Cart/CartServiceTest.php b/tests/Feature/Cart/CartServiceTest.php new file mode 100644 index 00000000..e0e5718d --- /dev/null +++ b/tests/Feature/Cart/CartServiceTest.php @@ -0,0 +1,184 @@ + $store] = $this->createStoreContext(); + + $cart = app(CartService::class)->create($store); + + expect($cart->store_id)->toBe($store->id); + expect($cart->currency)->toBe($store->default_currency); + expect($cart->cart_version)->toBe(1); + expect($cart->status)->toBe(CartStatus::Active); +}); + +it('adds a line item to the cart', function () { + ['store' => $store] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store, ['price_amount' => 2500]); + + $cart = app(CartService::class)->create($store); + $line = app(CartService::class)->addLine($cart, $variant->id, 2); + + expect($line->unit_price_amount)->toBe(2500); + expect($line->line_subtotal_amount)->toBe(5000); + expect($line->line_total_amount)->toBe(5000); +}); + +it('increments quantity when adding an existing variant', function () { + ['store' => $store] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store, ['price_amount' => 2500]); + + $cart = app(CartService::class)->create($store); + app(CartService::class)->addLine($cart, $variant->id, 1); + $line = app(CartService::class)->addLine($cart->refresh(), $variant->id, 2); + + expect($line->quantity)->toBe(3); + expect($line->line_subtotal_amount)->toBe(7500); + expect($cart->refresh()->cartLines()->count())->toBe(1); +}); + +it('rejects add when product is not active', function () { + ['store' => $store] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store); + $variant->product->update(['status' => App\Enums\ProductStatus::Draft]); + + $cart = app(CartService::class)->create($store); + + app(CartService::class)->addLine($cart, $variant->id, 1); +})->throws(Illuminate\Validation\ValidationException::class); + +it('rejects add when inventory is insufficient and policy is deny', function () { + ['store' => $store] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store, [], ['quantity_on_hand' => 2, 'policy' => 'deny']); + + $cart = app(CartService::class)->create($store); + + app(CartService::class)->addLine($cart, $variant->id, 5); +})->throws(InsufficientInventoryException::class); + +it('allows add when inventory is insufficient but policy is continue', function () { + ['store' => $store] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store, [], ['quantity_on_hand' => 2, 'policy' => 'continue']); + + $cart = app(CartService::class)->create($store); + $line = app(CartService::class)->addLine($cart, $variant->id, 5); + + expect($line->quantity)->toBe(5); +}); + +it('updates line quantity', function () { + ['store' => $store] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store, ['price_amount' => 2500]); + + $cart = app(CartService::class)->create($store); + $line = app(CartService::class)->addLine($cart, $variant->id, 2); + $updated = app(CartService::class)->updateLineQuantity($cart->refresh(), $line->id, 5); + + expect($updated->quantity)->toBe(5); + expect($updated->line_subtotal_amount)->toBe(12500); +}); + +it('removes a line when quantity set to zero', function () { + ['store' => $store] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store); + + $cart = app(CartService::class)->create($store); + $line = app(CartService::class)->addLine($cart, $variant->id, 2); + + expect(app(CartService::class)->updateLineQuantity($cart->refresh(), $line->id, 0))->toBeNull(); + expect($cart->refresh()->cartLines()->count())->toBe(0); +}); + +it('removes a specific line item', function () { + ['store' => $store] = $this->createStoreContext(); + $variantA = CommerceTestHelper::purchasableVariant($store); + $variantB = CommerceTestHelper::purchasableVariant($store); + + $cart = app(CartService::class)->create($store); + $lineA = app(CartService::class)->addLine($cart, $variantA->id, 1); + app(CartService::class)->addLine($cart->refresh(), $variantB->id, 1); + app(CartService::class)->removeLine($cart->refresh(), $lineA->id); + + expect($cart->refresh()->cartLines()->count())->toBe(1); +}); + +it('increments cart version on every mutation', function () { + ['store' => $store] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store); + + $cart = app(CartService::class)->create($store); + expect($cart->cart_version)->toBe(1); + + $line = app(CartService::class)->addLine($cart, $variant->id, 1); + expect($cart->refresh()->cart_version)->toBe(2); + + app(CartService::class)->updateLineQuantity($cart->refresh(), $line->id, 2); + expect($cart->refresh()->cart_version)->toBe(3); + + app(CartService::class)->removeLine($cart->refresh(), $line->id); + expect($cart->refresh()->cart_version)->toBe(4); +}); + +it('returns cart via session for guest users', function () { + ['store' => $store] = $this->createStoreContext(); + + $cart = app(CartService::class)->getOrCreateForSession($store); + + expect(session('cart_id'))->toBe($cart->id); + expect(app(CartService::class)->getOrCreateForSession($store)->id)->toBe($cart->id); +}); + +it('merges guest cart into customer cart on login', function () { + ['store' => $store] = $this->createStoreContext(); + $variantA = CommerceTestHelper::purchasableVariant($store); + $variantB = CommerceTestHelper::purchasableVariant($store); + $customer = App\Models\Customer::factory()->create(['store_id' => $store->id]); + + $guestCart = app(CartService::class)->create($store); + app(CartService::class)->addLine($guestCart, $variantA->id, 2); + + $customerCart = app(CartService::class)->create($store, $customer); + app(CartService::class)->addLine($customerCart, $variantA->id, 1); + app(CartService::class)->addLine($customerCart->refresh(), $variantB->id, 3); + + $merged = app(CartService::class)->mergeOnLogin($guestCart->refresh(), $customerCart->refresh()); + + $lineA = $merged->cartLines()->where('variant_id', $variantA->id)->first(); + $lineB = $merged->cartLines()->where('variant_id', $variantB->id)->first(); + + expect($lineA->quantity)->toBe(2); + expect($lineB->quantity)->toBe(3); + expect($guestCart->refresh()->status)->toBe(CartStatus::Abandoned); +}); + +it('abandons stale carts with the cleanup job', function () { + ['store' => $store] = $this->createStoreContext(); + + $stale = app(CartService::class)->create($store); + $stale->updated_at = now()->subDays(15); + $stale->save(); + + $fresh = app(CartService::class)->create($store); + + app(App\Jobs\CleanupAbandonedCarts::class)->handle(); + + expect($stale->refresh()->status)->toBe(CartStatus::Abandoned); + expect($fresh->refresh()->status)->toBe(CartStatus::Active); +}); + +it('rejects mutations with a stale expected version', function () { + ['store' => $store] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store); + + $cart = app(CartService::class)->create($store); + app(CartService::class)->addLine($cart, $variant->id, 1); + + app(CartService::class)->addLine($cart->refresh(), $variant->id, 1, 1); +})->throws(CartVersionMismatchException::class); diff --git a/tests/Feature/Checkout/CheckoutFlowTest.php b/tests/Feature/Checkout/CheckoutFlowTest.php new file mode 100644 index 00000000..3daa2959 --- /dev/null +++ b/tests/Feature/Checkout/CheckoutFlowTest.php @@ -0,0 +1,111 @@ + $store] = test()->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + $rate = CommerceTestHelper::flatShippingRate($store); + + return ['store' => $store, 'cart' => $cart, 'rate' => $rate]; +} + +function driveToPaymentSelected(array $ctx): App\Models\Checkout +{ + $checkouts = app(CheckoutService::class); + + $checkout = $checkouts->createFromCart($ctx['cart'], 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $ctx['rate']->id); + + return $checkouts->selectPaymentMethod($checkout, PaymentMethod::CreditCard); +} + +it('creates a checkout from a cart', function () { + $ctx = flowContext(); + + $checkout = app(CheckoutService::class)->createFromCart($ctx['cart'], 'customer@example.com'); + + expect($checkout->status)->toBe(CheckoutStatus::Started); + expect($checkout->cart_id)->toBe($ctx['cart']->id); + expect($checkout->email)->toBe('customer@example.com'); +}); + +it('completes full checkout happy path', function () { + Event::fake([OrderCreated::class]); + + $ctx = flowContext(); + $checkout = driveToPaymentSelected($ctx); + + $variantId = $ctx['cart']->cartLines()->first()->variant_id; + $item = App\Models\InventoryItem::query()->where('variant_id', $variantId)->first(); + + expect($item->quantity_reserved)->toBe(2); + + $order = app(CheckoutService::class)->pay($checkout, PaymentMethod::CreditCard, [ + 'card_number' => '4242424242424242', + 'card_expiry' => '12/28', + 'card_cvc' => '123', + 'card_holder' => 'Jane Doe', + ]); + + expect($order->status)->toBe(OrderStatus::Paid); + expect($order->financial_status)->toBe(FinancialStatus::Paid); + expect($order->orderLines()->count())->toBe(1); + expect($ctx['cart']->refresh()->status)->toBe(CartStatus::Converted); + expect($checkout->refresh()->status)->toBe(CheckoutStatus::Completed); + + $item->refresh(); + expect($item->quantity_on_hand)->toBe(48); + expect($item->quantity_reserved)->toBe(0); + + Event::assertDispatched(OrderCreated::class); +}); + +it('rejects checkout for empty cart', function () { + ['store' => $store] = $this->createStoreContext(); + $cart = app(App\Services\CartService::class)->create($store); + + app(CheckoutService::class)->createFromCart($cart, 'customer@example.com'); +})->throws(Illuminate\Validation\ValidationException::class); + +it('expires checkout after timeout', function () { + $ctx = flowContext(); + $checkout = driveToPaymentSelected($ctx); + $checkout->expires_at = now()->subHour(); + $checkout->save(); + + app(App\Jobs\ExpireAbandonedCheckouts::class)->handle(); + + expect($checkout->refresh()->status)->toBe(CheckoutStatus::Expired); + + $variantId = $ctx['cart']->cartLines()->first()->variant_id; + $item = App\Models\InventoryItem::query()->where('variant_id', $variantId)->first(); + expect($item->quantity_reserved)->toBe(0); +}); + +it('prevents duplicate orders from same checkout', function () { + $ctx = flowContext(); + $checkout = driveToPaymentSelected($ctx); + + $checkouts = app(CheckoutService::class); + $details = ['card_number' => '4242424242424242']; + + $first = $checkouts->pay($checkout, PaymentMethod::CreditCard, $details); + $second = $checkouts->pay($checkout->refresh(), PaymentMethod::CreditCard, $details); + + expect($second->id)->toBe($first->id); + expect(Order::query()->count())->toBe(1); +}); diff --git a/tests/Feature/Checkout/CheckoutStateTest.php b/tests/Feature/Checkout/CheckoutStateTest.php new file mode 100644 index 00000000..e48eb610 --- /dev/null +++ b/tests/Feature/Checkout/CheckoutStateTest.php @@ -0,0 +1,157 @@ + $store, 'domain' => $domain] = test()->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + $rate = CommerceTestHelper::flatShippingRate($store); + + return ['store' => $store, 'domain' => $domain, 'cart' => $cart, 'rate' => $rate]; +} + +it('transitions from started to addressed with valid address', function () { + $ctx = stateContext(); + $checkout = app(CheckoutService::class)->createFromCart($ctx['cart'], 'customer@example.com'); + + $checkout = app(CheckoutService::class)->setAddress($checkout, CommerceTestHelper::address()); + + expect($checkout->status)->toBe(CheckoutStatus::Addressed); + expect($checkout->billing_address_json['city'])->toBe('Berlin'); +}); + +it('rejects address transition with missing required fields', function () { + $this->withoutMiddleware(ThrottleRequests::class); + Illuminate\Support\Facades\Cache::flush(); + + $ctx = stateContext(); + $checkout = app(CheckoutService::class)->createFromCart($ctx['cart'], 'customer@example.com'); + + $address = CommerceTestHelper::address(); + unset($address['city']); + + $response = $this->putJson( + CommerceTestHelper::storefrontUrl($ctx['domain'], "/api/storefront/v1/checkouts/{$checkout->id}/address"), + ['shipping_address' => $address] + ); + + $response->assertUnprocessable(); + expect($checkout->refresh()->status)->toBe(CheckoutStatus::Started); +}); + +it('transitions from addressed to shipping selected', function () { + $ctx = stateContext(); + $checkouts = app(CheckoutService::class); + + $checkout = $checkouts->createFromCart($ctx['cart'], 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $ctx['rate']->id); + + expect($checkout->status)->toBe(CheckoutStatus::ShippingSelected); + expect($checkout->shipping_method_id)->toBe($ctx['rate']->id); +}); + +it('rejects shipping selection with rate from wrong zone', function () { + $ctx = stateContext(); + $checkouts = app(CheckoutService::class); + + $usRate = CommerceTestHelper::flatShippingRate($ctx['store'], ['US']); + + $checkout = $checkouts->createFromCart($ctx['cart'], 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + + $checkouts->setShippingMethod($checkout, $usRate->id); +})->throws(Illuminate\Validation\ValidationException::class); + +it('skips shipping selection when no items require shipping', function () { + ['store' => $store] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store, [['price' => 2500, 'qty' => 1, 'shipping' => false]]); + $checkouts = app(CheckoutService::class); + + $checkout = $checkouts->createFromCart($cart, 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, null); + + expect($checkout->status)->toBe(CheckoutStatus::ShippingSelected); + expect($checkout->shipping_method_id)->toBeNull(); + expect($checkout->totals_json['shipping'])->toBe(0); +}); + +it('transitions from shipping selected to payment selected', function () { + $ctx = stateContext(); + $checkouts = app(CheckoutService::class); + + $checkout = $checkouts->createFromCart($ctx['cart'], 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $ctx['rate']->id); + $checkout = $checkouts->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + + expect($checkout->status)->toBe(CheckoutStatus::PaymentSelected); + expect($checkout->payment_method)->toBe(PaymentMethod::CreditCard); + expect($checkout->expires_at)->not->toBeNull(); + + $variantId = $ctx['cart']->cartLines()->first()->variant_id; + $item = App\Models\InventoryItem::query()->where('variant_id', $variantId)->first(); + expect($item->quantity_reserved)->toBe(2); +}); + +it('transitions from payment selected to completed', function () { + $ctx = stateContext(); + $checkouts = app(CheckoutService::class); + + $checkout = $checkouts->createFromCart($ctx['cart'], 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $ctx['rate']->id); + $checkout = $checkouts->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + $checkouts->pay($checkout, PaymentMethod::CreditCard, ['card_number' => '4242424242424242']); + + expect($checkout->refresh()->status)->toBe(CheckoutStatus::Completed); +}); + +it('rejects invalid state transitions', function () { + $ctx = stateContext(); + $checkout = app(CheckoutService::class)->createFromCart($ctx['cart'], 'customer@example.com'); + + app(CheckoutService::class)->selectPaymentMethod($checkout, PaymentMethod::CreditCard); +})->throws(InvalidCheckoutTransitionException::class); + +it('recalculates pricing on address change', function () { + $ctx = stateContext(); + + TaxSetting::factory()->create([ + 'store_id' => $ctx['store']->id, + 'mode' => App\Enums\TaxMode::Manual, + 'provider' => App\Enums\TaxProvider::None, + 'prices_include_tax' => false, + 'config_json' => [ + 'default_tax_rate' => 1900, + 'tax_rates' => [ + ['country_code' => 'DE', 'rate' => 1900, 'name' => 'MwSt', 'shipping_taxed' => true], + ['country_code' => 'AT', 'rate' => 2000, 'name' => 'USt', 'shipping_taxed' => true], + ], + ], + ]); + + CommerceTestHelper::flatShippingRate($ctx['store'], ['AT']); + + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->createFromCart($ctx['cart'], 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $deTax = $checkout->totals_json['tax']; + + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address([ + 'country' => 'Austria', 'country_code' => 'AT', 'city' => 'Vienna', 'postal_code' => '1010', + ])); + + expect($checkout->totals_json['tax'])->not->toBe($deTax); + expect($checkout->totals_json['tax'])->toBe(1000); +}); diff --git a/tests/Feature/Checkout/DiscountTest.php b/tests/Feature/Checkout/DiscountTest.php new file mode 100644 index 00000000..0fd3403f --- /dev/null +++ b/tests/Feature/Checkout/DiscountTest.php @@ -0,0 +1,105 @@ + $store] = test()->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store, [['price' => 2500, 'qty' => 2]]); + $rate = CommerceTestHelper::flatShippingRate($store, ['DE'], 500); + + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->createFromCart($cart, 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $rate->id); + + return ['store' => $store, 'cart' => $cart, 'rate' => $rate, 'checkout' => $checkout]; +} + +function makeDiscount($store, array $overrides = []): Discount +{ + return Discount::factory()->create(array_merge([ + 'store_id' => $store->id, + 'type' => DiscountType::Code, + 'code' => 'SAVE10', + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + 'starts_at' => now()->subDay(), + 'ends_at' => now()->addMonth(), + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => [], + 'status' => DiscountStatus::Active, + ], $overrides)); +} + +it('applies a valid percent discount code at checkout', function () { + $ctx = discountCheckoutContext(); + makeDiscount($ctx['store']); + + $checkout = app(CheckoutService::class)->applyDiscount($ctx['checkout'], 'SAVE10', app(DiscountService::class)); + + expect($checkout->discount_code)->toBe('SAVE10'); + expect($checkout->totals_json['discount'])->toBe(500); +}); + +it('applies a valid fixed discount code at checkout', function () { + $ctx = discountCheckoutContext(); + makeDiscount($ctx['store'], ['code' => '5OFF', 'value_type' => DiscountValueType::Fixed, 'value_amount' => 500]); + + $checkout = app(CheckoutService::class)->applyDiscount($ctx['checkout'], '5OFF', app(DiscountService::class)); + + expect($checkout->totals_json['discount'])->toBe(500); +}); + +it('removes discount when code is cleared', function () { + $ctx = discountCheckoutContext(); + makeDiscount($ctx['store']); + + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->applyDiscount($ctx['checkout'], 'SAVE10', app(DiscountService::class)); + expect($checkout->totals_json['discount'])->toBe(500); + + $checkout = $checkouts->removeDiscount($checkout); + + expect($checkout->discount_code)->toBeNull(); + expect($checkout->totals_json['discount'])->toBe(0); +}); + +it('rejects expired discount at checkout', function () { + $ctx = discountCheckoutContext(); + makeDiscount($ctx['store'], ['ends_at' => now()->subDay()]); + + app(CheckoutService::class)->applyDiscount($ctx['checkout'], 'SAVE10', app(DiscountService::class)); +})->throws(App\Exceptions\InvalidDiscountException::class); + +it('increments usage count on order completion', function () { + $ctx = discountCheckoutContext(); + $discount = makeDiscount($ctx['store'], ['usage_count' => 5]); + + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->applyDiscount($ctx['checkout'], 'SAVE10', app(DiscountService::class)); + $checkout = $checkouts->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + $checkouts->pay($checkout, PaymentMethod::CreditCard, ['card_number' => '4242424242424242']); + + expect($discount->refresh()->usage_count)->toBe(6); +}); + +it('handles free shipping discount at checkout', function () { + $ctx = discountCheckoutContext(); + makeDiscount($ctx['store'], ['code' => 'FREESHIP', 'value_type' => DiscountValueType::FreeShipping, 'value_amount' => 0]); + + $checkout = app(CheckoutService::class)->applyDiscount($ctx['checkout'], 'FREESHIP', app(DiscountService::class)); + + expect($checkout->totals_json['shipping'])->toBe(0); + expect($checkout->totals_json['discount'])->toBe(0); +}); diff --git a/tests/Feature/Checkout/PricingIntegrationTest.php b/tests/Feature/Checkout/PricingIntegrationTest.php new file mode 100644 index 00000000..cfd5a6e1 --- /dev/null +++ b/tests/Feature/Checkout/PricingIntegrationTest.php @@ -0,0 +1,121 @@ + $store] = test()->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store, [['price' => 2500, 'qty' => 2]]); + $rate = CommerceTestHelper::flatShippingRate($store, ['DE'], 499); + + TaxSetting::factory()->create([ + 'store_id' => $store->id, + 'mode' => TaxMode::Manual, + 'provider' => TaxProvider::None, + 'prices_include_tax' => false, + 'config_json' => ['default_tax_rate' => 1900], + ]); + + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->createFromCart($cart, 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $rate->id); + + return ['store' => $store, 'cart' => $cart, 'rate' => $rate, 'checkout' => $checkout]; +} + +it('calculates correct totals for a simple checkout', function () { + $totals = pricingContext()['checkout']->refresh()->totals_json; + + expect($totals['subtotal'])->toBe(5000); + expect($totals['discount'])->toBe(0); + expect($totals['shipping'])->toBe(499); + expect($totals['tax'])->toBe(1044); + expect($totals['total'])->toBe(6543); + expect($totals['currency'])->toBe('USD'); +}); + +it('applies discount code and recalculates', function () { + $ctx = pricingContext(); + + Discount::factory()->create([ + 'store_id' => $ctx['store']->id, + 'type' => DiscountType::Code, + 'code' => 'SAVE10', + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + 'starts_at' => now()->subDay(), + 'ends_at' => now()->addMonth(), + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => [], + 'status' => DiscountStatus::Active, + ]); + + $checkout = app(CheckoutService::class)->applyDiscount($ctx['checkout'], 'SAVE10', app(DiscountService::class)); + $totals = $checkout->totals_json; + + expect($totals['discount'])->toBe(500); + expect($totals['tax'])->toBe(949); + expect($totals['total'])->toBe(5948); +}); + +it('stores pricing snapshot in totals json', function () { + $totals = pricingContext()['checkout']->refresh()->totals_json; + + expect($totals)->toHaveKeys(['subtotal', 'discount', 'shipping', 'tax', 'total', 'currency', 'tax_lines']); +}); + +it('recalculates on shipping method change', function () { + $ctx = pricingContext(); + + $express = App\Models\ShippingRate::factory()->create([ + 'zone_id' => $ctx['rate']->zone_id, + 'type' => App\Enums\ShippingRateType::Flat, + 'config_json' => ['amount' => 899], + 'is_active' => true, + ]); + $checkout = app(CheckoutService::class)->setShippingMethod($ctx['checkout'], $express->id); + $totals = $checkout->totals_json; + + expect($totals['shipping'])->toBe(899); + expect($totals['tax'])->toBe(1120); + expect($totals['total'])->toBe(7019); +}); + +it('handles prices-include-tax correctly', function () { + ['store' => $store] = test()->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store, [['price' => 2500, 'qty' => 2]]); + $rate = CommerceTestHelper::flatShippingRate($store, ['DE'], 499); + + TaxSetting::factory()->create([ + 'store_id' => $store->id, + 'mode' => TaxMode::Manual, + 'provider' => TaxProvider::None, + 'prices_include_tax' => true, + 'config_json' => ['default_tax_rate' => 1900], + ]); + + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->createFromCart($cart, 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $rate->id); + + $totals = $checkout->refresh()->totals_json; + + expect($totals['tax'])->toBe(879); + expect($totals['total'])->toBe(5499); + expect($checkout->status)->toBe(CheckoutStatus::ShippingSelected); +}); diff --git a/tests/Feature/Checkout/ShippingTest.php b/tests/Feature/Checkout/ShippingTest.php new file mode 100644 index 00000000..2cc92dbf --- /dev/null +++ b/tests/Feature/Checkout/ShippingTest.php @@ -0,0 +1,74 @@ + $store] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + $rate = CommerceTestHelper::flatShippingRate($store, ['DE'], 499); + + $rates = app(ShippingCalculator::class)->getAvailableRates($store, ['country_code' => 'DE'], $cart); + + expect($rates)->toHaveCount(1); + expect($rates->first()->id)->toBe($rate->id); + expect($rates->first()->amount)->toBe(499); +}); + +it('returns empty when no zone matches address', function () { + ['store' => $store] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + CommerceTestHelper::flatShippingRate($store, ['DE'], 499); + + $rates = app(ShippingCalculator::class)->getAvailableRates($store, ['country_code' => 'FR'], $cart); + + expect($rates)->toBeEmpty(); +}); + +it('calculates flat rate correctly', function () { + ['store' => $store] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + + $zone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries_json' => ['DE']]); + $rate = ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'type' => ShippingRateType::Flat, + 'config_json' => ['amount' => 499], + 'is_active' => true, + ]); + + expect(app(ShippingCalculator::class)->calculate($rate, $cart))->toBe(499); +}); + +it('calculates weight-based rate correctly', function () { + ['store' => $store] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store, [['price' => 2500, 'qty' => 3, 'weight' => 250]]); + + $zone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries_json' => ['DE']]); + $rate = ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'type' => ShippingRateType::Weight, + 'config_json' => ['ranges' => [ + ['min_g' => 0, 'max_g' => 500, 'amount' => 499], + ['min_g' => 501, 'max_g' => 2000, 'amount' => 899], + ]], + 'is_active' => true, + ]); + + expect(app(ShippingCalculator::class)->calculate($rate, $cart))->toBe(899); +}); + +it('returns zero shipping when all items are digital', function () { + ['store' => $store] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store, [['price' => 2500, 'qty' => 1, 'shipping' => false]]); + CommerceTestHelper::flatShippingRate($store, ['DE'], 499); + + $rates = app(ShippingCalculator::class)->getAvailableRates($store, ['country_code' => 'DE'], $cart); + + expect($rates)->toBeEmpty(); +}); diff --git a/tests/Feature/Checkout/TaxTest.php b/tests/Feature/Checkout/TaxTest.php new file mode 100644 index 00000000..205d564d --- /dev/null +++ b/tests/Feature/Checkout/TaxTest.php @@ -0,0 +1,64 @@ + $store] = test()->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store, [['price' => 2500, 'qty' => 2]]); + $rate = CommerceTestHelper::flatShippingRate($store, ['DE'], 499); + + TaxSetting::factory()->create([ + 'store_id' => $store->id, + 'mode' => TaxMode::Manual, + 'provider' => TaxProvider::None, + 'prices_include_tax' => $inclusive, + 'config_json' => ['default_tax_rate' => 1900], + ]); + + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->createFromCart($cart, 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $rate->id); + + return ['store' => $store, 'checkout' => $checkout]; +} + +it('calculates exclusive tax correctly at checkout', function () { + $totals = taxCheckoutContext()['checkout']->refresh()->totals_json; + + expect($totals['tax'])->toBe(1044); + expect($totals['total'])->toBe(6543); +}); + +it('extracts inclusive tax correctly at checkout', function () { + $totals = taxCheckoutContext(true)['checkout']->refresh()->totals_json; + + expect($totals['tax'])->toBe(879); + expect($totals['total'])->toBe(5499); +}); + +it('applies zero tax when no tax settings exist', function () { + ['store' => $store] = $this->createStoreContext(); + + $result = app(TaxCalculator::class)->calculate($store, [['amount' => 5000]], 499, ['country_code' => 'DE']); + + expect($result->totalAmount)->toBe(0); + expect($result->taxLines)->toBeEmpty(); +}); + +it('stores tax lines in totals json', function () { + $totals = taxCheckoutContext()['checkout']->refresh()->totals_json; + + expect($totals['tax_lines'])->toHaveCount(2); + expect($totals['tax_lines'][0]['rate'])->toBe(1900); + expect($totals['tax_lines'][0]['amount'])->toBe(950); + expect($totals['tax_lines'][1]['amount'])->toBe(94); +}); diff --git a/tests/Feature/Customer/AccountTest.php b/tests/Feature/Customer/AccountTest.php new file mode 100644 index 00000000..2098c41c --- /dev/null +++ b/tests/Feature/Customer/AccountTest.php @@ -0,0 +1,190 @@ +createFromCart($cart, $customer->email, $customer); + $checkout->payment_method = App\Enums\PaymentMethod::CreditCard; + $checkout->save(); + + return app(OrderService::class)->createFromCheckout( + $checkout->refresh(), + new PaymentResult(success: true, referenceId: 'mock_test', status: 'captured') + ); +} + +it('redirects guests away from account pages', function () { + ['domain' => $domain] = $this->createStoreContext(); + + $this->get(storefrontUrl($domain->hostname, '/account'))->assertRedirect(storefrontUrl($domain->hostname, '/account/login')); + $this->get(storefrontUrl($domain->hostname, '/account/orders'))->assertRedirect(storefrontUrl($domain->hostname, '/account/login')); + $this->get(storefrontUrl($domain->hostname, '/account/addresses'))->assertRedirect(storefrontUrl($domain->hostname, '/account/login')); + $this->get(storefrontUrl($domain->hostname, '/account/profile'))->assertRedirect(storefrontUrl($domain->hostname, '/account/login')); +}); + +it('renders the account dashboard with recent orders', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $customer = Customer::factory()->create(['store_id' => $store->id, 'name' => 'Jane Doe']); + $order = placeOrderForCustomer($store, $customer); + + $response = $this->actingAs($customer, 'customer')->get(storefrontUrl($domain->hostname, '/account')); + + $response->assertOk(); + $response->assertSee('Welcome back, Jane Doe!'); + $response->assertSee($order->order_number); +}); + +it('renders the dashboard component', function () { + ['store' => $store] = $this->createStoreContext(); + $customer = Customer::factory()->create(['store_id' => $store->id]); + $this->actingAs($customer, 'customer'); + + Livewire::test(Dashboard::class)->assertSee('Order history')->assertStatus(200); +}); + +it('lists customer orders without leaking other customers orders', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $customer = Customer::factory()->create(['store_id' => $store->id]); + $other = Customer::factory()->create(['store_id' => $store->id]); + $mine = placeOrderForCustomer($store, $customer); + $theirs = placeOrderForCustomer($store, $other); + + $response = $this->actingAs($customer, 'customer')->get(storefrontUrl($domain->hostname, '/account/orders')); + + $response->assertOk(); + $response->assertSee($mine->order_number); + $response->assertDontSee($theirs->order_number); + + Livewire::test(OrdersIndex::class)->assertSee($mine->order_number)->assertStatus(200); +}); + +it('shows order detail for the owning customer only', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $customer = Customer::factory()->create(['store_id' => $store->id]); + $other = Customer::factory()->create(['store_id' => $store->id]); + $mine = placeOrderForCustomer($store, $customer); + $theirs = placeOrderForCustomer($store, $other); + + $url = fn (string $number): string => storefrontUrl($domain->hostname, '/account/orders/'.urlencode($number)); + + $this->actingAs($customer, 'customer')->get($url($mine->order_number))->assertOk()->assertSee($mine->order_number); + $this->actingAs($customer, 'customer')->get($url($theirs->order_number))->assertNotFound(); + + Livewire::test(OrdersShow::class, ['orderNumber' => $mine->order_number]) + ->assertSee($mine->order_number) + ->assertStatus(200); +}); + +it('updates the customer profile with per-store email uniqueness', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $customer = Customer::factory()->create(['store_id' => $store->id]); + $sibling = Customer::factory()->create(['store_id' => $store->id]); + $this->actingAs($customer, 'customer'); + + $this->get(storefrontUrl($domain->hostname, '/account/profile'))->assertOk(); + + Livewire::test(Profile::class) + ->set('name', 'Janet Doe') + ->set('email', $sibling->email) + ->call('save') + ->assertHasErrors('email'); + + Livewire::test(Profile::class) + ->set('name', 'Janet Doe') + ->set('email', 'janet@example.com') + ->set('marketingOptIn', true) + ->call('save') + ->assertHasNoErrors() + ->assertSet('status', 'Profile updated successfully.'); + + expect($customer->refresh()->name)->toBe('Janet Doe'); + expect($customer->refresh()->marketing_opt_in)->toBeTrue(); +}); + +it('manages addresses with a single default', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $customer = Customer::factory()->create(['store_id' => $store->id]); + $this->actingAs($customer, 'customer'); + + $this->get(storefrontUrl($domain->hostname, '/account/addresses'))->assertOk(); + + Livewire::test(AddressesIndex::class) + ->call('openCreate') + ->assertSet('showModal', true) + ->set('label', 'Home') + ->set('address', accountAddress()) + ->call('save') + ->assertHasNoErrors() + ->assertSet('showModal', false); + + $first = CustomerAddress::query()->where('customer_id', $customer->id)->first(); + + expect($first->is_default)->toBeTrue(); + + Livewire::test(AddressesIndex::class) + ->call('openCreate') + ->set('label', 'Work') + ->set('address', accountAddress()) + ->call('save') + ->assertHasNoErrors(); + + $second = CustomerAddress::query()->where('customer_id', $customer->id)->where('label', 'Work')->first(); + + expect($second->is_default)->toBeFalse(); + + Livewire::test(AddressesIndex::class) + ->call('setDefault', $second->id); + + expect($first->refresh()->is_default)->toBeFalse(); + expect($second->refresh()->is_default)->toBeTrue(); + + Livewire::test(AddressesIndex::class) + ->call('openEdit', $second->id) + ->assertSet('editingId', $second->id) + ->set('label', 'Office') + ->call('save') + ->assertHasNoErrors(); + + expect($second->refresh()->label)->toBe('Office'); + + Livewire::test(AddressesIndex::class) + ->call('confirmDelete', $second->id) + ->call('delete'); + + expect(CustomerAddress::query()->whereKey($second->id)->exists())->toBeFalse(); + expect($first->refresh()->is_default)->toBeTrue(); +}); + +it('validates address fields', function () { + ['store' => $store] = $this->createStoreContext(); + $customer = Customer::factory()->create(['store_id' => $store->id]); + $this->actingAs($customer, 'customer'); + + Livewire::test(AddressesIndex::class) + ->call('openCreate') + ->set('address.first_name', '') + ->set('address.country_code', 'XXL') + ->call('save') + ->assertHasErrors(['address.first_name', 'address.country_code']); +}); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 8b5843f4..b6f75c4a 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -1,7 +1,16 @@ get('/'); + $store = Store::factory()->create(['status' => StoreStatus::Active]); + StoreDomain::factory()->create(['store_id' => $store->id, 'hostname' => 'acme-fashion.test']); + + $response = $this->get('http://acme-fashion.test/'); - $response->assertStatus(200); + $response->assertOk(); }); diff --git a/tests/Feature/Orders/FulfillmentTest.php b/tests/Feature/Orders/FulfillmentTest.php new file mode 100644 index 00000000..874ff757 --- /dev/null +++ b/tests/Feature/Orders/FulfillmentTest.php @@ -0,0 +1,200 @@ +withoutMiddleware(Illuminate\Routing\Middleware\ThrottleRequests::class); +}); + +function fulfillmentOrderContext(array $lines = [['price' => 2500, 'qty' => 3], ['price' => 1000, 'qty' => 2]]): array +{ + ['store' => $store] = test()->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store, $lines); + $rate = CommerceTestHelper::flatShippingRate($store, ['DE'], 0); + + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->createFromCart($cart, 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $rate->id); + $checkout = $checkouts->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + $order = $checkouts->pay($checkout, PaymentMethod::CreditCard, ['card_number' => '4242424242424242']); + + return ['store' => $store, 'cart' => $cart, 'order' => $order]; +} + +it('creates a fulfillment for specific order lines', function () { + $order = fulfillmentOrderContext()['order']; + $line = $order->orderLines()->first(); + + $fulfillment = app(FulfillmentService::class)->create($order, [$line->id => 3]); + + expect($fulfillment->status)->toBe(FulfillmentShipmentStatus::Pending); + expect($fulfillment->fulfillmentLines()->count())->toBe(1); +}); + +it('updates order fulfillment status to partial', function () { + $order = fulfillmentOrderContext()['order']; + $line = $order->orderLines()->first(); + + app(FulfillmentService::class)->create($order, [$line->id => 3]); + + expect($order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Partial); +}); + +it('updates order fulfillment status to fulfilled when all lines done', function () { + $order = fulfillmentOrderContext()['order']; + $lines = []; + + foreach ($order->orderLines()->get() as $line) { + $lines[$line->id] = $line->quantity; + } + + app(FulfillmentService::class)->create($order, $lines); + + expect($order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled); + expect($order->refresh()->status)->toBe(OrderStatus::Fulfilled); +}); + +it('adds tracking information', function () { + $order = fulfillmentOrderContext()['order']; + $line = $order->orderLines()->first(); + + $fulfillment = app(FulfillmentService::class)->create($order, [$line->id => 3], [ + 'tracking_company' => 'DHL', + 'tracking_number' => '123456', + 'tracking_url' => 'https://dhl.test/123456', + ]); + $fulfillment = app(FulfillmentService::class)->markAsShipped($fulfillment); + + expect($fulfillment->tracking_company)->toBe('DHL'); + expect($fulfillment->tracking_number)->toBe('123456'); + expect($fulfillment->shipped_at)->not->toBeNull(); +}); + +it('transitions fulfillment from pending to shipped', function () { + $order = fulfillmentOrderContext()['order']; + $line = $order->orderLines()->first(); + + $fulfillment = app(FulfillmentService::class)->create($order, [$line->id => 3]); + $fulfillment = app(FulfillmentService::class)->markAsShipped($fulfillment); + + expect($fulfillment->status)->toBe(FulfillmentShipmentStatus::Shipped); +}); + +it('transitions fulfillment from shipped to delivered', function () { + $order = fulfillmentOrderContext()['order']; + $line = $order->orderLines()->first(); + + $fulfillment = app(FulfillmentService::class)->create($order, [$line->id => 3]); + $fulfillment = app(FulfillmentService::class)->markAsShipped($fulfillment); + $fulfillment = app(FulfillmentService::class)->markAsDelivered($fulfillment); + + expect($fulfillment->status)->toBe(FulfillmentShipmentStatus::Delivered); +}); + +it('prevents fulfilling more than ordered quantity', function () { + $order = fulfillmentOrderContext()['order']; + $line = $order->orderLines()->first(); + + app(FulfillmentService::class)->create($order, [$line->id => $line->quantity + 1]); +})->throws(Illuminate\Validation\ValidationException::class); + +it('fulfillment guard blocks fulfillment when financial status is pending', function () { + ['store' => $store] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + $rate = CommerceTestHelper::flatShippingRate($store); + + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->createFromCart($cart, 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $rate->id); + $checkout = $checkouts->selectPaymentMethod($checkout, PaymentMethod::BankTransfer); + $order = $checkouts->pay($checkout, PaymentMethod::BankTransfer, []); + + $line = $order->orderLines()->first(); + + app(FulfillmentService::class)->create($order, [$line->id => 1]); +})->throws(FulfillmentGuardException::class); + +it('fulfillment guard allows fulfillment when financial status is paid', function () { + $order = fulfillmentOrderContext()['order']; + $line = $order->orderLines()->first(); + + $fulfillment = app(FulfillmentService::class)->create($order, [$line->id => 1]); + + expect($fulfillment->exists)->toBeTrue(); +}); + +it('fulfillment guard allows fulfillment when financial status is partially refunded', function () { + $ctx = fulfillmentOrderContext(); + $order = $ctx['order']; + + app(RefundService::class)->create($order, 500); + + expect($order->refresh()->financial_status)->toBe(FinancialStatus::PartiallyRefunded); + + $line = $order->orderLines()->first(); + $fulfillment = app(FulfillmentService::class)->create($order->refresh(), [$line->id => 1]); + + expect($fulfillment->exists)->toBeTrue(); +}); + +it('auto-fulfills digital products on payment confirmation', function () { + ['store' => $store] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store, [['price' => 2500, 'qty' => 1, 'shipping' => false]]); + $rate = CommerceTestHelper::flatShippingRate($store); + + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->createFromCart($cart, 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, null); + $checkout = $checkouts->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + $order = $checkouts->pay($checkout, PaymentMethod::CreditCard, ['card_number' => '4242424242424242']); + + $fulfillment = $order->fulfillments()->first(); + + expect($fulfillment)->not->toBeNull(); + expect($fulfillment->status)->toBe(FulfillmentShipmentStatus::Delivered); + expect($order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled); +}); + +it('only allows admin owner or staff to create fulfillments', function () { + ['store' => $store] = $this->createStoreContext(); + + $cart = CommerceTestHelper::cartWithLines($store); + $rate = CommerceTestHelper::flatShippingRate($store, ['DE'], 0); + + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->createFromCart($cart, 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $rate->id); + $checkout = $checkouts->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + $order = $checkouts->pay($checkout, PaymentMethod::CreditCard, ['card_number' => '4242424242424242']); + + $support = App\Models\User::factory()->create(); + StoreUser::factory()->create(['store_id' => $store->id, 'user_id' => $support->id, 'role' => StoreUserRole::Support]); + + $line = $order->orderLines()->first(); + + $response = $this->postJson( + "/api/admin/v1/stores/{$store->id}/orders/{$order->id}/fulfillments", + ['line_items' => [['order_line_id' => $line->id, 'quantity' => 1]]], + CatalogTestHelper::adminHeaders($support, ['write-orders']) + ); + + $response->assertForbidden(); +}); diff --git a/tests/Feature/Orders/OrderCreationTest.php b/tests/Feature/Orders/OrderCreationTest.php new file mode 100644 index 00000000..662b03ec --- /dev/null +++ b/tests/Feature/Orders/OrderCreationTest.php @@ -0,0 +1,167 @@ + 2500, 'qty' => 2]]): array +{ + ['store' => $store] = test()->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store, $lines); + $rate = CommerceTestHelper::flatShippingRate($store, ['DE'], 499); + + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->createFromCart($cart, 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $rate->id); + $checkout = $checkouts->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + + return ['store' => $store, 'cart' => $cart, 'checkout' => $checkout]; +} + +it('creates an order from a completed checkout', function () { + $ctx = orderContext(); + + $order = app(CheckoutService::class)->pay($ctx['checkout'], PaymentMethod::CreditCard, [ + 'card_number' => '4242424242424242', + ]); + + expect($order->status)->toBe(OrderStatus::Paid); + expect($order->financial_status)->toBe(FinancialStatus::Paid); + expect($order->subtotal_amount)->toBe(5000); + expect($order->shipping_amount)->toBe(499); + expect($order->total_amount)->toBe($order->subtotal_amount - $order->discount_amount + $order->shipping_amount + $order->tax_amount); +}); + +it('generates sequential order numbers per store', function () { + ['store' => $store] = $this->createStoreContext(); + + $numbers = []; + + for ($i = 0; $i < 3; $i++) { + $cart = CommerceTestHelper::cartWithLines($store, [['price' => 1000, 'qty' => 1]]); + $rate = $i === 0 ? CommerceTestHelper::flatShippingRate($store) : App\Models\ShippingRate::query()->first(); + + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->createFromCart($cart, "customer{$i}@example.com"); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $rate->id); + $checkout = $checkouts->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + $numbers[] = $checkouts->pay($checkout, PaymentMethod::CreditCard, ['card_number' => '4242424242424242'])->order_number; + } + + expect($numbers)->toBe(['#1001', '#1002', '#1003']); +}); + +it('creates order lines with snapshots', function () { + $ctx = orderContext(); + + $order = app(CheckoutService::class)->pay($ctx['checkout'], PaymentMethod::CreditCard, [ + 'card_number' => '4242424242424242', + ]); + + $line = $order->orderLines()->first(); + + expect($line->title_snapshot)->not->toBe(''); + expect($line->sku_snapshot)->not->toBeNull(); + expect($line->quantity)->toBe(2); + expect($line->unit_price_amount)->toBe(2500); +}); + +it('commits inventory on order creation', function () { + $ctx = orderContext(); + + app(CheckoutService::class)->pay($ctx['checkout'], PaymentMethod::CreditCard, [ + 'card_number' => '4242424242424242', + ]); + + $item = App\Models\InventoryItem::query() + ->where('variant_id', $ctx['cart']->cartLines()->first()->variant_id) + ->first(); + + expect($item->quantity_on_hand)->toBe(48); + expect($item->quantity_reserved)->toBe(0); +}); + +it('marks cart as converted', function () { + $ctx = orderContext(); + + app(CheckoutService::class)->pay($ctx['checkout'], PaymentMethod::CreditCard, [ + 'card_number' => '4242424242424242', + ]); + + expect($ctx['cart']->refresh()->status)->toBe(CartStatus::Converted); +}); + +it('dispatches OrderCreated event', function () { + Event::fake([OrderCreated::class]); + $ctx = orderContext(); + + app(CheckoutService::class)->pay($ctx['checkout'], PaymentMethod::CreditCard, [ + 'card_number' => '4242424242424242', + ]); + + Event::assertDispatched(OrderCreated::class); +}); + +it('preserves order data when product is archived', function () { + $ctx = orderContext(); + + $order = app(CheckoutService::class)->pay($ctx['checkout'], PaymentMethod::CreditCard, [ + 'card_number' => '4242424242424242', + ]); + + $line = $order->orderLines()->first(); + $product = App\Models\ProductVariant::query()->whereKey($ctx['cart']->cartLines()->first()->variant_id)->first()->product; + app(App\Services\ProductService::class)->archive($product->refresh()); + + expect($line->refresh()->title_snapshot)->not->toBe(''); + expect($order->refresh()->orderLines()->count())->toBe(1); +}); + +it('links order to customer when authenticated', function () { + ['store' => $store] = $this->createStoreContext(); + $customer = Customer::factory()->create(['store_id' => $store->id, 'email' => 'member@example.com']); + $cart = CommerceTestHelper::cartWithLines($store); + $cart->customer_id = $customer->id; + $cart->save(); + $rate = CommerceTestHelper::flatShippingRate($store); + + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->createFromCart($cart, 'member@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $rate->id); + $checkout = $checkouts->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + $order = $checkouts->pay($checkout, PaymentMethod::CreditCard, ['card_number' => '4242424242424242']); + + expect($order->customer_id)->toBe($customer->id); +}); + +it('sets email from checkout on the order', function () { + $ctx = orderContext(); + + $order = app(CheckoutService::class)->pay($ctx['checkout'], PaymentMethod::CreditCard, [ + 'card_number' => '4242424242424242', + ]); + + expect($order->email)->toBe('customer@example.com'); +}); + +it('creates a guest customer for unknown checkout email', function () { + $ctx = orderContext(); + + $order = app(CheckoutService::class)->pay($ctx['checkout'], PaymentMethod::CreditCard, [ + 'card_number' => '4242424242424242', + ]); + + expect($order->customer_id)->not->toBeNull(); + expect(Customer::query()->whereKey($order->customer_id)->first()->email)->toBe('customer@example.com'); +}); diff --git a/tests/Feature/Orders/RefundTest.php b/tests/Feature/Orders/RefundTest.php new file mode 100644 index 00000000..893c1278 --- /dev/null +++ b/tests/Feature/Orders/RefundTest.php @@ -0,0 +1,124 @@ +withoutMiddleware(Illuminate\Routing\Middleware\ThrottleRequests::class); +}); + +function paidOrderContext(array $lines = [['price' => 2500, 'qty' => 2]]): array +{ + ['store' => $store] = test()->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store, $lines); + $rate = CommerceTestHelper::flatShippingRate($store, ['DE'], 0); + + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->createFromCart($cart, 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $rate->id); + $checkout = $checkouts->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + $order = $checkouts->pay($checkout, PaymentMethod::CreditCard, ['card_number' => '4242424242424242']); + + return ['store' => $store, 'cart' => $cart, 'order' => $order]; +} + +it('creates a full refund', function () { + $ctx = paidOrderContext(); + $order = $ctx['order']; + + $refund = app(RefundService::class)->create($order, $order->total_amount, null, 'Full refund'); + + expect($refund->status)->toBe(RefundStatus::Processed); + expect($refund->provider_refund_id)->toStartWith('mock_rf_'); + expect($order->refresh()->financial_status)->toBe(FinancialStatus::Refunded); + expect($order->refresh()->status)->toBe(OrderStatus::Refunded); +}); + +it('creates a partial refund', function () { + $ctx = paidOrderContext(); + $order = $ctx['order']; + + app(RefundService::class)->create($order, 2000); + + expect($order->refresh()->financial_status)->toBe(FinancialStatus::PartiallyRefunded); +}); + +it('rejects refund exceeding payment amount', function () { + $ctx = paidOrderContext(); + + app(RefundService::class)->create($ctx['order'], $ctx['order']->total_amount + 1); +})->throws(Illuminate\Validation\ValidationException::class); + +it('restocks inventory when restock flag is true', function () { + $ctx = paidOrderContext(); + $order = $ctx['order']; + $line = $order->orderLines()->first(); + + app(RefundService::class)->create($order, 2500, [$line->id => 1], null, true); + + $item = App\Models\InventoryItem::query() + ->where('variant_id', $ctx['cart']->cartLines()->first()->variant_id) + ->first(); + + expect($item->quantity_on_hand)->toBe(49); +}); + +it('does not restock when restock flag is false', function () { + $ctx = paidOrderContext(); + $order = $ctx['order']; + $line = $order->orderLines()->first(); + + app(RefundService::class)->create($order, 2500, [$line->id => 1], null, false); + + $item = App\Models\InventoryItem::query() + ->where('variant_id', $ctx['cart']->cartLines()->first()->variant_id) + ->first(); + + expect($item->quantity_on_hand)->toBe(48); +}); + +it('only allows admin or owner to process refunds', function () { + ['store' => $store] = $this->createStoreContext(); + + $cart = CommerceTestHelper::cartWithLines($store); + $rate = CommerceTestHelper::flatShippingRate($store, ['DE'], 0); + + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->createFromCart($cart, 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $rate->id); + $checkout = $checkouts->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + $order = $checkouts->pay($checkout, PaymentMethod::CreditCard, ['card_number' => '4242424242424242']); + + $staff = App\Models\User::factory()->create(); + StoreUser::factory()->create(['store_id' => $store->id, 'user_id' => $staff->id, 'role' => StoreUserRole::Staff]); + + $response = $this->postJson( + "/api/admin/v1/stores/{$store->id}/orders/{$order->id}/refunds", + ['amount' => 1000], + CatalogTestHelper::adminHeaders($staff, ['write-orders']) + ); + + $response->assertForbidden(); +}); + +it('records refund reason', function () { + $ctx = paidOrderContext(); + + $refund = app(RefundService::class)->create($ctx['order'], 1000, null, 'Customer requested'); + + expect($refund->reason)->toBe('Customer requested'); + expect(Refund::query()->whereKey($refund->id)->exists())->toBeTrue(); +}); diff --git a/tests/Feature/Payments/BankTransferConfirmationTest.php b/tests/Feature/Payments/BankTransferConfirmationTest.php new file mode 100644 index 00000000..9ea4ebdb --- /dev/null +++ b/tests/Feature/Payments/BankTransferConfirmationTest.php @@ -0,0 +1,106 @@ + 2500, 'qty' => 2]]): array +{ + ['store' => $store] = test()->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store, $lines); + $rate = CommerceTestHelper::flatShippingRate($store); + + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->createFromCart($cart, 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $rate->id); + $checkout = $checkouts->selectPaymentMethod($checkout, PaymentMethod::BankTransfer); + $order = $checkouts->pay($checkout, PaymentMethod::BankTransfer, []); + + return ['store' => $store, 'cart' => $cart, 'checkout' => $checkout, 'order' => $order]; +} + +it('admin can confirm bank transfer payment', function () { + $ctx = bankTransferOrder(); + + $order = app(OrderService::class)->confirmBankTransferPayment($ctx['order']); + + expect($order->financial_status)->toBe(FinancialStatus::Paid); + expect($order->status)->toBe(OrderStatus::Paid); + expect($order->payments()->first()->status)->toBe(PaymentStatus::Captured); + + $item = App\Models\InventoryItem::query() + ->where('variant_id', $ctx['cart']->cartLines()->first()->variant_id) + ->first(); + expect($item->quantity_on_hand)->toBe(48); + expect($item->quantity_reserved)->toBe(0); +}); + +it('cannot confirm payment for non-bank-transfer orders', function () { + ['store' => $store] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + $rate = CommerceTestHelper::flatShippingRate($store); + + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->createFromCart($cart, 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $rate->id); + $checkout = $checkouts->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + $order = $checkouts->pay($checkout, PaymentMethod::CreditCard, ['card_number' => '4242424242424242']); + + app(OrderService::class)->confirmBankTransferPayment($order); +})->throws(InvalidOrderTransitionException::class); + +it('cannot confirm already confirmed payment', function () { + $ctx = bankTransferOrder(); + + app(OrderService::class)->confirmBankTransferPayment($ctx['order']); + app(OrderService::class)->confirmBankTransferPayment($ctx['order']->refresh()); +})->throws(InvalidOrderTransitionException::class); + +it('auto-cancel job cancels unpaid bank transfer orders after config days', function () { + $ctx = bankTransferOrder(); + $ctx['order']->placed_at = now()->subDays(8); + $ctx['order']->save(); + + app(App\Jobs\CancelUnpaidBankTransferOrders::class)->handle(); + + $order = $ctx['order']->refresh(); + expect($order->status)->toBe(OrderStatus::Cancelled); + expect($order->financial_status)->toBe(FinancialStatus::Voided); + + $item = App\Models\InventoryItem::query() + ->where('variant_id', $ctx['cart']->cartLines()->first()->variant_id) + ->first(); + expect($item->quantity_reserved)->toBe(0); +}); + +it('auto-cancel job does not cancel orders within config days', function () { + $ctx = bankTransferOrder(); + $ctx['order']->placed_at = now()->subDays(2); + $ctx['order']->save(); + + app(App\Jobs\CancelUnpaidBankTransferOrders::class)->handle(); + + $order = $ctx['order']->refresh(); + expect($order->status)->toBe(OrderStatus::Pending); + expect($order->financial_status)->toBe(FinancialStatus::Pending); +}); + +it('auto-fulfills digital products on payment confirmation', function () { + $ctx = bankTransferOrder([['price' => 2500, 'qty' => 1, 'shipping' => false]]); + + $order = app(OrderService::class)->confirmBankTransferPayment($ctx['order']); + + $fulfillment = $order->fulfillments()->first(); + expect($fulfillment)->not->toBeNull(); + expect($fulfillment->status)->toBe(FulfillmentShipmentStatus::Delivered); +}); diff --git a/tests/Feature/Payments/MockPaymentProviderTest.php b/tests/Feature/Payments/MockPaymentProviderTest.php new file mode 100644 index 00000000..be308d70 --- /dev/null +++ b/tests/Feature/Payments/MockPaymentProviderTest.php @@ -0,0 +1,84 @@ + $store] = test()->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + + return App\Models\Checkout::factory()->create([ + 'store_id' => $store->id, + 'cart_id' => $cart->id, + 'status' => App\Enums\CheckoutStatus::PaymentSelected, + ]); +} + +it('charges credit card with success card number', function () { + $result = app(MockPaymentProvider::class)->charge( + paymentCheckoutContext(), + PaymentMethod::CreditCard, + ['card_number' => '4242424242424242'] + ); + + expect($result->success)->toBeTrue(); + expect($result->status)->toBe('captured'); +}); + +it('declines credit card with decline card number', function () { + $result = app(MockPaymentProvider::class)->charge( + paymentCheckoutContext(), + PaymentMethod::CreditCard, + ['card_number' => '4000000000000002'] + ); + + expect($result->success)->toBeFalse(); + expect($result->errorCode)->toBe('card_declined'); +}); + +it('returns insufficient funds for that card number', function () { + $result = app(MockPaymentProvider::class)->charge( + paymentCheckoutContext(), + PaymentMethod::CreditCard, + ['card_number' => '4000000000009995'] + ); + + expect($result->success)->toBeFalse(); + expect($result->errorCode)->toBe('insufficient_funds'); +}); + +it('charges PayPal successfully', function () { + $result = app(MockPaymentProvider::class)->charge( + paymentCheckoutContext(), + PaymentMethod::Paypal, + [] + ); + + expect($result->success)->toBeTrue(); + expect($result->status)->toBe('captured'); +}); + +it('creates pending payment for bank transfer', function () { + $result = app(MockPaymentProvider::class)->charge( + paymentCheckoutContext(), + PaymentMethod::BankTransfer, + [] + ); + + expect($result->success)->toBeTrue(); + expect($result->status)->toBe('pending'); +}); + +it('generates mock reference ID', function () { + $result = app(MockPaymentProvider::class)->charge( + paymentCheckoutContext(), + PaymentMethod::CreditCard, + ['card_number' => '4242424242424242'] + ); + + expect($result->referenceId)->toStartWith('mock_'); +}); diff --git a/tests/Feature/Payments/PaymentServiceTest.php b/tests/Feature/Payments/PaymentServiceTest.php new file mode 100644 index 00000000..350b86c7 --- /dev/null +++ b/tests/Feature/Payments/PaymentServiceTest.php @@ -0,0 +1,85 @@ + $store] = test()->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store, [['price' => 2500, 'qty' => 2]]); + $rate = CommerceTestHelper::flatShippingRate($store); + + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->createFromCart($cart, 'customer@example.com'); + $checkout = $checkouts->setAddress($checkout, CommerceTestHelper::address()); + $checkout = $checkouts->setShippingMethod($checkout, $rate->id); + + return ['store' => $store, 'cart' => $cart, 'checkout' => $checkout]; +} + +it('processes credit card payment and creates order as paid', function () { + $ctx = paidCheckoutContext(); + + $checkout = app(CheckoutService::class)->selectPaymentMethod($ctx['checkout'], PaymentMethod::CreditCard); + $order = app(CheckoutService::class)->pay($checkout, PaymentMethod::CreditCard, ['card_number' => '4242424242424242']); + + expect($order->financial_status)->toBe(FinancialStatus::Paid); + + $item = App\Models\InventoryItem::query() + ->where('variant_id', $ctx['cart']->cartLines()->first()->variant_id) + ->first(); + expect($item->quantity_on_hand)->toBe(48); + expect($item->quantity_reserved)->toBe(0); +}); + +it('processes PayPal payment and creates order as paid', function () { + $ctx = paidCheckoutContext(); + + $checkout = app(CheckoutService::class)->selectPaymentMethod($ctx['checkout'], PaymentMethod::Paypal); + $order = app(CheckoutService::class)->pay($checkout, PaymentMethod::Paypal, []); + + expect($order->financial_status)->toBe(FinancialStatus::Paid); + expect($order->payment_method)->toBe(PaymentMethod::Paypal); +}); + +it('processes bank transfer and creates order as pending', function () { + $ctx = paidCheckoutContext(); + + $checkout = app(CheckoutService::class)->selectPaymentMethod($ctx['checkout'], PaymentMethod::BankTransfer); + $order = app(CheckoutService::class)->pay($checkout, PaymentMethod::BankTransfer, []); + + expect($order->financial_status)->toBe(FinancialStatus::Pending); + expect($order->payment_method)->toBe(PaymentMethod::BankTransfer); + + $item = App\Models\InventoryItem::query() + ->where('variant_id', $ctx['cart']->cartLines()->first()->variant_id) + ->first(); + expect($item->quantity_on_hand)->toBe(50); + expect($item->quantity_reserved)->toBe(2); +}); + +it('resolves MockPaymentProvider from container', function () { + expect(app(PaymentProvider::class))->toBeInstanceOf(MockPaymentProvider::class); + expect(app(PaymentService::class))->toBeInstanceOf(PaymentService::class); +}); + +it('creates a payment record with correct method', function () { + $ctx = paidCheckoutContext(); + + $checkout = app(CheckoutService::class)->selectPaymentMethod($ctx['checkout'], PaymentMethod::CreditCard); + $order = app(CheckoutService::class)->pay($checkout, PaymentMethod::CreditCard, ['card_number' => '4242424242424242']); + + $payment = $order->payments()->first(); + + expect($payment->method)->toBe(PaymentMethod::CreditCard); + expect($payment->provider->value)->toBe('mock'); + expect($payment->provider_payment_id)->toStartWith('mock_'); + expect($payment->status->value)->toBe('captured'); +}); diff --git a/tests/Feature/Products/CollectionTest.php b/tests/Feature/Products/CollectionTest.php new file mode 100644 index 00000000..554f6fdb --- /dev/null +++ b/tests/Feature/Products/CollectionTest.php @@ -0,0 +1,228 @@ + $store, 'user' => $user] = $this->createStoreContext(); + + $response = $this->postJson("/api/admin/v1/stores/{$store->id}/collections", [ + 'title' => 'Summer Sale', + 'description_html' => '

Best items for summer.

', + 'type' => 'manual', + 'status' => 'active', + ], CatalogTestHelper::adminHeaders($user)); + + $response->assertCreated(); + $response->assertJsonPath('data.title', 'Summer Sale'); + $response->assertJsonPath('data.handle', 'summer-sale'); + $response->assertJsonPath('data.type', 'manual'); + expect(Collection::query()->where('store_id', $store->id)->count())->toBe(1); +}); + +it('dedupes collection handles', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $headers = CatalogTestHelper::adminHeaders($user); + + $this->postJson("/api/admin/v1/stores/{$store->id}/collections", + ['title' => 'Sale', 'type' => 'manual'], $headers)->assertCreated()->assertJsonPath('data.handle', 'sale'); + $this->postJson("/api/admin/v1/stores/{$store->id}/collections", + ['title' => 'Sale', 'type' => 'manual'], $headers)->assertCreated()->assertJsonPath('data.handle', 'sale-1'); + + $this->postJson("/api/admin/v1/stores/{$store->id}/collections", + ['title' => 'Other', 'handle' => 'sale', 'type' => 'manual'], $headers) + ->assertUnprocessable()->assertJsonValidationErrors(['handle']); +}); + +it('validates collection payloads', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $headers = CatalogTestHelper::adminHeaders($user); + + $this->postJson("/api/admin/v1/stores/{$store->id}/collections", [], $headers) + ->assertUnprocessable()->assertJsonValidationErrors(['title', 'type']); + + $this->postJson("/api/admin/v1/stores/{$store->id}/collections", + ['title' => 'Bad', 'type' => 'magic'], $headers) + ->assertUnprocessable()->assertJsonValidationErrors(['type']); + + ['store' => $otherStore] = $this->createStoreContext(); + $foreignProduct = app(ProductService::class)->create($otherStore, CatalogTestHelper::productPayload()); + + $this->postJson("/api/admin/v1/stores/{$store->id}/collections", [ + 'title' => 'Mixed', 'type' => 'manual', 'product_ids' => [$foreignProduct->id], + ], $headers)->assertUnprocessable()->assertJsonValidationErrors(['product_ids.0']); +}); + +it('adds products to a collection on create', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $service = app(ProductService::class); + + $ids = []; + foreach (['One', 'Two', 'Three'] as $index => $title) { + $payload = CatalogTestHelper::productPayload(['title' => "Product {$title}"]); + $payload['variants'][0]['sku'] = "SKU-{$index}"; + $ids[] = $service->create($store, $payload)->id; + } + + $response = $this->postJson("/api/admin/v1/stores/{$store->id}/collections", [ + 'title' => 'Bundle', 'type' => 'manual', 'product_ids' => $ids, + ], CatalogTestHelper::adminHeaders($user)); + + $response->assertCreated(); + $response->assertJsonPath('data.products_count', 3); + expect(Collection::query()->findOrFail($response->json('data.id'))->products()->count())->toBe(3); +}); + +it('lists collections with product counts and filters', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $headers = CatalogTestHelper::adminHeaders($user); + $service = app(ProductService::class); + + $product = $service->create($store, CatalogTestHelper::productPayload()); + $active = Collection::factory()->create(['store_id' => $store->id, 'title' => 'Active Picks', 'status' => 'active']); + $active->products()->attach($product->id, ['position' => 0]); + Collection::factory()->create(['store_id' => $store->id, 'title' => 'Draft Picks', 'status' => 'draft']); + + $response = $this->getJson("/api/admin/v1/stores/{$store->id}/collections", $headers); + $response->assertOk(); + expect($response->json('data'))->toHaveCount(2); + $response->assertJsonPath('data.0.products_count', 1); + + expect($this->getJson("/api/admin/v1/stores/{$store->id}/collections?status=draft", $headers)->json('data'))->toHaveCount(1); + expect($this->getJson("/api/admin/v1/stores/{$store->id}/collections?query=Active", $headers)->json('data'))->toHaveCount(1); + + $paged = $this->getJson("/api/admin/v1/stores/{$store->id}/collections?per_page=1&page=2", $headers); + $paged->assertOk(); + $paged->assertJsonPath('meta.total', 2); + $paged->assertJsonPath('meta.current_page', 2); +}); + +it('shows a collection with ordered products', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $service = app(ProductService::class); + + $payloadB = CatalogTestHelper::productPayload(['title' => 'Product B']); + $payloadB['variants'][0]['sku'] = 'SKU-B'; + $productA = $service->create($store, CatalogTestHelper::productPayload(['title' => 'Product A'])); + $productB = $service->create($store, $payloadB); + + $collection = Collection::factory()->create(['store_id' => $store->id]); + $collection->products()->attach([$productA->id => ['position' => 1], $productB->id => ['position' => 0]]); + + $response = $this->getJson("/api/admin/v1/stores/{$store->id}/collections/{$collection->id}", CatalogTestHelper::adminHeaders($user)); + + $response->assertOk(); + $response->assertJsonPath('data.products.0.id', $productB->id); + $response->assertJsonPath('data.products.0.position', 0); + $response->assertJsonPath('data.products.1.id', $productA->id); +}); + +it('replaces the product set and reorders positions', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $service = app(ProductService::class); + + $ids = []; + foreach (['A', 'B', 'C'] as $title) { + $payload = CatalogTestHelper::productPayload(['title' => "Product {$title}"]); + $payload['variants'][0]['sku'] = "SKU-{$title}"; + $ids[] = $service->create($store, $payload)->id; + } + + $collection = Collection::factory()->create(['store_id' => $store->id]); + $collection->products()->attach([$ids[0] => ['position' => 0]]); + + $reordered = [$ids[2], $ids[0], $ids[1]]; + $this->putJson("/api/admin/v1/stores/{$store->id}/collections/{$collection->id}", [ + 'product_ids' => $reordered, + ], CatalogTestHelper::adminHeaders($user))->assertOk(); + + $positions = $collection->fresh()->products()->orderByPivot('position')->pluck('products.id')->all(); + expect($positions)->toBe($reordered); +}); + +it('adds and removes products incrementally', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $service = app(ProductService::class); + + $ids = []; + foreach (['A', 'B', 'C'] as $title) { + $payload = CatalogTestHelper::productPayload(['title' => "Product {$title}"]); + $payload['variants'][0]['sku'] = "SKU-{$title}"; + $ids[] = $service->create($store, $payload)->id; + } + + $collection = Collection::factory()->create(['store_id' => $store->id]); + $collection->products()->attach([$ids[0] => ['position' => 0], $ids[1] => ['position' => 1]]); + + $this->putJson("/api/admin/v1/stores/{$store->id}/collections/{$collection->id}", [ + 'add_product_ids' => [$ids[2]], + 'remove_product_ids' => [$ids[0]], + ], CatalogTestHelper::adminHeaders($user))->assertOk(); + + expect($collection->fresh()->products()->pluck('products.id')->sort()->values()->all()) + ->toBe(collect([$ids[1], $ids[2]])->sort()->values()->all()); +}); + +it('updates collection fields and transitions status', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $collection = Collection::factory()->create(['store_id' => $store->id, 'status' => 'draft']); + + $response = $this->putJson("/api/admin/v1/stores/{$store->id}/collections/{$collection->id}", [ + 'title' => 'Renamed', + 'status' => 'active', + ], CatalogTestHelper::adminHeaders($user)); + + $response->assertOk(); + $response->assertJsonPath('data.title', 'Renamed'); + $response->assertJsonPath('data.status', 'active'); +}); + +it('deletes a collection without deleting products', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $product = app(ProductService::class)->create($store, CatalogTestHelper::productPayload()); + + $collection = Collection::factory()->create(['store_id' => $store->id]); + $collection->products()->attach($product->id); + + $this->deleteJson("/api/admin/v1/stores/{$store->id}/collections/{$collection->id}", [], CatalogTestHelper::adminHeaders($user)) + ->assertOk()->assertJsonPath('message', 'Collection deleted'); + + expect(Collection::query()->whereKey($collection->id)->exists())->toBeFalse(); + expect(Product::query()->whereKey($product->id)->exists())->toBeTrue(); +}); + +it('scopes collections to the current store', function () { + ['store' => $storeA, 'user' => $userA] = $this->createStoreContext(); + ['store' => $storeB] = $this->createStoreContext(); + $headersA = CatalogTestHelper::adminHeaders($userA); + + Collection::factory()->count(2)->create(['store_id' => $storeA->id]); + $foreign = Collection::factory()->create(['store_id' => $storeB->id]); + + expect($this->getJson("/api/admin/v1/stores/{$storeA->id}/collections", $headersA)->json('data'))->toHaveCount(2); + $this->getJson("/api/admin/v1/stores/{$storeA->id}/collections/{$foreign->id}", $headersA)->assertNotFound(); + $this->putJson("/api/admin/v1/stores/{$storeA->id}/collections/{$foreign->id}", ['title' => 'Hijack'], $headersA)->assertNotFound(); + $this->deleteJson("/api/admin/v1/stores/{$storeA->id}/collections/{$foreign->id}", [], $headersA)->assertNotFound(); +}); + +it('enforces authentication and abilities on collection endpoints', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + + $this->getJson("/api/admin/v1/stores/{$store->id}/collections")->assertUnauthorized(); + + $this->app['auth']->forgetGuards(); + $readOnly = CatalogTestHelper::adminHeaders($user, ['read-collections']); + $this->getJson("/api/admin/v1/stores/{$store->id}/collections", $readOnly)->assertOk(); + + $this->app['auth']->forgetGuards(); + $this->postJson("/api/admin/v1/stores/{$store->id}/collections", + ['title' => 'Nope', 'type' => 'manual'], $readOnly)->assertForbidden(); + + $stranger = \App\Models\User::factory()->create(); + $this->app['auth']->forgetGuards(); + $this->getJson("/api/admin/v1/stores/{$store->id}/collections", CatalogTestHelper::adminHeaders($stranger))->assertForbidden(); +}); diff --git a/tests/Feature/Products/InventoryTest.php b/tests/Feature/Products/InventoryTest.php new file mode 100644 index 00000000..40184cf6 --- /dev/null +++ b/tests/Feature/Products/InventoryTest.php @@ -0,0 +1,186 @@ + 10, 'quantity_reserved' => 0, 'policy' => 'deny']): array +{ + ['store' => $store, 'user' => $user] = test()->createStoreContext(); + + $payload = CatalogTestHelper::productPayload(); + $payload['variants'][0]['inventory'] = ['quantity_on_hand' => $inventory['quantity_on_hand'], 'policy' => $inventory['policy']]; + $product = app(ProductService::class)->create($store, $payload); + + $item = InventoryItem::query()->where('variant_id', $product->productVariants()->first()->id)->firstOrFail(); + + if (($inventory['quantity_reserved'] ?? 0) > 0) { + $item->update(['quantity_reserved' => $inventory['quantity_reserved']]); + $item->refresh(); + } + + return ['store' => $store, 'user' => $user, 'product' => $product->fresh(), 'item' => $item]; +} + +it('creates an inventory item when a variant is created', function () { + ['store' => $store, 'item' => $item] = createInventoryContext(); + + expect($item->store_id)->toBe($store->id); + expect($item->quantity_on_hand)->toBe(10); + expect($item->quantity_reserved)->toBe(0); + expect($item->policy)->toBe(InventoryPolicy::Deny); +}); + +it('computes available quantity', function () { + ['item' => $item] = createInventoryContext(['quantity_on_hand' => 10, 'quantity_reserved' => 3, 'policy' => 'deny']); + + expect(app(InventoryService::class)->available($item))->toBe(7); + expect(app(InventoryService::class)->checkAvailability($item, 7))->toBeTrue(); + expect(app(InventoryService::class)->checkAvailability($item, 8))->toBeFalse(); +}); + +it('reserves inventory', function () { + ['item' => $item] = createInventoryContext(); + + $reserved = app(InventoryService::class)->reserve($item, 3); + + expect($reserved->quantity_reserved)->toBe(3); + expect(app(InventoryService::class)->available($reserved))->toBe(7); +}); + +it('throws when reserving more than available with deny policy', function () { + ['item' => $item] = createInventoryContext(['quantity_on_hand' => 5, 'quantity_reserved' => 3, 'policy' => 'deny']); + + expect(fn () => app(InventoryService::class)->reserve($item, 3))->toThrow(InsufficientInventoryException::class); + expect($item->fresh()->quantity_reserved)->toBe(3); +}); + +it('allows overselling with continue policy', function () { + ['item' => $item] = createInventoryContext(['quantity_on_hand' => 2, 'quantity_reserved' => 0, 'policy' => 'continue']); + + $reserved = app(InventoryService::class)->reserve($item, 5); + + expect($reserved->quantity_reserved)->toBe(5); + expect(app(InventoryService::class)->available($reserved))->toBe(-3); +}); + +it('releases reserved inventory', function () { + ['item' => $item] = createInventoryContext(['quantity_on_hand' => 10, 'quantity_reserved' => 5, 'policy' => 'deny']); + + $released = app(InventoryService::class)->release($item, 3); + + expect($released->quantity_reserved)->toBe(2); +}); + +it('clamps released inventory at zero', function () { + ['item' => $item] = createInventoryContext(['quantity_on_hand' => 10, 'quantity_reserved' => 2, 'policy' => 'deny']); + + $released = app(InventoryService::class)->release($item, 9); + + expect($released->quantity_reserved)->toBe(0); +}); + +it('commits inventory on order completion', function () { + ['item' => $item] = createInventoryContext(['quantity_on_hand' => 10, 'quantity_reserved' => 3, 'policy' => 'deny']); + + $committed = app(InventoryService::class)->commit($item, 3); + + expect($committed->quantity_on_hand)->toBe(7); + expect($committed->quantity_reserved)->toBe(0); +}); + +it('restocks inventory', function () { + ['item' => $item] = createInventoryContext(['quantity_on_hand' => 5, 'quantity_reserved' => 1, 'policy' => 'deny']); + + $restocked = app(InventoryService::class)->restock($item, 10); + + expect($restocked->quantity_on_hand)->toBe(15); + expect($restocked->quantity_reserved)->toBe(1); +}); + +it('signals low stock', function () { + $service = app(InventoryService::class); + ['item' => $item] = createInventoryContext(['quantity_on_hand' => 6, 'quantity_reserved' => 2, 'policy' => 'deny']); + + expect($service->isLowStock($item))->toBeTrue(); + expect($service->isLowStock($item, 3))->toBeFalse(); +}); + +it('adjusts inventory via the api', function () { + ['store' => $store, 'user' => $user, 'product' => $product] = createInventoryContext(); + $variant = $product->productVariants()->first(); + + $response = $this->putJson( + "/api/admin/v1/stores/{$store->id}/products/{$product->id}/variants/{$variant->id}/inventory", + ['quantity_on_hand' => 25, 'policy' => 'continue'], + CatalogTestHelper::adminHeaders($user) + ); + + $response->assertOk(); + $response->assertJsonPath('data.variant_id', $variant->id); + $response->assertJsonPath('data.quantity_on_hand', 25); + $response->assertJsonPath('data.policy', 'continue'); + $response->assertJsonPath('data.available', 25); + expect($response->json('data'))->toHaveKey('low_stock'); +}); + +it('shows inventory via the api', function () { + ['store' => $store, 'user' => $user, 'product' => $product] = createInventoryContext(); + $variant = $product->productVariants()->first(); + + $response = $this->getJson( + "/api/admin/v1/stores/{$store->id}/products/{$product->id}/variants/{$variant->id}/inventory", + CatalogTestHelper::adminHeaders($user, ['read-products']) + ); + + $response->assertOk(); + $response->assertJsonPath('data.quantity_on_hand', 10); + $response->assertJsonPath('data.available', 10); +}); + +it('validates inventory adjustments', function () { + ['store' => $store, 'user' => $user, 'product' => $product] = createInventoryContext(); + $variant = $product->productVariants()->first(); + $headers = CatalogTestHelper::adminHeaders($user); + + $this->putJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}/variants/{$variant->id}/inventory", + ['quantity_on_hand' => -1], $headers)->assertUnprocessable(); + + $this->putJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}/variants/{$variant->id}/inventory", + ['policy' => 'sometimes'], $headers)->assertUnprocessable(); + + $this->putJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}/variants/{$variant->id}/inventory", + [], $headers)->assertUnprocessable(); +}); + +it('returns 404 for foreign inventory records', function () { + ['store' => $storeA, 'user' => $userA] = $this->createStoreContext(); + ['store' => $storeB] = $this->createStoreContext(); + $headersA = CatalogTestHelper::adminHeaders($userA); + + $productA = app(ProductService::class)->create($storeA, CatalogTestHelper::productPayload()); + $productB = app(ProductService::class)->create($storeB, CatalogTestHelper::productPayload()); + + $variantB = $productB->productVariants()->first(); + + $this->getJson("/api/admin/v1/stores/{$storeA->id}/products/{$productA->id}/variants/{$variantB->id}/inventory", $headersA)->assertNotFound(); + $this->putJson("/api/admin/v1/stores/{$storeA->id}/products/{$productB->id}/variants/{$variantB->id}/inventory", + ['quantity_on_hand' => 5], $headersA)->assertNotFound(); +}); + +it('enforces abilities on inventory endpoints', function () { + ['store' => $store, 'user' => $user, 'product' => $product] = createInventoryContext(); + $variant = $product->productVariants()->first(); + + $this->putJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}/variants/{$variant->id}/inventory", + ['quantity_on_hand' => 5])->assertUnauthorized(); + + $this->app['auth']->forgetGuards(); + $this->putJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}/variants/{$variant->id}/inventory", + ['quantity_on_hand' => 5], CatalogTestHelper::adminHeaders($user, ['read-products']))->assertForbidden(); +}); diff --git a/tests/Feature/Products/MediaUploadTest.php b/tests/Feature/Products/MediaUploadTest.php new file mode 100644 index 00000000..aadab01c --- /dev/null +++ b/tests/Feature/Products/MediaUploadTest.php @@ -0,0 +1,158 @@ + $store, 'user' => $user] = test()->createStoreContext(); + $product = app(ProductService::class)->create($store, CatalogTestHelper::productPayload()); + + return ['store' => $store, 'user' => $user, 'product' => $product]; +} + +it('uploads an image for a product', function () { + ['store' => $store, 'user' => $user, 'product' => $product] = createMediaProduct(); + + $response = $this->postJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}/media", [ + 'file' => UploadedFile::fake()->image('shirt.jpg', 800, 1000), + 'alt_text' => 'Classic T-Shirt in Blue', + ], CatalogTestHelper::adminHeaders($user)); + + $response->assertCreated(); + $response->assertJsonPath('data.type', 'image'); + $response->assertJsonPath('data.alt_text', 'Classic T-Shirt in Blue'); + $response->assertJsonPath('data.status', 'ready'); + $response->assertJsonPath('data.position', 0); + expect($response->json('data.storage_key'))->toStartWith("media/{$product->id}/"); + expect($response->json('data.url'))->toContain('/storage/'); + + $media = ProductMedia::query()->findOrFail($response->json('data.id')); + expect($media->status)->toBe(MediaStatus::Ready); + expect($media->mime_type)->toBe('image/jpeg'); + expect($media->byte_size)->toBeGreaterThan(0); + expect($media->width)->toBe(800); + expect($media->height)->toBe(1000); + + Storage::disk('public')->assertExists($media->storage_key); + Storage::disk('public')->assertExists("media/{$product->id}/{$media->id}/thumbnail.jpg"); + Storage::disk('public')->assertExists("media/{$product->id}/{$media->id}/large.jpg"); +}); + +it('assigns incremental positions to uploaded media', function () { + ['store' => $store, 'user' => $user, 'product' => $product] = createMediaProduct(); + $headers = CatalogTestHelper::adminHeaders($user); + + $first = $this->postJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}/media", [ + 'file' => UploadedFile::fake()->image('one.jpg'), + ], $headers); + $second = $this->postJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}/media", [ + 'file' => UploadedFile::fake()->image('two.jpg'), + ], $headers); + + $first->assertCreated()->assertJsonPath('data.position', 0); + $second->assertCreated()->assertJsonPath('data.position', 1); +}); + +it('rejects non-image file types', function () { + ['store' => $store, 'user' => $user, 'product' => $product] = createMediaProduct(); + + $this->postJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}/media", [ + 'file' => UploadedFile::fake()->create('notes.txt', 10, 'text/plain'), + ], CatalogTestHelper::adminHeaders($user)) + ->assertUnprocessable()->assertJsonValidationErrors(['file']); + + expect(ProductMedia::query()->count())->toBe(0); +}); + +it('requires a file for media upload', function () { + ['store' => $store, 'user' => $user, 'product' => $product] = createMediaProduct(); + + $this->postJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}/media", [ + 'alt_text' => 'Missing file', + ], CatalogTestHelper::adminHeaders($user)) + ->assertUnprocessable()->assertJsonValidationErrors(['file']); +}); + +it('updates media alt text and position', function () { + ['store' => $store, 'user' => $user, 'product' => $product] = createMediaProduct(); + $headers = CatalogTestHelper::adminHeaders($user); + + $created = $this->postJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}/media", [ + 'file' => UploadedFile::fake()->image('shirt.jpg'), + ], $headers); + $created->assertCreated(); + + $response = $this->putJson( + "/api/admin/v1/stores/{$store->id}/products/{$product->id}/media/{$created->json('data.id')}", + ['alt_text' => 'Updated alt', 'position' => 3], + $headers + ); + + $response->assertOk(); + $response->assertJsonPath('data.alt_text', 'Updated alt'); + $response->assertJsonPath('data.position', 3); +}); + +it('deletes media and removes files from storage', function () { + ['store' => $store, 'user' => $user, 'product' => $product] = createMediaProduct(); + $headers = CatalogTestHelper::adminHeaders($user); + + $created = $this->postJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}/media", [ + 'file' => UploadedFile::fake()->image('shirt.jpg'), + ], $headers); + $created->assertCreated(); + + $media = ProductMedia::query()->findOrFail($created->json('data.id')); + Storage::disk('public')->assertExists($media->storage_key); + + $this->deleteJson( + "/api/admin/v1/stores/{$store->id}/products/{$product->id}/media/{$media->id}", + [], $headers + )->assertOk()->assertJsonPath('message', 'Media deleted'); + + expect(ProductMedia::query()->whereKey($media->id)->exists())->toBeFalse(); + Storage::disk('public')->assertMissing($media->storage_key); + expect(Storage::disk('public')->allFiles("media/{$product->id}/{$media->id}"))->toBe([]); +}); + +it('returns 404 for foreign media records', function () { + ['store' => $storeA, 'user' => $userA] = $this->createStoreContext(); + ['store' => $storeB] = $this->createStoreContext(); + $headersA = CatalogTestHelper::adminHeaders($userA); + + $productA = app(ProductService::class)->create($storeA, CatalogTestHelper::productPayload()); + $productB = app(ProductService::class)->create($storeB, CatalogTestHelper::productPayload()); + $mediaB = $productB->productMedia()->create([ + 'type' => 'image', 'storage_key' => 'media/foreign.jpg', 'position' => 0, 'status' => 'ready', + ]); + + $this->postJson("/api/admin/v1/stores/{$storeA->id}/products/{$productB->id}/media", [ + 'file' => UploadedFile::fake()->image('shirt.jpg'), + ], $headersA)->assertNotFound(); + + $this->deleteJson("/api/admin/v1/stores/{$storeA->id}/products/{$productA->id}/media/{$mediaB->id}", [], $headersA)->assertNotFound(); +}); + +it('enforces authentication and abilities on media endpoints', function () { + ['store' => $store, 'user' => $user, 'product' => $product] = createMediaProduct(); + + $this->postJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}/media", [ + 'file' => UploadedFile::fake()->image('shirt.jpg'), + ])->assertUnauthorized(); + + $this->app['auth']->forgetGuards(); + $this->postJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}/media", [ + 'file' => UploadedFile::fake()->image('shirt.jpg'), + ], CatalogTestHelper::adminHeaders($user, ['read-products']))->assertForbidden(); +}); diff --git a/tests/Feature/Products/ProductCrudTest.php b/tests/Feature/Products/ProductCrudTest.php new file mode 100644 index 00000000..9ea41f59 --- /dev/null +++ b/tests/Feature/Products/ProductCrudTest.php @@ -0,0 +1,416 @@ + $store, 'user' => $user] = test()->createStoreContext(); + + $response = test()->postJson( + "/api/admin/v1/stores/{$store->id}/products", + CatalogTestHelper::productPayload($overrides), + CatalogTestHelper::adminHeaders($user) + ); + + $response->assertCreated(); + + return ['store' => $store, 'user' => $user, 'product' => Product::query()->findOrFail($response->json('data.id'))]; +} + +it('lists products for the current store', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + + foreach (['Alpha Shirt', 'Beta Pants', 'Gamma Hat'] as $index => $title) { + $this->postJson("/api/admin/v1/stores/{$store->id}/products", + CatalogTestHelper::productPayload(['title' => $title, 'variants' => [ + [...CatalogTestHelper::productPayload()['variants'][0], 'sku' => "SKU-{$index}"], + ]]), + CatalogTestHelper::adminHeaders($user) + )->assertCreated(); + } + + $response = $this->getJson("/api/admin/v1/stores/{$store->id}/products", CatalogTestHelper::adminHeaders($user)); + + $response->assertOk(); + expect($response->json('data'))->toHaveCount(3); + $response->assertJsonPath('meta.total', 3); + $response->assertJsonStructure(['data' => [[ + 'id', 'store_id', 'title', 'handle', 'status', 'vendor', 'product_type', 'tags', + 'variants_count', 'total_inventory', 'published_at', 'created_at', 'updated_at', 'featured_image', + ]], 'meta' => ['current_page', 'per_page', 'total', 'last_page']]); + $response->assertJsonPath('data.0.variants_count', 1); + $response->assertJsonPath('data.0.total_inventory', 50); +}); + +it('paginates product lists', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + + foreach (range(1, 3) as $index) { + $this->postJson("/api/admin/v1/stores/{$store->id}/products", + CatalogTestHelper::productPayload(['title' => "Product {$index}", 'variants' => [ + [...CatalogTestHelper::productPayload()['variants'][0], 'sku' => "SKU-{$index}"], + ]]), + CatalogTestHelper::adminHeaders($user) + )->assertCreated(); + } + + $response = $this->getJson("/api/admin/v1/stores/{$store->id}/products?per_page=2&page=2", CatalogTestHelper::adminHeaders($user)); + + $response->assertOk(); + expect($response->json('data'))->toHaveCount(1); + $response->assertJsonPath('meta.total', 3); + $response->assertJsonPath('meta.current_page', 2); + $response->assertJsonPath('meta.per_page', 2); + $response->assertJsonPath('meta.last_page', 2); +}); + +it('filters products by status', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + + $this->postJson("/api/admin/v1/stores/{$store->id}/products", + CatalogTestHelper::productPayload(['title' => 'Draft One']), CatalogTestHelper::adminHeaders($user))->assertCreated(); + $this->postJson("/api/admin/v1/stores/{$store->id}/products", + CatalogTestHelper::productPayload(['title' => 'Active One', 'status' => 'active', 'variants' => [ + [...CatalogTestHelper::productPayload()['variants'][0], 'sku' => 'SKU-ACTIVE'], + ]]), CatalogTestHelper::adminHeaders($user))->assertCreated(); + + $response = $this->getJson("/api/admin/v1/stores/{$store->id}/products?status=active", CatalogTestHelper::adminHeaders($user)); + + $response->assertOk(); + expect($response->json('data'))->toHaveCount(1); + $response->assertJsonPath('data.0.title', 'Active One'); +}); + +it('searches products by title vendor and sku', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $headers = CatalogTestHelper::adminHeaders($user); + + $this->postJson("/api/admin/v1/stores/{$store->id}/products", + CatalogTestHelper::productPayload(['title' => 'Organic Cotton Hoodie']), $headers)->assertCreated(); + $this->postJson("/api/admin/v1/stores/{$store->id}/products", + CatalogTestHelper::productPayload(['title' => 'Leather Belt', 'vendor' => 'Belt Co', 'variants' => [ + [...CatalogTestHelper::productPayload()['variants'][0], 'sku' => 'BELT-9'], + ]]), $headers)->assertCreated(); + + expect($this->getJson("/api/admin/v1/stores/{$store->id}/products?query=cotton", $headers)->json('data'))->toHaveCount(1); + expect($this->getJson("/api/admin/v1/stores/{$store->id}/products?query=Belt%20Co", $headers)->json('data'))->toHaveCount(1); + expect($this->getJson("/api/admin/v1/stores/{$store->id}/products?query=BELT-9", $headers)->json('data'))->toHaveCount(1); + expect($this->getJson("/api/admin/v1/stores/{$store->id}/products?query=nonexistent", $headers)->json('data'))->toHaveCount(0); +}); + +it('filters products by collection', function () { + ['store' => $store, 'user' => $user, 'product' => $product] = createCatalogProduct(); + $headers = CatalogTestHelper::adminHeaders($user); + + $collection = Collection::factory()->create(['store_id' => $store->id]); + $collection->products()->attach($product->id, ['position' => 0]); + + $this->postJson("/api/admin/v1/stores/{$store->id}/products", + CatalogTestHelper::productPayload(['title' => 'Unlisted Item', 'variants' => [ + [...CatalogTestHelper::productPayload()['variants'][0], 'sku' => 'SKU-OTHER'], + ]]), $headers)->assertCreated(); + + $response = $this->getJson("/api/admin/v1/stores/{$store->id}/products?collection_id={$collection->id}", $headers); + + $response->assertOk(); + expect($response->json('data'))->toHaveCount(1); + $response->assertJsonPath('data.0.id', $product->id); +}); + +it('sorts product lists', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $headers = CatalogTestHelper::adminHeaders($user); + + foreach (['Zebra', 'Apple'] as $index => $title) { + $this->postJson("/api/admin/v1/stores/{$store->id}/products", + CatalogTestHelper::productPayload(['title' => $title, 'variants' => [ + [...CatalogTestHelper::productPayload()['variants'][0], 'sku' => "SKU-{$index}"], + ]]), $headers)->assertCreated(); + } + + $response = $this->getJson("/api/admin/v1/stores/{$store->id}/products?sort=title_asc", $headers); + + $response->assertOk(); + $response->assertJsonPath('data.0.title', 'Apple'); + $response->assertJsonPath('data.1.title', 'Zebra'); +}); + +it('creates a product with options variants inventory and collections', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $collection = Collection::factory()->create(['store_id' => $store->id]); + + $response = $this->postJson("/api/admin/v1/stores/{$store->id}/products", + CatalogTestHelper::productPayload(['collections' => [$collection->id]]), + CatalogTestHelper::adminHeaders($user)); + + $response->assertCreated(); + $response->assertJsonPath('data.title', 'Classic T-Shirt'); + $response->assertJsonPath('data.handle', 'classic-t-shirt'); + $response->assertJsonPath('data.status', 'draft'); + $response->assertJsonPath('data.options.0.name', 'Size'); + $response->assertJsonPath('data.options.0.values.0.value', 'Small'); + $response->assertJsonPath('data.variants.0.sku', 'TSH-S'); + $response->assertJsonPath('data.variants.0.inventory.quantity_on_hand', 50); + $response->assertJsonPath('data.variants.0.inventory.policy', 'deny'); + $response->assertJsonPath('data.variants.0.option_values.0.value', 'Small'); + $response->assertJsonPath('data.collections.0.id', $collection->id); + + $product = Product::query()->findOrFail($response->json('data.id')); + expect($product->productOptions()->count())->toBe(1) + ->and($product->productVariants()->count())->toBe(1) + ->and(InventoryItem::query()->where('store_id', $store->id)->count())->toBe(1) + ->and($product->collections()->count())->toBe(1); +}); + +it('generates unique handles with suffix on collision', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $headers = CatalogTestHelper::adminHeaders($user); + + $first = $this->postJson("/api/admin/v1/stores/{$store->id}/products", + CatalogTestHelper::productPayload(['title' => 'Summer T-Shirt']), $headers); + $second = $this->postJson("/api/admin/v1/stores/{$store->id}/products", + CatalogTestHelper::productPayload(['title' => 'Summer T-Shirt', 'variants' => [ + [...CatalogTestHelper::productPayload()['variants'][0], 'sku' => 'SKU-2'], + ]]), $headers); + + $first->assertCreated()->assertJsonPath('data.handle', 'summer-t-shirt'); + $second->assertCreated()->assertJsonPath('data.handle', 'summer-t-shirt-1'); +}); + +it('rejects duplicate handles and skus', function () { + ['store' => $store, 'user' => $user] = createCatalogProduct(); + $headers = CatalogTestHelper::adminHeaders($user); + + $this->postJson("/api/admin/v1/stores/{$store->id}/products", + CatalogTestHelper::productPayload(['title' => 'Other', 'handle' => 'classic-t-shirt', 'variants' => [ + [...CatalogTestHelper::productPayload()['variants'][0], 'sku' => 'SKU-NEW'], + ]]), $headers) + ->assertUnprocessable() + ->assertJsonValidationErrors(['handle']); + + $this->postJson("/api/admin/v1/stores/{$store->id}/products", + CatalogTestHelper::productPayload(['title' => 'Other']), $headers) + ->assertUnprocessable() + ->assertJsonValidationErrors(['variants.0.sku']); +}); + +it('validates product creation payloads', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $headers = CatalogTestHelper::adminHeaders($user); + + $this->postJson("/api/admin/v1/stores/{$store->id}/products", [], $headers) + ->assertUnprocessable() + ->assertJsonValidationErrors(['title', 'variants']); + + $this->postJson("/api/admin/v1/stores/{$store->id}/products", + CatalogTestHelper::productPayload(['variants' => [ + [...CatalogTestHelper::productPayload()['variants'][0], 'price_amount' => 2500, 'compare_at_amount' => 1000], + ]]), $headers) + ->assertUnprocessable() + ->assertJsonValidationErrors(['variants.0.compare_at_amount']); + + $this->postJson("/api/admin/v1/stores/{$store->id}/products", + CatalogTestHelper::productPayload(['handle' => 'NOT A HANDLE']), $headers) + ->assertUnprocessable() + ->assertJsonValidationErrors(['handle']); +}); + +it('shows a single product with all relations', function () { + ['store' => $store, 'user' => $user, 'product' => $product] = createCatalogProduct(); + + $response = $this->getJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}", CatalogTestHelper::adminHeaders($user)); + + $response->assertOk(); + $response->assertJsonPath('data.id', $product->id); + $response->assertJsonStructure(['data' => [ + 'id', 'store_id', 'title', 'handle', 'description_html', 'vendor', 'product_type', + 'status', 'tags', 'published_at', 'created_at', 'updated_at', 'options', 'variants', 'media', 'collections', + ]]); + $response->assertJsonPath('data.variants.0.inventory.available', 50); +}); + +it('updates a product partially', function () { + ['store' => $store, 'user' => $user, 'product' => $product] = createCatalogProduct(); + + $response = $this->putJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}", [ + 'title' => 'Classic Cotton T-Shirt', + 'tags' => ['organic', 'cotton', 'bestseller'], + ], CatalogTestHelper::adminHeaders($user)); + + $response->assertOk(); + $response->assertJsonPath('data.title', 'Classic Cotton T-Shirt'); + $response->assertJsonPath('data.tags', ['organic', 'cotton', 'bestseller']); + $response->assertJsonPath('data.handle', 'classic-t-shirt'); +}); + +it('transitions draft to active and sets published at', function () { + ['store' => $store, 'user' => $user, 'product' => $product] = createCatalogProduct(); + + expect($product->published_at)->toBeNull(); + + $response = $this->putJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}", [ + 'status' => 'active', + ], CatalogTestHelper::adminHeaders($user)); + + $response->assertOk(); + $response->assertJsonPath('data.status', 'active'); + expect($response->json('data.published_at'))->not->toBeNull(); + expect($product->fresh()->status)->toBe(ProductStatus::Active); +}); + +it('rejects draft to active without a priced variant', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $headers = CatalogTestHelper::adminHeaders($user); + + $created = $this->postJson("/api/admin/v1/stores/{$store->id}/products", + CatalogTestHelper::productPayload(['variants' => [ + [...CatalogTestHelper::productPayload()['variants'][0], 'price_amount' => 0, 'compare_at_amount' => null], + ]]), $headers); + $created->assertCreated(); + + $this->putJson("/api/admin/v1/stores/{$store->id}/products/{$created->json('data.id')}", [ + 'status' => 'active', + ], $headers)->assertUnprocessable(); + + expect(Product::query()->findOrFail($created->json('data.id'))->status)->toBe(ProductStatus::Draft); +}); + +it('transitions active to archived and republishes', function () { + ['store' => $store, 'user' => $user, 'product' => $product] = createCatalogProduct(['status' => 'active']); + $headers = CatalogTestHelper::adminHeaders($user); + + expect($product->fresh()->status)->toBe(ProductStatus::Active); + + $this->putJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}", ['status' => 'archived'], $headers) + ->assertOk()->assertJsonPath('data.status', 'archived'); + + $this->putJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}", ['status' => 'active'], $headers) + ->assertOk()->assertJsonPath('data.status', 'active'); +}); + +it('prevents reverting to draft when order lines exist', function () { + ['store' => $store, 'user' => $user, 'product' => $product] = createCatalogProduct(['status' => 'active']); + $headers = CatalogTestHelper::adminHeaders($user); + + OrderLine::factory()->create([ + 'product_id' => $product->id, + 'variant_id' => $product->productVariants()->first()->id, + ]); + + $this->putJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}", ['status' => 'draft'], $headers) + ->assertUnprocessable(); + + expect($product->fresh()->status)->toBe(ProductStatus::Active); +}); + +it('archives a product on delete', function () { + ['store' => $store, 'user' => $user, 'product' => $product] = createCatalogProduct(['status' => 'active']); + + $response = $this->deleteJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}", [], CatalogTestHelper::adminHeaders($user)); + + $response->assertOk(); + $response->assertJsonPath('data.id', $product->id); + $response->assertJsonPath('data.status', 'archived'); + expect($product->fresh()->status)->toBe(ProductStatus::Archived); + + $this->deleteJson("/api/admin/v1/stores/{$store->id}/products/{$product->id}", [], CatalogTestHelper::adminHeaders($user)) + ->assertOk()->assertJsonPath('data.status', 'archived'); +}); + +it('hard deletes draft products without order references via the service', function () { + ['store' => $store] = $this->createStoreContext(); + $service = app(ProductService::class); + + $product = $service->create($store, CatalogTestHelper::productPayload()); + $service->delete($product->fresh()); + + expect(Product::query()->whereKey($product->id)->exists())->toBeFalse(); + expect(ProductVariant::query()->where('product_id', $product->id)->exists())->toBeFalse(); +}); + +it('prevents hard deleting products with order references or non-draft status', function () { + ['store' => $store] = $this->createStoreContext(); + $service = app(ProductService::class); + + $ordered = $service->create($store, CatalogTestHelper::productPayload()); + OrderLine::factory()->create([ + 'product_id' => $ordered->id, + 'variant_id' => $ordered->productVariants()->first()->id, + ]); + + expect(fn () => $service->delete($ordered->fresh()))->toThrow(InvalidProductTransitionException::class); + + $active = $service->create($store, CatalogTestHelper::productPayload([ + 'title' => 'Active Item', + 'status' => 'active', + 'variants' => [[...CatalogTestHelper::productPayload()['variants'][0], 'sku' => 'SKU-ACTIVE-2']], + ])); + + expect(fn () => $service->delete($active->fresh()))->toThrow(InvalidProductTransitionException::class); +}); + +it('isolates products by store', function () { + ['store' => $storeA, 'user' => $userA] = $this->createStoreContext(); + ['store' => $storeB] = $this->createStoreContext(); + $headersA = CatalogTestHelper::adminHeaders($userA); + + $other = app(ProductService::class)->create($storeB, CatalogTestHelper::productPayload()); + + $this->getJson("/api/admin/v1/stores/{$storeA->id}/products/{$other->id}", $headersA)->assertNotFound(); + $this->putJson("/api/admin/v1/stores/{$storeA->id}/products/{$other->id}", ['title' => 'Hijack'], $headersA)->assertNotFound(); + $this->deleteJson("/api/admin/v1/stores/{$storeA->id}/products/{$other->id}", [], $headersA)->assertNotFound(); + + $response = $this->getJson("/api/admin/v1/stores/{$storeA->id}/products", $headersA); + $response->assertOk(); + expect($response->json('data'))->toHaveCount(0); +}); + +it('enforces authentication and abilities on product endpoints', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + + $this->getJson("/api/admin/v1/stores/{$store->id}/products")->assertUnauthorized(); + $this->postJson("/api/admin/v1/stores/{$store->id}/products", CatalogTestHelper::productPayload())->assertUnauthorized(); + + $readOnly = CatalogTestHelper::adminHeaders($user, ['read-products']); + $this->getJson("/api/admin/v1/stores/{$store->id}/products", $readOnly)->assertOk(); + $this->app['auth']->forgetGuards(); + $this->postJson("/api/admin/v1/stores/{$store->id}/products", CatalogTestHelper::productPayload(), $readOnly)->assertForbidden(); + + $stranger = \App\Models\User::factory()->create(); + $this->app['auth']->forgetGuards(); + $this->getJson("/api/admin/v1/stores/{$store->id}/products", CatalogTestHelper::adminHeaders($stranger))->assertForbidden(); + + $this->app['auth']->forgetGuards(); + $this->getJson('/api/admin/v1/stores/999999/products', CatalogTestHelper::adminHeaders($user))->assertNotFound(); +}); + +it('allows staff to manage products but blocks support writes', function () { + ['store' => $store] = $this->createStoreContext(); + + $staff = \App\Models\User::factory()->create(); + \App\Models\StoreUser::factory()->create(['store_id' => $store->id, 'user_id' => $staff->id, 'role' => \App\Enums\StoreUserRole::Staff]); + + $this->postJson("/api/admin/v1/stores/{$store->id}/products", + CatalogTestHelper::productPayload(), CatalogTestHelper::adminHeaders($staff))->assertCreated(); + + $support = \App\Models\User::factory()->create(); + \App\Models\StoreUser::factory()->create(['store_id' => $store->id, 'user_id' => $support->id, 'role' => \App\Enums\StoreUserRole::Support]); + + $this->app['auth']->forgetGuards(); + $this->getJson("/api/admin/v1/stores/{$store->id}/products", CatalogTestHelper::adminHeaders($support))->assertOk(); + $this->app['auth']->forgetGuards(); + $this->postJson("/api/admin/v1/stores/{$store->id}/products", + CatalogTestHelper::productPayload(['title' => 'Support Item', 'variants' => [ + [...CatalogTestHelper::productPayload()['variants'][0], 'sku' => 'SKU-SUP'], + ]]), CatalogTestHelper::adminHeaders($support))->assertForbidden(); +}); diff --git a/tests/Feature/Products/VariantMatrixTest.php b/tests/Feature/Products/VariantMatrixTest.php new file mode 100644 index 00000000..6990fa41 --- /dev/null +++ b/tests/Feature/Products/VariantMatrixTest.php @@ -0,0 +1,201 @@ + $store] = $this->createStoreContext(); + + $product = app(ProductService::class)->create($store, [ + 'title' => 'Matrix Tee', + 'options' => [ + ['name' => 'Size', 'position' => 1, 'values' => ['S', 'M', 'L']], + ['name' => 'Color', 'position' => 2, 'values' => ['Red', 'Blue']], + ], + ]); + + expect($product->productVariants()->count())->toBe(6) + ->and(InventoryItem::query()->where('store_id', $store->id)->count())->toBe(6) + ->and($product->productVariants()->where('is_default', true)->count())->toBe(1); + + $combos = $product->productVariants()->with('optionValues')->get() + ->map(fn ($variant) => $variant->optionValues->pluck('value')->sort()->values()->all()) + ->all(); + + expect($combos)->toHaveCount(6); + foreach ($combos as $combo) { + expect($combo)->toHaveCount(2); + } +}); + +it('preserves existing variants when adding an option value', function () { + ['store' => $store] = $this->createStoreContext(); + $service = app(ProductService::class); + + $product = $service->create($store, [ + 'title' => 'Growing Tee', + 'options' => [ + ['name' => 'Size', 'position' => 1, 'values' => ['S', 'M']], + ], + 'variants' => [ + ['sku' => 'G-S', 'price_amount' => 1000, 'is_default' => true, 'option_values' => [['option_name' => 'Size', 'value' => 'S']]], + ['sku' => 'G-M', 'price_amount' => 2000, 'option_values' => [['option_name' => 'Size', 'value' => 'M']]], + ], + ]); + + $service->update($product->fresh(), [ + 'options' => [ + ['name' => 'Size', 'position' => 1, 'values' => ['S', 'M', 'L']], + ], + ]); + + $fresh = $product->fresh(); + expect($fresh->productVariants()->count())->toBe(3); + expect($fresh->productVariants()->where('sku', 'G-S')->first()->price_amount)->toBe(1000); + expect($fresh->productVariants()->where('sku', 'G-M')->first()->price_amount)->toBe(2000); + + $new = $fresh->productVariants()->whereNull('sku')->first(); + expect($new)->not->toBeNull(); + expect($new->optionValues->pluck('value')->all())->toBe(['L']); +}); + +it('archives orphaned variants with order references', function () { + ['store' => $store] = $this->createStoreContext(); + $service = app(ProductService::class); + + $product = $service->create($store, [ + 'title' => 'Ordered Tee', + 'options' => [['name' => 'Size', 'position' => 1, 'values' => ['S', 'M']]], + 'variants' => [ + ['sku' => 'O-S', 'price_amount' => 1000, 'is_default' => true, 'option_values' => [['option_name' => 'Size', 'value' => 'S']]], + ['sku' => 'O-M', 'price_amount' => 1000, 'option_values' => [['option_name' => 'Size', 'value' => 'M']]], + ], + ]); + + $orderedVariant = $product->productVariants()->where('sku', 'O-S')->first(); + OrderLine::factory()->create(['product_id' => $product->id, 'variant_id' => $orderedVariant->id]); + + $service->update($product->fresh(), [ + 'options' => [['name' => 'Size', 'position' => 1, 'values' => ['M']]], + ]); + + $orphan = $orderedVariant->fresh(); + expect($orphan)->not->toBeNull(); + expect($orphan->status)->toBe(VariantStatus::Archived); + expect($product->fresh()->productVariants()->where('sku', 'O-M')->exists())->toBeTrue(); +}); + +it('deletes orphaned variants without order references', function () { + ['store' => $store] = $this->createStoreContext(); + $service = app(ProductService::class); + + $product = $service->create($store, [ + 'title' => 'Shrinking Tee', + 'options' => [['name' => 'Size', 'position' => 1, 'values' => ['S', 'M', 'L']]], + ]); + + expect($product->productVariants()->count())->toBe(3); + + $service->update($product->fresh(), [ + 'options' => [['name' => 'Size', 'position' => 1, 'values' => ['S']]], + ]); + + $fresh = $product->fresh(); + expect($fresh->productVariants()->count())->toBe(1); + expect($fresh->productVariants()->first()->optionValues->pluck('value')->all())->toBe(['S']); +}); + +it('auto-creates a default variant for products without options', function () { + ['store' => $store] = $this->createStoreContext(); + + $product = app(ProductService::class)->create($store, ['title' => 'Simple Mug']); + + $variants = $product->productVariants; + expect($variants)->toHaveCount(1); + expect($variants->first()->is_default)->toBeTrue(); + expect(InventoryItem::query()->where('variant_id', $variants->first()->id)->exists())->toBeTrue(); +}); + +it('validates sku uniqueness within the store', function () { + ['store' => $store] = $this->createStoreContext(); + $service = app(ProductService::class); + + $service->create($store, CatalogTestHelper::productPayload()); + + expect(fn () => $service->create($store, CatalogTestHelper::productPayload(['title' => 'Dup SKU']))) + ->toThrow(ValidationException::class); +}); + +it('allows duplicate skus across different stores', function () { + ['store' => $storeA] = $this->createStoreContext(); + ['store' => $storeB] = $this->createStoreContext(); + $service = app(ProductService::class); + + $service->create($storeA, CatalogTestHelper::productPayload()); + $productB = $service->create($storeB, CatalogTestHelper::productPayload()); + + expect($productB->productVariants()->where('sku', 'TSH-S')->exists())->toBeTrue(); +}); + +it('allows null skus', function () { + ['store' => $store] = $this->createStoreContext(); + + $product = app(ProductService::class)->create($store, [ + 'title' => 'SKU-less Tee', + 'options' => [['name' => 'Size', 'position' => 1, 'values' => ['S', 'M']]], + 'variants' => [ + ['price_amount' => 1000, 'is_default' => true, 'option_values' => [['option_name' => 'Size', 'value' => 'S']]], + ['sku' => '', 'price_amount' => 1000, 'option_values' => [['option_name' => 'Size', 'value' => 'M']]], + ], + ]); + + expect($product->productVariants()->whereNull('sku')->count())->toBe(2); +}); + +it('rejects duplicate skus within the same payload', function () { + ['store' => $store] = $this->createStoreContext(); + + expect(fn () => app(ProductService::class)->create($store, [ + 'title' => 'Dup Payload Tee', + 'options' => [['name' => 'Size', 'position' => 1, 'values' => ['S', 'M']]], + 'variants' => [ + ['sku' => 'SAME', 'price_amount' => 1000, 'option_values' => [['option_name' => 'Size', 'value' => 'S']]], + ['sku' => 'SAME', 'price_amount' => 1000, 'option_values' => [['option_name' => 'Size', 'value' => 'M']]], + ], + ]))->toThrow(ValidationException::class); +}); + +it('rebuilds the matrix directly and keeps a single default', function () { + ['store' => $store] = $this->createStoreContext(); + + $product = Product::factory()->create(['store_id' => $store->id]); + $option = $product->productOptions()->create(['name' => 'Size', 'position' => 1]); + $option->productOptionValues()->create(['value' => 'S', 'position' => 1]); + $option->productOptionValues()->create(['value' => 'M', 'position' => 2]); + + app(VariantMatrixService::class)->rebuildMatrix($product); + + $fresh = $product->fresh(); + expect($fresh->productVariants()->count())->toBe(2); + expect($fresh->productVariants()->where('is_default', true)->count())->toBe(1); +}); + +it('rejects variants referencing undefined options', function () { + ['store' => $store] = $this->createStoreContext(); + + expect(fn () => app(ProductService::class)->create($store, [ + 'title' => 'Bad Option Tee', + 'options' => [['name' => 'Size', 'position' => 1]], + 'variants' => [ + ['sku' => 'BAD-1', 'price_amount' => 1000, 'option_values' => [['option_name' => 'Color', 'value' => 'Red']]], + ], + ]))->toThrow(ValidationException::class); +}); diff --git a/tests/Feature/Schema/SchemaSmokeTest.php b/tests/Feature/Schema/SchemaSmokeTest.php new file mode 100644 index 00000000..db145f1b --- /dev/null +++ b/tests/Feature/Schema/SchemaSmokeTest.php @@ -0,0 +1,201 @@ +toBeTrue(); + } +}); + +it('has the spec columns on the fortify users table', function () { + expect(Schema::hasColumn('users', 'status'))->toBeTrue() + ->and(Schema::hasColumn('users', 'last_login_at'))->toBeTrue() + ->and(Schema::hasColumn('users', 'password'))->toBeTrue() + ->and(Schema::hasColumn('users', 'two_factor_secret'))->toBeTrue(); +}); + +it('creates a record from every factory', function (string $modelClass) { + $model = $modelClass::factory()->create(); + + expect($model->exists)->toBeTrue(); +})->with([ + 'organization' => Organization::class, + 'store' => Store::class, + 'store domain' => StoreDomain::class, + 'user' => User::class, + 'store user' => StoreUser::class, + 'store setting' => StoreSetting::class, + 'product' => Product::class, + 'product option' => ProductOption::class, + 'product option value' => ProductOptionValue::class, + 'product variant' => ProductVariant::class, + 'variant option value' => VariantOptionValue::class, + 'inventory item' => InventoryItem::class, + 'collection' => Collection::class, + 'collection product' => CollectionProduct::class, + 'product media' => ProductMedia::class, + 'theme' => Theme::class, + 'theme file' => ThemeFile::class, + 'theme setting' => ThemeSetting::class, + 'page' => Page::class, + 'navigation menu' => NavigationMenu::class, + 'navigation item' => NavigationItem::class, + 'search setting' => SearchSetting::class, + 'search query' => SearchQuery::class, + 'cart' => Cart::class, + 'cart line' => CartLine::class, + 'checkout' => Checkout::class, + 'shipping zone' => ShippingZone::class, + 'shipping rate' => ShippingRate::class, + 'tax setting' => TaxSetting::class, + 'discount' => Discount::class, + 'customer' => Customer::class, + 'customer address' => CustomerAddress::class, + 'order' => Order::class, + 'order line' => OrderLine::class, + 'payment' => Payment::class, + 'refund' => Refund::class, + 'fulfillment' => Fulfillment::class, + 'fulfillment line' => FulfillmentLine::class, + 'analytics event' => AnalyticsEvent::class, + 'analytics daily' => AnalyticsDaily::class, + 'app' => App::class, + 'app installation' => AppInstallation::class, + 'oauth client' => OauthClient::class, + 'oauth token' => OauthToken::class, + 'webhook subscription' => WebhookSubscription::class, + 'webhook delivery' => WebhookDelivery::class, + 'customer password reset token' => CustomerPasswordResetToken::class, +]); + +it('casts enum attributes to enum instances', function () { + expect(Store::factory()->create()->status)->toBeInstanceOf(StoreStatus::class) + ->and(User::factory()->create()->status)->toBeInstanceOf(UserStatus::class) + ->and(StoreUser::factory()->create()->role)->toBeInstanceOf(StoreUserRole::class) + ->and(Product::factory()->create()->status)->toBeInstanceOf(ProductStatus::class) + ->and(ProductMedia::factory()->create()->type)->toBeInstanceOf(MediaType::class) + ->and(ProductMedia::factory()->create()->status)->toBeInstanceOf(MediaStatus::class) + ->and(Checkout::factory()->create()->status)->toBeInstanceOf(CheckoutStatus::class) + ->and(Checkout::factory()->create()->payment_method)->toBeInstanceOf(PaymentMethod::class) + ->and(Discount::factory()->create()->type)->toBeInstanceOf(DiscountType::class) + ->and(Discount::factory()->create()->value_type)->toBeInstanceOf(DiscountValueType::class) + ->and(Order::factory()->create()->status)->toBeInstanceOf(OrderStatus::class) + ->and(Order::factory()->create()->financial_status)->toBeInstanceOf(FinancialStatus::class) + ->and(Order::factory()->create()->fulfillment_status)->toBeInstanceOf(FulfillmentStatus::class) + ->and(Payment::factory()->create()->method)->toBeInstanceOf(PaymentMethod::class) + ->and(Payment::factory()->create()->status)->toBeInstanceOf(PaymentStatus::class) + ->and(Fulfillment::factory()->create()->status)->toBeInstanceOf(FulfillmentShipmentStatus::class); +}); + +it('casts json, boolean, hashed and encrypted attributes', function () { + $product = Product::factory()->create(); + + expect($product->tags)->toBeArray(); + + $variant = ProductVariant::factory()->create(); + + expect($variant->requires_shipping)->toBeBool() + ->and($variant->is_default)->toBeBool(); + + $customer = Customer::factory()->create(); + + expect(Hash::check('password', $customer->password_hash))->toBeTrue(); + + $payment = Payment::factory()->create(); + $stored = Illuminate\Support\Facades\DB::table('payments')->where('id', $payment->id)->value('raw_json_encrypted'); + + expect($stored)->not->toContain('succeeded') + ->and($payment->raw_json_encrypted)->toContain('succeeded'); +}); + +it('resolves store membership roles', function () { + $user = User::factory()->create(); + $store = Store::factory()->create(); + StoreUser::factory()->for($store, 'store')->for($user, 'user')->create(['role' => StoreUserRole::Owner]); + + expect($user->roleForStore($store))->toBe(StoreUserRole::Owner) + ->and($user->stores)->toHaveCount(1) + ->and($store->users)->toHaveCount(1); +}); diff --git a/tests/Feature/Search/SearchTest.php b/tests/Feature/Search/SearchTest.php new file mode 100644 index 00000000..358d7b00 --- /dev/null +++ b/tests/Feature/Search/SearchTest.php @@ -0,0 +1,295 @@ +create(app(\App\Tenancy\CurrentStore::class)->get(), $payload); +} + +beforeEach(function () { + ['store' => $store, 'domain' => $domain, 'user' => $user] = $this->createStoreContext(); + $this->searchStore = $store; + $this->searchDomain = $domain; + $this->searchUser = $user; +}); + +function storefrontSearchUrl(string $hostname, string $path): string +{ + return "http://{$hostname}{$path}"; +} + +it('returns products matching the search query', function () { + createSearchProduct(['title' => 'Blue Cotton T-Shirt', 'tags' => ['cotton']], ['sku' => 'COT-1']); + createSearchProduct(['title' => 'Red Wool Sweater', 'tags' => ['wool'], 'description_html' => '

A warm wool sweater.

'], ['sku' => 'WOL-1']); + + $response = $this->getJson(storefrontSearchUrl($this->searchDomain->hostname, '/api/storefront/v1/search?q=cotton')); + + $response->assertOk(); + $response->assertJsonPath('query', 'cotton'); + $titles = collect($response->json('results'))->pluck('title')->all(); + expect($titles)->toContain('Blue Cotton T-Shirt'); + expect($titles)->not->toContain('Red Wool Sweater'); + $response->assertJsonStructure(['query', 'results' => [[ + 'id', 'title', 'handle', 'vendor', 'product_type', 'price_amount', 'compare_at_amount', + 'currency', 'image_url', 'in_stock', 'tags', + ]], 'facets' => ['vendors', 'tags', 'price_range'], 'pagination' => [ + 'current_page', 'per_page', 'total', 'last_page', + ]]); +}); + +it('ranks multi-token matches higher', function () { + createSearchProduct(['title' => 'Cotton Shirt', 'description_html' => '

Crisp and cool.

', 'tags' => []], ['sku' => 'CS-1']); + createSearchProduct(['title' => 'Cotton Pants', 'description_html' => '

Relaxed fit.

', 'tags' => []], ['sku' => 'CP-1']); + + $response = $this->getJson(storefrontSearchUrl($this->searchDomain->hostname, '/api/storefront/v1/search?q=cotton%20shirt')); + + $response->assertOk(); + expect($response->json('pagination.total'))->toBe(1); + $response->assertJsonPath('results.0.title', 'Cotton Shirt'); +}); + +it('matches description vendor and tags', function () { + createSearchProduct(['title' => 'Plain Mug', 'description_html' => '

Handmade ceramic mug.

', 'vendor' => 'Kiln Works', 'tags' => ['ceramic']], ['sku' => 'MUG-1']); + + $hostname = $this->searchDomain->hostname; + expect($this->getJson(storefrontSearchUrl($hostname, '/api/storefront/v1/search?q=ceramic'))->json('pagination.total'))->toBe(1); + expect($this->getJson(storefrontSearchUrl($hostname, '/api/storefront/v1/search?q=kiln'))->json('pagination.total'))->toBe(1); + expect($this->getJson(storefrontSearchUrl($hostname, '/api/storefront/v1/search?q=mug'))->json('pagination.total'))->toBe(1); +}); + +it('scopes search to the current store', function () { + createSearchProduct(['title' => 'T-Shirt'], ['sku' => 'TS-A']); + + ['store' => $storeB, 'domain' => $domainB] = $this->createStoreContext(); + app(\App\Tenancy\CurrentStore::class)->set($storeB); + app(ProductService::class)->create($storeB, [...CatalogTestHelper::productPayload(['title' => 'T-Shirt Deluxe', 'status' => 'active'])]); + + $responseA = $this->getJson(storefrontSearchUrl($this->searchDomain->hostname, '/api/storefront/v1/search?q=t-shirt')); + $responseB = $this->getJson(storefrontSearchUrl($domainB->hostname, '/api/storefront/v1/search?q=t-shirt')); + + expect(collect($responseA->json('results'))->pluck('title')->all())->toBe(['T-Shirt']); + expect(collect($responseB->json('results'))->pluck('title')->all())->toBe(['T-Shirt Deluxe']); +}); + +it('returns empty results for no matches', function () { + createSearchProduct(['title' => 'Blue Cotton T-Shirt'], ['sku' => 'COT-1']); + + $response = $this->getJson(storefrontSearchUrl($this->searchDomain->hostname, '/api/storefront/v1/search?q=xyznonexistent')); + + $response->assertOk(); + expect($response->json('results'))->toBe([]); + $response->assertJsonPath('pagination.total', 0); + $response->assertJsonPath('facets.price_range', ['min' => 0, 'max' => 0]); +}); + +it('excludes inactive and unpublished products', function () { + createSearchProduct(['title' => 'Hidden Draft', 'status' => 'draft'], ['sku' => 'H-1']); + $archived = createSearchProduct(['title' => 'Hidden Archived'], ['sku' => 'H-2']); + app(ProductService::class)->transitionStatus($archived->fresh(), \App\Enums\ProductStatus::Archived); + + $response = $this->getJson(storefrontSearchUrl($this->searchDomain->hostname, '/api/storefront/v1/search?q=hidden')); + + $response->assertOk(); + $response->assertJsonPath('pagination.total', 0); +}); + +it('logs search queries for analytics', function () { + createSearchProduct(['title' => 'Blue Cotton T-Shirt'], ['sku' => 'COT-1']); + + $filters = urlencode(json_encode(['vendor' => 'Acme Apparel'])); + $this->getJson(storefrontSearchUrl($this->searchDomain->hostname, "/api/storefront/v1/search?q=cotton&filters={$filters}"))->assertOk(); + + $logged = SearchQuery::query()->where('store_id', $this->searchStore->id)->firstOrFail(); + expect($logged->query)->toBe('cotton'); + expect($logged->results_count)->toBe(1); + expect($logged->filters_json)->toBe(['vendor' => 'Acme Apparel']); +}); + +it('paginates search results', function () { + foreach (range(1, 26) as $index) { + createSearchProduct(['title' => "Bulk Item {$index}"], ['sku' => "BULK-{$index}"]); + } + + $hostname = $this->searchDomain->hostname; + $page1 = $this->getJson(storefrontSearchUrl($hostname, '/api/storefront/v1/search?q=bulk&per_page=12&page=1')); + $page2 = $this->getJson(storefrontSearchUrl($hostname, '/api/storefront/v1/search?q=bulk&per_page=12&page=2')); + $page3 = $this->getJson(storefrontSearchUrl($hostname, '/api/storefront/v1/search?q=bulk&per_page=12&page=3')); + + expect($page1->json('results'))->toHaveCount(12); + expect($page2->json('results'))->toHaveCount(12); + expect($page3->json('results'))->toHaveCount(2); + $page1->assertJsonPath('pagination.total', 26); + $page1->assertJsonPath('pagination.last_page', 3); +}); + +it('filters search results', function () { + $collection = Collection::factory()->create(['store_id' => $this->searchStore->id]); + + $cheap = createSearchProduct(['title' => 'Cheap Cotton Tee', 'tags' => ['sale'], 'collections' => [$collection->id]], ['sku' => 'F-1', 'price_amount' => 1000, 'compare_at_amount' => null]); + createSearchProduct(['title' => 'Expensive Cotton Tee', 'tags' => ['luxury']], ['sku' => 'F-2', 'price_amount' => 9000, 'compare_at_amount' => null]); + createSearchProduct(['title' => 'Sold Out Cotton Tee'], ['sku' => 'F-3', 'price_amount' => 2000, 'compare_at_amount' => null, 'inventory' => ['quantity_on_hand' => 0, 'policy' => 'deny']]); + createSearchProduct(['title' => 'Backorder Cotton Tee', 'vendor' => 'Other Vendor'], ['sku' => 'F-4', 'price_amount' => 2000, 'compare_at_amount' => null, 'inventory' => ['quantity_on_hand' => 0, 'policy' => 'continue']]); + + $hostname = $this->searchDomain->hostname; + $search = fn (array $filters): array => $this->getJson( + storefrontSearchUrl($hostname, '/api/storefront/v1/search?q=cotton&filters='.urlencode(json_encode($filters))) + )->json('results'); + + expect(collect($search(['collection_id' => $collection->id]))->pluck('id')->all())->toBe([$cheap->id]); + expect(collect($search(['price_min' => 5000]))->pluck('title')->all())->toBe(['Expensive Cotton Tee']); + expect(collect($search(['price_max' => 1500]))->pluck('title')->all())->toBe(['Cheap Cotton Tee']); + + $inStock = collect($search(['in_stock' => true]))->pluck('title')->all(); + expect($inStock)->toContain('Cheap Cotton Tee', 'Backorder Cotton Tee'); + expect($inStock)->not->toContain('Sold Out Cotton Tee'); + + expect(collect($search(['tags' => ['luxury']]))->pluck('title')->all())->toBe(['Expensive Cotton Tee']); + expect(collect($search(['vendor' => 'other vendor']))->pluck('title')->all())->toBe(['Backorder Cotton Tee']); +}); + +it('sorts search results', function () { + $first = createSearchProduct(['title' => 'Sortable Alpha'], ['sku' => 'S-1', 'price_amount' => 3000, 'compare_at_amount' => null]); + $second = createSearchProduct(['title' => 'Sortable Beta'], ['sku' => 'S-2', 'price_amount' => 1000, 'compare_at_amount' => null]); + $third = createSearchProduct(['title' => 'Sortable Gamma'], ['sku' => 'S-3', 'price_amount' => 2000, 'compare_at_amount' => null]); + + Product::query()->whereKey($first->id)->update(['created_at' => now()->subDays(3)]); + Product::query()->whereKey($second->id)->update(['created_at' => now()->subDays(2)]); + Product::query()->whereKey($third->id)->update(['created_at' => now()->subDay()]); + + OrderLine::factory()->create(['product_id' => $second->id, 'variant_id' => $second->productVariants()->first()->id, 'quantity' => 10]); + OrderLine::factory()->create(['product_id' => $third->id, 'variant_id' => $third->productVariants()->first()->id, 'quantity' => 3]); + + $hostname = $this->searchDomain->hostname; + $titles = fn (string $sort): array => collect($this->getJson( + storefrontSearchUrl($hostname, "/api/storefront/v1/search?q=sortable&sort={$sort}") + )->json('results'))->pluck('title')->all(); + + expect($titles('price_asc'))->toBe(['Sortable Beta', 'Sortable Gamma', 'Sortable Alpha']); + expect($titles('price_desc'))->toBe(['Sortable Alpha', 'Sortable Gamma', 'Sortable Beta']); + expect($titles('newest'))->toBe(['Sortable Gamma', 'Sortable Beta', 'Sortable Alpha']); + expect($titles('best_selling'))->toBe(['Sortable Beta', 'Sortable Gamma', 'Sortable Alpha']); +}); + +it('returns search facets', function () { + createSearchProduct(['title' => 'Faceted Tee', 'tags' => ['cotton', 'sale']], ['sku' => 'FA-1', 'price_amount' => 1500, 'compare_at_amount' => null]); + createSearchProduct(['title' => 'Faceted Hat', 'tags' => ['cotton']], ['sku' => 'FA-2', 'price_amount' => 4500, 'compare_at_amount' => null]); + + $response = $this->getJson(storefrontSearchUrl($this->searchDomain->hostname, '/api/storefront/v1/search?q=faceted')); + + $response->assertOk(); + $response->assertJsonPath('facets.vendors', [['value' => 'Acme Apparel', 'count' => 2]]); + $response->assertJsonPath('facets.price_range', ['min' => 1500, 'max' => 4500]); + + $tags = collect($response->json('facets.tags'))->pluck('count', 'value')->all(); + expect($tags)->toBe(['cotton' => 2, 'sale' => 1]); +}); + +it('validates search requests', function () { + $hostname = $this->searchDomain->hostname; + + $this->getJson(storefrontSearchUrl($hostname, '/api/storefront/v1/search'))->assertUnprocessable(); + $this->getJson(storefrontSearchUrl($hostname, '/api/storefront/v1/search?q='.str_repeat('a', 201)))->assertUnprocessable(); + $this->getJson(storefrontSearchUrl($hostname, '/api/storefront/v1/search?q=tee&sort=bogus'))->assertUnprocessable(); + $this->getJson(storefrontSearchUrl($hostname, '/api/storefront/v1/search?q=tee&filters=not-json'))->assertUnprocessable(); + $this->getJson(storefrontSearchUrl($hostname, '/api/storefront/v1/search?q=tee&per_page=99'))->assertUnprocessable(); +}); + +it('returns 404 for unknown storefront hostnames', function () { + $this->getJson(storefrontSearchUrl('nonexistent.test', '/api/storefront/v1/search?q=tee'))->assertNotFound(); +}); + +it('suggests products and collections by prefix', function () { + createSearchProduct(['title' => 'Summer Dress'], ['sku' => 'SU-1']); + createSearchProduct(['title' => 'Summer Hat'], ['sku' => 'SU-2']); + createSearchProduct(['title' => 'Winter Coat'], ['sku' => 'WI-1']); + Collection::factory()->create(['store_id' => $this->searchStore->id, 'title' => 'Summer Sale']); + + $response = $this->getJson(storefrontSearchUrl($this->searchDomain->hostname, '/api/storefront/v1/search/suggest?q=sum&limit=10')); + + $response->assertOk(); + $response->assertJsonPath('query', 'sum'); + + $suggestions = $response->json('suggestions'); + $titles = collect($suggestions)->pluck('title')->all(); + expect($titles)->toContain('Summer Dress', 'Summer Hat', 'Summer Sale'); + expect($titles)->not->toContain('Winter Coat'); + + $product = collect($suggestions)->firstWhere('type', 'product'); + expect($product)->toHaveKeys(['type', 'title', 'handle', 'image_url', 'price_amount', 'currency']); + + $collection = collect($suggestions)->firstWhere('type', 'collection'); + expect($collection)->toHaveKeys(['type', 'title', 'handle', 'image_url']); +}); + +it('limits and scopes suggestions', function () { + foreach (range(1, 8) as $index) { + createSearchProduct(['title' => "Sunglasses Model {$index}"], ['sku' => "SG-{$index}"]); + } + + ['domain' => $domainB] = $this->createStoreContext(); + + $response = $this->getJson(storefrontSearchUrl($this->searchDomain->hostname, '/api/storefront/v1/search/suggest?q=sun&limit=5')); + expect($response->json('suggestions'))->toHaveCount(5); + + $responseB = $this->getJson(storefrontSearchUrl($domainB->hostname, '/api/storefront/v1/search/suggest?q=sun')); + expect($responseB->json('suggestions'))->toBe([]); +}); + +it('validates suggest requests', function () { + $hostname = $this->searchDomain->hostname; + + $this->getJson(storefrontSearchUrl($hostname, '/api/storefront/v1/search/suggest'))->assertUnprocessable(); + $this->getJson(storefrontSearchUrl($hostname, '/api/storefront/v1/search/suggest?q='.str_repeat('a', 101)))->assertUnprocessable(); + $this->getJson(storefrontSearchUrl($hostname, '/api/storefront/v1/search/suggest?q=sun&limit=99'))->assertUnprocessable(); +}); + +it('reindexes the search index via the admin api', function () { + $product = Product::factory()->create(['store_id' => $this->searchStore->id, 'title' => 'Unindexed Widget', 'status' => 'active', 'published_at' => now()]); + + $hostname = $this->searchDomain->hostname; + expect($this->getJson(storefrontSearchUrl($hostname, '/api/storefront/v1/search?q=unindexed'))->json('pagination.total'))->toBe(0); + + $reindex = $this->postJson("/api/admin/v1/stores/{$this->searchStore->id}/search/reindex", [], + CatalogTestHelper::adminHeaders($this->searchUser)); + $reindex->assertAccepted(); + $reindex->assertJsonPath('message', 'Reindex job queued.'); + $reindex->assertJsonPath('status', 'queued'); + + expect($this->getJson(storefrontSearchUrl($hostname, '/api/storefront/v1/search?q=unindexed'))->json('pagination.total'))->toBe(1); + + $status = $this->getJson("/api/admin/v1/stores/{$this->searchStore->id}/search/status", + CatalogTestHelper::adminHeaders($this->searchUser)); + $status->assertOk(); + $status->assertJsonPath('data.store_id', $this->searchStore->id); + $status->assertJsonPath('data.index_status', 'ready'); + expect($status->json('data.documents_count'))->toBeGreaterThanOrEqual(1); + expect($status->json('data.last_reindex_at'))->not->toBeNull(); +}); + +it('restricts search admin endpoints to owners and admins', function () { + $staff = \App\Models\User::factory()->create(); + \App\Models\StoreUser::factory()->create(['store_id' => $this->searchStore->id, 'user_id' => $staff->id, 'role' => \App\Enums\StoreUserRole::Staff]); + + $this->postJson("/api/admin/v1/stores/{$this->searchStore->id}/search/reindex", [], + CatalogTestHelper::adminHeaders($staff))->assertForbidden(); + + $this->app['auth']->forgetGuards(); + $this->getJson("/api/admin/v1/stores/{$this->searchStore->id}/search/status", + CatalogTestHelper::adminHeaders($staff))->assertForbidden(); + + $this->app['auth']->forgetGuards(); + $this->postJson("/api/admin/v1/stores/{$this->searchStore->id}/search/reindex", [], + CatalogTestHelper::adminHeaders($this->searchUser, ['read-settings']))->assertForbidden(); +}); diff --git a/tests/Feature/Seeders/SeederTest.php b/tests/Feature/Seeders/SeederTest.php new file mode 100644 index 00000000..186db7f3 --- /dev/null +++ b/tests/Feature/Seeders/SeederTest.php @@ -0,0 +1,213 @@ +seed(); +}); + +it('seeds the organization, stores, domains, users, and roles', function () { + expect(Organization::count())->toBe(1) + ->and(Store::count())->toBe(2) + ->and(StoreDomain::count())->toBe(3) + ->and(User::count())->toBe(5) + ->and(StoreUser::count())->toBe(5); + + $fashion = Store::where('handle', 'acme-fashion')->firstOrFail(); + $admin = User::where('email', 'admin@acme.test')->firstOrFail(); + + expect($admin->roleForStore($fashion)->value)->toBe('owner'); + expect(StoreDomain::where('hostname', 'admin.acme-fashion.test')->firstOrFail()->type->value)->toBe('admin'); +}); + +it('seeds store settings, tax settings, and shipping zones', function () { + expect(StoreSetting::count())->toBe(2) + ->and(TaxSetting::count())->toBe(2) + ->and(ShippingZone::count())->toBe(4) + ->and(ShippingRate::count())->toBe(5); + + $fashion = Store::where('handle', 'acme-fashion')->firstOrFail(); + + expect($fashion->taxSetting->config_json['default_rate_bps'])->toBe(1900) + ->and($fashion->storeSetting->settings_json['order_number_start'])->toBe(1001); +}); + +it('seeds the exact product catalog with variants and inventory', function () { + $fashion = Store::where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::where('handle', 'acme-electronics')->firstOrFail(); + + expect(Product::count())->toBe(25) + ->and(Product::where('store_id', $fashion->id)->count())->toBe(20) + ->and(Product::where('store_id', $electronics->id)->count())->toBe(5) + ->and(ProductVariant::count())->toBe(127) + ->and(InventoryItem::count())->toBe(127) + ->and(ProductMedia::count())->toBe(25); + + $tee = Product::where('store_id', $fashion->id)->where('handle', 'classic-cotton-t-shirt')->firstOrFail(); + + expect($tee->productVariants()->count())->toBe(12) + ->and($tee->productVariants()->where('is_default', true)->count())->toBe(1); + + expect(Product::where('handle', 'unreleased-winter-jacket')->firstOrFail()->status)->toBe(ProductStatus::Draft) + ->and(Product::where('handle', 'discontinued-raincoat')->firstOrFail()->status)->toBe(ProductStatus::Archived); + + $soldOut = Product::where('handle', 'limited-edition-sneakers')->firstOrFail(); + + expect($soldOut->productVariants()->first()->inventoryItem->quantity_on_hand)->toBe(0) + ->and($soldOut->productVariants()->first()->inventoryItem->policy->value)->toBe('deny'); + + $backorder = Product::where('handle', 'backorder-denim-jacket')->firstOrFail(); + + expect($backorder->productVariants()->first()->inventoryItem->policy->value)->toBe('continue'); + + $gift = Product::where('handle', 'gift-card')->firstOrFail(); + + expect($gift->productVariants()->where('requires_shipping', false)->count())->toBe(3); +}); + +it('seeds collections with ordered product assignments', function () { + expect(Collection::count())->toBe(6) + ->and(CollectionProduct::count())->toBe(23); + + $newArrivals = Collection::where('handle', 'new-arrivals')->firstOrFail(); + $handles = $newArrivals->products()->orderByPivot('position')->pluck('handle')->all(); + + expect($handles)->toBe([ + 'classic-cotton-t-shirt', + 'premium-slim-fit-jeans', + 'organic-hoodie', + 'running-sneakers', + 'chino-shorts', + 'bucket-hat', + 'cashmere-overcoat', + ]); +}); + +it('seeds discounts with behavioral states', function () { + expect(Discount::count())->toBe(5); + + $welcome = Discount::where('code', 'WELCOME10')->firstOrFail(); + + expect($welcome->value_amount)->toBe(10) + ->and($welcome->rules_json['min_purchase_amount'])->toBe(2000); + + expect(Discount::where('code', 'EXPIRED20')->firstOrFail()->status->value)->toBe('expired'); + + $maxed = Discount::where('code', 'MAXED')->firstOrFail(); + + expect($maxed->usage_limit)->toBe(5) + ->and($maxed->usage_count)->toBe(5); +}); + +it('seeds customers with addresses', function () { + expect(Customer::count())->toBe(12) + ->and(CustomerAddress::count())->toBe(13); + + $customer = Customer::where('email', 'customer@acme.test')->firstOrFail(); + + expect($customer->customerAddresses()->count())->toBe(2) + ->and($customer->customerAddresses()->where('is_default', true)->count())->toBe(1); +}); + +it('seeds orders with lines, payments, fulfillments, and refunds', function () { + expect(Order::count())->toBe(18) + ->and(OrderLine::count())->toBe(26) + ->and(Payment::count())->toBe(18) + ->and(Fulfillment::count())->toBe(7) + ->and(FulfillmentLine::count())->toBe(11) + ->and(Refund::count())->toBe(2); + + $pending = Order::where('order_number', '#1005')->firstOrFail(); + + expect($pending->status)->toBe(OrderStatus::Pending) + ->and($pending->payment_method)->toBe(PaymentMethod::BankTransfer); + + $digital = Order::where('order_number', '#1014')->firstOrFail(); + + expect($digital->shipping_amount)->toBe(0); + + $discounted = Order::where('order_number', '#1015')->firstOrFail(); + + expect($discounted->discount_amount)->toBe(550) + ->and($discounted->orderLines()->first()->discount_allocations_json[0]['amount'])->toBe(250); +}); + +it('seeds themes, pages, and navigation', function () { + expect(Theme::count())->toBe(2) + ->and(Page::count())->toBe(5) + ->and(NavigationMenu::count())->toBe(3) + ->and(NavigationItem::count())->toBe(13); + + $fashion = Store::where('handle', 'acme-fashion')->firstOrFail(); + $theme = Theme::where('store_id', $fashion->id)->firstOrFail(); + + expect($theme->themeSetting->settings_json['hero_heading'])->toBe('Welcome to Acme Fashion'); + + $mainMenu = NavigationMenu::where('store_id', $fashion->id)->where('handle', 'main-menu')->firstOrFail(); + + expect($mainMenu->navigationItems()->orderBy('position')->pluck('label')->all())->toBe([ + 'Home', 'New Arrivals', 'T-Shirts', 'Pants & Jeans', 'Sale', + ]); +}); + +it('seeds analytics aggregates, events, and search settings', function () { + expect(AnalyticsDaily::count())->toBe(31) + ->and(AnalyticsEvent::count())->toBe(220) + ->and(SearchSetting::count())->toBe(2); + + $fashion = Store::where('handle', 'acme-fashion')->firstOrFail(); + + expect($fashion->searchSetting->synonyms_json)->toBe([ + ['tee', 't-shirt', 'tshirt'], + ['pants', 'trousers', 'jeans'], + ['sneakers', 'trainers', 'shoes'], + ['hoodie', 'sweatshirt'], + ]); +}); + +it('is idempotent when run twice', function () { + $this->seed(); + + expect(Organization::count())->toBe(1) + ->and(Store::count())->toBe(2) + ->and(Product::count())->toBe(25) + ->and(ProductVariant::count())->toBe(127) + ->and(ProductMedia::count())->toBe(25) + ->and(Order::count())->toBe(18) + ->and(AnalyticsDaily::count())->toBe(31); +}); diff --git a/tests/Feature/Storefront/CartPageTest.php b/tests/Feature/Storefront/CartPageTest.php new file mode 100644 index 00000000..b7d78d52 --- /dev/null +++ b/tests/Feature/Storefront/CartPageTest.php @@ -0,0 +1,131 @@ + $store, 'domain' => $domain] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + + $response = $this->withSession(['cart_id' => $cart->id])->get(storefrontUrl($domain->hostname, '/cart')); + + $response->assertOk(); + $response->assertSee('Your Cart'); + $response->assertSee($cart->cartLines()->first()->variant->product->title); +}); + +it('renders an empty cart state', function () { + ['domain' => $domain] = $this->createStoreContext(); + + $response = $this->get(storefrontUrl($domain->hostname, '/cart')); + + $response->assertOk(); + $response->assertSee('Your cart is empty'); +}); + +it('updates quantities and removes lines', function () { + ['store' => $store] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store, [['price' => 2500, 'qty' => 1]]); + session()->put('cart_id', $cart->id); + $lineId = $cart->cartLines()->first()->id; + + Livewire::test(Show::class) + ->call('incrementLine', $lineId) + ->assertDispatched('cart-updated'); + + expect(CartLine::query()->find($lineId)->quantity)->toBe(2); + + Livewire::test(Show::class) + ->call('decrementLine', $lineId); + + expect(CartLine::query()->find($lineId)->quantity)->toBe(1); + + Livewire::test(Show::class) + ->call('removeLine', $lineId) + ->assertSet('cartNotice', 'Item removed'); + + expect(CartLine::query()->find($lineId))->toBeNull(); +}); + +it('shows a conflict message on cart version mismatch', function () { + ['store' => $store] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + session()->put('cart_id', $cart->id); + $lineId = $cart->cartLines()->first()->id; + + Livewire::test(Show::class) + ->set('cartVersion', 999) + ->call('incrementLine', $lineId) + ->assertSet('cartNotice', 'Your cart was updated elsewhere. Please review and try again.'); +}); + +it('applies and removes discount codes on the cart page', function () { + ['store' => $store] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + session()->put('cart_id', $cart->id); + App\Models\Discount::factory()->create([ + 'store_id' => $store->id, + 'type' => App\Enums\DiscountType::Code, + 'code' => 'SAVE10', + 'value_type' => App\Enums\DiscountValueType::Percent, + 'value_amount' => 10, + 'status' => App\Enums\DiscountStatus::Active, + 'starts_at' => now()->subDay(), + 'ends_at' => now()->addDay(), + 'usage_limit' => null, + ]); + + Livewire::test(Show::class) + ->set('discountCodeInput', 'SAVE10') + ->call('applyDiscount') + ->assertSet('cartNotice', 'Discount applied'); + + expect(session()->get('storefront.discount_code'))->toBe('SAVE10'); + + Livewire::test(Show::class) + ->call('removeDiscount'); + + expect(session()->has('storefront.discount_code'))->toBeFalse(); +}); + +it('rejects invalid discount codes', function () { + ['store' => $store] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + session()->put('cart_id', $cart->id); + + Livewire::test(Show::class) + ->set('discountCodeInput', 'NOPE') + ->call('applyDiscount') + ->assertHasErrors('discountCodeInput'); +}); + +it('starts checkout from the cart page', function () { + ['store' => $store] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + session()->put('cart_id', $cart->id); + + Livewire::test(Show::class) + ->call('startCheckout') + ->assertRedirect(); + + expect(App\Models\Checkout::query()->where('cart_id', $cart->id)->exists())->toBeTrue(); +}); + +it('opens the cart drawer on cart updates', function () { + ['store' => $store] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + session()->put('cart_id', $cart->id); + + Livewire::test(CartDrawer::class) + ->assertSet('open', false) + ->dispatch('open-cart-drawer') + ->assertSet('open', true) + ->assertSee($cart->cartLines()->first()->variant->product->title) + ->call('closeDrawer') + ->assertSet('open', false); +}); diff --git a/tests/Feature/Storefront/CheckoutJourneyTest.php b/tests/Feature/Storefront/CheckoutJourneyTest.php new file mode 100644 index 00000000..167e9231 --- /dev/null +++ b/tests/Feature/Storefront/CheckoutJourneyTest.php @@ -0,0 +1,245 @@ + '4242424242424242', + 'cardExpiry' => '12/28', + 'cardCvc' => '123', + 'cardHolder' => 'Jane Doe', + ]; +} + +it('completes a full guest checkout journey to confirmation', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store, [['price' => 2500, 'qty' => 2]]); + $rate = CommerceTestHelper::flatShippingRate($store); + $checkout = app(CheckoutService::class)->createFromCart($cart, 'guest@example.com'); + + $page = $this->get(storefrontUrl($domain->hostname, '/checkout/'.$checkout->id)); + $page->assertOk(); + $page->assertSee('Checkout'); + + $test = Livewire::test(Show::class, ['checkoutId' => $checkout->id]) + ->set('email', 'guest@example.com') + ->call('saveContact') + ->set('shipping', checkoutAddress()) + ->call('saveAddress'); + + expect($checkout->refresh()->status)->toBe(CheckoutStatus::Addressed); + + $test->set('shippingRateId', $rate->id)->call('saveShipping'); + + expect($checkout->refresh()->status)->toBe(CheckoutStatus::ShippingSelected); + + $test->set(checkoutCardDetails())->call('pay')->assertRedirect(); + + $order = Order::query()->where('checkout_id', $checkout->id)->first(); + + expect($order)->not->toBeNull(); + expect($order->status->value)->toBe('paid'); + expect($order->total_amount)->toBeGreaterThan(0); + + $confirmation = $this->get(storefrontUrl($domain->hostname, '/checkout/'.$checkout->id.'/confirmation')); + $confirmation->assertOk(); + $confirmation->assertSee('Thank you for your order!'); + $confirmation->assertSee($order->order_number); +}); + +it('applies a discount code during checkout', function () { + ['store' => $store] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store, [['price' => 5000, 'qty' => 1]]); + $rate = CommerceTestHelper::flatShippingRate($store); + Discount::factory()->create([ + 'store_id' => $store->id, + 'type' => DiscountType::Code, + 'code' => 'WELCOME10', + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + 'status' => DiscountStatus::Active, + 'starts_at' => now()->subDay(), + 'ends_at' => now()->addDay(), + 'usage_limit' => null, + ]); + $checkout = app(CheckoutService::class)->createFromCart($cart, 'guest@example.com'); + + Livewire::test(Show::class, ['checkoutId' => $checkout->id]) + ->set('email', 'guest@example.com') + ->set('shipping', checkoutAddress()) + ->call('saveAddress') + ->set('shippingRateId', $rate->id) + ->call('saveShipping') + ->set('discountCodeInput', 'WELCOME10') + ->call('applyDiscount') + ->assertHasNoErrors(); + + expect($checkout->refresh()->discount_code)->toBe('WELCOME10'); + expect((int) ($checkout->refresh()->totals_json['discount'] ?? 0))->toBe(500); +}); + +it('rejects invalid discount codes at checkout', function () { + ['store' => $store] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + $checkout = app(CheckoutService::class)->createFromCart($cart, 'guest@example.com'); + + Livewire::test(Show::class, ['checkoutId' => $checkout->id]) + ->set('discountCodeInput', 'NOPE') + ->call('applyDiscount') + ->assertHasErrors('discountCodeInput'); +}); + +it('shows a payment decline error and lets the customer retry', function () { + ['store' => $store] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + $rate = CommerceTestHelper::flatShippingRate($store); + $checkout = app(CheckoutService::class)->createFromCart($cart, 'guest@example.com'); + + $test = Livewire::test(Show::class, ['checkoutId' => $checkout->id]) + ->set('email', 'guest@example.com') + ->set('shipping', checkoutAddress()) + ->call('saveAddress') + ->set('shippingRateId', $rate->id) + ->call('saveShipping') + ->set('cardNumber', '4000000000000002') + ->set('cardExpiry', '12/28') + ->set('cardCvc', '123') + ->set('cardHolder', 'Jane Doe') + ->call('pay'); + + $test->assertSet('payError', 'Payment declined: Your card was declined.'); + + expect(Order::query()->where('checkout_id', $checkout->id)->exists())->toBeFalse(); + + $test->set('cardNumber', '4242424242424242')->call('pay')->assertRedirect(); + + expect(Order::query()->where('checkout_id', $checkout->id)->exists())->toBeTrue(); +}); + +it('places bank transfer orders with instructions on the confirmation page', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + $rate = CommerceTestHelper::flatShippingRate($store); + $checkout = app(CheckoutService::class)->createFromCart($cart, 'guest@example.com'); + + Livewire::test(Show::class, ['checkoutId' => $checkout->id]) + ->set('email', 'guest@example.com') + ->set('shipping', checkoutAddress()) + ->call('saveAddress') + ->set('shippingRateId', $rate->id) + ->call('saveShipping') + ->set('paymentMethod', 'bank_transfer') + ->call('pay') + ->assertRedirect(); + + $order = Order::query()->where('checkout_id', $checkout->id)->first(); + + expect($order->financial_status->value)->toBe('pending'); + + $confirmation = $this->get(storefrontUrl($domain->hostname, '/checkout/'.$checkout->id.'/confirmation')); + $confirmation->assertOk(); + $confirmation->assertSee('Bank Transfer Instructions'); + $confirmation->assertSee('DE89 3704 0044 0532 0130 00'); +}); + +it('completes checkout for a logged in customer', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $customer = Customer::factory()->create(['store_id' => $store->id]); + $cart = CommerceTestHelper::cartWithLines($store); + $rate = CommerceTestHelper::flatShippingRate($store); + $this->actingAs($customer, 'customer'); + $checkout = app(CheckoutService::class)->createFromCart($cart, $customer->email, $customer); + + Livewire::test(Show::class, ['checkoutId' => $checkout->id]) + ->assertSet('email', $customer->email) + ->set('shipping', checkoutAddress()) + ->call('saveAddress') + ->set('shippingRateId', $rate->id) + ->call('saveShipping') + ->set(checkoutCardDetails()) + ->call('pay') + ->assertRedirect(); + + $order = Order::query()->where('checkout_id', $checkout->id)->first(); + + expect($order->customer_id)->toBe($customer->id); +}); + +it('validates checkout addresses', function () { + ['store' => $store] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + $checkout = app(CheckoutService::class)->createFromCart($cart, 'guest@example.com'); + + Livewire::test(Show::class, ['checkoutId' => $checkout->id]) + ->set('email', 'not-an-email') + ->set('shipping', ['first_name' => '', 'country_code' => 'XXL']) + ->call('saveAddress') + ->assertHasErrors(['email', 'shipping.first_name', 'shipping.last_name', 'shipping.address1', 'shipping.city', 'shipping.country_code', 'shipping.postal_code']); +}); + +it('isolates checkouts by store and handles expired checkouts', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + ['store' => $otherStore] = $this->createStoreContext(); + app(App\Tenancy\CurrentStore::class)->set($otherStore); + $otherCart = CommerceTestHelper::cartWithLines($otherStore); + $foreign = app(CheckoutService::class)->createFromCart($otherCart, 'guest@example.com'); + app(App\Tenancy\CurrentStore::class)->set($store); + $cart = CommerceTestHelper::cartWithLines($store); + $checkout = app(CheckoutService::class)->createFromCart($cart, 'guest@example.com'); + $expired = app(CheckoutService::class)->createFromCart($cart, 'guest@example.com'); + app(CheckoutService::class)->expireCheckout($expired); + + $this->get(storefrontUrl($domain->hostname, '/checkout/'.$foreign->id))->assertNotFound(); + $this->get(storefrontUrl($domain->hostname, '/checkout/'.$expired->id))->assertStatus(410); + $this->get(storefrontUrl($domain->hostname, '/checkout/'.$foreign->id.'/confirmation'))->assertNotFound(); +}); + +it('redirects incomplete checkouts away from the confirmation page', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + $checkout = app(CheckoutService::class)->createFromCart($cart, 'guest@example.com'); + + $this->get(storefrontUrl($domain->hostname, '/checkout/'.$checkout->id.'/confirmation')) + ->assertRedirect(storefrontUrl($domain->hostname, '/checkout/'.$checkout->id)); +}); + +it('renders the confirmation component for completed checkouts', function () { + ['store' => $store] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + $rate = CommerceTestHelper::flatShippingRate($store); + $checkout = app(CheckoutService::class)->createFromCart($cart, 'guest@example.com'); + + Livewire::test(Show::class, ['checkoutId' => $checkout->id]) + ->set('email', 'guest@example.com') + ->set('shipping', checkoutAddress()) + ->call('saveAddress') + ->set('shippingRateId', $rate->id) + ->call('saveShipping') + ->set(checkoutCardDetails()) + ->call('pay'); + + $order = Order::query()->where('checkout_id', $checkout->id)->first(); + + Livewire::test(Confirmation::class, ['checkoutId' => $checkout->id]) + ->assertSee('Thank you for your order!') + ->assertSee($order->order_number); +}); diff --git a/tests/Feature/Storefront/CollectionPageTest.php b/tests/Feature/Storefront/CollectionPageTest.php new file mode 100644 index 00000000..e804323e --- /dev/null +++ b/tests/Feature/Storefront/CollectionPageTest.php @@ -0,0 +1,93 @@ + $store, 'domain' => $domain] = $this->createStoreContext(); + $collection = Collection::factory()->create(['store_id' => $store->id, 'status' => 'active']); + + $response = $this->get(storefrontUrl($domain->hostname, '/collections')); + + $response->assertOk(); + $response->assertSee($collection->title); +}); + +it('does not list draft collections or other stores collections', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + Collection::factory()->create(['store_id' => $store->id, 'status' => 'draft', 'title' => 'Draft Collection Title']); + ['store' => $otherStore] = $this->createStoreContext(); + Collection::factory()->create(['store_id' => $otherStore->id, 'status' => 'active', 'title' => 'Foreign Collection Title']); + + $response = $this->get(storefrontUrl($domain->hostname, '/collections')); + + $response->assertOk(); + $response->assertDontSee('Draft Collection Title'); + $response->assertDontSee('Foreign Collection Title'); +}); + +it('renders a collection page with its products', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $collection = Collection::factory()->create(['store_id' => $store->id, 'status' => 'active']); + $variant = CommerceTestHelper::purchasableVariant($store); + $collection->products()->attach($variant->product_id); + + $response = $this->get(storefrontUrl($domain->hostname, '/collections/'.$collection->handle)); + + $response->assertOk(); + $response->assertSee($collection->title); + $response->assertSee($variant->product->title); +}); + +it('returns 404 for unknown or foreign collections', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + ['store' => $otherStore] = $this->createStoreContext(); + $foreign = Collection::factory()->create(['store_id' => $otherStore->id, 'status' => 'active']); + + $this->get(storefrontUrl($domain->hostname, '/collections/nope'))->assertNotFound(); + $this->get(storefrontUrl($domain->hostname, '/collections/'.$foreign->handle))->assertNotFound(); +}); + +it('sorts collection products by price', function () { + ['store' => $store] = $this->createStoreContext(); + $collection = Collection::factory()->create(['store_id' => $store->id, 'status' => 'active']); + $cheap = CommerceTestHelper::purchasableVariant($store, ['price_amount' => 1000], productOverrides: ['title' => 'Cheap Widget']); + $pricey = CommerceTestHelper::purchasableVariant($store, ['price_amount' => 9000], productOverrides: ['title' => 'Pricey Widget']); + $collection->products()->attach([$cheap->product_id, $pricey->product_id]); + + Livewire::test(Show::class, ['handle' => $collection->handle]) + ->set('sort', 'price_asc') + ->assertSeeInOrder(['Cheap Widget', 'Pricey Widget']) + ->set('sort', 'price_desc') + ->assertSeeInOrder(['Pricey Widget', 'Cheap Widget']); +}); + +it('filters collection products by vendor and stock', function () { + ['store' => $store] = $this->createStoreContext(); + $collection = Collection::factory()->create(['store_id' => $store->id, 'status' => 'active']); + $inStock = CommerceTestHelper::purchasableVariant($store, [], [], ['title' => 'Stocked Acme Item', 'vendor' => 'Acme']); + $outOfStock = CommerceTestHelper::purchasableVariant($store, [], ['quantity_on_hand' => 0], ['title' => 'Gone Globex Item', 'vendor' => 'Globex']); + $collection->products()->attach([$inStock->product_id, $outOfStock->product_id]); + + Livewire::test(Show::class, ['handle' => $collection->handle]) + ->set('vendors', ['Acme']) + ->assertSee('Stocked Acme Item') + ->assertDontSee('Gone Globex Item') + ->call('clearFilters') + ->assertSee('Gone Globex Item') + ->set('inStock', true) + ->assertSee('Stocked Acme Item') + ->assertDontSee('Gone Globex Item'); +}); + +it('renders the collections index component', function () { + ['store' => $store] = $this->createStoreContext(); + $collection = Collection::factory()->create(['store_id' => $store->id, 'status' => 'active']); + + Livewire::test(Index::class)->assertSee($collection->title)->assertStatus(200); +}); diff --git a/tests/Feature/Storefront/HomeTest.php b/tests/Feature/Storefront/HomeTest.php new file mode 100644 index 00000000..568bc04f --- /dev/null +++ b/tests/Feature/Storefront/HomeTest.php @@ -0,0 +1,49 @@ + $store, 'domain' => $domain] = $this->createStoreContext(); + + $response = $this->get(storefrontUrl($domain->hostname, '/')); + + $response->assertOk(); + $response->assertSee($store->name); + $response->assertSee('Featured Products'); +}); + +it('shows featured products and collections on the home page', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store); + $collection = Collection::factory()->create(['store_id' => $store->id, 'status' => 'active']); + + $response = $this->get(storefrontUrl($domain->hostname, '/')); + + $response->assertOk(); + $response->assertSee($variant->product->title); + $response->assertSee($collection->title); +}); + +it('does not show other stores products on the home page', function () { + ['domain' => $domain] = $this->createStoreContext(); + ['store' => $otherStore] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($otherStore, productOverrides: ['title' => 'Other Store Exclusive Product']); + + $response = $this->get(storefrontUrl($domain->hostname, '/')); + + $response->assertOk(); + $response->assertDontSee('Other Store Exclusive Product'); +}); + +it('shows the cart icon with a count badge when the session cart has lines', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store); + + $response = $this->withSession(['cart_id' => $cart->id])->get(storefrontUrl($domain->hostname, '/')); + + $response->assertOk(); + $response->assertSee('aria-label="Open cart"', false); +}); diff --git a/tests/Feature/Storefront/NavigationThemeTest.php b/tests/Feature/Storefront/NavigationThemeTest.php new file mode 100644 index 00000000..f1c709b3 --- /dev/null +++ b/tests/Feature/Storefront/NavigationThemeTest.php @@ -0,0 +1,87 @@ + $store] = $this->createStoreContext(); + $menu = NavigationMenu::factory()->create(['store_id' => $store->id, 'handle' => 'main-menu']); + $page = Page::factory()->create(['store_id' => $store->id, 'handle' => 'about-us', 'status' => 'published']); + $collection = Collection::factory()->create(['store_id' => $store->id, 'handle' => 'summer', 'status' => 'active']); + $variant = CommerceTestHelper::purchasableVariant($store); + + NavigationItem::factory()->create(['menu_id' => $menu->id, 'type' => 'collection', 'label' => 'Summer', 'resource_id' => $collection->id, 'position' => 2]); + NavigationItem::factory()->create(['menu_id' => $menu->id, 'type' => 'link', 'label' => 'External', 'url' => 'https://example.com', 'position' => 3]); + NavigationItem::factory()->create(['menu_id' => $menu->id, 'type' => 'page', 'label' => 'About', 'resource_id' => $page->id, 'position' => 1]); + NavigationItem::factory()->create(['menu_id' => $menu->id, 'type' => 'product', 'label' => 'Featured', 'resource_id' => $variant->product_id, 'position' => 0]); + + $tree = app(NavigationService::class)->buildTree($menu->refresh()); + + expect($tree)->toHaveCount(4); + expect(array_column($tree, 'label'))->toBe(['Featured', 'About', 'Summer', 'External']); + expect($tree[0]['url'])->toContain('/products/'.$variant->product->handle); + expect($tree[1]['url'])->toContain('/pages/about-us'); + expect($tree[2]['url'])->toContain('/collections/summer'); + expect($tree[3]['url'])->toBe('https://example.com'); +}); + +it('falls back to a placeholder url for missing resources', function () { + ['store' => $store] = $this->createStoreContext(); + $menu = NavigationMenu::factory()->create(['store_id' => $store->id, 'handle' => 'main-menu']); + $item = NavigationItem::factory()->create(['menu_id' => $menu->id, 'type' => 'page', 'resource_id' => 999999]); + + expect(app(NavigationService::class)->resolveUrl($item))->toBe('#'); +}); + +it('renders header and footer navigation on storefront pages', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $menu = NavigationMenu::factory()->create(['store_id' => $store->id, 'handle' => 'main-menu']); + NavigationItem::factory()->create(['menu_id' => $menu->id, 'type' => 'link', 'label' => 'Header Link Label', 'url' => '/collections']); + $footer = NavigationMenu::factory()->create(['store_id' => $store->id, 'handle' => 'footer-menu']); + NavigationItem::factory()->create(['menu_id' => $footer->id, 'type' => 'link', 'label' => 'Footer Link Label', 'url' => '/cart']); + + $response = $this->get(storefrontUrl($domain->hostname, '/')); + + $response->assertOk(); + $response->assertSee('Header Link Label'); + $response->assertSee('Footer Link Label'); +}); + +it('resolves the active theme and its settings', function () { + ['store' => $store] = $this->createStoreContext(); + $theme = App\Models\Theme::factory()->create(['store_id' => $store->id, 'status' => 'published', 'published_at' => now()]); + App\Models\ThemeSetting::factory()->create([ + 'theme_id' => $theme->id, + 'settings_json' => ['announcement' => ['show' => true, 'text' => 'Big Sale Today']], + ]); + + $service = app(ThemeService::class); + + expect($service->activeTheme($store)?->id)->toBe($theme->id); + + $settings = $service->settings($store); + + expect($settings['announcement']['text'])->toBe('Big Sale Today'); + expect($settings['hero']['heading'])->toContain($store->name); +}); + +it('renders the announcement bar when enabled in theme settings', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $theme = App\Models\Theme::factory()->create(['store_id' => $store->id, 'status' => 'published', 'published_at' => now()]); + App\Models\ThemeSetting::factory()->create([ + 'theme_id' => $theme->id, + 'settings_json' => ['announcement' => ['show' => true, 'text' => 'Storewide Announcement Text']], + ]); + + $response = $this->get(storefrontUrl($domain->hostname, '/')); + + $response->assertOk(); + $response->assertSee('Storewide Announcement Text'); +}); diff --git a/tests/Feature/Storefront/ProductPageTest.php b/tests/Feature/Storefront/ProductPageTest.php new file mode 100644 index 00000000..57e00d74 --- /dev/null +++ b/tests/Feature/Storefront/ProductPageTest.php @@ -0,0 +1,86 @@ + $store, 'domain' => $domain] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store); + + $response = $this->get(storefrontUrl($domain->hostname, '/products/'.$variant->product->handle)); + + $response->assertOk(); + $response->assertSee($variant->product->title); + $response->assertSee('Add to cart'); + $response->assertSee('25.00'); +}); + +it('returns 404 for unknown, draft, or foreign products', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + ['store' => $otherStore] = $this->createStoreContext(); + $foreign = CommerceTestHelper::purchasableVariant($otherStore); + $draft = CommerceTestHelper::purchasableVariant($store, productOverrides: ['title' => 'Drafted Product']); + app(App\Tenancy\CurrentStore::class)->set($store); + $draft->product->update(['status' => App\Enums\ProductStatus::Draft]); + + $foreignHandle = App\Models\Product::withoutGlobalScopes()->find($foreign->product_id)->handle; + + $this->get(storefrontUrl($domain->hostname, '/products/nope'))->assertNotFound(); + $this->get(storefrontUrl($domain->hostname, '/products/'.$foreignHandle))->assertNotFound(); + $this->get(storefrontUrl($domain->hostname, '/products/'.$draft->product->handle))->assertNotFound(); +}); + +it('selects variants and adds the chosen variant to cart', function () { + ['store' => $store] = $this->createStoreContext(); + + $product = app(ProductService::class)->create($store, CatalogTestHelper::productPayload([ + 'title' => 'Sized Shirt', + 'variants' => [ + [ + 'sku' => 'SHIRT-S', 'price_amount' => 2000, 'is_default' => true, 'position' => 1, 'status' => 'active', + 'option_values' => [['option_name' => 'Size', 'value' => 'Small']], + 'inventory' => ['quantity_on_hand' => 10, 'policy' => 'deny'], + ], + [ + 'sku' => 'SHIRT-L', 'price_amount' => 3000, 'is_default' => false, 'position' => 2, 'status' => 'active', + 'option_values' => [['option_name' => 'Size', 'value' => 'Large']], + 'inventory' => ['quantity_on_hand' => 10, 'policy' => 'deny'], + ], + ], + ])); + app(ProductService::class)->transitionStatus($product->refresh(), App\Enums\ProductStatus::Active); + + Livewire::test(Show::class, ['handle' => $product->handle]) + ->assertSee('20.00') + ->set('selectedOptions', ['Size' => 'Large']) + ->assertSee('30.00') + ->set('quantity', 2) + ->call('addToCart') + ->assertDispatched('cart-updated'); + + expect(App\Models\CartLine::query()->where('quantity', 2)->exists())->toBeTrue(); +}); + +it('shows out of stock messaging for unavailable variants', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store, [], ['quantity_on_hand' => 0]); + + $response = $this->get(storefrontUrl($domain->hostname, '/products/'.$variant->product->handle)); + + $response->assertOk(); + $response->assertSee('Out of stock'); + $response->assertSee('Sold out'); +}); + +it('shows low stock messaging when few items remain', function () { + ['store' => $store] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store, [], ['quantity_on_hand' => 3]); + + Livewire::test(Show::class, ['handle' => $variant->product->handle]) + ->assertSee('Only 3 left in stock'); +}); diff --git a/tests/Feature/Storefront/SearchPageTest.php b/tests/Feature/Storefront/SearchPageTest.php new file mode 100644 index 00000000..28e77c40 --- /dev/null +++ b/tests/Feature/Storefront/SearchPageTest.php @@ -0,0 +1,60 @@ + $store, 'domain' => $domain] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store, productOverrides: ['title' => 'Blue Cotton T-Shirt']); + + $response = $this->get(storefrontUrl($domain->hostname, '/search?q=cotton')); + + $response->assertOk(); + $response->assertSee('results for'); + $response->assertSee($variant->product->title); +}); + +it('shows an empty state when nothing matches', function () { + ['domain' => $domain] = $this->createStoreContext(); + + $response = $this->get(storefrontUrl($domain->hostname, '/search?q=zzz-no-match-zzz')); + + $response->assertOk(); + $response->assertSee('No results found'); +}); + +it('does not leak other stores products in search', function () { + ['domain' => $domain] = $this->createStoreContext(); + ['store' => $otherStore] = $this->createStoreContext(); + CommerceTestHelper::purchasableVariant($otherStore, productOverrides: ['title' => 'Foreign Searchable Gadget']); + + $response = $this->get(storefrontUrl($domain->hostname, '/search?q=gadget')); + + $response->assertOk(); + $response->assertDontSee('Foreign Searchable Gadget'); +}); + +it('searches via the Livewire component', function () { + ['store' => $store] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store, productOverrides: ['title' => 'Red Wool Sweater']); + + Livewire::test(Index::class) + ->set('query', 'wool') + ->assertSee($variant->product->title); +}); + +it('suggests products in the search modal', function () { + ['store' => $store] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store, productOverrides: ['title' => 'Modal Suggest Jacket']); + + Livewire::test(SearchModal::class) + ->assertSet('open', false) + ->dispatch('open-search-modal') + ->assertSet('open', true) + ->set('query', 'Modal Suggest') + ->assertSee($variant->product->title); +}); diff --git a/tests/Feature/Storefront/StaticPageTest.php b/tests/Feature/Storefront/StaticPageTest.php new file mode 100644 index 00000000..40665edd --- /dev/null +++ b/tests/Feature/Storefront/StaticPageTest.php @@ -0,0 +1,33 @@ + $store, 'domain' => $domain] = $this->createStoreContext(); + $page = Page::factory()->create([ + 'store_id' => $store->id, + 'title' => 'About Us', + 'handle' => 'about-us', + 'body_html' => '

Our story begins here.

', + 'status' => 'published', + ]); + + $response = $this->get(storefrontUrl($domain->hostname, '/pages/about-us')); + + $response->assertOk(); + $response->assertSee('About Us'); + $response->assertSee('Our story begins here.'); +}); + +it('returns 404 for draft, missing, or foreign pages', function () { + ['store' => $store, 'domain' => $domain] = $this->createStoreContext(); + Page::factory()->create(['store_id' => $store->id, 'handle' => 'draft-page', 'status' => 'draft']); + ['store' => $otherStore] = $this->createStoreContext(); + Page::factory()->create(['store_id' => $otherStore->id, 'handle' => 'foreign-page', 'status' => 'published']); + + $this->get(storefrontUrl($domain->hostname, '/pages/nope'))->assertNotFound(); + $this->get(storefrontUrl($domain->hostname, '/pages/draft-page'))->assertNotFound(); + $this->get(storefrontUrl($domain->hostname, '/pages/foreign-page'))->assertNotFound(); +}); diff --git a/tests/Feature/Storefront/StorefrontWidgetsTest.php b/tests/Feature/Storefront/StorefrontWidgetsTest.php new file mode 100644 index 00000000..b5593fe2 --- /dev/null +++ b/tests/Feature/Storefront/StorefrontWidgetsTest.php @@ -0,0 +1,77 @@ + $store] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store); + + Livewire::test(QuickAdd::class, ['variantId' => $variant->id]) + ->call('add') + ->assertDispatched('cart-updated'); + + expect(CartLine::query()->where('variant_id', $variant->id)->exists())->toBeTrue(); +}); + +it('quick add reports out of stock variants', function () { + ['store' => $store] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store, [], ['quantity_on_hand' => 0]); + + Livewire::test(QuickAdd::class, ['variantId' => $variant->id]) + ->call('add') + ->assertSet('message', 'This product is currently out of stock'); +}); + +it('subscribes newsletter emails with validation', function () { + Livewire::test(NewsletterSignup::class) + ->set('email', 'not-an-email') + ->call('subscribe') + ->assertHasErrors('email'); + + Livewire::test(NewsletterSignup::class) + ->set('email', 'fan@example.com') + ->call('subscribe') + ->assertSet('subscribed', true) + ->assertSee('Thanks for subscribing!'); +}); + +it('renders featured products scoped to the store', function () { + ['store' => $store] = $this->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store); + + Livewire::test(FeaturedProducts::class, ['count' => 4]) + ->assertSee($variant->product->title) + ->assertStatus(200); +}); + +it('counts session cart items for the badge', function () { + ['store' => $store] = $this->createStoreContext(); + $cart = CommerceTestHelper::cartWithLines($store, [['price' => 2500, 'qty' => 3]]); + session()->put('cart_id', $cart->id); + + Livewire::test(CartCount::class) + ->assertSet('count', 3) + ->assertSee('3'); +}); + +it('isolates session carts by store', function () { + ['store' => $store] = $this->createStoreContext(); + ['domain' => $domainB] = $this->createStoreContext(); + app(App\Tenancy\CurrentStore::class)->set($store); + $cart = CommerceTestHelper::cartWithLines($store); + $title = $cart->cartLines()->first()->variant->product->title; + + $response = $this->withSession(['cart_id' => $cart->id])->get(storefrontUrl($domainB->hostname, '/cart')); + + $response->assertOk(); + $response->assertSee('Your cart is empty'); + $response->assertDontSee($title); +}); diff --git a/tests/Feature/Tenancy/StoreIsolationTest.php b/tests/Feature/Tenancy/StoreIsolationTest.php new file mode 100644 index 00000000..6028fede --- /dev/null +++ b/tests/Feature/Tenancy/StoreIsolationTest.php @@ -0,0 +1,103 @@ + $storeA] = $this->createStoreContext(); + ['store' => $storeB] = $this->createStoreContext(); + Product::factory()->count(3)->create(['store_id' => $storeA->id]); + Product::factory()->count(5)->create(['store_id' => $storeB->id]); + + app(CurrentStore::class)->set($storeA); + + expect(Product::all())->toHaveCount(3); +}); + +it('scopes order queries to the current store', function () { + ['store' => $storeA] = $this->createStoreContext(); + ['store' => $storeB] = $this->createStoreContext(); + Order::factory()->count(2)->create(['store_id' => $storeA->id]); + Order::factory()->count(7)->create(['store_id' => $storeB->id]); + + app(CurrentStore::class)->set($storeA); + + expect(Order::query()->count())->toBe(2); +}); + +it('automatically sets store_id on model creation', function () { + ['store' => $store] = $this->createStoreContext(); + + $product = Product::query()->create([ + 'title' => 'Auto-scoped product', + 'handle' => 'auto-scoped-product', + ]); + + expect($product->store_id)->toBe($store->id); +}); + +it('prevents accessing another stores records via direct id', function () { + ['store' => $storeA] = $this->createStoreContext(); + ['store' => $storeB] = $this->createStoreContext(); + $product = Product::factory()->create(['store_id' => $storeA->id]); + + app(CurrentStore::class)->set($storeB); + + expect(Product::query()->find($product->id))->toBeNull(); +}); + +it('allows cross-store access when global scope is removed', function () { + ['store' => $storeA] = $this->createStoreContext(); + ['store' => $storeB] = $this->createStoreContext(); + Product::factory()->count(3)->create(['store_id' => $storeA->id]); + Product::factory()->count(5)->create(['store_id' => $storeB->id]); + + app(CurrentStore::class)->set($storeA); + + expect(Product::withoutGlobalScope(StoreScope::class)->count())->toBe(8); +}); + +it('scopes customers sharing the same email across stores', function () { + ['store' => $storeA] = $this->createStoreContext(); + ['store' => $storeB] = $this->createStoreContext(); + Customer::factory()->create(['store_id' => $storeA->id, 'email' => 'sam@example.com']); + Customer::factory()->create(['store_id' => $storeB->id, 'email' => 'sam@example.com']); + + app(CurrentStore::class)->set($storeB); + + $customers = Customer::all(); + + expect($customers)->toHaveCount(1); + expect($customers->first()->store_id)->toBe($storeB->id); +}); + +it('does not filter queries when no store is bound', function () { + ['store' => $storeA] = $this->createStoreContext(); + ['store' => $storeB] = $this->createStoreContext(); + Product::factory()->count(2)->create(['store_id' => $storeA->id]); + Product::factory()->count(2)->create(['store_id' => $storeB->id]); + + app(CurrentStore::class)->forget(); + + expect(Product::query()->count())->toBe(4); +}); + +it('does not overwrite an explicit store_id on creation', function () { + ['store' => $storeA] = $this->createStoreContext(); + ['store' => $storeB] = $this->createStoreContext(); + + app(CurrentStore::class)->set($storeA); + + $product = Product::query()->create([ + 'store_id' => $storeB->id, + 'title' => 'Explicit store product', + 'handle' => 'explicit-store-product', + ]); + + expect($product->store_id)->toBe($storeB->id); +}); diff --git a/tests/Feature/Tenancy/TenantResolutionTest.php b/tests/Feature/Tenancy/TenantResolutionTest.php new file mode 100644 index 00000000..f091d525 --- /dev/null +++ b/tests/Feature/Tenancy/TenantResolutionTest.php @@ -0,0 +1,116 @@ +create(['status' => StoreStatus::Active]); + StoreDomain::factory()->create(['store_id' => $store->id, 'hostname' => 'acme-fashion.test']); + + $response = $this->get('http://acme-fashion.test/'); + + $response->assertOk(); + expect(app(CurrentStore::class)->get()?->id)->toBe($store->id); + expect(app()->bound('current_store'))->toBeTrue(); +}); + +it('returns 404 for unknown hostname', function () { + $response = $this->get('http://nonexistent.test/'); + + $response->assertNotFound(); + expect(app(CurrentStore::class)->get())->toBeNull(); +}); + +it('returns 503 for suspended store on storefront', function () { + $store = Store::factory()->create(['status' => StoreStatus::Suspended]); + StoreDomain::factory()->create(['store_id' => $store->id, 'hostname' => 'suspended.test']); + + $response = $this->get('http://suspended.test/'); + + $response->assertStatus(503); +}); + +it('resolves store from session for admin requests', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + + $response = $this->actingAsAdmin($user, $store)->get('/admin'); + + $response->assertOk(); + expect(app(CurrentStore::class)->get()?->id)->toBe($store->id); +}); + +it('denies admin access when user has no store_users record', function () { + $store = Store::factory()->create(['status' => StoreStatus::Active]); + $stranger = User::factory()->create(); + + $response = $this->actingAs($stranger)->withSession(['current_store_id' => $store->id])->get('/admin'); + + $response->assertForbidden(); +}); + +it('caches hostname lookup', function () { + $store = Store::factory()->create(['status' => StoreStatus::Active]); + StoreDomain::factory()->create(['store_id' => $store->id, 'hostname' => 'cached.test']); + + $this->get('http://cached.test/')->assertOk(); + + expect(Cache::has('store_domain:cached.test'))->toBeTrue(); + expect(Cache::get('store_domain:cached.test'))->toBe($store->id); + + DB::enableQueryLog(); + $this->get('http://cached.test/')->assertOk(); + $domainQueries = collect(DB::getQueryLog())->filter(fn (array $query): bool => str_contains($query['query'], 'store_domains')); + + expect($domainQueries)->toBeEmpty(); +}); + +it('allows admin reads but blocks mutations on suspended stores', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + $store->update(['status' => StoreStatus::Suspended]); + + $this->actingAsAdmin($user, $store)->get('/admin')->assertOk(); + + Route::middleware(['web', 'auth', 'store.resolve'])->post('/admin/mutation-probe', fn () => 'mutated'); + + $this->actingAsAdmin($user, $store)->post('/admin/mutation-probe')->assertForbidden(); +}); + +it('redirects to the store selector when the session store is missing', function () { + $user = User::factory()->create(); + $storeA = Store::factory()->create(['status' => StoreStatus::Active]); + $storeB = Store::factory()->create(['status' => StoreStatus::Active]); + StoreUser::factory()->create(['store_id' => $storeA->id, 'user_id' => $user->id, 'role' => StoreUserRole::Admin]); + StoreUser::factory()->create(['store_id' => $storeB->id, 'user_id' => $user->id, 'role' => StoreUserRole::Admin]); + + $response = $this->actingAs($user)->get('/admin'); + + $response->assertRedirect(route('admin.store-selector')); +}); + +it('auto-selects the only store when the session store is missing', function () { + ['store' => $store, 'user' => $user] = $this->createStoreContext(); + + $response = $this->actingAs($user)->get('/admin'); + + $response->assertOk(); + $response->assertSessionHas('current_store_id', $store->id); +}); + +it('resolves store from the ?store handle fallback', function () { + $store = Store::factory()->create(['status' => StoreStatus::Active, 'handle' => 'fallback-shop']); + + $response = $this->get('http://unknown-host.test/?store=fallback-shop'); + + $response->assertOk(); + expect(app(CurrentStore::class)->get()?->id)->toBe($store->id); +}); diff --git a/tests/Feature/Webhooks/OAuthStubTest.php b/tests/Feature/Webhooks/OAuthStubTest.php new file mode 100644 index 00000000..9535d6a7 --- /dev/null +++ b/tests/Feature/Webhooks/OAuthStubTest.php @@ -0,0 +1,19 @@ + $user] = $this->createStoreContext(); + $this->actingAs($user); + + $this->get('/oauth/authorize?client_id=x&redirect_uri=https://app.example.com/cb&response_type=code&scope=read-products&state=y') + ->assertStatus(501); + + $this->postJson('/oauth/token', ['grant_type' => 'authorization_code'])->assertStatus(501); +}); + +it('returns 501 for app api endpoints', function () { + $this->getJson('/api/apps/v1/stores/1/products')->assertStatus(501); + $this->getJson('/api/apps/v1/stores/1/orders')->assertStatus(501); + $this->getJson('/api/apps/v1/stores/1/customers')->assertStatus(501); +}); diff --git a/tests/Feature/Webhooks/WebhooksTest.php b/tests/Feature/Webhooks/WebhooksTest.php new file mode 100644 index 00000000..5ed50b51 --- /dev/null +++ b/tests/Feature/Webhooks/WebhooksTest.php @@ -0,0 +1,168 @@ +sign('{"hello":"world"}', 'secret'); + + expect($signature)->toBe(hash_hmac('sha256', '{"hello":"world"}', 'secret')) + ->and($webhooks->verify('{"hello":"world"}', $signature, 'secret'))->toBeTrue() + ->and($webhooks->verify('{"hello":"world"}', 'invalid', 'secret'))->toBeFalse(); +}); + +it('creates subscriptions and dispatches deliveries to matching subscribers', function () { + Queue::fake([DeliverWebhook::class]); + ['store' => $store] = $this->createStoreContext(); + $webhooks = app(WebhookService::class); + + $subscription = $webhooks->subscribe($store, 'order.created', 'https://example.com/hooks'); + + expect($subscription->status)->toBe(WebhookSubscriptionStatus::Active); + + $webhooks->dispatch($store, 'order.created', ['id' => 1]); + + expect(WebhookDelivery::query()->where('subscription_id', $subscription->getKey())->count())->toBe(1); + + Queue::assertPushed(DeliverWebhook::class); +}); + +it('skips paused subscriptions on dispatch', function () { + Queue::fake([DeliverWebhook::class]); + ['store' => $store] = $this->createStoreContext(); + $webhooks = app(WebhookService::class); + + $subscription = $webhooks->subscribe($store, 'order.created', 'https://example.com/hooks'); + $subscription->update(['status' => WebhookSubscriptionStatus::Paused]); + + $webhooks->dispatch($store, 'order.created', ['id' => 1]); + + expect(WebhookDelivery::query()->where('subscription_id', $subscription->getKey())->count())->toBe(0); + + Queue::assertNotPushed(DeliverWebhook::class); +}); + +it('delivers webhooks with signature headers and records success', function () { + Http::fake(['*' => Http::response('ok', 200)]); + ['store' => $store] = $this->createStoreContext(); + $subscription = WebhookSubscription::factory()->create([ + 'store_id' => $store->getKey(), + 'event_type' => 'order.created', + 'target_url' => 'https://example.com/hooks', + 'status' => WebhookSubscriptionStatus::Active, + ]); + $delivery = WebhookDelivery::factory()->create([ + 'subscription_id' => $subscription->getKey(), + 'status' => WebhookDeliveryStatus::Pending, + 'attempt_count' => 0, + ]); + + (new DeliverWebhook($delivery->getKey(), 'order.created', ['id' => 1]))->handle(app(WebhookService::class)); + + Http::assertSent(function ($request) use ($delivery) { + return $request->url() === 'https://example.com/hooks' + && $request->hasHeader('X-Platform-Signature') + && $request->header('X-Platform-Event') === ['order.created'] + && $request->header('X-Platform-Delivery-Id') === [$delivery->event_id]; + }); + + expect($delivery->refresh()->status)->toBe(WebhookDeliveryStatus::Success) + ->and($delivery->response_code)->toBe(200) + ->and($delivery->attempt_count)->toBe(1); +}); + +it('marks deliveries failed after exhausting retries', function () { + Http::fake(['*' => Http::response('error', 500)]); + ['store' => $store] = $this->createStoreContext(); + $subscription = WebhookSubscription::factory()->create([ + 'store_id' => $store->getKey(), + 'status' => WebhookSubscriptionStatus::Active, + ]); + $delivery = WebhookDelivery::factory()->create([ + 'subscription_id' => $subscription->getKey(), + 'status' => WebhookDeliveryStatus::Pending, + 'attempt_count' => 5, + ]); + + $job = new DeliverWebhook($delivery->getKey(), 'order.created', ['id' => 1]); + + expect($job->tries)->toBe(6) + ->and($job->backoff())->toBe([60, 300, 1800, 7200, 43200]); + + $job->handle(app(WebhookService::class)); + + expect($delivery->refresh()->status)->toBe(WebhookDeliveryStatus::Failed) + ->and($delivery->attempt_count)->toBe(6); +}); + +it('pauses subscriptions after five consecutive failures', function () { + ['store' => $store] = $this->createStoreContext(); + $subscription = WebhookSubscription::factory()->create([ + 'store_id' => $store->getKey(), + 'status' => WebhookSubscriptionStatus::Active, + ]); + + WebhookDelivery::factory()->count(5)->create([ + 'subscription_id' => $subscription->getKey(), + 'status' => WebhookDeliveryStatus::Failed, + ]); + + app(WebhookService::class)->recordFailure($subscription->refresh()); + + expect($subscription->refresh()->status)->toBe(WebhookSubscriptionStatus::Paused); +}); + +it('dispatches webhooks when domain events fire', function () { + Queue::fake([DeliverWebhook::class]); + ['store' => $store] = $this->createStoreContext(); + $webhooks = app(WebhookService::class); + + $subscription = $webhooks->subscribe($store, 'order.paid', 'https://example.com/hooks'); + $order = Order::factory()->create([ + 'store_id' => $store->getKey(), + 'payment_method' => PaymentMethod::CreditCard, + 'status' => OrderStatus::Paid, + 'financial_status' => FinancialStatus::Paid, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled, + 'placed_at' => now(), + ]); + + event(new OrderPaid($order)); + + expect(WebhookDelivery::query()->where('subscription_id', $subscription->getKey())->count())->toBe(1); +}); + +it('maps product status changes to created and deleted events', function () { + Queue::fake([DeliverWebhook::class]); + ['store' => $store] = $this->createStoreContext(); + $webhooks = app(WebhookService::class); + + $created = $webhooks->subscribe($store, 'product.created', 'https://example.com/hooks'); + $deleted = $webhooks->subscribe($store, 'product.deleted', 'https://example.com/hooks'); + $product = Product::factory()->create(['store_id' => $store->getKey()]); + + event(new ProductStatusChanged($product, ProductStatus::Draft, ProductStatus::Active)); + event(new ProductStatusChanged($product, ProductStatus::Active, ProductStatus::Archived)); + + expect(WebhookDelivery::query()->where('subscription_id', $created->getKey())->count())->toBe(1) + ->and(WebhookDelivery::query()->where('subscription_id', $deleted->getKey())->count())->toBe(1); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 60f04a45..3ea2f429 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -45,3 +45,8 @@ function something() { // .. } + +function storefrontUrl(string $hostname, string $path): string +{ + return "http://{$hostname}{$path}"; +} diff --git a/tests/Support/CatalogTestHelper.php b/tests/Support/CatalogTestHelper.php new file mode 100644 index 00000000..bed95baf --- /dev/null +++ b/tests/Support/CatalogTestHelper.php @@ -0,0 +1,53 @@ + $overrides + * @return array + */ + public static function productPayload(array $overrides = []): array + { + return array_merge([ + 'title' => 'Classic T-Shirt', + 'description_html' => '

A comfortable cotton t-shirt.

', + 'vendor' => 'Acme Apparel', + 'product_type' => 'Apparel', + 'status' => 'draft', + 'tags' => ['organic', 'cotton'], + 'options' => [ + ['name' => 'Size', 'position' => 1], + ], + 'variants' => [ + [ + 'sku' => 'TSH-S', + 'barcode' => '1234567890123', + 'price_amount' => 2500, + 'compare_at_amount' => 3500, + 'weight_g' => 200, + 'requires_shipping' => true, + 'is_default' => true, + 'position' => 1, + 'status' => 'active', + 'option_values' => [ + ['option_name' => 'Size', 'value' => 'Small'], + ], + 'inventory' => ['quantity_on_hand' => 50, 'policy' => 'deny'], + ], + ], + ], $overrides); + } + + /** + * @param array $abilities + * @return array + */ + public static function adminHeaders(User $user, array $abilities = ['read-products', 'write-products', 'read-collections', 'write-collections', 'read-settings', 'write-settings']): array + { + return ['Authorization' => 'Bearer '.$user->createToken('test', $abilities)->plainTextToken]; + } +} diff --git a/tests/Support/CommerceTestHelper.php b/tests/Support/CommerceTestHelper.php new file mode 100644 index 00000000..eba8e34c --- /dev/null +++ b/tests/Support/CommerceTestHelper.php @@ -0,0 +1,131 @@ + $variantOverrides + * @param array $inventoryOverrides + */ + public static function purchasableVariant( + Store $store, + array $variantOverrides = [], + array $inventoryOverrides = [], + array $productOverrides = [], + ): ProductVariant { + self::$skuSequence++; + + $payload = array_merge([ + 'title' => 'Test Product '.self::$skuSequence, + 'status' => 'draft', + 'variants' => [ + array_merge([ + 'sku' => 'TEST-SKU-'.self::$skuSequence.'-'.$store->getKey(), + 'price_amount' => 2500, + 'weight_g' => 200, + 'requires_shipping' => true, + 'is_default' => true, + 'position' => 1, + 'status' => 'active', + 'inventory' => array_merge( + ['quantity_on_hand' => 50, 'policy' => 'deny'], + $inventoryOverrides + ), + ], $variantOverrides), + ], + ], $productOverrides); + + $product = app(ProductService::class)->create($store, $payload); + app(ProductService::class)->transitionStatus($product->refresh(), ProductStatus::Active); + + return $product->refresh()->productVariants()->first(); + } + + /** + * @param array $overrides + * @return array + */ + public static function address(array $overrides = []): array + { + return array_merge([ + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'company' => null, + 'address1' => '123 Main St', + 'address2' => null, + 'city' => 'Berlin', + 'province' => 'Berlin', + 'province_code' => 'BE', + 'country' => 'Germany', + 'country_code' => 'DE', + 'postal_code' => '10115', + 'phone' => null, + ], $overrides); + } + + public static function storefrontUrl(StoreDomain $domain, string $path): string + { + return "http://{$domain->hostname}{$path}"; + } + + /** + * Create a cart with one line per spec entry. + * + * @param array $lines + */ + public static function cartWithLines(Store $store, array $lines = [['price' => 2500, 'qty' => 2]]): Cart + { + $cart = app(CartService::class)->create($store); + + foreach ($lines as $spec) { + $variant = self::purchasableVariant( + $store, + [ + 'price_amount' => $spec['price'] ?? 2500, + 'weight_g' => $spec['weight'] ?? 200, + 'requires_shipping' => $spec['shipping'] ?? true, + ], + ['quantity_on_hand' => $spec['stock'] ?? 50] + ); + + app(CartService::class)->addLine($cart->refresh(), $variant->id, $spec['qty'] ?? 1); + } + + return $cart->refresh(); + } + + /** + * @param array $countries + */ + public static function flatShippingRate(Store $store, array $countries = ['DE'], int $amount = 499): ShippingRate + { + $zone = ShippingZone::factory()->create([ + 'store_id' => $store->id, + 'countries_json' => $countries, + 'regions_json' => [], + ]); + + return ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'type' => ShippingRateType::Flat, + 'config_json' => ['amount' => $amount], + 'is_active' => true, + ]); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index fe1ffc2f..34167565 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,9 +2,59 @@ namespace Tests; +use App\Enums\StoreStatus; +use App\Enums\StoreUserRole; +use App\Models\Customer; +use App\Models\Store; +use App\Models\StoreDomain; +use App\Models\StoreUser; +use App\Models\User; +use App\Tenancy\CurrentStore; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { - // + /** + * Create a store with domain and an owner membership, and bind it + * as the current store. + * + * @param array $storeAttributes + * @return array{store: Store, domain: StoreDomain, user: User} + */ + protected function createStoreContext(array $storeAttributes = [], StoreUserRole $role = StoreUserRole::Owner): array + { + $store = Store::factory()->create(['status' => StoreStatus::Active, ...$storeAttributes]); + $domain = StoreDomain::factory()->create(['store_id' => $store->getKey()]); + $user = User::factory()->create(); + StoreUser::factory()->create([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => $role, + ]); + + app(CurrentStore::class)->set($store); + + return ['store' => $store, 'domain' => $domain, 'user' => $user]; + } + + protected function actingAsAdmin(User $user, ?Store $store = null): static + { + $store ??= $user->stores()->first(); + + $this->actingAs($user); + + if ($store !== null) { + $this->withSession(['current_store_id' => $store->getKey()]); + app(CurrentStore::class)->set($store); + } + + return $this; + } + + protected function actingAsCustomer(Customer $customer): static + { + $this->actingAs($customer, 'customer'); + + return $this; + } } diff --git a/tests/Unit/CartVersionTest.php b/tests/Unit/CartVersionTest.php new file mode 100644 index 00000000..5afbc84f --- /dev/null +++ b/tests/Unit/CartVersionTest.php @@ -0,0 +1,63 @@ + $store] = test()->createStoreContext(); + + $cart = app(CartService::class)->create($store); + + expect($cart->cart_version)->toBe(1); +}); + +it('increments version on add line', function () { + ['store' => $store] = test()->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store); + + $cart = app(CartService::class)->create($store); + app(CartService::class)->addLine($cart, $variant->id, 1); + + expect($cart->refresh()->cart_version)->toBe(2); +}); + +it('increments version on update quantity', function () { + ['store' => $store] = test()->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store); + + $cart = app(CartService::class)->create($store); + $line = app(CartService::class)->addLine($cart, $variant->id, 1); + app(CartService::class)->updateLineQuantity($cart->refresh(), $line->id, 3); + + expect($cart->refresh()->cart_version)->toBe(3); +}); + +it('increments version on remove line', function () { + ['store' => $store] = test()->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store); + + $cart = app(CartService::class)->create($store); + $line = app(CartService::class)->addLine($cart, $variant->id, 1); + app(CartService::class)->removeLine($cart->refresh(), $line->id); + + expect($cart->refresh()->cart_version)->toBe(3); + expect(Cart::query()->whereKey($cart->id)->first()->status)->toBe(CartStatus::Active); +}); + +it('detects version mismatch', function () { + ['store' => $store] = test()->createStoreContext(); + $variant = CommerceTestHelper::purchasableVariant($store); + + $cart = app(CartService::class)->create($store); + app(CartService::class)->addLine($cart, $variant->id, 1); + app(CartService::class)->addLine($cart->refresh(), $variant->id, 1); + + expect($cart->refresh()->cart_version)->toBe(3); + + app(CartService::class)->addLine($cart->refresh(), $variant->id, 1, 2); +})->throws(CartVersionMismatchException::class); diff --git a/tests/Unit/DiscountCalculatorTest.php b/tests/Unit/DiscountCalculatorTest.php new file mode 100644 index 00000000..53402ed5 --- /dev/null +++ b/tests/Unit/DiscountCalculatorTest.php @@ -0,0 +1,182 @@ + $store] = test()->createStoreContext(); + + $variant = CommerceTestHelper::purchasableVariant($store, ['price_amount' => 2500]); + + $cart = Cart::factory()->create([ + 'store_id' => $store->id, + 'currency' => $store->default_currency, + 'cart_version' => 1, + 'status' => CartStatus::Active, + ]); + + $quantity = max(1, intdiv($cartSubtotal, 2500)); + + $cart->cartLines()->create([ + 'variant_id' => $variant->id, + 'quantity' => $quantity, + 'unit_price_amount' => 2500, + 'line_subtotal_amount' => 2500 * $quantity, + 'line_discount_amount' => 0, + 'line_total_amount' => 2500 * $quantity, + ]); + + $discount = Discount::factory()->create(array_merge([ + 'store_id' => $store->id, + 'type' => DiscountType::Code, + 'code' => 'SAVE10', + 'value_type' => DiscountValueType::Percent, + 'value_amount' => 10, + 'starts_at' => now()->subDay(), + 'ends_at' => now()->addMonth(), + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => [], + 'status' => DiscountStatus::Active, + ], $discountOverrides)); + + return ['store' => $store, 'cart' => $cart->refresh(), 'discount' => $discount]; +} + +it('validates an active discount code', function () { + ['store' => $store, 'cart' => $cart] = discountContext(); + + $result = app(DiscountService::class)->validate('SAVE10', $store, $cart); + + expect($result->valid)->toBeTrue(); + expect($result->discount)->not->toBeNull(); + expect($result->discount->code)->toBe('SAVE10'); +}); + +it('rejects an expired discount code', function () { + ['store' => $store, 'cart' => $cart] = discountContext(['ends_at' => now()->subDay()]); + + $result = app(DiscountService::class)->validate('SAVE10', $store, $cart); + + expect($result->valid)->toBeFalse(); + expect($result->errorCode)->toBe('discount_expired'); +}); + +it('rejects a not-yet-active discount code', function () { + ['store' => $store, 'cart' => $cart] = discountContext(['starts_at' => now()->addDay()]); + + $result = app(DiscountService::class)->validate('SAVE10', $store, $cart); + + expect($result->valid)->toBeFalse(); + expect($result->errorCode)->toBe('discount_not_yet_active'); +}); + +it('rejects a discount that has reached its usage limit', function () { + ['store' => $store, 'cart' => $cart] = discountContext(['usage_limit' => 10, 'usage_count' => 10]); + + $result = app(DiscountService::class)->validate('SAVE10', $store, $cart); + + expect($result->valid)->toBeFalse(); + expect($result->errorCode)->toBe('discount_usage_limit_reached'); +}); + +it('rejects an unknown discount code', function () { + ['store' => $store, 'cart' => $cart] = discountContext(); + + $result = app(DiscountService::class)->validate('DOESNOTEXIST', $store, $cart); + + expect($result->valid)->toBeFalse(); + expect($result->errorCode)->toBe('discount_not_found'); +}); + +it('performs case-insensitive code lookup', function () { + ['store' => $store, 'cart' => $cart] = discountContext(['code' => 'SUMMER20']); + + $result = app(DiscountService::class)->validate('summer20', $store, $cart); + + expect($result->valid)->toBeTrue(); +}); + +it('enforces minimum purchase amount rule', function () { + ['store' => $store, 'cart' => $cart] = discountContext([ + 'rules_json' => ['min_purchase_amount' => 5000], + ], 2500); + + $result = app(DiscountService::class)->validate('SAVE10', $store, $cart); + + expect($result->valid)->toBeFalse(); + expect($result->errorCode)->toBe('discount_min_purchase_not_met'); +}); + +it('passes minimum purchase when cart meets threshold', function () { + ['store' => $store, 'cart' => $cart] = discountContext([ + 'rules_json' => ['min_purchase_amount' => 5000], + ], 5000); + + $result = app(DiscountService::class)->validate('SAVE10', $store, $cart); + + expect($result->valid)->toBeTrue(); +}); + +it('calculates percent discount amount', function () { + ['discount' => $discount] = discountContext(['value_type' => DiscountValueType::Percent, 'value_amount' => 15]); + + $result = app(DiscountService::class)->calculate($discount, 10000, [ + ['line_id' => 1, 'subtotal' => 10000], + ]); + + expect($result['total'])->toBe(1500); +}); + +it('calculates fixed discount amount', function () { + ['discount' => $discount] = discountContext(['value_type' => DiscountValueType::Fixed, 'value_amount' => 500]); + + $result = app(DiscountService::class)->calculate($discount, 10000, [ + ['line_id' => 1, 'subtotal' => 10000], + ]); + + expect($result['total'])->toBe(500); +}); + +it('handles free shipping discount type', function () { + ['discount' => $discount] = discountContext(['value_type' => DiscountValueType::FreeShipping, 'value_amount' => 0]); + + $result = app(DiscountService::class)->calculate($discount, 10000, [ + ['line_id' => 1, 'subtotal' => 10000], + ]); + + expect($result['total'])->toBe(0); + expect($result['free_shipping'])->toBeTrue(); +}); + +it('allocates discount proportionally across multiple lines', function () { + ['discount' => $discount] = discountContext(['value_type' => DiscountValueType::Percent, 'value_amount' => 10]); + + $result = app(DiscountService::class)->calculate($discount, 10000, [ + ['line_id' => 1, 'subtotal' => 7500], + ['line_id' => 2, 'subtotal' => 2500], + ]); + + expect($result['total'])->toBe(1000); + expect($result['allocations'][1])->toBe(750); + expect($result['allocations'][2])->toBe(250); +}); + +it('distributes rounding remainder to the last qualifying line', function () { + $result = app(DiscountService::class)->allocate(100, [ + ['line_id' => 1, 'subtotal' => 3333], + ['line_id' => 2, 'subtotal' => 3333], + ['line_id' => 3, 'subtotal' => 3334], + ]); + + expect(array_sum($result))->toBe(100); +}); diff --git a/tests/Unit/HandleGeneratorTest.php b/tests/Unit/HandleGeneratorTest.php new file mode 100644 index 00000000..e25e84cc --- /dev/null +++ b/tests/Unit/HandleGeneratorTest.php @@ -0,0 +1,47 @@ +generate('My Amazing Product', 'products', 1))->toBe('my-amazing-product'); +}); + +it('appends a suffix on collision', function () { + ['store' => $store] = $this->createStoreContext(); + Product::factory()->create(['store_id' => $store->id, 'handle' => 't-shirt']); + + expect(app(HandleGenerator::class)->generate('T-Shirt', 'products', $store->id))->toBe('t-shirt-1'); +}); + +it('increments the suffix on multiple collisions', function () { + ['store' => $store] = $this->createStoreContext(); + Product::factory()->create(['store_id' => $store->id, 'handle' => 't-shirt']); + Product::factory()->create(['store_id' => $store->id, 'handle' => 't-shirt-1']); + + expect(app(HandleGenerator::class)->generate('T-Shirt', 'products', $store->id))->toBe('t-shirt-2'); +}); + +it('handles special characters', function () { + $handle = app(HandleGenerator::class)->generate("Loewe's Fall/Winter 2026", 'products', 1); + + expect($handle)->toMatch('/^[a-z0-9-]+$/'); + expect($handle)->not->toContain("'", '/'); +}); + +it('excludes the current record id from collision checks', function () { + ['store' => $store] = $this->createStoreContext(); + $product = Product::factory()->create(['store_id' => $store->id, 'handle' => 't-shirt']); + + expect(app(HandleGenerator::class)->generate('T-Shirt', 'products', $store->id, $product->id))->toBe('t-shirt'); +}); + +it('scopes uniqueness checks to the store', function () { + ['store' => $storeA] = $this->createStoreContext(); + ['store' => $storeB] = $this->createStoreContext(); + Product::factory()->create(['store_id' => $storeA->id, 'handle' => 't-shirt']); + + expect(app(HandleGenerator::class)->generate('T-Shirt', 'products', $storeB->id))->toBe('t-shirt'); +}); diff --git a/tests/Unit/PricingEngineTest.php b/tests/Unit/PricingEngineTest.php new file mode 100644 index 00000000..eac9ab5c --- /dev/null +++ b/tests/Unit/PricingEngineTest.php @@ -0,0 +1,154 @@ +lineSubtotal(2499, 2); + $b = $engine->lineSubtotal(7999, 1); + + expect($engine->sumSubtotals([$a, $b]))->toBe(12997); +}); + +it('calculates subtotal for a single line', function () { + expect(pricingEngine()->lineSubtotal(1500, 3))->toBe(4500); +}); + +it('returns zero subtotal for empty cart', function () { + expect(pricingEngine()->sumSubtotals([]))->toBe(0); +}); + +it('applies percent discount correctly', function () { + $engine = pricingEngine(); + + $discount = $engine->percentDiscount(10000, 10); + + expect($discount)->toBe(1000); + expect($engine->discountedSubtotal(10000, $discount))->toBe(9000); +}); + +it('applies fixed discount correctly', function () { + $engine = pricingEngine(); + + $discount = $engine->fixedDiscount(10000, 500); + + expect($discount)->toBe(500); + expect($engine->discountedSubtotal(10000, $discount))->toBe(9500); +}); + +it('caps fixed discount at subtotal so it never goes negative', function () { + $engine = pricingEngine(); + + $discount = $engine->fixedDiscount(300, 500); + + expect($discount)->toBe(300); + expect($engine->discountedSubtotal(300, $discount))->toBe(0); +}); + +it('applies free shipping discount by zeroing shipping', function () { + $service = new DiscountService; + $discount = new App\Models\Discount([ + 'value_type' => App\Enums\DiscountValueType::FreeShipping, + 'value_amount' => 0, + 'rules_json' => [], + ]); + + $result = $service->calculate($discount, 5000, [ + ['line_id' => 1, 'subtotal' => 5000], + ]); + + expect($result['free_shipping'])->toBeTrue(); + expect($result['total'])->toBe(0); +}); + +it('calculates tax exclusive correctly', function () { + $tax = new TaxCalculator(new ManualTaxProvider, new StripeTaxProvider); + + expect($tax->addExclusive(10000, 1900))->toBe(1900); + expect(10000 + $tax->addExclusive(10000, 1900))->toBe(11900); +}); + +it('extracts tax from inclusive price correctly', function () { + $tax = new TaxCalculator(new ManualTaxProvider, new StripeTaxProvider); + + $extracted = $tax->extractInclusive(11900, 1900); + + expect($extracted)->toBe(1900); + expect(11900 - $extracted)->toBe(10000); +}); + +it('returns zero tax when rate is zero', function () { + $tax = new TaxCalculator(new ManualTaxProvider, new StripeTaxProvider); + + expect($tax->addExclusive(10000, 0))->toBe(0); + expect($tax->extractInclusive(11900, 0))->toBe(0); +}); + +it('calculates shipping flat rate', function () { + $calculator = new ShippingCalculator; + $rate = new App\Models\ShippingRate([ + 'type' => App\Enums\ShippingRateType::Flat, + 'config_json' => ['amount' => 499], + ]); + $cart = new App\Models\Cart; + + expect($calculator->calculate($rate, $cart))->toBe(499); +}); + +it('calculates full checkout totals end to end', function () { + $engine = pricingEngine(); + + $subtotal = $engine->lineSubtotal(2499, 2); + expect($subtotal)->toBe(4998); + + $discount = $engine->percentDiscount($subtotal, 10); + expect($discount)->toBe(499); + + $discounted = $engine->discountedSubtotal($subtotal, $discount); + expect($discounted)->toBe(4499); + + $shipping = 499; + $gross = $discounted + $shipping; + + $tax = new TaxCalculator(new ManualTaxProvider, new StripeTaxProvider); + $extracted = $tax->extractInclusive($gross, 1900); + + expect($engine->total($discounted, $shipping, $extracted, true))->toBe(4998); + expect($extracted)->toBe(798); +}); + +it('handles rounding correctly with odd cent amounts', function () { + $service = new DiscountService; + + $allocations = $service->allocate(100, [ + ['line_id' => 1, 'subtotal' => 3333], + ['line_id' => 2, 'subtotal' => 3333], + ['line_id' => 3, 'subtotal' => 3334], + ]); + + expect(array_sum($allocations))->toBe(100); +}); + +it('produces identical results for identical inputs', function () { + $engine = pricingEngine(); + + $first = [$engine->percentDiscount(4998, 10), $engine->fixedDiscount(4998, 499)]; + $second = [$engine->percentDiscount(4998, 10), $engine->fixedDiscount(4998, 499)]; + + expect($first)->toBe($second); +}); diff --git a/tests/Unit/ShippingCalculatorTest.php b/tests/Unit/ShippingCalculatorTest.php new file mode 100644 index 00000000..33cbced3 --- /dev/null +++ b/tests/Unit/ShippingCalculatorTest.php @@ -0,0 +1,225 @@ + $store] = test()->createStoreContext(); + + ShippingZone::factory()->create([ + 'store_id' => $store->id, + 'countries_json' => ['DE', 'AT', 'CH'], + 'regions_json' => [], + ]); + + $zone = app(ShippingCalculator::class)->getMatchingZone($store, ['country_code' => 'DE']); + + expect($zone)->not->toBeNull(); + expect($zone->countries_json)->toBe(['DE', 'AT', 'CH']); +}); + +it('matches a zone by region code', function () { + ['store' => $store] = test()->createStoreContext(); + + ShippingZone::factory()->create([ + 'store_id' => $store->id, + 'name' => 'US East', + 'countries_json' => ['US'], + 'regions_json' => ['US-NY', 'US-CA'], + ]); + ShippingZone::factory()->create([ + 'store_id' => $store->id, + 'name' => 'US Wide', + 'countries_json' => ['US'], + 'regions_json' => [], + ]); + + $zone = app(ShippingCalculator::class)->getMatchingZone($store, [ + 'country_code' => 'US', + 'province_code' => 'US-NY', + ]); + + expect($zone)->not->toBeNull(); + expect($zone->name)->toBe('US East'); +}); + +it('returns empty when no zone matches the address', function () { + ['store' => $store] = test()->createStoreContext(); + + ShippingZone::factory()->create([ + 'store_id' => $store->id, + 'countries_json' => ['DE'], + 'regions_json' => [], + ]); + + $zone = app(ShippingCalculator::class)->getMatchingZone($store, ['country_code' => 'FR']); + + expect($zone)->toBeNull(); +}); + +it('calculates a flat rate', function () { + ['store' => $store] = test()->createStoreContext(); + + $variant = CommerceTestHelper::purchasableVariant($store); + $cart = Cart::factory()->create(['store_id' => $store->id, 'status' => CartStatus::Active]); + $cart->cartLines()->create([ + 'variant_id' => $variant->id, + 'quantity' => 1, + 'unit_price_amount' => 2500, + 'line_subtotal_amount' => 2500, + 'line_discount_amount' => 0, + 'line_total_amount' => 2500, + ]); + + $rate = ShippingRate::factory()->create([ + 'type' => ShippingRateType::Flat, + 'config_json' => ['amount' => 499], + ]); + + expect(app(ShippingCalculator::class)->calculate($rate, $cart->refresh()))->toBe(499); +}); + +it('calculates a weight-based rate', function () { + ['store' => $store] = test()->createStoreContext(); + + $variant = CommerceTestHelper::purchasableVariant($store, ['weight_g' => 250]); + + $cart = Cart::factory()->create(['store_id' => $store->id, 'status' => CartStatus::Active]); + $cart->cartLines()->create([ + 'variant_id' => $variant->id, + 'quantity' => 3, + 'unit_price_amount' => 2500, + 'line_subtotal_amount' => 7500, + 'line_discount_amount' => 0, + 'line_total_amount' => 7500, + ]); + + $rate = ShippingRate::factory()->create([ + 'type' => ShippingRateType::Weight, + 'config_json' => ['ranges' => [ + ['min_g' => 0, 'max_g' => 500, 'amount' => 499], + ['min_g' => 501, 'max_g' => 2000, 'amount' => 899], + ]], + ]); + + expect(app(ShippingCalculator::class)->calculate($rate, $cart->refresh()))->toBe(899); +}); + +it('calculates a price-based rate', function () { + ['store' => $store] = test()->createStoreContext(); + + $variant = CommerceTestHelper::purchasableVariant($store, ['price_amount' => 2500]); + + $cart = Cart::factory()->create(['store_id' => $store->id, 'status' => CartStatus::Active]); + $cart->cartLines()->create([ + 'variant_id' => $variant->id, + 'quantity' => 3, + 'unit_price_amount' => 2500, + 'line_subtotal_amount' => 7500, + 'line_discount_amount' => 0, + 'line_total_amount' => 7500, + ]); + + $rate = ShippingRate::factory()->create([ + 'type' => ShippingRateType::Price, + 'config_json' => ['ranges' => [ + ['min_amount' => 0, 'max_amount' => 5000, 'amount' => 799], + ['min_amount' => 5001, 'amount' => 399], + ]], + ]); + + expect(app(ShippingCalculator::class)->calculate($rate, $cart->refresh()))->toBe(399); +}); + +it('returns zero shipping when no items require shipping', function () { + ['store' => $store] = test()->createStoreContext(); + + $variant = CommerceTestHelper::purchasableVariant($store, ['requires_shipping' => false]); + + $cart = Cart::factory()->create(['store_id' => $store->id, 'status' => CartStatus::Active]); + $cart->cartLines()->create([ + 'variant_id' => $variant->id, + 'quantity' => 1, + 'unit_price_amount' => 2500, + 'line_subtotal_amount' => 2500, + 'line_discount_amount' => 0, + 'line_total_amount' => 2500, + ]); + + $zone = ShippingZone::factory()->create([ + 'store_id' => $store->id, + 'countries_json' => ['DE'], + ]); + ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'type' => ShippingRateType::Flat, + 'config_json' => ['amount' => 499], + 'is_active' => true, + ]); + + $rates = app(ShippingCalculator::class)->getAvailableRates($store, ['country_code' => 'DE'], $cart->refresh()); + + expect($rates)->toBeEmpty(); +}); + +it('prefers the most specific zone when multiple zones match', function () { + ['store' => $store] = test()->createStoreContext(); + + ShippingZone::factory()->create([ + 'store_id' => $store->id, + 'name' => 'Country wide', + 'countries_json' => ['DE'], + 'regions_json' => [], + ]); + $specific = ShippingZone::factory()->create([ + 'store_id' => $store->id, + 'name' => 'Berlin', + 'countries_json' => ['DE'], + 'regions_json' => ['BE'], + ]); + + $zone = app(ShippingCalculator::class)->getMatchingZone($store, [ + 'country_code' => 'DE', + 'province_code' => 'BE', + ]); + + expect($zone->id)->toBe($specific->id); +}); + +it('skips inactive rates', function () { + ['store' => $store] = test()->createStoreContext(); + + $variant = CommerceTestHelper::purchasableVariant($store); + + $cart = Cart::factory()->create(['store_id' => $store->id, 'status' => CartStatus::Active]); + $cart->cartLines()->create([ + 'variant_id' => $variant->id, + 'quantity' => 1, + 'unit_price_amount' => 2500, + 'line_subtotal_amount' => 2500, + 'line_discount_amount' => 0, + 'line_total_amount' => 2500, + ]); + + $zone = ShippingZone::factory()->create([ + 'store_id' => $store->id, + 'countries_json' => ['DE'], + ]); + ShippingRate::factory()->create([ + 'zone_id' => $zone->id, + 'type' => ShippingRateType::Flat, + 'config_json' => ['amount' => 499], + 'is_active' => false, + ]); + + $rates = app(ShippingCalculator::class)->getAvailableRates($store, ['country_code' => 'DE'], $cart->refresh()); + + expect($rates)->toBeEmpty(); +}); diff --git a/tests/Unit/TaxCalculatorTest.php b/tests/Unit/TaxCalculatorTest.php new file mode 100644 index 00000000..a6dbe54e --- /dev/null +++ b/tests/Unit/TaxCalculatorTest.php @@ -0,0 +1,44 @@ +addExclusive(10000, 1900))->toBe(1900); +}); + +it('extracts manual tax from inclusive amount', function () { + $tax = taxCalculator()->extractInclusive(11900, 1900); + + expect($tax)->toBe(1900); + expect(11900 - $tax)->toBe(10000); +}); + +it('returns zero tax when no rate is configured', function () { + expect(taxCalculator()->addExclusive(10000, 0))->toBe(0); +}); + +it('handles zero amount lines', function () { + expect(taxCalculator()->addExclusive(0, 1900))->toBe(0); +}); + +it('calculates tax with non-standard rate', function () { + expect(taxCalculator()->addExclusive(8999, 700))->toBe(629); +}); + +it('extracts tax correctly for small amounts', function () { + $tax = taxCalculator()->extractInclusive(119, 1900); + + expect($tax)->toBe(19); + expect(119 - $tax)->toBe(100); +}); + +it('handles high tax rates', function () { + expect(taxCalculator()->addExclusive(10000, 2500))->toBe(2500); +});