commit d610cc5717e19cb2241143d06190f994b0e8dd49 Author: a6a2f5842 Date: Fri Nov 21 11:49:41 2025 +0100 init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..930be90 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +docker +.vscode \ No newline at end of file diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..1d953f4 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use nix diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e5743dc --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +/.vscode export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore +/docs export-ignore +/tests export-ignore \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..780f7a4 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,39 @@ +name: Tests + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + php: [8.1, 8.2, 8.3] + laravel: [10.*, 11.*] + exclude: + - php: 8.1 + laravel: 11.* + + name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite + coverage: none + + - name: Install dependencies + run: | + composer require "illuminate/support:${{ matrix.laravel }}" --no-update + composer update --prefer-dist --no-interaction --no-progress + + - name: Execute tests + run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b39bae1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +reports +sandbox +vendor +composer.lock +.idea/ +workbench +.phpunit.result.cache \ No newline at end of file diff --git a/.phpunit.cache/test-results b/.phpunit.cache/test-results new file mode 100644 index 0000000..7fac347 --- /dev/null +++ b/.phpunit.cache/test-results @@ -0,0 +1 @@ +{"version":2,"defects":{"Blax\\Shop\\Tests\\Feature\\CartTest::it_can_add_product_to_cart":8,"Blax\\Shop\\Tests\\Feature\\CartTest::it_can_remove_product_from_cart":8,"Blax\\Shop\\Tests\\Feature\\CartTest::it_can_update_product_quantity":8,"Blax\\Shop\\Tests\\Feature\\CartTest::it_can_clear_cart":8,"Blax\\Shop\\Tests\\Feature\\CartTest::it_calculates_cart_totals_correctly":8,"Blax\\Shop\\Tests\\Feature\\CartTest::it_applies_discount_to_cart":8,"Blax\\Shop\\Tests\\Feature\\CartTest::it_prevents_adding_out_of_stock_products":8,"Blax\\Shop\\Tests\\Feature\\CartTest::it_prevents_adding_quantity_exceeding_stock":8,"Blax\\Shop\\Tests\\Feature\\OrderTest::it_can_create_an_order":8,"Blax\\Shop\\Tests\\Feature\\OrderTest::it_can_add_items_to_order":8,"Blax\\Shop\\Tests\\Feature\\OrderTest::it_calculates_order_totals_correctly":8,"Blax\\Shop\\Tests\\Feature\\OrderTest::it_can_change_order_status":8,"Blax\\Shop\\Tests\\Feature\\OrderTest::it_can_scope_completed_orders":8,"Blax\\Shop\\Tests\\Feature\\OrderTest::it_generates_unique_order_number":8,"Blax\\Shop\\Tests\\Feature\\OrderTest::it_can_add_shipping_address":8,"Blax\\Shop\\Tests\\Feature\\OrderTest::it_reduces_stock_when_order_is_completed":8,"Blax\\Shop\\Tests\\Feature\\ProductTest::it_can_create_a_minimal_product":8,"Blax\\Shop\\Tests\\Feature\\ProductTest::it_can_create_a_product_with_full_details":8,"Blax\\Shop\\Tests\\Feature\\ProductTest::it_detects_when_product_is_on_sale":8,"Blax\\Shop\\Tests\\Feature\\ProductTest::it_returns_current_price_correctly":8,"Blax\\Shop\\Tests\\Feature\\ProductTest::it_can_attach_categories_to_product":8,"Blax\\Shop\\Tests\\Feature\\ProductTest::it_can_scope_published_products":8,"Blax\\Shop\\Tests\\Feature\\ProductTest::it_can_scope_in_stock_products":8,"Blax\\Shop\\Tests\\Feature\\ProductTest::it_can_scope_featured_products":8,"Blax\\Shop\\Tests\\Feature\\ProductTest::it_can_create_variable_product_with_variants":8,"Blax\\Shop\\Tests\\Feature\\ProductTest::it_checks_if_product_is_low_stock":8,"Blax\\Shop\\Tests\\Feature\\ProductTest::it_can_set_localized_content":8,"Blax\\Shop\\Tests\\Unit\\ProductPricingTest::it_returns_regular_price_when_not_on_sale":8,"Blax\\Shop\\Tests\\Unit\\ProductPricingTest::it_returns_sale_price_when_on_sale":8,"Blax\\Shop\\Tests\\Unit\\ProductPricingTest::it_returns_regular_price_when_sale_has_ended":8,"Blax\\Shop\\Tests\\Unit\\ProductPricingTest::it_returns_regular_price_when_sale_hasnt_started":8,"Blax\\Shop\\Tests\\Unit\\ProductPricingTest::it_calculates_discount_percentage":8,"Blax\\Shop\\Tests\\Unit\\StockManagementTest::it_detects_low_stock":8,"Blax\\Shop\\Tests\\Unit\\StockManagementTest::it_detects_sufficient_stock":8,"Blax\\Shop\\Tests\\Unit\\StockManagementTest::it_marks_product_as_out_of_stock":8,"Blax\\Shop\\Tests\\Unit\\StockManagementTest::it_allows_backorders_when_enabled":8,"Blax\\Shop\\Tests\\Unit\\StockManagementTest::products_without_stock_management_are_always_in_stock":8,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_can_create_a_cart":7,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_automatically_generates_uuid":7,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_can_add_items_to_cart":8,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_can_update_cart_item_quantity":8,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_can_remove_items_from_cart":8,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_calculates_cart_total_correctly":8,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_calculates_total_items_correctly":8,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_can_check_if_cart_is_expired":8,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_can_check_if_cart_is_converted":8,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_can_scope_active_carts":8,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_can_scope_carts_for_user":7,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_belongs_to_a_user":8,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::cart_items_have_correct_relationships":8,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_calculates_cart_item_subtotal":8,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_can_store_cart_item_attributes":8,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_can_have_multiple_items_of_same_product_with_different_attributes":8,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_deletes_cart_items_when_cart_is_deleted":7,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::it_can_create_a_product_action":8,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::product_has_many_actions":8,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::action_belongs_to_product":8,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::it_can_enable_and_disable_actions":7,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::it_can_store_action_parameters":8,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::it_can_set_action_priority":8,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::it_can_have_different_events":8,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::it_can_get_actions_for_specific_event":8,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::it_can_filter_enabled_actions":8,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::multiple_products_can_have_same_action":8,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::it_can_update_action_parameters":8,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::deleting_product_deletes_actions":7,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::action_can_have_empty_parameters":8,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::it_can_query_actions_by_priority_order":8,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_create_a_category":8,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_have_a_parent_category":8,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_have_multiple_children":8,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_attach_products_to_category":8,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_count_products_in_category":8,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_check_visibility":8,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_have_a_sort_order":8,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_store_meta_data":8,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::product_can_belong_to_multiple_categories":8,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_get_all_products_from_category_hierarchy":8,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_detach_products_from_category":8,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::deleting_category_does_not_delete_products":8,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_scope_visible_categories":8,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_get_root_categories":8,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_maintains_category_hierarchy_integrity":8,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_create_a_product":8,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_automatically_generates_slug_if_not_provided":8,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_returns_current_price_correctly":8,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_applies_sale_price_when_active":8,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_ignores_sale_price_when_not_started":8,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_ignores_sale_price_when_ended":8,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_manage_stock":8,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_cannot_decrease_stock_below_zero":8,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_returns_available_stock":8,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_check_if_in_stock":8,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_attach_categories":8,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_have_attributes":8,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_have_multiple_prices":8,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_have_related_products":8,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_have_upsell_products":8,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_have_cross_sell_products":8,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_scope_published_products":8,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_scope_in_stock_products":8,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_scope_visible_products":8,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_have_parent_child_relationships":8,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_validates_virtual_and_downloadable_flags":8,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_check_featured_status":8,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_can_purchase_a_product_directly":8,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_can_add_product_to_cart":8,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_can_get_cart_items":8,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_can_update_cart_item_quantity":8,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_can_remove_item_from_cart":8,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_can_checkout_cart":8,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_can_get_cart_total":8,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_can_get_cart_items_count":8,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_can_clear_cart":8,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_can_check_if_product_was_purchased":8,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_can_get_completed_purchases":8,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::purchase_reduces_stock_when_managed":8,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::cannot_purchase_more_than_available_stock":8,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::adding_to_cart_checks_stock_availability":8,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::purchase_can_store_metadata":8,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::purchase_can_be_associated_with_cart":7,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::checkout_marks_cart_as_converted":8,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_cannot_add_out_of_stock_product_to_cart":8,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::purchase_stores_amount_correctly":8,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_can_reserve_stock_for_a_product":8,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_cannot_reserve_more_stock_than_available":8,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_can_release_reserved_stock":8,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_can_check_if_stock_is_pending":8,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_can_check_if_stock_is_released":8,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_can_find_expired_reservations":8,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_can_distinguish_temporary_and_permanent_reservations":8,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_belongs_to_a_product":8,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::product_has_many_stock_records":8,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_can_get_active_stock_reservations":8,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_cannot_release_stock_twice":8,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_can_store_reservation_note":8,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_handles_stock_transactions_atomically":8,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_calculates_available_stock_correctly":8,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::product_tracks_low_stock_threshold":8,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_updates_in_stock_status_automatically":8,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_automatically_generates_slug_from_name":8},"times":{"Blax\\Shop\\Tests\\Feature\\OrderTest::it_can_create_an_order":0,"Blax\\Shop\\Tests\\Feature\\OrderTest::it_can_add_items_to_order":0,"Blax\\Shop\\Tests\\Feature\\OrderTest::it_calculates_order_totals_correctly":0,"Blax\\Shop\\Tests\\Feature\\OrderTest::it_can_change_order_status":0,"Blax\\Shop\\Tests\\Feature\\OrderTest::it_can_scope_completed_orders":0,"Blax\\Shop\\Tests\\Feature\\OrderTest::it_generates_unique_order_number":0,"Blax\\Shop\\Tests\\Feature\\OrderTest::it_can_add_shipping_address":0,"Blax\\Shop\\Tests\\Feature\\OrderTest::it_reduces_stock_when_order_is_completed":0.003,"Blax\\Shop\\Tests\\Feature\\ProductTest::it_can_create_a_minimal_product":0.006,"Blax\\Shop\\Tests\\Feature\\ProductTest::it_can_create_a_product_with_full_details":0.001,"Blax\\Shop\\Tests\\Feature\\ProductTest::it_detects_when_product_is_on_sale":0,"Blax\\Shop\\Tests\\Feature\\ProductTest::it_returns_current_price_correctly":0,"Blax\\Shop\\Tests\\Feature\\ProductTest::it_can_attach_categories_to_product":0,"Blax\\Shop\\Tests\\Feature\\ProductTest::it_can_scope_published_products":0,"Blax\\Shop\\Tests\\Feature\\ProductTest::it_can_scope_in_stock_products":0,"Blax\\Shop\\Tests\\Feature\\ProductTest::it_can_scope_featured_products":0,"Blax\\Shop\\Tests\\Feature\\ProductTest::it_can_create_variable_product_with_variants":0,"Blax\\Shop\\Tests\\Feature\\ProductTest::it_checks_if_product_is_low_stock":0,"Blax\\Shop\\Tests\\Feature\\ProductTest::it_can_set_localized_content":0,"Blax\\Shop\\Tests\\Unit\\ProductPricingTest::it_returns_regular_price_when_not_on_sale":0.001,"Blax\\Shop\\Tests\\Unit\\ProductPricingTest::it_returns_sale_price_when_on_sale":0.001,"Blax\\Shop\\Tests\\Unit\\ProductPricingTest::it_returns_regular_price_when_sale_has_ended":0.001,"Blax\\Shop\\Tests\\Unit\\ProductPricingTest::it_returns_regular_price_when_sale_hasnt_started":0.001,"Blax\\Shop\\Tests\\Unit\\ProductPricingTest::it_calculates_discount_percentage":0.001,"Blax\\Shop\\Tests\\Unit\\StockManagementTest::it_detects_low_stock":0.001,"Blax\\Shop\\Tests\\Unit\\StockManagementTest::it_detects_sufficient_stock":0.001,"Blax\\Shop\\Tests\\Unit\\StockManagementTest::it_marks_product_as_out_of_stock":0.001,"Blax\\Shop\\Tests\\Unit\\StockManagementTest::it_allows_backorders_when_enabled":0.001,"Blax\\Shop\\Tests\\Unit\\StockManagementTest::products_without_stock_management_are_always_in_stock":0.001,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_can_create_a_cart":0.187,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_automatically_generates_uuid":0.001,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_can_add_items_to_cart":0.005,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_can_update_cart_item_quantity":0.002,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_can_remove_items_from_cart":0.002,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_calculates_cart_total_correctly":0.002,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_calculates_total_items_correctly":0.002,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_can_check_if_cart_is_expired":0.002,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_can_check_if_cart_is_converted":0.001,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_can_scope_active_carts":0.001,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_can_scope_carts_for_user":0.002,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_belongs_to_a_user":0.003,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::cart_items_have_correct_relationships":0.002,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_calculates_cart_item_subtotal":0.001,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_can_store_cart_item_attributes":0.002,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_can_have_multiple_items_of_same_product_with_different_attributes":0.002,"Blax\\Shop\\Tests\\Feature\\CartManagementTest::it_deletes_cart_items_when_cart_is_deleted":0.004,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::it_can_create_a_product_action":0.001,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::product_has_many_actions":0.002,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::action_belongs_to_product":0.001,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::it_can_enable_and_disable_actions":0.001,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::it_can_store_action_parameters":0.001,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::it_can_set_action_priority":0.001,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::it_can_have_different_events":0.001,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::it_can_get_actions_for_specific_event":0.001,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::it_can_filter_enabled_actions":0.001,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::multiple_products_can_have_same_action":0.002,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::it_can_update_action_parameters":0.001,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::deleting_product_deletes_actions":0.001,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::action_can_have_empty_parameters":0.001,"Blax\\Shop\\Tests\\Feature\\ProductActionTest::it_can_query_actions_by_priority_order":0.001,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_create_a_category":0.001,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_automatically_generates_slug_from_name":0.002,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_have_a_parent_category":0.001,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_have_multiple_children":0.001,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_attach_products_to_category":0.001,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_count_products_in_category":0.001,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_check_visibility":0.001,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_have_a_sort_order":0.001,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_store_meta_data":0.001,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::product_can_belong_to_multiple_categories":0.001,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_get_all_products_from_category_hierarchy":0.001,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_detach_products_from_category":0.001,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::deleting_category_does_not_delete_products":0.001,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_scope_visible_categories":0.001,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_can_get_root_categories":0.001,"Blax\\Shop\\Tests\\Feature\\ProductCategoryTest::it_maintains_category_hierarchy_integrity":0.001,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_create_a_product":0.001,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_automatically_generates_slug_if_not_provided":0.001,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_returns_current_price_correctly":0.001,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_applies_sale_price_when_active":0.001,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_ignores_sale_price_when_not_started":0.001,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_ignores_sale_price_when_ended":0.001,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_manage_stock":0.001,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_cannot_decrease_stock_below_zero":0.001,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_returns_available_stock":0.001,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_check_if_in_stock":0.001,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_attach_categories":0.001,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_have_attributes":0.002,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_have_multiple_prices":0.001,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_have_related_products":0.003,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_have_upsell_products":0.002,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_have_cross_sell_products":0.001,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_scope_published_products":0.001,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_scope_in_stock_products":0.001,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_scope_visible_products":0.001,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_have_parent_child_relationships":0.001,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_validates_virtual_and_downloadable_flags":0.001,"Blax\\Shop\\Tests\\Feature\\ProductManagementTest::it_can_check_featured_status":0.001,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_can_purchase_a_product_directly":0.001,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_can_add_product_to_cart":0.002,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_can_get_cart_items":0.002,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_can_update_cart_item_quantity":0.001,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_can_remove_item_from_cart":0.001,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_can_checkout_cart":0.001,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_can_get_cart_total":0.001,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_can_get_cart_items_count":0.002,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_can_clear_cart":0.002,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_can_check_if_product_was_purchased":0.001,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_can_get_completed_purchases":0.002,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::purchase_reduces_stock_when_managed":0.001,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::cannot_purchase_more_than_available_stock":0.001,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::adding_to_cart_checks_stock_availability":0.002,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::purchase_can_store_metadata":0.001,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::purchase_can_be_associated_with_cart":0.003,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::checkout_marks_cart_as_converted":0.002,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::user_cannot_add_out_of_stock_product_to_cart":0.003,"Blax\\Shop\\Tests\\Feature\\PurchaseFlowTest::purchase_stores_amount_correctly":0.002,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_can_reserve_stock_for_a_product":0.002,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_cannot_reserve_more_stock_than_available":0.001,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_can_release_reserved_stock":0.001,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_can_check_if_stock_is_pending":0.002,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_can_check_if_stock_is_released":0.001,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_can_find_expired_reservations":0.001,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_can_distinguish_temporary_and_permanent_reservations":0.001,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_belongs_to_a_product":0.001,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::product_has_many_stock_records":0.001,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_can_get_active_stock_reservations":0.001,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_cannot_release_stock_twice":0.001,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_can_store_reservation_note":0.001,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_handles_stock_transactions_atomically":0.001,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_calculates_available_stock_correctly":0.001,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::product_tracks_low_stock_threshold":0.001,"Blax\\Shop\\Tests\\Feature\\StockManagementTest::it_updates_in_stock_status_automatically":0.001}} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..977478f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "editor.formatOnSave": true, + "[php]": { + "editor.defaultFormatter": "bmewburn.vscode-intelephense-client" + }, +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f64ffa --- /dev/null +++ b/README.md @@ -0,0 +1,162 @@ +# Laravel Shop Package + +A comprehensive headless e-commerce package for Laravel with multi-currency support, stock management, Stripe integration, and product actions. + +## Features + +- πŸ›οΈ **Product Management** - Simple, variable, grouped, and external products +- πŸ’° **Multi-Currency Support** - Handle multiple currencies with ease +- πŸ“¦ **Advanced Stock Management** - Stock reservations, low stock alerts, and backorders +- πŸ’³ **Stripe Integration** - Built-in Stripe product and price synchronization +- 🎯 **Product Actions** - Execute custom actions on product events (purchases, refunds) +- πŸ”— **Product Relations** - Related products, upsells, and cross-sells +- 🌍 **Translation Ready** - Built-in meta translation support +- πŸ“Š **Stock Logging** - Complete audit trail of stock changes +- 🎨 **Headless Architecture** - Perfect for API-first applications +- ⚑ **Caching Support** - Built-in cache management for better performance +- πŸ›’ **Shopping Capabilities** - Built-in trait for any purchaser model + +## Installation + +```bash +composer require blax-software/laravel-shop +``` + +Publish the configuration: + +```bash +php artisan vendor:publish --provider="Blax\Shop\ShopServiceProvider" +``` + +Run migrations: + +```bash +php artisan migrate +``` + +## Quick Start + +### Setup Your User Model + +Add the `HasShoppingCapabilities` trait to any model that should be able to purchase products (typically your User model): + +```php +use Blax\Shop\Traits\HasShoppingCapabilities; + +class User extends Authenticatable +{ + use HasShoppingCapabilities; + + // ...existing code... +} +``` + +### Creating Your First Product + +```php +use Blax\Shop\Models\Product; + +$product = Product::create([ + 'slug' => 'amazing-t-shirt', + 'sku' => 'TSH-001', + 'type' => 'simple', + 'price' => 29.99, + 'regular_price' => 29.99, + 'manage_stock' => true, + 'stock_quantity' => 100, + 'status' => 'published', +]); + +// Add translated name +$product->setLocalized('name', 'Amazing T-Shirt', 'en'); +$product->setLocalized('description', 'A comfortable cotton t-shirt', 'en'); +``` + +### Purchasing a Product + +```php +use Blax\Shop\Models\Product; + +$product = Product::find($productId); +$user = auth()->user(); + +// Simple purchase +$purchase = $user->purchase($product, quantity: 1); + +// Purchase with options +$purchase = $user->purchase($product, quantity: 2, options: [ + 'price_id' => $priceId, + 'charge_id' => $paymentIntent->id, +]); + +// Add to cart +$cartItem = $user->addToCart($product, quantity: 1); + +// Checkout cart +$completedPurchases = $user->checkout(); + +// Check if user has purchased +if ($user->hasPurchased($product)) { + // Grant access +} +``` + +## Documentation + +- [Product Management](docs/01-products.md) +- [Stripe Integration](docs/02-stripe.md) +- [Purchasing Products](docs/03-purchasing.md) +- [Subscriptions](docs/04-subscriptions.md) +- [Stock Management](docs/05-stock.md) +- [API Usage](docs/06-api.md) + +## Configuration + +The `config/shop.php` file contains all configuration options: + +```php +return [ + 'tables' => [ + 'products' => 'products', + 'product_categories' => 'product_categories', + // ... + ], + + 'stripe' => [ + 'enabled' => env('SHOP_STRIPE_ENABLED', false), + 'sync_prices' => env('SHOP_STRIPE_SYNC_PRICES', true), + ], + + 'stock' => [ + 'allow_backorders' => env('SHOP_ALLOW_BACKORDERS', false), + 'log_changes' => env('SHOP_LOG_STOCK_CHANGES', true), + ], + + 'cache' => [ + 'enabled' => env('SHOP_CACHE_ENABLED', true), + 'prefix' => 'shop:', + ], +]; +``` + +## Commands + +### Reinstall Shop Tables + +```bash +# With confirmation +php artisan shop:reinstall + +# Force without confirmation +php artisan shop:reinstall --force +``` + +⚠️ **Warning:** This will delete all shop data! + +## License + +MIT License + +## Support + +For issues and questions, please use the [GitHub issue tracker](https://github.com/blax/laravel-shop/issues). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..f3812b1 --- /dev/null +++ b/composer.json @@ -0,0 +1,59 @@ +{ + "name": "blax-software/laravel-shop", + "description": "A comprehensive e-commerce package for Laravel", + "type": "library", + "license": "MIT", + "autoload": { + "psr-4": { + "Blax\\Shop\\": "src/", + "Blax\\Shop\\Database\\Factories\\": "database/factories/" + } + }, + "autoload-dev": { + "psr-4": { + "Blax\\Shop\\Tests\\": "tests/", + "Workbench\\App\\": "workbench/app/", + "Workbench\\Database\\Factories\\": "workbench/database/factories/", + "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" + } + }, + "require": { + "php": "^8.1", + "illuminate/support": "^10.0|^11.0", + "illuminate/database": "^10.0|^11.0", + "blax-software/laravel-workkit": "dev-master" + }, + "require-dev": { + "orchestra/testbench": "^8.0|^9.0", + "phpunit/phpunit": "^10.0", + "mockery/mockery": "^1.5" + }, + "scripts": { + "test": "vendor/bin/phpunit", + "test-coverage": "vendor/bin/phpunit --coverage-html coverage", + "post-autoload-dump": [ + "@clear", + "@prepare" + ], + "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", + "prepare": "@php vendor/bin/testbench package:discover --ansi", + "build": "@php vendor/bin/testbench workbench:build --ansi", + "serve": [ + "Composer\\Config::disableProcessTimeout", + "@build", + "@php vendor/bin/testbench serve --ansi" + ], + "lint": [ + "pint" + ] + }, + "extra": { + "laravel": { + "providers": [ + "Blax\\Shop\\ShopServiceProvider" + ] + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} \ No newline at end of file diff --git a/config/shop.php b/config/shop.php new file mode 100644 index 0000000..ef0ffac --- /dev/null +++ b/config/shop.php @@ -0,0 +1,84 @@ + [ + 'products' => 'products', + 'product_prices' => 'product_prices', + 'product_categories' => 'product_categories', + 'product_images' => 'product_images', + 'product_attributes' => 'product_attributes', + 'product_purchases' => 'product_purchases', + 'product_stocks' => 'product_stocks', + 'carts' => 'carts', + 'cart_items' => 'cart_items', + ], + + // Model classes (allow overriding in main instance) + 'models' => [ + 'product' => \Blax\Shop\Models\Product::class, + 'product_price' => \Blax\Shop\Models\ProductPrice::class, + 'product_category' => \Blax\Shop\Models\ProductCategory::class, + 'product_stock' => \Blax\Shop\Models\ProductStock::class, + 'product_attribute' => \Blax\Shop\Models\ProductAttribute::class, + 'cart' => \Blax\Shop\Models\Cart::class, + 'cart_item' => \Blax\Shop\Models\CartItem::class, + ], + + // API Routes configuration + 'routes' => [ + 'enabled' => true, + 'prefix' => 'api/shop', + 'middleware' => ['api'], + 'name_prefix' => 'shop.', + ], + + // Stock management + 'stock' => [ + 'track_inventory' => true, + 'allow_backorders' => false, + 'low_stock_threshold' => 5, + 'log_changes' => true, + 'auto_release_expired' => true, + ], + + // Product actions (extensible by main instance) + 'actions' => [ + 'path' => app_path('Jobs/ProductAction'), + 'namespace' => 'App\\Jobs\\ProductAction', + 'auto_discover' => true, + ], + + // Stripe integration (optional) + 'stripe' => [ + 'enabled' => env('SHOP_STRIPE_ENABLED', false), + 'sync_prices' => true, + ], + + // Cache configuration + 'cache' => [ + 'enabled' => env('SHOP_CACHE_ENABLED', true), + 'ttl' => 3600, + 'prefix' => 'shop:', + ], + + // Pagination + 'pagination' => [ + 'per_page' => 20, + 'max_per_page' => 100, + ], + + // Cart configuration + 'cart' => [ + 'expire_after_days' => 30, + 'auto_cleanup' => true, + 'merge_on_login' => true, + ], + + // API Response format + 'api' => [ + 'include_meta' => true, + 'wrap_response' => true, + 'response_key' => 'data', + ], +]; diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php new file mode 100644 index 0000000..3af524e --- /dev/null +++ b/database/factories/OrderFactory.php @@ -0,0 +1,47 @@ + 'ORD-' . strtoupper($this->faker->bothify('####-????')), + 'customer_id' => null, + 'customer_email' => $this->faker->safeEmail(), + 'customer_first_name' => $this->faker->firstName(), + 'customer_last_name' => $this->faker->lastName(), + 'status' => 'pending', + 'payment_status' => 'pending', + 'subtotal' => 0, + 'tax_total' => 0, + 'shipping_total' => 0, + 'discount_total' => 0, + 'total' => 0, + 'currency' => 'USD', + 'payment_method' => 'stripe', + ]; + } + + public function completed(): static + { + return $this->state([ + 'status' => 'completed', + 'payment_status' => 'paid', + ]); + } + + public function cancelled(): static + { + return $this->state([ + 'status' => 'cancelled', + 'payment_status' => 'failed', + ]); + } +} diff --git a/database/factories/ProductCategoryFactory.php b/database/factories/ProductCategoryFactory.php new file mode 100644 index 0000000..77a588d --- /dev/null +++ b/database/factories/ProductCategoryFactory.php @@ -0,0 +1,25 @@ +faker->words(2, true); + + return [ + 'name' => $name, + 'slug' => Str::slug($name), + 'is_visible' => true, + 'sort_order' => $this->faker->numberBetween(0, 100), + 'meta' => json_encode(new \stdClass()), + ]; + } +} diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php new file mode 100644 index 0000000..656e845 --- /dev/null +++ b/database/factories/ProductFactory.php @@ -0,0 +1,70 @@ +faker->words(3, true); + + return [ + 'slug' => Str::slug($name), + 'sku' => strtoupper($this->faker->bothify('??-####')), + 'type' => 'simple', + 'status' => 'published', + 'visible' => true, + 'featured' => false, + 'price' => $this->faker->randomFloat(2, 10, 1000), + 'regular_price' => $this->faker->randomFloat(2, 10, 1000), + 'manage_stock' => true, + 'stock_quantity' => $this->faker->numberBetween(0, 100), + 'in_stock' => true, + 'stock_status' => 'instock', + 'published_at' => now(), + 'meta' => json_encode(new \stdClass()), + ]; + } + + public function onSale(): static + { + return $this->state(function (array $attributes) { + $regularPrice = $attributes['regular_price']; + return [ + 'sale_price' => $regularPrice * 0.8, + 'sale_start' => now()->subDay(), + 'sale_end' => now()->addWeek(), + ]; + }); + } + + public function outOfStock(): static + { + return $this->state([ + 'stock_quantity' => 0, + 'in_stock' => false, + 'stock_status' => 'outofstock', + ]); + } + + public function variable(): static + { + return $this->state(['type' => 'variable']); + } + + public function draft(): static + { + return $this->state(['status' => 'draft']); + } + + public function featured(): static + { + return $this->state(['featured' => true]); + } +} diff --git a/database/migrations/create_blax_shop_tables.php.stub b/database/migrations/create_blax_shop_tables.php.stub new file mode 100644 index 0000000..2648301 --- /dev/null +++ b/database/migrations/create_blax_shop_tables.php.stub @@ -0,0 +1,299 @@ +uuid('id')->primary(); + $table->string('slug')->unique(); + $table->string('sku')->nullable()->unique(); + $table->string('type')->default('simple'); // simple, variable, grouped, external + $table->string('stripe_product_id')->nullable(); + $table->decimal('price', 10, 2)->default(0); + $table->decimal('regular_price', 10, 2)->nullable(); + $table->decimal('sale_price', 10, 2)->nullable(); + $table->timestamp('sale_start')->nullable(); + $table->timestamp('sale_end')->nullable(); + $table->boolean('manage_stock')->default(false); + $table->integer('stock_quantity')->default(0); + $table->integer('low_stock_threshold')->nullable(); + $table->boolean('in_stock')->default(true); + $table->string('stock_status')->default('instock'); // instock, outofstock, onbackorder + $table->decimal('weight', 10, 2)->nullable(); + $table->decimal('length', 10, 2)->nullable(); + $table->decimal('width', 10, 2)->nullable(); + $table->decimal('height', 10, 2)->nullable(); + $table->boolean('virtual')->default(false); + $table->boolean('downloadable')->default(false); + $table->uuid('parent_id')->nullable(); + $table->boolean('featured')->default(false); + $table->boolean('visible')->default(true); + $table->string('status')->default('draft'); // draft, published, archived + $table->timestamp('published_at')->nullable(); + $table->integer('sort_order')->default(0); + $table->json('meta')->default('{}'); + $table->string('tax_class')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['slug', 'status']); + $table->index(['featured', 'visible', 'status']); + $table->index('parent_id'); + $table->foreign('parent_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade'); + }); + } else { + // Add new fields to existing products table + Schema::table(config('shop.tables.products', 'products'), function (Blueprint $table) { + if (!Schema::hasColumn(config('shop.tables.products', 'products'), 'low_stock_threshold')) { + $table->integer('low_stock_threshold')->nullable()->after('stock_quantity'); + } + if (!Schema::hasColumn(config('shop.tables.products', 'products'), 'published_at')) { + $table->timestamp('published_at')->nullable()->after('status'); + } + if (!Schema::hasColumn(config('shop.tables.products', 'products'), 'sort_order')) { + $table->integer('sort_order')->default(0)->after('published_at'); + } + }); + } + + // Product categories table + if (!Schema::hasTable(config('shop.tables.product_categories', 'product_categories'))) { + Schema::create(config('shop.tables.product_categories', 'product_categories'), function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->string('slug')->unique(); + $table->uuid('parent_id')->nullable(); + $table->integer('sort_order')->default(0); + $table->boolean('visible')->default(true); + $table->json('meta')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['parent_id', 'visible']); + $table->foreign('parent_id')->references('id')->on(config('shop.tables.product_categories', 'product_categories'))->onDelete('cascade'); + }); + } + + // Product category pivot table + if (!Schema::hasTable(config('shop.tables.product_category_product', 'product_category_product'))) { + Schema::create(config('shop.tables.product_category_product', 'product_category_product'), function (Blueprint $table) { + $table->uuid('product_id'); + $table->uuid('product_category_id'); + $table->integer('sort_order')->default(0); + $table->timestamps(); + + $table->primary(['product_id', 'product_category_id'], 'product_category_product_primary'); + $table->foreign('product_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade'); + $table->foreign('product_category_id')->references('id')->on(config('shop.tables.product_categories', 'product_categories'))->onDelete('cascade'); + }); + } + + // Product prices table (multi-currency support) + if (!Schema::hasTable(config('shop.tables.product_prices', 'product_prices'))) { + Schema::create(config('shop.tables.product_prices', 'product_prices'), function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->uuid('product_id'); + $table->string('currency', 3)->default('USD'); + $table->decimal('price', 10, 2); + $table->string('stripe_price_id')->nullable(); + $table->boolean('is_default')->default(false); + $table->timestamps(); + + $table->index(['product_id', 'currency']); + $table->foreign('product_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade'); + }); + } + + // Product attributes table + if (!Schema::hasTable(config('shop.tables.product_attributes', 'product_attributes'))) { + Schema::create(config('shop.tables.product_attributes', 'product_attributes'), function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->uuid('product_id'); + $table->string('key'); + $table->text('value')->nullable(); + $table->string('type')->default('text'); // text, select, color, image + $table->integer('sort_order')->default(0); + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->index(['product_id', 'key']); + $table->foreign('product_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade'); + }); + } + + // Product stocks table (reservations) + if (!Schema::hasTable(config('shop.tables.product_stocks', 'product_stocks'))) { + Schema::create(config('shop.tables.product_stocks', 'product_stocks'), function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->uuid('product_id'); + $table->integer('quantity'); + $table->string('type')->default('reservation'); // reservation, adjustment, sale, return + $table->string('status')->default('pending'); // pending, completed, cancelled, expired + $table->string('reference_type')->nullable(); + $table->string('reference_id')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->text('note')->nullable(); + $table->timestamps(); + + $table->index(['product_id', 'status']); + $table->index(['reference_type', 'reference_id']); + $table->foreign('product_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade'); + }); + } + + // Product stock logs table + if (!Schema::hasTable(config('shop.tables.product_stock_logs', 'product_stock_logs'))) { + Schema::create(config('shop.tables.product_stock_logs', 'product_stock_logs'), function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->uuid('product_id'); + $table->integer('quantity_change'); + $table->integer('quantity_after'); + $table->string('type'); // increase, decrease, adjustment + $table->string('reference_type')->nullable(); + $table->string('reference_id')->nullable(); + $table->text('note')->nullable(); + $table->timestamps(); + + $table->index(['product_id', 'created_at']); + $table->foreign('product_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade'); + }); + } + + // Product relations table (related, upsell, cross-sell) + if (!Schema::hasTable(config('shop.tables.product_relations', 'product_relations'))) { + Schema::create(config('shop.tables.product_relations', 'product_relations'), function (Blueprint $table) { + $table->uuid('product_id'); + $table->uuid('related_product_id'); + $table->string('type')->default('related'); // related, upsell, cross-sell + $table->integer('sort_order')->default(0); + $table->timestamps(); + + $table->primary(['product_id', 'related_product_id', 'type'], 'product_relations_primary'); + $table->foreign('product_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade'); + $table->foreign('related_product_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade'); + + $table->index(['product_id', 'type']); + }); + } + + // Product actions table + if (!Schema::hasTable(config('shop.tables.product_actions', 'product_actions'))) { + Schema::create(config('shop.tables.product_actions', 'product_actions'), function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->uuid('product_id'); + $table->string('action_type'); + $table->string('event')->default('purchased'); // purchased, refunded, etc. + $table->json('config')->nullable(); + $table->boolean('active')->default(true); + $table->integer('sort_order')->default(0); + $table->timestamps(); + + $table->index(['product_id', 'event', 'active']); + $table->foreign('product_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade'); + }); + } + + // Product purchases table + if (!Schema::hasTable(config('shop.tables.product_purchases', 'product_purchases'))) { + Schema::create(config('shop.tables.product_purchases', 'product_purchases'), function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->uuid('product_id'); + $table->morphs('purchasable'); + $table->string('status')->default('pending'); + $table->integer('quantity')->default(1); + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->index(['product_id', 'status']); + $table->foreign('product_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade'); + }); + } + + // Carts table + if (!Schema::hasTable(config('shop.tables.carts', 'carts'))) { + Schema::create(config('shop.tables.carts', 'carts'), function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->string('session_id')->nullable()->unique(); + $table->nullableMorphs('customer'); + $table->string('currency', 3)->default('USD'); + $table->string('status')->default('active'); // active, abandoned, converted, expired + $table->timestamp('last_activity_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('converted_at')->nullable(); + $table->json('meta')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['session_id', 'status']); + $table->index(['customer_type', 'customer_id', 'status']); + }); + } + + // Cart items table + if (!Schema::hasTable(config('shop.tables.cart_items', 'cart_items'))) { + Schema::create(config('shop.tables.cart_items', 'cart_items'), function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->uuid('cart_id'); + $table->uuid('product_id'); + $table->integer('quantity')->default(1); + $table->decimal('price', 10, 2); + $table->decimal('regular_price', 10, 2)->nullable(); + $table->decimal('subtotal', 10, 2); + $table->json('attributes')->nullable(); + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->index(['cart_id', 'product_id']); + $table->foreign('cart_id')->references('id')->on(config('shop.tables.carts', 'carts'))->onDelete('cascade'); + $table->foreign('product_id')->references('id')->on(config('shop.tables.products', 'products'))->onDelete('cascade'); + }); + } + + // Cart discounts table + if (!Schema::hasTable(config('shop.tables.cart_discounts', 'cart_discounts'))) { + Schema::create(config('shop.tables.cart_discounts', 'cart_discounts'), function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->uuid('cart_id'); + $table->string('code')->nullable(); + $table->string('type')->default('percentage'); // percentage, fixed, shipping + $table->decimal('amount', 10, 2); + $table->decimal('discount_amount', 10, 2); + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->index('cart_id'); + $table->foreign('cart_id')->references('id')->on(config('shop.tables.carts', 'carts'))->onDelete('cascade'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists(config('shop.tables.cart_discounts', 'cart_discounts')); + Schema::dropIfExists(config('shop.tables.cart_items', 'cart_items')); + Schema::dropIfExists(config('shop.tables.carts', 'carts')); + Schema::dropIfExists(config('shop.tables.product_purchases', 'product_purchases')); + Schema::dropIfExists(config('shop.tables.product_actions', 'product_actions')); + Schema::dropIfExists(config('shop.tables.product_category_product', 'product_category_product')); + Schema::dropIfExists(config('shop.tables.product_relations', 'product_relations')); + Schema::dropIfExists(config('shop.tables.product_stock_logs', 'product_stock_logs')); + Schema::dropIfExists(config('shop.tables.product_stocks', 'product_stocks')); + Schema::dropIfExists(config('shop.tables.product_attributes', 'product_attributes')); + Schema::dropIfExists(config('shop.tables.product_prices', 'product_prices')); + Schema::dropIfExists('product_category_product'); + Schema::dropIfExists(config('shop.tables.product_categories', 'product_categories')); + Schema::dropIfExists(config('shop.tables.products', 'products')); + } +}; diff --git a/docs/01-products.md b/docs/01-products.md new file mode 100644 index 0000000..9c20c72 --- /dev/null +++ b/docs/01-products.md @@ -0,0 +1,406 @@ +# Product Management + +## Creating Products + +### Minimal Product Creation + +The absolute minimum to create a product: + +```php +use Blax\Shop\Models\Product; + +$product = Product::create([ + 'slug' => 'my-product', +]); +``` + +This will automatically: +- Generate a random slug if not provided +- Create a default name "New Product [slug]" +- Set status to 'draft' +- Set type to 'simple' + +### Basic Product Creation + +```php +$product = Product::create([ + 'slug' => 'blue-hoodie', + 'sku' => 'HOOD-BLU-001', + 'type' => 'simple', + 'price' => 49.99, + 'regular_price' => 49.99, + 'status' => 'published', + 'visible' => true, + 'featured' => false, +]); + +// Add translated content +$product->setLocalized('name', 'Blue Hoodie', 'en'); +$product->setLocalized('description', 'Comfortable cotton hoodie', 'en'); +$product->setLocalized('short_description', 'Cotton hoodie', 'en'); +``` + +### Advanced Product Creation + +```php +$product = Product::create([ + // Basic Info + 'slug' => 'premium-headphones', + 'sku' => 'HEAD-PREM-001', + 'type' => 'simple', + 'status' => 'published', + 'visible' => true, + 'featured' => true, + 'published_at' => now(), + 'sort_order' => 10, + + // Pricing + 'price' => 199.99, + 'regular_price' => 249.99, + 'sale_price' => 199.99, + 'sale_start' => now(), + 'sale_end' => now()->addDays(7), + + // Stock Management + 'manage_stock' => true, + 'stock_quantity' => 50, + 'low_stock_threshold' => 10, + 'in_stock' => true, + 'stock_status' => 'instock', + + // Physical Properties + 'weight' => 0.5, // kg + 'length' => 20, // cm + 'width' => 15, // cm + 'height' => 10, // cm + 'virtual' => false, + 'downloadable' => false, + + // Tax + 'tax_class' => 'standard', + + // Custom Meta + 'meta' => [ + 'brand' => 'AudioPro', + 'color' => 'black', + 'warranty' => '2 years', + ], +]); + +// Add translations +$product->setLocalized('name', 'Premium Wireless Headphones', 'en'); +$product->setLocalized('name', 'Auriculares Premium InalΓ‘mbricos', 'es'); + +$product->setLocalized('description', 'High-quality wireless headphones with noise cancellation', 'en'); +$product->setLocalized('short_description', 'Premium wireless headphones', 'en'); +``` + +## Product Types + +### Simple Product + +```php +$product = Product::create([ + 'type' => 'simple', + 'slug' => 't-shirt', + 'price' => 19.99, +]); +``` + +### Variable Product (Parent) + +```php +$parent = Product::create([ + 'type' => 'variable', + 'slug' => 'hoodie', + 'price' => 49.99, // Base price +]); + +// Create variants +$small = Product::create([ + 'type' => 'simple', + 'slug' => 'hoodie-small', + 'sku' => 'HOOD-S', + 'parent_id' => $parent->id, + 'price' => 49.99, +]); + +$medium = Product::create([ + 'type' => 'simple', + 'slug' => 'hoodie-medium', + 'sku' => 'HOOD-M', + 'parent_id' => $parent->id, + 'price' => 49.99, +]); + +$large = Product::create([ + 'type' => 'simple', + 'slug' => 'hoodie-large', + 'sku' => 'HOOD-L', + 'parent_id' => $parent->id, + 'price' => 54.99, // Different price +]); +``` + +### Grouped Product + +```php +$bundle = Product::create([ + 'type' => 'grouped', + 'slug' => 'starter-bundle', + 'price' => 99.99, +]); + +// Link products to the bundle (handle this in your app logic) +``` + +### Virtual/Downloadable Product + +```php +$ebook = Product::create([ + 'slug' => 'laravel-guide', + 'price' => 29.99, + 'virtual' => true, + 'downloadable' => true, + 'manage_stock' => false, // Virtual products don't need stock +]); +``` + +## Product Attributes + +Add custom attributes to products: + +```php +use Blax\Shop\Models\ProductAttribute; + +// Add size attribute +ProductAttribute::create([ + 'product_id' => $product->id, + 'key' => 'size', + 'value' => 'Large', + 'type' => 'select', + 'sort_order' => 1, +]); + +// Add color attribute +ProductAttribute::create([ + 'product_id' => $product->id, + 'key' => 'color', + 'value' => '#FF0000', + 'type' => 'color', + 'sort_order' => 2, +]); + +// Retrieve attributes +$attributes = $product->attributes; +``` + +## Product Categories + +```php +use Blax\Shop\Models\ProductCategory; + +// Create category +$category = ProductCategory::create([ + 'slug' => 'clothing', +]); +$category->setLocalized('name', 'Clothing', 'en'); + +// Attach product to category +$product->categories()->attach($category->id); + +// Detach +$product->categories()->detach($category->id); + +// Sync categories +$product->categories()->sync([ + $category1->id, + $category2->id, +]); +``` + +## Multi-Currency Pricing + +```php +use Blax\Shop\Models\ProductPrice; + +// Add EUR pricing +ProductPrice::create([ + 'product_id' => $product->id, + 'currency' => 'EUR', + 'price' => 39.99, + 'is_default' => false, +]); + +// Add GBP pricing +ProductPrice::create([ + 'product_id' => $product->id, + 'currency' => 'GBP', + 'price' => 34.99, + 'is_default' => false, +]); + +// Get all prices +$prices = $product->prices; + +// Get price for specific currency +$eurPrice = $product->prices()->where('currency', 'EUR')->first(); +``` + +## Product Relations + +### Related Products + +```php +// Attach related products +$product->relatedProducts()->attach($relatedProduct->id, [ + 'type' => 'related', + 'sort_order' => 1, +]); + +// Get all related products +$related = $product->relatedProducts()->get(); +``` + +### Upsells + +```php +// Attach upsell product +$product->relatedProducts()->attach($premiumProduct->id, [ + 'type' => 'upsell', + 'sort_order' => 1, +]); + +// Get upsells +$upsells = $product->upsells; +``` + +### Cross-sells + +```php +// Attach cross-sell product +$product->relatedProducts()->attach($accessory->id, [ + 'type' => 'cross-sell', + 'sort_order' => 1, +]); + +// Get cross-sells +$crossSells = $product->crossSells; +``` + +## Querying Products + +### Basic Queries + +```php +// Published products +$products = Product::published()->get(); + +// In stock products +$products = Product::inStock()->get(); + +// Featured products +$products = Product::featured()->get(); + +// Visible products (published and within publish date) +$products = Product::visible()->get(); +``` + +### Advanced Queries + +```php +// Search products +$products = Product::search('hoodie')->get(); + +// Filter by category +$products = Product::byCategory($categoryId)->get(); + +// Price range +$products = Product::priceRange(10, 50)->get(); + +// Order by price +$products = Product::orderByPrice('asc')->get(); + +// Low stock products +$products = Product::lowStock()->get(); + +// Combined query +$products = Product::visible() + ->inStock() + ->byCategory($categoryId) + ->priceRange(20, 100) + ->orderByPrice('asc') + ->paginate(20); +``` + +## Product Methods + +### Sale Detection + +```php +if ($product->isOnSale()) { + echo "On sale!"; +} +``` + +### Current Price + +```php +$price = $product->getCurrentPrice(); // Returns sale_price if on sale, otherwise regular_price +``` + +### Visibility Check + +```php +if ($product->isVisible()) { + // Show product +} +``` + +### Low Stock Check + +```php +if ($product->isLowStock()) { + // Show low stock warning +} +``` + +## API Serialization + +```php +// Get API-friendly array +$data = $product->toApiArray(); + +// Returns: +// [ +// 'id' => '...', +// 'slug' => '...', +// 'name' => '...', +// 'price' => 49.99, +// 'is_on_sale' => true, +// 'in_stock' => true, +// 'categories' => [...], +// 'attributes' => [...], +// 'variants' => [...], +// // ... +// ] +``` + +## Events + +The package dispatches events on product lifecycle: + +```php +use Blax\Shop\Events\ProductCreated; +use Blax\Shop\Events\ProductUpdated; + +// Listen to events in your EventServiceProvider +protected $listen = [ + ProductCreated::class => [ + SendProductCreatedNotification::class, + ], + ProductUpdated::class => [ + ClearProductCache::class, + ], +]; +``` diff --git a/docs/02-stripe.md b/docs/02-stripe.md new file mode 100644 index 0000000..35c1a0d --- /dev/null +++ b/docs/02-stripe.md @@ -0,0 +1,355 @@ +# Stripe Integration + +## Configuration + +### Enable Stripe + +Add to your `.env`: + +```env +SHOP_STRIPE_ENABLED=true +SHOP_STRIPE_SYNC_PRICES=true +STRIPE_KEY=your_stripe_key +STRIPE_SECRET=your_stripe_secret +``` + +Update `config/shop.php`: + +```php +'stripe' => [ + 'enabled' => env('SHOP_STRIPE_ENABLED', false), + 'sync_prices' => env('SHOP_STRIPE_SYNC_PRICES', true), + 'api_version' => '2023-10-16', +], +``` + +## Creating Products in Stripe + +### Manual Stripe Product Creation + +```php +use App\Services\StripeService; + +$product = Product::create([ + 'slug' => 'premium-plan', + 'price' => 29.99, + 'status' => 'published', +]); + +// Create in Stripe +$stripeProduct = StripeService::createProduct($product); + +// Store Stripe product ID +$product->update([ + 'stripe_product_id' => $stripeProduct->id, +]); +``` + +### Automatic Sync + +If you have event listeners set up, products can be automatically synced to Stripe: + +```php +use Blax\Shop\Events\ProductCreated; +use App\Listeners\SyncProductToStripe; + +// In EventServiceProvider +protected $listen = [ + ProductCreated::class => [ + SyncProductToStripe::class, + ], +]; + +// Listener implementation +class SyncProductToStripe +{ + public function handle(ProductCreated $event) + { + if (config('shop.stripe.enabled')) { + $stripeProduct = StripeService::createProduct($event->product); + + $event->product->update([ + 'stripe_product_id' => $stripeProduct->id, + ]); + } + } +} +``` + +## Syncing Prices to Stripe + +### Create Stripe Prices + +```php +use App\Services\StripeService; +use Blax\Shop\Models\ProductPrice; + +// Sync default price +StripeService::syncProductPricesDown($product); + +// Create additional currency prices +$eurPrice = ProductPrice::create([ + 'product_id' => $product->id, + 'currency' => 'EUR', + 'price' => 24.99, +]); + +// Create corresponding Stripe price +$stripePrice = StripeService::createPrice($product, $eurPrice); + +$eurPrice->update([ + 'stripe_price_id' => $stripePrice->id, +]); +``` + +## Creating Checkout Sessions + +### One-time Payment + +```php +use Stripe\Stripe; +use Stripe\Checkout\Session; + +Stripe::setApiKey(config('services.stripe.secret')); + +$product = Product::find($productId); + +$session = Session::create([ + 'payment_method_types' => ['card'], + 'line_items' => [[ + 'price_data' => [ + 'currency' => 'usd', + 'product_data' => [ + 'name' => $product->getLocalized('name'), + 'description' => $product->getLocalized('short_description'), + ], + 'unit_amount' => $product->getCurrentPrice() * 100, // Convert to cents + ], + 'quantity' => 1, + ]], + 'mode' => 'payment', + 'success_url' => route('checkout.success') . '?session_id={CHECKOUT_SESSION_ID}', + 'cancel_url' => route('checkout.cancel'), + 'metadata' => [ + 'product_id' => $product->id, + ], +]); + +return redirect($session->url); +``` + +### Using Stripe Price IDs + +```php +// If you have synced prices +$priceId = $product->prices() + ->where('currency', 'USD') + ->where('is_default', true) + ->first() + ->stripe_price_id; + +$session = Session::create([ + 'payment_method_types' => ['card'], + 'line_items' => [[ + 'price' => $priceId, + 'quantity' => 1, + ]], + 'mode' => 'payment', + 'success_url' => route('checkout.success') . '?session_id={CHECKOUT_SESSION_ID}', + 'cancel_url' => route('checkout.cancel'), +]); +``` + +## Handling Webhooks + +### Register Webhook Endpoint + +```php +// routes/api.php +use App\Http\Controllers\StripeWebhookController; + +Route::post('/stripe/webhook', [StripeWebhookController::class, 'handle']); +``` + +### Webhook Controller + +```php +getContent(); + $sigHeader = $request->header('Stripe-Signature'); + $webhookSecret = config('services.stripe.webhook_secret'); + + try { + $event = Webhook::constructEvent($payload, $sigHeader, $webhookSecret); + } catch (\Exception $e) { + return response()->json(['error' => 'Invalid signature'], 400); + } + + switch ($event->type) { + case 'checkout.session.completed': + $this->handleCheckoutCompleted($event->data->object); + break; + + case 'payment_intent.succeeded': + $this->handlePaymentSucceeded($event->data->object); + break; + + case 'charge.refunded': + $this->handleRefund($event->data->object); + break; + } + + return response()->json(['status' => 'success']); + } + + protected function handleCheckoutCompleted($session) + { + $productId = $session->metadata->product_id ?? null; + + if (!$productId) { + return; + } + + $product = Product::find($productId); + + if (!$product) { + return; + } + + // Decrease stock + $quantity = $session->metadata->quantity ?? 1; + $product->decreaseStock($quantity); + + // Create purchase record + $purchase = $product->purchases()->create([ + 'purchasable_type' => get_class(auth()->user()), + 'purchasable_id' => $session->customer ?? $session->client_reference_id, + 'quantity' => $quantity, + 'status' => 'completed', + 'meta' => [ + 'stripe_session_id' => $session->id, + 'stripe_payment_intent' => $session->payment_intent, + ], + ]); + + // Trigger product actions + $product->callActions('purchased', $purchase, [ + 'stripe_session' => $session, + ]); + } + + protected function handlePaymentSucceeded($paymentIntent) + { + // Handle successful payment + } + + protected function handleRefund($charge) + { + // Handle refund + $metadata = $charge->metadata; + $productId = $metadata->product_id ?? null; + + if ($productId) { + $product = Product::find($productId); + $quantity = $metadata->quantity ?? 1; + + $product->increaseStock($quantity); + + // Trigger refund actions + $product->callActions('refunded', null, [ + 'stripe_charge' => $charge, + ]); + } + } +} +``` + +### Configure Webhook Secret + +Add to `.env`: + +```env +STRIPE_WEBHOOK_SECRET=whsec_... +``` + +Get your webhook secret from Stripe Dashboard β†’ Developers β†’ Webhooks. + +## Multi-Currency Support + +### Create Prices for Multiple Currencies + +```php +$product = Product::create([ + 'slug' => 'premium-plan', + 'price' => 29.99, // USD base price +]); + +// USD (default) +ProductPrice::create([ + 'product_id' => $product->id, + 'currency' => 'USD', + 'price' => 29.99, + 'is_default' => true, +]); + +// EUR +ProductPrice::create([ + 'product_id' => $product->id, + 'currency' => 'EUR', + 'price' => 24.99, +]); + +// GBP +ProductPrice::create([ + 'product_id' => $product->id, + 'currency' => 'GBP', + 'price' => 21.99, +]); + +// Sync all to Stripe +StripeService::syncProductPricesDown($product); +``` + +### Checkout with Currency Selection + +```php +$currency = $request->input('currency', 'USD'); + +$price = $product->prices() + ->where('currency', $currency) + ->first(); + +$session = Session::create([ + 'payment_method_types' => ['card'], + 'line_items' => [[ + 'price' => $price->stripe_price_id, + 'quantity' => 1, + ]], + 'mode' => 'payment', + 'success_url' => route('checkout.success'), + 'cancel_url' => route('checkout.cancel'), +]); +``` + +## Testing + +### Use Stripe Test Mode + +```env +STRIPE_KEY=pk_test_... +STRIPE_SECRET=sk_test_... +``` + +### Test Card Numbers + diff --git a/docs/03-purchasing.md b/docs/03-purchasing.md new file mode 100644 index 0000000..da2be59 --- /dev/null +++ b/docs/03-purchasing.md @@ -0,0 +1,582 @@ +# Purchasing Products + +## Setup + +First, add the `HasShoppingCapabilities` trait to your User model (or any model that should purchase products): + +```php +use Blax\Shop\Traits\HasShoppingCapabilities; + +class User extends Authenticatable +{ + use HasShoppingCapabilities; +} +``` + +## Direct Purchase + +### Simple Purchase + +```php +$user = auth()->user(); +$product = Product::find($productId); + +try { + $purchase = $user->purchase($product, quantity: 1); + + // Purchase successful + return response()->json([ + 'success' => true, + 'purchase_id' => $purchase->id, + ]); +} catch (\Exception $e) { + return response()->json([ + 'error' => $e->getMessage() + ], 400); +} +``` + +### Purchase with Options + +```php +$purchase = $user->purchase($product, quantity: 2, options: [ + 'price_id' => $priceId, // Use specific price + 'charge_id' => $paymentId, // Associate with payment + 'cart_id' => $cartId, // Associate with cart + 'status' => 'pending', // Custom status +]); +``` + +### Check Purchase History + +```php +// Check if user has purchased a product +if ($user->hasPurchased($product)) { + // User has purchased this product +} + +// Get purchase history for a product +$history = $user->getPurchaseHistory($product); + +// Get all completed purchases +$purchases = $user->completedPurchases()->get(); +``` + +## Shopping Cart + +### Add to Cart + +```php +$user = auth()->user(); +$product = Product::find($productId); + +try { + $cartItem = $user->addToCart($product, quantity: 1); + + return response()->json([ + 'success' => true, + 'cart_item' => $cartItem, + 'cart_total' => $user->getCartTotal(), + 'cart_count' => $user->getCartItemsCount(), + ]); +} catch (\Exception $e) { + return response()->json(['error' => $e->getMessage()], 400); +} +``` + +### Update Cart Quantity + +```php +$cartItem = ProductPurchase::find($cartItemId); + +try { + $user->updateCartQuantity($cartItem, quantity: 3); + + return response()->json([ + 'success' => true, + 'cart_total' => $user->getCartTotal(), + ]); +} catch (\Exception $e) { + return response()->json(['error' => $e->getMessage()], 400); +} +``` + +### Remove from Cart + +```php +$cartItem = ProductPurchase::find($cartItemId); + +$user->removeFromCart($cartItem); +``` + +### Get Cart Information + +```php +// Get all cart items +$cartItems = $user->cartItems()->with('product')->get(); + +// Get cart total +$total = $user->getCartTotal(); + +// Get items count +$count = $user->getCartItemsCount(); + +// Clear cart +$user->clearCart(); +``` + +### Checkout + +```php +try { + $completedPurchases = $user->checkout(options: [ + 'charge_id' => $paymentIntent->id, + ]); + + return response()->json([ + 'success' => true, + 'purchases' => $completedPurchases, + 'total' => $completedPurchases->sum('amount'), + ]); +} catch (\Exception $e) { + return response()->json(['error' => $e->getMessage()], 400); +} +``` + +## Refunds + +```php +$purchase = ProductPurchase::find($purchaseId); +$user = $purchase->purchasable; + +try { + $user->refundPurchase($purchase, options: [ + 'refund_id' => $refundId, + 'reason' => 'Customer request', + ]); + + return response()->json(['success' => true]); +} catch (\Exception $e) { + return response()->json(['error' => $e->getMessage()], 400); +} +``` + +## Purchase Statistics + +```php +$stats = $user->getPurchaseStats(); + +// Returns: +// [ +// 'total_purchases' => 10, +// 'total_spent' => 299.90, +// 'total_items' => 15, +// 'cart_items' => 2, +// 'cart_total' => 49.98, +// ] +``` + +## Basic Purchase Flow + +### 1. Check Product Availability + +```php +use Blax\Shop\Models\Product; + +$product = Product::find($productId); +$quantity = 1; + +// Check if product is available +if (!$product->isVisible()) { + return response()->json(['error' => 'Product not available'], 404); +} + +// Check stock +if ($product->manage_stock) { + $available = $product->getAvailableStock(); + + if ($available < $quantity) { + return response()->json([ + 'error' => 'Insufficient stock', + 'available' => $available + ], 400); + } +} +``` + +### 2. Reserve Stock (Optional) + +Reserve stock during checkout process: + +```php +// Reserve for 15 minutes +$reservation = $product->reserveStock( + quantity: $quantity, + reference: auth()->user(), + until: now()->addMinutes(15), + note: 'Checkout reservation' +); + +if (!$reservation) { + return response()->json(['error' => 'Unable to reserve stock'], 400); +} + +// Store reservation ID in session +session(['stock_reservation_id' => $reservation->id]); +``` + +### 3. Process Payment + +```php +// Your payment processing logic +$payment = PaymentService::process([ + 'amount' => $product->getCurrentPrice() * $quantity, + 'currency' => 'USD', + 'product_id' => $product->id, +]); + +if ($payment->failed()) { + // Release reservation + $reservation->update(['status' => 'cancelled']); + return response()->json(['error' => 'Payment failed'], 400); +} +``` + +### 4. Complete Purchase + +```php +use Blax\Shop\Models\ProductPurchase; + +// Decrease stock +$product->decreaseStock($quantity); + +// Create purchase record +$purchase = ProductPurchase::create([ + 'product_id' => $product->id, + 'purchasable_type' => get_class(auth()->user()), + 'purchasable_id' => auth()->id(), + 'quantity' => $quantity, + 'status' => 'completed', + 'meta' => [ + 'payment_id' => $payment->id, + 'price_paid' => $product->getCurrentPrice(), + 'currency' => 'USD', + ], +]); + +// Complete reservation +if ($reservation) { + $reservation->update(['status' => 'completed']); +} + +// Trigger product actions +$product->callActions('purchased', $purchase, [ + 'user' => auth()->user(), + 'payment' => $payment, +]); + +return response()->json([ + 'success' => true, + 'purchase_id' => $purchase->id, +]); +``` + +## Shopping Cart Implementation + +### Cart Item Model + +```php +// app/Models/CartItem.php +namespace App\Models; + +use Illuminate\Database\Eloquent\Model; +use Blax\Shop\Models\Product; + +class CartItem extends Model +{ + protected $fillable = [ + 'cart_id', + 'product_id', + 'quantity', + 'price', + ]; + + protected $casts = [ + 'price' => 'decimal:2', + ]; + + public function product() + { + return $this->belongsTo(Product::class); + } + + public function getSubtotal() + { + return $this->price * $this->quantity; + } +} +``` + +### Cart Service + +```php +// app/Services/CartService.php +namespace App\Services; + +use App\Models\CartItem; +use Blax\Shop\Models\Product; + +class CartService +{ + public function add(Product $product, int $quantity = 1) + { + $cart = $this->getCart(); + + // Check stock + if ($product->manage_stock && $product->getAvailableStock() < $quantity) { + throw new \Exception('Insufficient stock'); + } + + // Check if item already in cart + $cartItem = $cart->items()->where('product_id', $product->id)->first(); + + if ($cartItem) { + $newQuantity = $cartItem->quantity + $quantity; + + // Check stock for new quantity + if ($product->manage_stock && $product->getAvailableStock() < $newQuantity) { + throw new \Exception('Insufficient stock for requested quantity'); + } + + $cartItem->update(['quantity' => $newQuantity]); + } else { + $cartItem = $cart->items()->create([ + 'product_id' => $product->id, + 'quantity' => $quantity, + 'price' => $product->getCurrentPrice(), + ]); + } + + return $cartItem; + } + + public function update(CartItem $cartItem, int $quantity) + { + $product = $cartItem->product; + + // Check stock + if ($product->manage_stock && $product->getAvailableStock() < $quantity) { + throw new \Exception('Insufficient stock'); + } + + $cartItem->update(['quantity' => $quantity]); + + return $cartItem; + } + + public function remove(CartItem $cartItem) + { + $cartItem->delete(); + } + + public function clear() + { + $cart = $this->getCart(); + $cart->items()->delete(); + } + + public function getTotal() + { + $cart = $this->getCart(); + return $cart->items->sum(fn($item) => $item->getSubtotal()); + } + + public function checkout() + { + $cart = $this->getCart(); + $items = $cart->items()->with('product')->get(); + + // Reserve stock for all items + $reservations = []; + foreach ($items as $item) { + $reservation = $item->product->reserveStock( + $item->quantity, + $cart, + now()->addMinutes(15) + ); + + if (!$reservation) { + // Rollback previous reservations + foreach ($reservations as $res) { + $res->update(['status' => 'cancelled']); + } + throw new \Exception('Unable to reserve stock for: ' . $item->product->getLocalized('name')); + } + + $reservations[] = $reservation; + } + + return [ + 'items' => $items, + 'reservations' => $reservations, + 'total' => $this->getTotal(), + ]; + } + + protected function getCart() + { + // Implementation depends on your cart system + // Could be session-based or user-based + return auth()->user()->cart ?? session()->get('cart'); + } +} +``` + +### Cart Controller + +```php +// app/Http/Controllers/CartController.php +namespace App\Http\Controllers; + +use App\Services\CartService; +use Blax\Shop\Models\Product; +use Illuminate\Http\Request; + +class CartController extends Controller +{ + public function __construct( + protected CartService $cartService + ) {} + + public function add(Request $request, Product $product) + { + $validated = $request->validate([ + 'quantity' => 'required|integer|min:1', + ]); + + try { + $cartItem = $this->cartService->add($product, $validated['quantity']); + + return response()->json([ + 'success' => true, + 'cart_item' => $cartItem, + 'cart_total' => $this->cartService->getTotal(), + ]); + } catch (\Exception $e) { + return response()->json([ + 'error' => $e->getMessage() + ], 400); + } + } + + public function update(Request $request, $cartItemId) + { + $validated = $request->validate([ + 'quantity' => 'required|integer|min:1', + ]); + + $cartItem = CartItem::findOrFail($cartItemId); + + try { + $this->cartService->update($cartItem, $validated['quantity']); + + return response()->json([ + 'success' => true, + 'cart_total' => $this->cartService->getTotal(), + ]); + } catch (\Exception $e) { + return response()->json([ + 'error' => $e->getMessage() + ], 400); + } + } + + public function remove($cartItemId) + { + $cartItem = CartItem::findOrFail($cartItemId); + $this->cartService->remove($cartItem); + + return response()->json([ + 'success' => true, + 'cart_total' => $this->cartService->getTotal(), + ]); + } + + public function checkout() + { + try { + $checkoutData = $this->cartService->checkout(); + + return response()->json([ + 'success' => true, + 'checkout' => $checkoutData, + ]); + } catch (\Exception $e) { + return response()->json([ + 'error' => $e->getMessage() + ], 400); + } + } +} +``` + +## Handling Refunds + +```php +public function refund($purchaseId) +{ + $purchase = ProductPurchase::findOrFail($purchaseId); + $product = $purchase->product; + + // Process refund with payment processor + $refund = PaymentService::refund($purchase->meta['payment_id']); + + if ($refund->success) { + // Return stock + $product->increaseStock($purchase->quantity); + + // Update purchase status + $purchase->update([ + 'status' => 'refunded', + 'meta' => array_merge($purchase->meta, [ + 'refund_id' => $refund->id, + 'refunded_at' => now(), + ]), + ]); + + // Trigger refund actions + $product->callActions('refunded', $purchase, [ + 'refund' => $refund, + ]); + + return response()->json(['success' => true]); + } + + return response()->json(['error' => 'Refund failed'], 400); +} +``` + +## Product Actions on Purchase + +Product actions allow you to execute custom logic when products are purchased: + +```php +use Blax\Shop\Models\ProductAction; + +// Create action to grant access to a course +ProductAction::create([ + 'product_id' => $product->id, + 'action_type' => 'grant_access', + 'event' => 'purchased', + 'config' => [ + 'resource_type' => 'course', + 'resource_id' => 123, + ], + 'active' => true, +]); + +// Action is automatically triggered when product is purchased +// Implement the action handler in your application +``` + +See [Product Actions documentation](docs/07-product-actions.md) for more details. diff --git a/docs/04-subscriptions.md b/docs/04-subscriptions.md new file mode 100644 index 0000000..0b4d4d1 --- /dev/null +++ b/docs/04-subscriptions.md @@ -0,0 +1,489 @@ +# Subscriptions + +## Creating Subscription Products + +### Basic Subscription Product + +```php +use Blax\Shop\Models\Product; + +$subscription = Product::create([ + 'slug' => 'monthly-premium', + 'sku' => 'SUB-PREM-M', + 'type' => 'simple', + 'price' => 29.99, + 'virtual' => true, + 'downloadable' => false, + 'manage_stock' => false, // Subscriptions don't need stock management + 'status' => 'published', + 'meta' => [ + 'billing_period' => 'month', + 'billing_interval' => 1, + 'trial_days' => 7, + ], +]); + +$subscription->setLocalized('name', 'Premium Monthly Subscription', 'en'); +$subscription->setLocalized('description', 'Access to all premium features', 'en'); +``` + +### Subscription Tiers + +```php +// Basic +$basic = Product::create([ + 'slug' => 'basic-monthly', + 'price' => 9.99, + 'virtual' => true, + 'meta' => [ + 'billing_period' => 'month', + 'features' => ['feature_1', 'feature_2'], + ], +]); + +// Pro +$pro = Product::create([ + 'slug' => 'pro-monthly', + 'price' => 29.99, + 'virtual' => true, + 'meta' => [ + 'billing_period' => 'month', + 'features' => ['feature_1', 'feature_2', 'feature_3', 'feature_4'], + ], +]); + +// Enterprise +$enterprise = Product::create([ + 'slug' => 'enterprise-monthly', + 'price' => 99.99, + 'virtual' => true, + 'meta' => [ + 'billing_period' => 'month', + 'features' => ['all_features', 'priority_support', 'custom_branding'], + ], +]); +``` + +## Stripe Subscription Integration + +### Create Subscription Prices in Stripe + +```php +use Stripe\Stripe; +use Stripe\Product as StripeProduct; +use Stripe\Price; + +Stripe::setApiKey(config('services.stripe.secret')); + +// Create Stripe product +$stripeProduct = StripeProduct::create([ + 'name' => $subscription->getLocalized('name'), + 'description' => $subscription->getLocalized('description'), + 'metadata' => [ + 'product_id' => $subscription->id, + ], +]); + +// Create recurring price +$price = Price::create([ + 'product' => $stripeProduct->id, + 'unit_amount' => $subscription->price * 100, + 'currency' => 'usd', + 'recurring' => [ + 'interval' => 'month', + 'interval_count' => 1, + ], +]); + +// Save Stripe IDs +$subscription->update([ + 'stripe_product_id' => $stripeProduct->id, +]); + +ProductPrice::create([ + 'product_id' => $subscription->id, + 'currency' => 'USD', + 'price' => $subscription->price, + 'stripe_price_id' => $price->id, + 'is_default' => true, +]); +``` + +### Create Subscription Checkout + +```php +use Stripe\Checkout\Session; + +$subscription = Product::find($subscriptionId); +$priceId = $subscription->prices() + ->where('currency', 'USD') + ->first() + ->stripe_price_id; + +$session = Session::create([ + 'payment_method_types' => ['card'], + 'line_items' => [[ + 'price' => $priceId, + 'quantity' => 1, + ]], + 'mode' => 'subscription', + 'success_url' => route('subscription.success') . '?session_id={CHECKOUT_SESSION_ID}', + 'cancel_url' => route('subscription.cancel'), + 'client_reference_id' => auth()->id(), + 'customer_email' => auth()->user()->email, + 'subscription_data' => [ + 'trial_period_days' => $subscription->meta['trial_days'] ?? null, + 'metadata' => [ + 'product_id' => $subscription->id, + 'user_id' => auth()->id(), + ], + ], +]); + +return redirect($session->url); +``` + +## Handling Subscription Webhooks + +### Webhook Controller + +```php +namespace App\Http\Controllers; + +use Stripe\Webhook; +use Blax\Shop\Models\Product; +use Blax\Shop\Models\ProductPurchase; + +class StripeSubscriptionWebhookController extends Controller +{ + public function handle(Request $request) + { + $payload = $request->getContent(); + $sigHeader = $request->header('Stripe-Signature'); + $webhookSecret = config('services.stripe.webhook_secret'); + + try { + $event = Webhook::constructEvent($payload, $sigHeader, $webhookSecret); + } catch (\Exception $e) { + return response()->json(['error' => 'Invalid signature'], 400); + } + + switch ($event->type) { + case 'customer.subscription.created': + $this->handleSubscriptionCreated($event->data->object); + break; + + case 'customer.subscription.updated': + $this->handleSubscriptionUpdated($event->data->object); + break; + + case 'customer.subscription.deleted': + $this->handleSubscriptionCancelled($event->data->object); + break; + + case 'invoice.payment_succeeded': + $this->handlePaymentSucceeded($event->data->object); + break; + + case 'invoice.payment_failed': + $this->handlePaymentFailed($event->data->object); + break; + } + + return response()->json(['status' => 'success']); + } + + protected function handleSubscriptionCreated($subscription) + { + $productId = $subscription->metadata->product_id ?? null; + $userId = $subscription->metadata->user_id ?? null; + + if (!$productId || !$userId) { + return; + } + + $product = Product::find($productId); + $user = User::find($userId); + + // Create purchase record + $purchase = ProductPurchase::create([ + 'product_id' => $product->id, + 'purchasable_type' => get_class($user), + 'purchasable_id' => $user->id, + 'quantity' => 1, + 'status' => $subscription->status, + 'meta' => [ + 'stripe_subscription_id' => $subscription->id, + 'stripe_customer_id' => $subscription->customer, + 'current_period_end' => $subscription->current_period_end, + 'trial_end' => $subscription->trial_end, + ], + ]); + + // Trigger subscription started actions + $product->callActions('subscription_started', $purchase, [ + 'subscription' => $subscription, + 'user' => $user, + ]); + + // Grant access + $user->subscriptions()->create([ + 'product_id' => $product->id, + 'stripe_subscription_id' => $subscription->id, + 'status' => 'active', + 'trial_ends_at' => $subscription->trial_end ? + Carbon::createFromTimestamp($subscription->trial_end) : null, + 'ends_at' => Carbon::createFromTimestamp($subscription->current_period_end), + ]); + } + + protected function handleSubscriptionUpdated($subscription) + { + $purchase = ProductPurchase::where('meta->stripe_subscription_id', $subscription->id)->first(); + + if ($purchase) { + $purchase->update([ + 'status' => $subscription->status, + 'meta' => array_merge($purchase->meta, [ + 'current_period_end' => $subscription->current_period_end, + ]), + ]); + + // Update user subscription + $userSubscription = $purchase->purchasable->subscriptions() + ->where('stripe_subscription_id', $subscription->id) + ->first(); + + if ($userSubscription) { + $userSubscription->update([ + 'status' => $subscription->status === 'active' ? 'active' : 'inactive', + 'ends_at' => Carbon::createFromTimestamp($subscription->current_period_end), + ]); + } + } + } + + protected function handleSubscriptionCancelled($subscription) + { + $purchase = ProductPurchase::where('meta->stripe_subscription_id', $subscription->id)->first(); + + if ($purchase) { + $purchase->update([ + 'status' => 'cancelled', + ]); + + // Revoke access + $userSubscription = $purchase->purchasable->subscriptions() + ->where('stripe_subscription_id', $subscription->id) + ->first(); + + if ($userSubscription) { + $userSubscription->update([ + 'status' => 'cancelled', + 'ends_at' => now(), + ]); + } + + // Trigger cancellation actions + $purchase->product->callActions('subscription_cancelled', $purchase, [ + 'subscription' => $subscription, + ]); + } + } + + protected function handlePaymentSucceeded($invoice) + { + $subscriptionId = $invoice->subscription; + $purchase = ProductPurchase::where('meta->stripe_subscription_id', $subscriptionId)->first(); + + if ($purchase) { + // Trigger renewal actions + $purchase->product->callActions('subscription_renewed', $purchase, [ + 'invoice' => $invoice, + ]); + } + } + + protected function handlePaymentFailed($invoice) + { + $subscriptionId = $invoice->subscription; + $purchase = ProductPurchase::where('meta->stripe_subscription_id', $subscriptionId)->first(); + + if ($purchase) { + // Trigger payment failed actions + $purchase->product->callActions('subscription_payment_failed', $purchase, [ + 'invoice' => $invoice, + ]); + } + } +} +``` + +## User Subscription Model + +```php +// app/Models/UserSubscription.php +namespace App\Models; + +use Illuminate\Database\Eloquent\Model; +use Blax\Shop\Models\Product; + +class UserSubscription extends Model +{ + protected $fillable = [ + 'user_id', + 'product_id', + 'stripe_subscription_id', + 'status', + 'trial_ends_at', + 'ends_at', + ]; + + protected $casts = [ + 'trial_ends_at' => 'datetime', + 'ends_at' => 'datetime', + ]; + + public function user() + { + return $this->belongsTo(User::class); + } + + public function product() + { + return $this->belongsTo(Product::class); + } + + public function isActive() + { + return $this->status === 'active' && + (!$this->ends_at || $this->ends_at->isFuture()); + } + + public function onTrial() + { + return $this->trial_ends_at && $this->trial_ends_at->isFuture(); + } + + public function cancel() + { + if (!$this->stripe_subscription_id) { + return false; + } + + try { + $stripe = new \Stripe\StripeClient(config('services.stripe.secret')); + $stripe->subscriptions->cancel($this->stripe_subscription_id); + + $this->update([ + 'status' => 'cancelled', + 'ends_at' => now(), + ]); + + return true; + } catch (\Exception $e) { + return false; + } + } +} +``` + +## Checking Subscription Access + +```php +// Add to User model +public function subscriptions() +{ + return $this->hasMany(UserSubscription::class); +} + +public function hasActiveSubscription($productSlug = null) +{ + $query = $this->subscriptions()->where('status', 'active'); + + if ($productSlug) { + $query->whereHas('product', function ($q) use ($productSlug) { + $q->where('slug', $productSlug); + }); + } + + return $query->where(function ($q) { + $q->whereNull('ends_at') + ->orWhere('ends_at', '>', now()); + }) + ->exists(); +} + +// Usage in controllers/middleware +if (!auth()->user()->hasActiveSubscription('premium-monthly')) { + abort(403, 'Active subscription required'); +} +``` + +## Subscription Management Routes + +```php +// routes/web.php +Route::middleware('auth')->group(function () { + Route::get('/subscriptions', [SubscriptionController::class, 'index']); + Route::post('/subscriptions/{product}/subscribe', [SubscriptionController::class, 'subscribe']); + Route::post('/subscriptions/{subscription}/cancel', [SubscriptionController::class, 'cancel']); + Route::post('/subscriptions/{subscription}/resume', [SubscriptionController::class, 'resume']); +}); +``` + +## Product Actions for Subscriptions + +```php +use Blax\Shop\Models\ProductAction; + +// Grant role on subscription +ProductAction::create([ + 'product_id' => $subscription->id, + 'action_type' => 'grant_role', + 'event' => 'subscription_started', + 'config' => [ + 'role' => 'premium_member', + ], + 'active' => true, +]); + +// Revoke role on cancellation +ProductAction::create([ + 'product_id' => $subscription->id, + 'action_type' => 'revoke_role', + 'event' => 'subscription_cancelled', + 'config' => [ + 'role' => 'premium_member', + ], + 'active' => true, +]); +``` + +## Annual Subscriptions with Discount + +```php +$annual = Product::create([ + 'slug' => 'premium-annual', + 'price' => 299.99, // Save $60 vs monthly + 'regular_price' => 359.88, + 'sale_price' => 299.99, + 'virtual' => true, + 'meta' => [ + 'billing_period' => 'year', + 'billing_interval' => 1, + 'savings' => 59.89, + ], +]); + +// Create Stripe price +$price = Price::create([ + 'product' => $stripeProduct->id, + 'unit_amount' => 29999, + 'currency' => 'usd', + 'recurring' => [ + 'interval' => 'year', + 'interval_count' => 1, + ], +]); +``` diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..f18a86d --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,31 @@ + + + + + tests + + + + + ./src + + + ./src/database + + + + + + + + + + + \ No newline at end of file diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..4ee3110 --- /dev/null +++ b/pint.json @@ -0,0 +1,4 @@ +{ + "preset": "laravel", + "rules": {} +} \ No newline at end of file diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..7487431 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,33 @@ +middleware($config['middleware']) + ->name($config['name_prefix']) + ->group(function () { + + // Categories + Route::get('categories', [CategoryController::class, 'index']) + ->name('categories.index'); + + Route::get('categories/tree', [CategoryController::class, 'tree']) + ->name('categories.tree'); + + Route::get('categories/{slug}', [CategoryController::class, 'show']) + ->name('categories.show'); + + Route::get('categories/{slug}/products', [CategoryController::class, 'products']) + ->name('categories.products'); + + // Products + Route::get('products', [ProductController::class, 'index']) + ->name('products.index'); + + Route::get('products/{slug}', [ProductController::class, 'show']) + ->name('products.show'); + }); diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..b3a53e6 --- /dev/null +++ b/shell.nix @@ -0,0 +1,27 @@ +{ pkgs ? import {} }: + +pkgs.mkShell { + buildInputs = with pkgs; [ + php82 + php82Packages.composer + php82Extensions.dom + php82Extensions.mbstring + php82Extensions.xml + php82Extensions.xmlwriter + php82Extensions.tokenizer + php82Extensions.pdo + php82Extensions.pdo_sqlite + php82Extensions.sqlite3 + php82Extensions.json + php82Extensions.libxml + php82Extensions.curl + php82Extensions.openssl + ]; + + shellHook = '' + echo "Laravel Package Test Environment" + echo "PHP version: $(php --version | head -n 1)" + echo "" + echo "Run tests with: composer test" + ''; +} diff --git a/src/Console/Commands/ReleaseExpiredStocks.php b/src/Console/Commands/ReleaseExpiredStocks.php new file mode 100644 index 0000000..74edbd6 --- /dev/null +++ b/src/Console/Commands/ReleaseExpiredStocks.php @@ -0,0 +1,29 @@ +info('Auto-release is disabled in config.'); + return self::SUCCESS; + } + + $this->info('Checking for expired stock reservations...'); + + $count = ProductStock::releaseExpired(); + + $this->info("Released {$count} expired stock reservation(s)."); + + return self::SUCCESS; + } +} diff --git a/src/Console/Commands/ShopAvailableActionsCommand.php b/src/Console/Commands/ShopAvailableActionsCommand.php new file mode 100644 index 0000000..8b019ce --- /dev/null +++ b/src/Console/Commands/ShopAvailableActionsCommand.php @@ -0,0 +1,47 @@ +warn('No action classes found.'); + $this->info('Make sure auto_discover is enabled in config/shop.php'); + $this->info('Path: ' . config('shop.actions.path', app_path('Jobs/ProductAction'))); + return 0; + } + + $this->info('Available Action Classes:'); + $this->newLine(); + + foreach ($actions as $className => $parameters) { + $this->line("β€’ {$className}"); + + if (!empty($parameters)) { + $this->line(' Parameters:'); + foreach ($parameters as $param => $description) { + $this->line(" - {$param}: {$description}"); + } + } else { + $this->line(' No parameters'); + } + + $this->newLine(); + } + + $this->info("Total: " . count($actions) . " action class(es)"); + + return 0; + } +} diff --git a/src/Console/Commands/ShopListActionsCommand.php b/src/Console/Commands/ShopListActionsCommand.php new file mode 100644 index 0000000..570714e --- /dev/null +++ b/src/Console/Commands/ShopListActionsCommand.php @@ -0,0 +1,62 @@ +argument('product')) { + $query->where('product_id', $productId); + } + + if ($event = $this->option('event')) { + $query->where('event', $event); + } + + if ($this->option('enabled')) { + $query->where('enabled', true); + } elseif ($this->option('disabled')) { + $query->where('enabled', false); + } + + $actions = $query->orderBy('product_id')->orderBy('priority')->get(); + + if ($actions->isEmpty()) { + $this->info('No actions found.'); + return 0; + } + + $headers = ['ID', 'Product', 'Event', 'Action Class', 'Priority', 'Enabled', 'Parameters']; + + $rows = $actions->map(function ($action) { + return [ + $action->id, + $action->product->name ?? "ID: {$action->product_id}", + $action->event, + $action->action_class, + $action->priority, + $action->enabled ? 'βœ“' : 'βœ—', + json_encode($action->parameters), + ]; + }); + + $this->table($headers, $rows); + $this->info("Total actions: {$actions->count()}"); + + return 0; + } +} diff --git a/src/Console/Commands/ShopListProductsCommand.php b/src/Console/Commands/ShopListProductsCommand.php new file mode 100644 index 0000000..112f53b --- /dev/null +++ b/src/Console/Commands/ShopListProductsCommand.php @@ -0,0 +1,78 @@ +option('enabled')) { + $query->where('enabled', true); + } elseif ($this->option('disabled')) { + $query->where('enabled', false); + } + + if ($this->option('with-actions')) { + $query->withCount('actions'); + } + + if ($this->option('with-purchases')) { + $query->withCount('purchases'); + } + + $products = $query->orderBy('id')->get(); + + if ($products->isEmpty()) { + $this->info('No products found.'); + return 0; + } + + $headers = ['ID', 'Name', 'Price', 'Type', 'Enabled']; + + if ($this->option('with-actions')) { + $headers[] = 'Actions'; + } + + if ($this->option('with-purchases')) { + $headers[] = 'Purchases'; + } + + $rows = $products->map(function ($product) { + $row = [ + $product->id, + $product->name, + $product->price, + $product->type ?? 'N/A', + $product->enabled ? 'βœ“' : 'βœ—', + ]; + + if ($this->option('with-actions')) { + $row[] = $product->actions_count ?? 0; + } + + if ($this->option('with-purchases')) { + $row[] = $product->purchases_count ?? 0; + } + + return $row; + }); + + $this->table($headers, $rows); + $this->info("Total products: {$products->count()}"); + + return 0; + } +} diff --git a/src/Console/Commands/ShopListPurchasesCommand.php b/src/Console/Commands/ShopListPurchasesCommand.php new file mode 100644 index 0000000..f260df4 --- /dev/null +++ b/src/Console/Commands/ShopListPurchasesCommand.php @@ -0,0 +1,60 @@ +argument('product')) { + $query->where('product_id', $productId); + } + + if ($userId = $this->option('user')) { + $query->where('user_id', $userId); + } + + if ($status = $this->option('status')) { + $query->where('status', $status); + } + + $limit = (int) $this->option('limit'); + $purchases = $query->latest()->limit($limit)->get(); + + if ($purchases->isEmpty()) { + $this->info('No purchases found.'); + return 0; + } + + $headers = ['ID', 'Product', 'User', 'Price', 'Status', 'Date']; + + $rows = $purchases->map(function ($purchase) { + return [ + $purchase->id, + $purchase->product->name ?? "ID: {$purchase->product_id}", + $purchase->user->name ?? "ID: {$purchase->user_id}", + $purchase->price, + $purchase->status ?? 'N/A', + $purchase->created_at->format('Y-m-d H:i:s'), + ]; + }); + + $this->table($headers, $rows); + $this->info("Showing {$purchases->count()} purchase(s)"); + + return 0; + } +} diff --git a/src/Console/Commands/ShopReinstallCommand.php b/src/Console/Commands/ShopReinstallCommand.php new file mode 100644 index 0000000..e78c62b --- /dev/null +++ b/src/Console/Commands/ShopReinstallCommand.php @@ -0,0 +1,105 @@ +option('force') && !$this->option('fresh')) { + $this->error('⚠️ WARNING: This will DELETE ALL shop data!'); + + if (!$this->confirm('Are you absolutely sure you want to continue?')) { + $this->info('Operation cancelled.'); + return 0; + } + + if (!$this->confirm('This action cannot be undone. Continue?')) { + $this->info('Operation cancelled.'); + return 0; + } + } + + $this->info('Starting shop reinstallation...'); + + // Disable foreign key checks + Schema::disableForeignKeyConstraints(); + + $this->dropShopTables(); + $this->runMigrations(); + + // Re-enable foreign key checks + Schema::enableForeignKeyConstraints(); + + $this->info('βœ… Shop tables reinstalled successfully!'); + + return 0; + } + + protected function dropShopTables(): void + { + $this->info('Dropping shop tables...'); + + // Add products table from config + $productsTable = config('shop.tables.products', 'products'); + $allTables = array_merge([$productsTable], $this->shopTables); + + foreach ($allTables as $table) { + if (Schema::hasTable($table)) { + Schema::dropIfExists($table); + $this->line(" - Dropped: {$table}"); + } + } + + // Remove migration records + $this->removeMigrationRecords(); + } + + protected function removeMigrationRecords(): void + { + $this->info('Cleaning migration records...'); + + DB::table('migrations') + ->where('migration', 'like', '%shop%') + ->orWhere('migration', 'like', '%product%') + ->orWhere('migration', 'like', '%cart%') + ->orWhere('migration', 'like', '%order%') + ->delete(); + } + + protected function runMigrations(): void + { + $this->info('Running shop migrations...'); + + $this->call('migrate', [ + '--path' => 'database/migrations/create_blax_shop_tables.php.stub', + '--force' => true, + ]); + } +} diff --git a/src/Console/Commands/ShopStatsCommand.php b/src/Console/Commands/ShopStatsCommand.php new file mode 100644 index 0000000..add020c --- /dev/null +++ b/src/Console/Commands/ShopStatsCommand.php @@ -0,0 +1,51 @@ +count(); + $disabledProducts = $productModel::where('enabled', false)->count(); + + $totalActions = ProductAction::count(); + $enabledActions = ProductAction::where('enabled', true)->count(); + $disabledActions = ProductAction::where('enabled', false)->count(); + + $totalPurchases = $purchaseModel::count(); + $totalRevenue = $purchaseModel::sum('price'); + + $this->info('=== Shop Statistics ==='); + $this->newLine(); + + $this->table( + ['Metric', 'Count'], + [ + ['Total Products', $totalProducts], + ['Enabled Products', $enabledProducts], + ['Disabled Products', $disabledProducts], + ['---', '---'], + ['Total Actions', $totalActions], + ['Enabled Actions', $enabledActions], + ['Disabled Actions', $disabledActions], + ['---', '---'], + ['Total Purchases', $totalPurchases], + ['Total Revenue', number_format($totalRevenue, 2)], + ] + ); + + return 0; + } +} diff --git a/src/Console/Commands/ShopTestActionCommand.php b/src/Console/Commands/ShopTestActionCommand.php new file mode 100644 index 0000000..f81bc98 --- /dev/null +++ b/src/Console/Commands/ShopTestActionCommand.php @@ -0,0 +1,62 @@ +argument('action-id'); + $action = ProductAction::with('product')->find($actionId); + + if (!$action) { + $this->error("Action with ID {$actionId} not found."); + return 1; + } + + $this->info("Testing action: {$action->action_class}"); + $this->info("Product: {$action->product->name} (ID: {$action->product_id})"); + $this->info("Event: {$action->event}"); + + if (!$this->confirm('Do you want to proceed?')) { + $this->info('Test cancelled.'); + return 0; + } + + try { + if ($this->option('sync')) { + $namespace = config('shop.actions.namespace', 'App\\Jobs\\ProductAction'); + $action_job = $namespace . '\\' . $action->action_class; + + $params = [ + 'product' => $action->product, + 'productPurchase' => null, + 'event' => $action->event, + ...($action->parameters ?? []), + ]; + + (new $action_job(...$params))->handle(); + $this->info('Action executed synchronously.'); + } else { + $action->execute($action->product, null, []); + $this->info('Action dispatched to queue.'); + } + + $this->info('βœ“ Action test completed successfully.'); + return 0; + } catch (\Exception $e) { + $this->error('βœ— Action test failed: ' . $e->getMessage()); + $this->error($e->getTraceAsString()); + return 1; + } + } +} diff --git a/src/Console/Commands/ShopToggleActionCommand.php b/src/Console/Commands/ShopToggleActionCommand.php new file mode 100644 index 0000000..bd7d06a --- /dev/null +++ b/src/Console/Commands/ShopToggleActionCommand.php @@ -0,0 +1,44 @@ +argument('action-id'); + $action = ProductAction::find($actionId); + + if (!$action) { + $this->error("Action with ID {$actionId} not found."); + return 1; + } + + if ($this->option('enable')) { + $action->enabled = true; + $status = 'enabled'; + } elseif ($this->option('disable')) { + $action->enabled = false; + $status = 'disabled'; + } else { + $action->enabled = !$action->enabled; + $status = $action->enabled ? 'enabled' : 'disabled'; + } + + $action->save(); + + $this->info("Action #{$action->id} ({$action->action_class}) has been {$status}."); + + return 0; + } +} diff --git a/src/Contracts/Purchasable.php b/src/Contracts/Purchasable.php new file mode 100644 index 0000000..0ce9c13 --- /dev/null +++ b/src/Contracts/Purchasable.php @@ -0,0 +1,14 @@ +roots() + ->with('children') + ->orderBy('sort_order') + ->get(); + + return response()->json([ + 'data' => $categories, + ]); + } + + public function tree(): JsonResponse + { + return response()->json([ + 'data' => ProductCategory::getTree(), + ]); + } + + public function show(string $slug): JsonResponse + { + $category = ProductCategory::visible() + ->where('slug', $slug) + ->with(['children', 'parent']) + ->firstOrFail(); + + return response()->json([ + 'data' => array_merge( + $category->toArray(), + ['breadcrumbs' => $category->getPath()] + ), + ]); + } + + public function products(string $slug): JsonResponse + { + $category = ProductCategory::visible() + ->where('slug', $slug) + ->firstOrFail(); + + $perPage = min( + request('per_page', config('shop.pagination.per_page')), + config('shop.pagination.max_per_page') + ); + + $products = $category->products() + ->published() + ->inStock() + ->paginate($perPage); + + return response()->json($products); + } +} diff --git a/src/Http/Controllers/Api/ProductController.php b/src/Http/Controllers/Api/ProductController.php new file mode 100644 index 0000000..b225b84 --- /dev/null +++ b/src/Http/Controllers/Api/ProductController.php @@ -0,0 +1,65 @@ +published() + ->visible(); + + if (request('category')) { + $query->whereHas('categories', function ($q) { + $q->where('slug', request('category')); + }); + } + + if (request('featured')) { + $query->featured(); + } + + if (request('in_stock')) { + $query->inStock(); + } + + $products = $query->with(['categories']) + ->paginate($perPage); + + return response()->json($products); + } + + public function show(string $slug): JsonResponse + { + $productModel = config('shop.models.product'); + + $product = $productModel::query() + ->published() + ->visible() + ->where('slug', $slug) + ->with(['categories', 'images', 'children', 'attributes']) + ->firstOrFail(); + + return response()->json([ + 'data' => array_merge( + $product->toArray(), + [ + 'current_price' => $product->getCurrentPrice(), + 'on_sale' => $product->isOnSale(), + 'average_rating' => $product->getAverageRating(), + ] + ), + ]); + } +} diff --git a/src/Models/Cart.php b/src/Models/Cart.php new file mode 100644 index 0000000..3784482 --- /dev/null +++ b/src/Models/Cart.php @@ -0,0 +1,112 @@ + 'datetime', + 'converted_at' => 'datetime', + 'last_activity_at' => 'datetime', + 'meta' => 'object', + ]; + + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + $this->table = config('shop.tables.carts', 'carts'); + } + + protected static function boot() + { + parent::boot(); + + // No longer need to generate uuid - using id as primary key + } + + public function customer(): MorphTo + { + return $this->morphTo(); + } + + // Alias for backward compatibility + public function user() + { + return $this->customer(); + } + + public function items(): HasMany + { + return $this->hasMany(config('shop.models.cart_item'), 'cart_id'); + } + + public function purchases(): HasMany + { + return $this->hasMany(config('shop.models.product_purchase', \Blax\Shop\Models\ProductPurchase::class), 'cart_id'); + } + + public function getTotal(): float + { + return $this->items->sum(function ($item) { + return $item->quantity * $item->price; + }); + } + + public function getTotalItems(): int + { + return $this->items->sum('quantity'); + } + + public function isExpired(): bool + { + return $this->expires_at && $this->expires_at->isPast(); + } + + public function isConverted(): bool + { + return !is_null($this->converted_at); + } + + public function scopeActive($query) + { + return $query->whereNull('converted_at') + ->where(function ($q) { + $q->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }); + } + + public function scopeForUser($query, $userOrId) + { + if (is_object($userOrId)) { + return $query->where('customer_id', $userOrId->id) + ->where('customer_type', get_class($userOrId)); + } + + // If just an ID is passed, try to determine the user model class + $userModel = config('auth.providers.users.model', \Workbench\App\Models\User::class); + return $query->where('customer_id', $userOrId) + ->where('customer_type', $userModel); + } +} diff --git a/src/Models/CartItem.php b/src/Models/CartItem.php new file mode 100644 index 0000000..1b37e92 --- /dev/null +++ b/src/Models/CartItem.php @@ -0,0 +1,81 @@ + 'integer', + 'price' => 'decimal:2', + 'regular_price' => 'decimal:2', + 'subtotal' => 'decimal:2', + 'attributes' => 'array', + 'meta' => 'array', + ]; + + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + $this->table = config('shop.tables.cart_items', 'cart_items'); + } + + protected static function boot() + { + parent::boot(); + + // Auto-calculate subtotal before saving + static::creating(function ($cartItem) { + if (!isset($cartItem->subtotal)) { + $cartItem->subtotal = $cartItem->quantity * $cartItem->price; + } + }); + + static::updating(function ($cartItem) { + if ($cartItem->isDirty(['quantity', 'price'])) { + $cartItem->subtotal = $cartItem->quantity * $cartItem->price; + } + }); + } + + public function cart(): BelongsTo + { + return $this->belongsTo(config('shop.models.cart'), 'cart_id'); + } + + public function product(): BelongsTo + { + return $this->belongsTo(config('shop.models.product'), 'product_id'); + } + + public function getSubtotal(): float + { + return $this->quantity * $this->price; + } + + public function scopeForCart($query, $cartId) + { + return $query->where('cart_id', $cartId); + } + + public function scopeForProduct($query, $productId) + { + return $query->where('product_id', $productId); + } +} diff --git a/src/Models/Product.php b/src/Models/Product.php new file mode 100644 index 0000000..cbfed49 --- /dev/null +++ b/src/Models/Product.php @@ -0,0 +1,487 @@ + 'boolean', + 'in_stock' => 'boolean', + 'virtual' => 'boolean', + 'downloadable' => 'boolean', + 'meta' => 'object', + 'sale_start' => 'datetime', + 'sale_end' => 'datetime', + 'published_at' => 'datetime', + 'featured' => 'boolean', + 'visible' => 'boolean', + 'low_stock_threshold' => 'integer', + 'sort_order' => 'integer', + ]; + + // Remove - causes issues with casting + + protected $dispatchesEvents = [ + 'created' => ProductCreated::class, + 'updated' => ProductUpdated::class, + ]; + + protected $hidden = [ + 'stripe_product_id', + ]; + + public function __construct(array $attributes = []) + { + // Initialize meta BEFORE parent constructor to avoid trait errors + if (!isset($attributes['meta'])) { + $attributes['meta'] = '{}'; + } + + parent::__construct($attributes); + $this->setTable(config('shop.tables.products', 'products')); + } + + /** + * Initialize the HasMetaTranslation trait for the model. + * + * @return void + */ + protected function initializeHasMetaTranslation() + { + // Ensure meta is never null + if (!isset($this->attributes['meta'])) { + $this->attributes['meta'] = '{}'; + } + } + + protected static function booted() + { + parent::booted(); + + static::creating(function ($model) { + if (! $model->slug) { + $model->slug = 'new-product-' . str()->random(8); + } + + $model->slug = str()->slug($model->slug); + + // Ensure meta is initialized before creation + if (is_null($model->getAttributes()['meta'] ?? null)) { + $model->setAttribute('meta', json_encode(new \stdClass())); + } + }); + + static::created(function ($model) { + if (! $model->name) { + // Temporarily disabled to fix meta initialization issue + // TODO: Fix this properly by ensuring meta is always available + // $model->setLocalized('name', 'New Product "' . $model->slug . '"', null, true); + // $model->save(); + } + }); + + static::updated(function ($model) { + if (config('shop.cache.enabled')) { + Cache::forget(config('shop.cache.prefix') . 'product:' . $model->id); + } + }); + } + + public function prices(): HasMany + { + return $this->hasMany(config('shop.models.product_price', ProductPrice::class)); + } + + public function parent() + { + return $this->belongsTo(self::class, 'parent_id'); + } + + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id'); + } + + public function categories(): BelongsToMany + { + return $this->belongsToMany( + config('shop.models.product_category'), + 'product_category_product' + ); + } + + public function attributes(): HasMany + { + return $this->hasMany(config('shop.models.product_attribute', 'Blax\Shop\Models\ProductAttribute')); + } + + public function stocks(): HasMany + { + return $this->hasMany(config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock')); + } + + public function actions(): HasMany + { + return $this->hasMany(config('shop.models.product_action', ProductAction::class)); + } + + public function activeStocks(): HasMany + { + return $this->stocks()->pending(); + } + + public function scopePublished($query) + { + return $query->where('status', 'published'); + } + + public function scopeInStock($query) + { + return $query->where('in_stock', true) + ->where(function ($q) { + $q->where('manage_stock', false) + ->orWhere('stock_quantity', '>', 0); + }); + } + + public function scopeFeatured($query) + { + return $query->where('featured', true); + } + + public function isOnSale(): bool + { + if (!$this->sale_price) { + return false; + } + + $now = now(); + + if ($this->sale_start && $now->lt($this->sale_start)) { + return false; + } + + if ($this->sale_end && $now->gt($this->sale_end)) { + return false; + } + + return true; + } + + public function getCurrentPrice(): ?float + { + if ($this->isOnSale()) { + return $this->sale_price; + } + + $defaultPrice = $this->defaultPrice()->first(); + return $defaultPrice ? $defaultPrice->price : $this->regular_price; + } + + public function decreaseStock(int $quantity = 1): bool + { + if (!$this->manage_stock) { + return true; + } + + if ($this->stock_quantity < $quantity && !config('shop.stock.allow_backorders')) { + return false; + } + + $this->stock_quantity -= $quantity; + $this->in_stock = $this->stock_quantity > 0; + + if (config('shop.stock.log_changes', true)) { + $this->logStockChange(-$quantity, 'decrease'); + } + + $this->save(); + + return true; + } + + public function increaseStock(int $quantity = 1): void + { + if (!$this->manage_stock) { + return; + } + + $this->stock_quantity += $quantity; + $this->in_stock = true; + + if (config('shop.stock.log_changes', true)) { + $this->logStockChange($quantity, 'increase'); + } + + $this->save(); + } + + public function reserveStock( + int $quantity, + $reference = null, + ?\DateTimeInterface $until = null, + ?string $note = null + ): ?\Blax\Shop\Models\ProductStock { + $stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'); + + return $stockModel::reserve( + $this, + $quantity, + 'reservation', + $reference, + $until, + $note + ); + } + + public function getAvailableStock(): int + { + if (!$this->manage_stock) { + return PHP_INT_MAX; + } + + return max(0, $this->stock_quantity); + } + + public function getReservedStock(): int + { + return $this->activeStocks()->sum('quantity'); + } + + protected function logStockChange(int $quantityChange, string $type): void + { + \DB::table('product_stock_logs')->insert([ + 'product_id' => $this->id, + 'quantity_change' => $quantityChange, + 'quantity_after' => $this->stock_quantity, + 'type' => $type, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function syncPricesDown() + { + if (config('shop.stripe.enabled') && config('shop.stripe.sync_prices')) { + StripeService::syncProductPricesDown($this); + } + return $this; + } + + public static function getAvailableActions(): array + { + return ProductAction::getAvailableActions(); + } + + public function callActions(string $event = 'purchased', ?ProductPurchase $productPurchase = null, array $additionalData = []): void + { + ProductAction::callForProduct($this, $event, $productPurchase, $additionalData); + } + + public function relatedProducts(): BelongsToMany + { + return $this->belongsToMany( + self::class, + 'product_relations', + 'product_id', + 'related_product_id' + )->withPivot('type')->withTimestamps(); + } + + public function upsells(): BelongsToMany + { + return $this->relatedProducts()->wherePivot('type', 'upsell'); + } + + public function crossSells(): BelongsToMany + { + return $this->relatedProducts()->wherePivot('type', 'cross-sell'); + } + + public function scopeVisible($query) + { + return $query->where('visible', true) + ->where('status', 'published') + ->where(function ($q) { + $q->whereNull('published_at') + ->orWhere('published_at', '<=', now()); + }); + } + + public function scopeByCategory($query, $categoryId) + { + return $query->whereHas('categories', function ($q) use ($categoryId) { + $q->where('id', $categoryId); + }); + } + + public function scopeSearch($query, string $search) + { + return $query->where(function ($q) use ($search) { + $q->where('slug', 'like', "%{$search}%") + ->orWhere('sku', 'like', "%{$search}%") + ->orWhereRaw("JSON_UNQUOTE(JSON_EXTRACT(meta, '$.name')) LIKE ?", ["%{$search}%"]); + }); + } + + public function scopePriceRange($query, ?float $min = null, ?float $max = null) + { + if ($min !== null) { + $query->where('price', '>=', $min); + } + if ($max !== null) { + $query->where('price', '<=', $max); + } + return $query; + } + + public function scopeOrderByPrice($query, string $direction = 'asc') + { + return $query->orderBy('price', $direction); + } + + public function scopeLowStock($query) + { + return $query->where('manage_stock', true) + ->whereColumn('stock_quantity', '<=', 'low_stock_threshold'); + } + + public function isLowStock(): bool + { + if (!$this->manage_stock || !$this->low_stock_threshold) { + return false; + } + + return $this->stock_quantity <= $this->low_stock_threshold; + } + + public function isVisible(): bool + { + if (!$this->visible || $this->status !== 'published') { + return false; + } + + if ($this->published_at && now()->lt($this->published_at)) { + return false; + } + + return true; + } + + public function toApiArray(): array + { + return [ + 'id' => $this->id, + 'slug' => $this->slug, + 'sku' => $this->sku, + 'name' => $this->getLocalized('name'), + 'description' => $this->getLocalized('description'), + 'short_description' => $this->getLocalized('short_description'), + 'type' => $this->type, + 'price' => $this->getCurrentPrice(), + 'regular_price' => $this->regular_price, + 'sale_price' => $this->sale_price, + 'is_on_sale' => $this->isOnSale(), + 'in_stock' => $this->in_stock, + 'stock_quantity' => $this->manage_stock ? $this->stock_quantity : null, + 'stock_status' => $this->stock_status, + 'low_stock' => $this->isLowStock(), + 'featured' => $this->featured, + 'virtual' => $this->virtual, + 'downloadable' => $this->downloadable, + 'weight' => $this->weight, + 'dimensions' => [ + 'length' => $this->length, + 'width' => $this->width, + 'height' => $this->height, + ], + 'categories' => $this->categories, + 'attributes' => $this->attributes, + 'variants' => $this->children, + 'parent' => $this->parent, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } + + /** + * Get an attribute from the model. + * + * @param string $key + * @return mixed + */ + public function getAttribute($key) + { + $value = parent::getAttribute($key); + + // Ensure meta is never null for HasMetaTranslation trait + if ($key === 'meta' && is_null($value)) { + $this->attributes['meta'] = '{}'; + return json_decode('{}'); + } + + return $value; + } + + /** + * Create a new instance of the given model. + * + * @param array $attributes + * @param bool $exists + * @return static + */ + public function newInstance($attributes = [], $exists = false) + { + // Ensure meta is initialized + if (!isset($attributes['meta'])) { + $attributes['meta'] = '{}'; + } + + return parent::newInstance($attributes, $exists); + } +} diff --git a/src/Models/ProductAction.php b/src/Models/ProductAction.php new file mode 100644 index 0000000..c8400c0 --- /dev/null +++ b/src/Models/ProductAction.php @@ -0,0 +1,147 @@ + 'array', + 'active' => 'boolean', + 'sort_order' => 'integer', + ]; + + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + $this->setTable(config('shop.tables.product_actions', 'product_actions')); + } + + public function product(): BelongsTo + { + return $this->belongsTo(config('shop.models.product', Product::class)); + } + + public static function getAvailableActions(): array + { + if (!config('shop.actions.auto_discover')) { + return []; + } + + $path = config('shop.actions.path', app_path('Jobs/ProductAction')); + $namespace = config('shop.actions.namespace', 'App\\Jobs\\ProductAction'); + + if (!file_exists($path)) { + return []; + } + + $actions = collect(glob($path . '/*.php')); + + $actions = $actions->mapWithKeys(function ($filePath) use ($path, $namespace) { + $className = str_replace(['.php', $path . '/'], '', $filePath); + $class = $namespace . '\\' . $className; + + if (!class_exists($class) || !method_exists($class, 'parameters')) { + return []; + } + + $params = $class::parameters(); + return [$className => $params]; + }); + + return $actions->toArray(); + } + + public static function callForProduct( + Product $product, + string $event, + ?ProductPurchase $productPurchase = null, + array $additionalData = [] + ): void { + $actions = $product->actions() + ->where('event', $event) + ->where('active', true) + ->orderBy('sort_order') + ->get(); + + if ($actions->isEmpty()) { + return; + } + + $available_actions = self::getAvailableActions(); + + foreach ($actions as $action) { + try { + if (!isset($available_actions[$action->action_type])) { + Log::warning('Product action not found', [ + 'product_id' => $product->id, + 'event' => $event, + 'action_type' => $action->action_type, + ]); + continue; + } + + $namespace = config('shop.actions.namespace', 'App\\Jobs\\ProductAction'); + $action_job = $namespace . '\\' . $action->action_type; + + $params = [ + 'product' => $product, + 'productPurchase' => $productPurchase, + 'event' => $event, + ...($action->config ?? []), + ...$additionalData, + ]; + + dispatch(new $action_job(...$params)); + } catch (\Exception $e) { + Log::error('Error calling product action', [ + 'product_id' => $product->id, + 'event' => $event, + 'action_type' => $action->action_type ?? 'unknown', + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + report($e); + } + } + } + + public function execute( + Product $product, + ?ProductPurchase $productPurchase = null, + array $additionalData = [] + ): void { + $namespace = config('shop.actions.namespace', 'App\\Jobs\\ProductAction'); + $action_job = $namespace . '\\' . $this->action_type; + + if (!class_exists($action_job)) { + throw new \Exception("Action class {$action_job} not found"); + } + + $params = [ + 'product' => $product, + 'productPurchase' => $productPurchase, + 'event' => $this->event, + ...($this->config ?? []), + ...$additionalData, + ]; + + dispatch(new $action_job(...$params)); + } +} diff --git a/src/Models/ProductAttribute.php b/src/Models/ProductAttribute.php new file mode 100644 index 0000000..1a33a41 --- /dev/null +++ b/src/Models/ProductAttribute.php @@ -0,0 +1,44 @@ + 'integer', + 'meta' => 'object', + ]; + + protected $hidden = [ + 'id', + 'product_id', + 'created_at', + 'updated_at', + ]; + + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + $this->setTable(config('shop.tables.product_attributes', 'product_attributes')); + } + + public function product(): BelongsTo + { + return $this->belongsTo(config('shop.models.product', Product::class)); + } +} diff --git a/src/Models/ProductCategory.php b/src/Models/ProductCategory.php new file mode 100644 index 0000000..e58ac27 --- /dev/null +++ b/src/Models/ProductCategory.php @@ -0,0 +1,168 @@ + 'boolean', + 'meta' => 'object', + ]; + + protected $hidden = [ + 'created_at', + 'updated_at', + ]; + + protected $appends = [ + 'product_count', + ]; + + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + $this->setTable(config('shop.tables.product_categories', 'product_categories')); + } + + protected static function booted() + { + static::creating(function ($model) { + if (!$model->slug) { + $model->slug = str()->slug($model->name); + } + }); + + static::saved(function ($model) { + if (config('shop.cache.enabled')) { + Cache::forget(config('shop.cache.prefix') . 'categories:tree'); + Cache::forget(config('shop.cache.prefix') . 'category:' . $model->id); + } + }); + + static::deleted(function ($model) { + if (config('shop.cache.enabled')) { + Cache::forget(config('shop.cache.prefix') . 'categories:tree'); + } + }); + } + + public function products(): BelongsToMany + { + return $this->belongsToMany( + config('shop.models.product'), + 'product_category_product' + ); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id') + ->where('visible', true) + ->orderBy('sort_order'); + } + + public function allChildren(): HasMany + { + return $this->hasMany(self::class, 'parent_id') + ->orderBy('sort_order'); + } + + public function scopeVisible($query) + { + return $query->where('visible', true); + } + + public function scopeRoots($query) + { + return $query->whereNull('parent_id'); + } + + // Backward compatibility accessor + public function getIsVisibleAttribute(): bool + { + return $this->attributes['visible'] ?? true; + } + + public function getProductCountAttribute(): int + { + return $this->products()->count(); + } + + public function toArray(): array + { + $array = parent::toArray(); + + // Only include nested children if explicitly loaded + if ($this->relationLoaded('children')) { + $array['children'] = $this->children->toArray(); + } + + return $array; + } + + public static function getTree(): array + { + if (config('shop.cache.enabled')) { + return Cache::remember( + config('shop.cache.prefix') . 'categories:tree', + config('shop.cache.ttl'), + fn() => self::buildTree() + ); + } + + return self::buildTree(); + } + + protected static function buildTree(): array + { + $categories = self::visible() + ->with('children') + ->whereNull('parent_id') + ->orderBy('sort_order') + ->get(); + + return $categories->toArray(); + } + + public function getPath(): array + { + $path = []; + $category = $this; + + while ($category) { + array_unshift($path, [ + 'id' => $category->id, + 'name' => $category->name, + 'slug' => $category->slug, + ]); + $category = $category->parent; + } + + return $path; + } +} diff --git a/src/Models/ProductPrice.php b/src/Models/ProductPrice.php new file mode 100644 index 0000000..ebcc7c7 --- /dev/null +++ b/src/Models/ProductPrice.php @@ -0,0 +1,48 @@ + 'integer', + 'sale_price' => 'integer', + 'is_default' => 'boolean', + 'trial_period_days' => 'integer', + 'meta' => 'object', + 'active' => 'boolean', + ]; + + public function product() + { + return $this->belongsTo(Product::class); + } + + public function scopeIsActive($query) + { + return $query->where('active', true); + } +} diff --git a/src/Models/ProductPurchase.php b/src/Models/ProductPurchase.php new file mode 100644 index 0000000..418936d --- /dev/null +++ b/src/Models/ProductPurchase.php @@ -0,0 +1,83 @@ + 'integer', + 'meta' => 'object', + ]; + + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + $this->setTable(config('shop.tables.product_purchases', 'product_purchases')); + } + + public function purchasable() + { + return $this->morphTo(); + } + + // Backward compatibility - user accessor + public function user() + { + if ($this->purchasable_type === config('auth.providers.users.model', \Workbench\App\Models\User::class)) { + return $this->purchasable(); + } + return null; + } + + // Backward compatibility accessor + public function getUserIdAttribute() + { + if ($this->purchasable_type === config('auth.providers.users.model', \Workbench\App\Models\User::class)) { + return $this->purchasable_id; + } + return null; + } + + public function product() + { + return $this->belongsTo(config('shop.models.product', Product::class)); + } + + public static function scopeFromCart($query, $cartId) + { + return $query->where('cart_id', $cartId); + } + + public static function scopeInCart($query) + { + return $query->where('status', 'cart'); + } + + public static function scopeCompleted($query) + { + return $query->where('status', 'completed'); + } + + protected static function booted() + { + static::created(function ($productPurchase) { + if ($productPurchase->status === 'completed' && $product = $productPurchase->product) { + $product->callActions('purchased', $productPurchase); + } + }); + } +} diff --git a/src/Models/ProductStock.php b/src/Models/ProductStock.php new file mode 100644 index 0000000..b7458b2 --- /dev/null +++ b/src/Models/ProductStock.php @@ -0,0 +1,219 @@ + 'integer', + 'expires_at' => 'datetime', + 'meta' => 'object', + ]; + + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + $this->setTable(config('shop.tables.product_stocks', 'product_stocks')); + } + + protected static function booted() + { + static::created(function ($model) { + $model->logStockChange(); + }); + + static::updated(function ($model) { + if ($model->wasChanged('status') && $model->status === 'completed') { + $model->releaseStock(); + } + }); + } + + public function product(): BelongsTo + { + return $this->belongsTo(config('shop.models.product', Product::class)); + } + + public function reference(): MorphTo + { + return $this->morphTo(); + } + + public function scopePending($query) + { + return $query->where('status', 'pending'); + } + + public function scopeReleased($query) + { + return $query->where('status', 'completed'); + } + + public function scopeExpired($query) + { + return $query->where('status', 'expired') + ->orWhere(function ($q) { + $q->where('status', 'pending') + ->whereNotNull('expires_at') + ->where('expires_at', '<=', now()); + }); + } + + public function scopeTemporary($query) + { + return $query->whereNotNull('expires_at'); + } + + public function scopePermanent($query) + { + return $query->whereNull('expires_at'); + } + + // Backward compatibility accessors + public function getReleasedAtAttribute() + { + return $this->status === 'completed' ? $this->updated_at : null; + } + + public function getUntilAtAttribute() + { + return $this->expires_at; + } + + public static function reserve( + Product $product, + int $quantity, + ?string $type = 'reservation', + $reference = null, + ?\DateTimeInterface $until = null, + ?string $note = null + ): ?self { + return DB::transaction(function () use ($product, $quantity, $type, $reference, $until, $note) { + if (!$product->decreaseStock($quantity)) { + return null; + } + + return self::create([ + 'product_id' => $product->id, + 'quantity' => $quantity, + 'type' => $type, + 'status' => 'pending', + 'reference_type' => $reference ? get_class($reference) : null, + 'reference_id' => $reference?->id, + 'expires_at' => $until, + 'note' => $note, + ]); + }); + } + + public function release(): bool + { + if ($this->status !== 'pending') { + return false; + } + + return DB::transaction(function () { + $this->product->increaseStock($this->quantity); + + $this->status = 'completed'; + $this->save(); + + return true; + }); + } + + public function isPermanent(): bool + { + return is_null($this->expires_at); + } + + public function isTemporary(): bool + { + return !is_null($this->expires_at); + } + + public function isExpired(): bool + { + return $this->isTemporary() + && $this->status === 'pending' + && $this->expires_at->isPast(); + } + + public function isActive(): bool + { + return $this->status === 'pending'; + } + + protected function logStockChange(): void + { + if (!config('shop.stock.log_changes', true)) { + return; + } + + DB::table('product_stock_logs')->insert([ + 'product_id' => $this->product_id, + 'quantity_change' => -$this->quantity, + 'quantity_after' => $this->product->stock_quantity, + 'type' => $this->type, + 'note' => $this->note, + 'reference_type' => $this->reference_type, + 'reference_id' => $this->reference_id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + protected function releaseStock(): void + { + if (!config('shop.stock.log_changes', true)) { + return; + } + + DB::table('product_stock_logs')->insert([ + 'product_id' => $this->product_id, + 'quantity_change' => $this->quantity, + 'quantity_after' => $this->product->stock_quantity, + 'type' => 'release', + 'note' => 'Stock released from reservation', + 'reference_type' => $this->reference_type, + 'reference_id' => $this->reference_id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public static function releaseExpired(): int + { + $expired = self::expired()->get(); + $count = 0; + + foreach ($expired as $stock) { + if ($stock->release()) { + $count++; + } + } + + return $count; + } +} diff --git a/src/Services/ShopStripeService.php b/src/Services/ShopStripeService.php new file mode 100644 index 0000000..1d70897 --- /dev/null +++ b/src/Services/ShopStripeService.php @@ -0,0 +1,72 @@ + $stripeProduct->id], + [ + 'slug' => str()->slug($stripeProduct->name), + 'type' => $stripeProduct->type, + 'virtual' => $stripeProduct->type === 'service', + 'status' => $stripeProduct->active ? 'published' : 'draft', + ] + ); + + $product->setLocalized('name', $stripeProduct->name); + + if (isset($stripeProduct->marketing_features)) { + $product->setLocalized( + 'features', + collect($stripeProduct->marketing_features)->map(fn($i) => $i->name)->toArray(), + ); + } + + $product->save(); + + // Sync prices + self::syncProductPricesDown($product); + + if (app()->runningInConsole()) { + echo "\n"; + } + + return $product; + } + + public static function syncProductPricesDown(Product $product) + { + self::getProductPrices($product->stripe_product_id)->each(function ($stripePrice) use ($product) { + if ($stripePrice->product !== $product->stripe_product_id) { + return; + } + + $price = $product->prices()->updateOrCreate( + ['stripe_price_id' => $stripePrice->id], + [ + 'name' => $stripePrice->nickname, + 'type' => $stripePrice->type, + 'price' => $stripePrice->unit_amount, + 'currency' => $stripePrice->currency, + 'billing_scheme' => $stripePrice->billing_scheme, + 'interval' => $stripePrice->recurring ? $stripePrice->recurring->interval : null, + 'interval_count' => $stripePrice->recurring ? $stripePrice->recurring->interval_count : null, + 'trial_period_days' => $stripePrice->recurring ? $stripePrice->recurring->trial_period_days : null, + 'is_default' => false, + ] + ); + + if (app()->runningInConsole()) { + echo " - Synced price {$price->id} ({$stripePrice->id})\n"; + } + }); + } +} diff --git a/src/ShopServiceProvider.php b/src/ShopServiceProvider.php new file mode 100644 index 0000000..d54716c --- /dev/null +++ b/src/ShopServiceProvider.php @@ -0,0 +1,55 @@ +mergeConfigFrom( + __DIR__ . '/../config/shop.php', + 'shop' + ); + } + + public function boot() + { + // Publish config + $this->publishes([ + __DIR__ . '/../config/shop.php' => config_path('shop.php'), + ], 'shop-config'); + + // Publish migrations + $this->publishes([ + __DIR__ . '/../database/migrations' => database_path('migrations'), + ], 'shop-migrations'); + + // Load migrations + if ($this->app->runningInConsole()) { + $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); + } + + // Load routes if enabled (API only) + if (config('shop.routes.enabled', true)) { + $this->loadRoutesFrom(__DIR__ . '/../routes/api.php'); + } + + // Register commands + if ($this->app->runningInConsole()) { + $this->commands([ + ShopReinstallCommand::class, + \Blax\Shop\Console\Commands\ReleaseExpiredStocks::class, + \Blax\Shop\Console\Commands\ShopListProductsCommand::class, + \Blax\Shop\Console\Commands\ShopListActionsCommand::class, + \Blax\Shop\Console\Commands\ShopToggleActionCommand::class, + \Blax\Shop\Console\Commands\ShopTestActionCommand::class, + \Blax\Shop\Console\Commands\ShopListPurchasesCommand::class, + \Blax\Shop\Console\Commands\ShopAvailableActionsCommand::class, + \Blax\Shop\Console\Commands\ShopStatsCommand::class, + ]); + } + } +} diff --git a/src/Traits/HasShoppingCapabilities.php b/src/Traits/HasShoppingCapabilities.php new file mode 100644 index 0000000..abe6688 --- /dev/null +++ b/src/Traits/HasShoppingCapabilities.php @@ -0,0 +1,403 @@ +morphMany( + config('shop.models.product_purchase', ProductPurchase::class), + 'purchasable' + ); + } + + /** + * Get cart items (purchases with status 'cart') + */ + public function cartItems(): MorphMany + { + return $this->purchases()->where('status', 'cart'); + } + + /** + * Get completed purchases + */ + public function completedPurchases(): MorphMany + { + return $this->purchases()->where('status', 'completed'); + } + + /** + * Purchase a product + * + * @param Product $product + * @param int $quantity + * @param array $options Additional options (price_id, meta, etc.) + * @return ProductPurchase + * @throws \Exception + */ + public function purchase(Product $product, int $quantity = 1, array $options = []): ProductPurchase + { + // Validate stock availability + if ($product->manage_stock) { + $available = $product->getAvailableStock(); + if ($available < $quantity) { + throw new \Exception("Insufficient stock. Available: {$available}, Requested: {$quantity}"); + } + } + + // Check if product is visible + if (!$product->isVisible()) { + throw new \Exception("Product is not available for purchase"); + } + + // Decrease stock + if (!$product->decreaseStock($quantity)) { + throw new \Exception("Unable to decrease stock"); + } + + // Determine price + $priceId = $options['price_id'] ?? null; + $price = $this->determinePurchasePrice($product, $priceId); + + // Create purchase record + $purchase = $this->purchases()->create([ + 'product_id' => $product->id, + 'quantity' => $quantity, + 'status' => $options['status'] ?? 'completed', + 'meta' => array_merge([ + 'price_id' => $priceId, + 'price' => $price, + 'amount' => $price * $quantity, + 'charge_id' => $options['charge_id'] ?? null, + ], $options['meta'] ?? []), + ]); + + // Trigger product actions + $product->callActions('purchased', $purchase, [ + 'purchaser' => $this, + ...$options, + ]); + + return $purchase; + } + + /** + * Add product to cart + * + * @param Product $product + * @param int $quantity + * @param array $options + * @return ProductPurchase + * @throws \Exception + */ + public function addToCart(Product $product, int $quantity = 1, array $options = []): ProductPurchase + { + // Check if product already in cart + $existingItem = $this->cartItems() + ->where('product_id', $product->id) + ->first(); + + if ($existingItem) { + return $this->updateCartQuantity($existingItem, $existingItem->quantity + $quantity); + } + + // Validate stock + if ($product->manage_stock && $product->getAvailableStock() < $quantity) { + throw new \Exception("Insufficient stock available"); + } + + $priceId = $options['price_id'] ?? null; + $price = $this->determinePurchasePrice($product, $priceId); + + return $this->purchases()->create([ + 'product_id' => $product->id, + 'quantity' => $quantity, + 'status' => 'cart', + 'meta' => array_merge([ + 'price_id' => $priceId, + 'price' => $price, + 'amount' => $price * $quantity, + ], $options['meta'] ?? []), + ]); + } + + /** + * Update cart item quantity + * + * @param ProductPurchase $cartItem + * @param int $quantity + * @return ProductPurchase + * @throws \Exception + */ + public function updateCartQuantity(ProductPurchase $cartItem, int $quantity): ProductPurchase + { + if ($cartItem->status !== 'cart') { + throw new \Exception("Cannot update non-cart item"); + } + + $product = $cartItem->product; + + // Validate stock + if ($product->manage_stock && $product->getAvailableStock() < $quantity) { + throw new \Exception("Insufficient stock available"); + } + + $meta = (array) $cartItem->meta; + $priceId = $meta['price_id'] ?? null; + $price = $this->determinePurchasePrice($product, $priceId); + + $cartItem->update([ + 'quantity' => $quantity, + 'meta' => array_merge($meta, [ + 'price' => $price, + 'amount' => $price * $quantity, + ]), + ]); + + return $cartItem->fresh(); + } + + /** + * Remove item from cart + * + * @param ProductPurchase $cartItem + * @return bool + * @throws \Exception + */ + public function removeFromCart(ProductPurchase $cartItem): bool + { + if ($cartItem->status !== 'cart') { + throw new \Exception("Cannot remove non-cart item"); + } + + return $cartItem->delete(); + } + + /** + * Clear all cart items + * + * @param string|null $cartId (deprecated - not used) + * @return int Number of items removed + */ + public function clearCart(?string $cartId = null): int + { + return $this->cartItems()->delete(); + } + + /** + * Get cart total + * + * @param string|null $cartId (deprecated - not used) + * @return float + */ + public function getCartTotal(?string $cartId = null): float + { + return $this->cartItems()->get()->sum(function ($item) { + $meta = (array) $item->meta; + return $meta['amount'] ?? 0; + }); + } + + /** + * Get cart items count + * + * @param string|null $cartId (deprecated - not used) + * @return int + */ + public function getCartItemsCount(?string $cartId = null): int + { + return $this->cartItems()->sum('quantity') ?? 0; + } + + /** + * Checkout cart - convert cart items to completed purchases + * + * @param string|null $cartId (deprecated - not used) + * @param array $options + * @return Collection + * @throws \Exception + */ + public function checkout(?string $cartId = null, array $options = []): Collection + { + $items = $this->cartItems()->with('product')->get(); + + if ($items->isEmpty()) { + throw new \Exception("Cart is empty"); + } + + // Validate stock for all items + foreach ($items as $item) { + $product = $item->product; + if ($product->manage_stock && $product->getAvailableStock() < $item->quantity) { + throw new \Exception("Insufficient stock for: {$product->getLocalized('name')}"); + } + } + + // Process each item + $completedPurchases = collect(); + foreach ($items as $item) { + $product = $item->product; + + // Decrease stock + if (!$product->decreaseStock($item->quantity)) { + // Rollback previous purchases + foreach ($completedPurchases as $purchase) { + $purchase->product->increaseStock($purchase->quantity); + $purchase->delete(); + } + throw new \Exception("Unable to process checkout"); + } + + // Update status and store charge info in meta + $meta = array_merge((array) $item->meta, [ + 'charge_id' => $options['charge_id'] ?? null, + 'completed_at' => now()->toISOString(), + ]); + + $item->update([ + 'status' => 'completed', + 'meta' => $meta, + ]); + + // Trigger actions + $product->callActions('purchased', $item, [ + 'purchaser' => $this, + ...$options, + ]); + + $completedPurchases->push($item); + } + + return $completedPurchases; + } + + /** + * Check if entity has purchased a product + * + * @param Product|int $product + * @return bool + */ + public function hasPurchased($product): bool + { + $productId = $product instanceof Product ? $product->id : $product; + + return $this->completedPurchases() + ->where('product_id', $productId) + ->exists(); + } + + /** + * Get purchase history for a product + * + * @param Product|int $product + * @return Collection + */ + public function getPurchaseHistory($product): Collection + { + $productId = $product instanceof Product ? $product->id : $product; + + return $this->purchases() + ->where('product_id', $productId) + ->orderBy('created_at', 'desc') + ->get(); + } + + /** + * Refund a purchase + * + * @param ProductPurchase $purchase + * @param array $options + * @return bool + * @throws \Exception + */ + public function refundPurchase(ProductPurchase $purchase, array $options = []): bool + { + if ($purchase->status !== 'completed') { + throw new \Exception("Can only refund completed purchases"); + } + + $product = $purchase->product; + + // Return stock + $product->increaseStock($purchase->quantity); + + // Update purchase + $purchase->update([ + 'status' => 'refunded', + ]); + + // Trigger refund actions + $product->callActions('refunded', $purchase, [ + 'purchaser' => $this, + ...$options, + ]); + + return true; + } + + /** + * Get total spent + * + * @return float + */ + public function getTotalSpent(): float + { + return $this->completedPurchases()->sum('amount') ?? 0; + } + + /** + * Get purchase statistics + * + * @return array + */ + public function getPurchaseStats(): array + { + return [ + 'total_purchases' => $this->completedPurchases()->count(), + 'total_spent' => $this->getTotalSpent(), + 'total_items' => $this->completedPurchases()->sum('quantity'), + 'cart_items' => $this->getCartItemsCount(), + 'cart_total' => $this->getCartTotal(), + ]; + } + + /** + * Determine purchase price for a product + * + * @param Product $product + * @param string|null $priceId + * @return float + */ + protected function determinePurchasePrice(Product $product, ?string $priceId = null): float + { + if ($priceId) { + $productPrice = $product->prices()->find($priceId); + if ($productPrice) { + return $productPrice->price; + } + } + + return $product->getCurrentPrice(); + } + + /** + * Get or generate current cart ID + * + * @return string + */ + protected function getCurrentCartId(): string + { + // Override this method if you need custom cart ID logic + return 'cart_' . $this->getKey(); + } +} diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..ed3b6a2 --- /dev/null +++ b/test.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env nix-shell +#!nix-shell -i bash -p php82 php82Extensions.dom php82Extensions.mbstring php82Extensions.xml php82Extensions.xmlwriter php82Extensions.tokenizer php82Extensions.pdo php82Extensions.pdo_sqlite php82Extensions.sqlite3 php82Extensions.curl php82Extensions.openssl php82Extensions.fileinfo + +# Test script for NixOS - runs PHPUnit with proper PHP extensions + +echo "Running Laravel Package Tests..." +echo "PHP version: $(php --version | head -n 1)" +echo "" + +vendor/bin/phpunit "$@" diff --git a/testbench.yaml b/testbench.yaml new file mode 100644 index 0000000..23e4015 --- /dev/null +++ b/testbench.yaml @@ -0,0 +1,33 @@ +laravel: '@testbench' + +providers: + # - Workbench\App\Providers\WorkbenchServiceProvider + +migrations: + - workbench/database/migrations + +seeders: + - Workbench\Database\Seeders\DatabaseSeeder + +workbench: + start: '/' + install: true + health: false + discovers: + web: true + api: true + commands: true + components: false + factories: true + views: false + build: + - asset-publish + - create-sqlite-db + - db-wipe + - migrate-fresh + assets: + - laravel-assets + sync: + - from: storage + to: workbench/storage + reverse: true diff --git a/tests/Feature/CartManagementTest.php b/tests/Feature/CartManagementTest.php new file mode 100644 index 0000000..4bed250 --- /dev/null +++ b/tests/Feature/CartManagementTest.php @@ -0,0 +1,339 @@ +create(); + + $cart = Cart::create([ + 'customer_type' => get_class($user), + 'customer_id' => $user->id, + 'expires_at' => now()->addDays(7), + ]); + + $this->assertDatabaseHas('carts', [ + 'id' => $cart->id, + 'customer_type' => get_class($user), + 'customer_id' => $user->id, + ]); + $this->assertNotNull($cart->id); + } + + /** @test */ + public function it_automatically_generates_uuid() + { + $cart = Cart::create(); + + $this->assertNotNull($cart->id); + $this->assertIsString($cart->id); + } + + /** @test */ + public function it_can_add_items_to_cart() + { + $cart = Cart::create(); + $product = Product::factory()->create(['price' => 99.99]); + + $cartItem = CartItem::create([ + 'cart_id' => $cart->id, + 'product_id' => $product->id, + 'quantity' => 2, + 'price' => $product->price, + 'subtotal' => $product->price * 2, + ]); + + $this->assertCount(1, $cart->fresh()->items); + $this->assertEquals(2, $cart->items->first()->quantity); + } + + /** @test */ + public function it_can_update_cart_item_quantity() + { + $cart = Cart::create(); + $product = Product::factory()->create(['price' => 50.00]); + + $cartItem = CartItem::create([ + 'cart_id' => $cart->id, + 'product_id' => $product->id, + 'quantity' => 1, + 'price' => $product->price, + 'subtotal' => $product->price, + ]); + + $cartItem->update(['quantity' => 3]); + + $this->assertEquals(3, $cartItem->fresh()->quantity); + } + + /** @test */ + public function it_can_remove_items_from_cart() + { + $cart = Cart::create(); + $product = Product::factory()->create(['price' => 75.00]); + + $cartItem = CartItem::create([ + 'cart_id' => $cart->id, + 'product_id' => $product->id, + 'quantity' => 1, + 'price' => $product->price, + 'subtotal' => $product->price, + ]); + + $this->assertCount(1, $cart->fresh()->items); + + $cartItem->delete(); + + $this->assertCount(0, $cart->fresh()->items); + } + + /** @test */ + public function it_calculates_cart_total_correctly() + { + $cart = Cart::create(); + $product1 = Product::factory()->create(['price' => 50.00]); + $product2 = Product::factory()->create(['price' => 30.00]); + + CartItem::create([ + 'cart_id' => $cart->id, + 'product_id' => $product1->id, + 'quantity' => 2, + 'price' => $product1->price, + 'subtotal' => $product1->price * 2, + ]); + + CartItem::create([ + 'cart_id' => $cart->id, + 'product_id' => $product2->id, + 'quantity' => 1, + 'price' => $product2->price, + 'subtotal' => $product2->price, + ]); + + $total = $cart->fresh()->getTotal(); + + $this->assertEquals(130.00, $total); // (50 * 2) + (30 * 1) + } + + /** @test */ + public function it_calculates_total_items_correctly() + { + $cart = Cart::create(); + $product1 = Product::factory()->create(); + $product2 = Product::factory()->create(); + + CartItem::create([ + 'cart_id' => $cart->id, + 'product_id' => $product1->id, + 'quantity' => 3, + 'price' => 10.00, + 'subtotal' => 30.00, + ]); + + CartItem::create([ + 'cart_id' => $cart->id, + 'product_id' => $product2->id, + 'quantity' => 2, + 'price' => 20.00, + 'subtotal' => 40.00, + ]); + + $totalItems = $cart->fresh()->getTotalItems(); + + $this->assertEquals(5, $totalItems); // 3 + 2 + } + + /** @test */ + public function it_can_check_if_cart_is_expired() + { + $expiredCart = Cart::create([ + 'expires_at' => now()->subDay(), + ]); + + $activeCart = Cart::create([ + 'expires_at' => now()->addDay(), + ]); + + $this->assertTrue($expiredCart->isExpired()); + $this->assertFalse($activeCart->isExpired()); + } + + /** @test */ + public function it_can_check_if_cart_is_converted() + { + $convertedCart = Cart::create([ + 'converted_at' => now(), + ]); + + $activeCart = Cart::create([ + 'converted_at' => null, + ]); + + $this->assertTrue($convertedCart->isConverted()); + $this->assertFalse($activeCart->isConverted()); + } + + /** @test */ + public function it_can_scope_active_carts() + { + Cart::create([ + 'expires_at' => now()->addDay(), + 'converted_at' => null, + ]); + + Cart::create([ + 'expires_at' => now()->subDay(), + 'converted_at' => null, + ]); + + Cart::create([ + 'expires_at' => now()->addDay(), + 'converted_at' => now(), + ]); + + $activeCarts = Cart::active()->get(); + + $this->assertCount(1, $activeCarts); + } + + /** @test */ + public function it_can_scope_carts_for_user() + { + $user = User::factory()->create(); + $otherUser = User::factory()->create(); + + Cart::create(['customer_type' => get_class($user), 'customer_id' => $user->id]); + Cart::create(['customer_type' => get_class($user), 'customer_id' => $user->id]); + Cart::create(['customer_type' => get_class($otherUser), 'customer_id' => $otherUser->id]); + + $userCarts = Cart::forUser($user)->get(); + + $this->assertCount(2, $userCarts); + } + + /** @test */ + public function it_belongs_to_a_user() + { + $user = User::factory()->create(); + $cart = Cart::create(['customer_type' => get_class($user), 'customer_id' => $user->id]); + + $this->assertEquals($user->id, $cart->user->id); + } + + /** @test */ + public function cart_items_have_correct_relationships() + { + $cart = Cart::create(); + $product = Product::factory()->create(['price' => 45.00]); + + $cartItem = CartItem::create([ + 'cart_id' => $cart->id, + 'product_id' => $product->id, + 'quantity' => 1, + 'price' => $product->price, + 'subtotal' => $product->price, + ]); + + $this->assertEquals($cart->id, $cartItem->cart->id); + $this->assertEquals($product->id, $cartItem->product->id); + } + + /** @test */ + public function it_calculates_cart_item_subtotal() + { + $cart = Cart::create(); + $product = Product::factory()->create(['price' => 25.00]); + + $cartItem = CartItem::create([ + 'cart_id' => $cart->id, + 'product_id' => $product->id, + 'quantity' => 4, + 'price' => $product->price, + 'subtotal' => $product->price * 4, + ]); + + $this->assertEquals(100.00, $cartItem->getSubtotal()); // 25 * 4 + } + + /** @test */ + public function it_can_store_cart_item_attributes() + { + $cart = Cart::create(); + $product = Product::factory()->create(); + + $cartItem = CartItem::create([ + 'cart_id' => $cart->id, + 'product_id' => $product->id, + 'quantity' => 1, + 'price' => 50.00, + 'subtotal' => 50.00, + 'attributes' => [ + 'color' => 'blue', + 'size' => 'large', + ], + ]); + + $this->assertEquals('blue', $cartItem->attributes['color']); + $this->assertEquals('large', $cartItem->attributes['size']); + } + + /** @test */ + public function it_can_have_multiple_items_of_same_product_with_different_attributes() + { + $cart = Cart::create(); + $product = Product::factory()->create(['price' => 30.00]); + + CartItem::create([ + 'cart_id' => $cart->id, + 'product_id' => $product->id, + 'quantity' => 1, + 'price' => $product->price, + 'subtotal' => $product->price, + 'attributes' => ['size' => 'small'], + ]); + + CartItem::create([ + 'cart_id' => $cart->id, + 'product_id' => $product->id, + 'quantity' => 2, + 'price' => $product->price, + 'subtotal' => $product->price * 2, + 'attributes' => ['size' => 'large'], + ]); + + $this->assertCount(2, $cart->fresh()->items); + } + + /** @test */ + public function it_deletes_cart_items_when_cart_is_deleted() + { + $cart = Cart::create(); + $product = Product::factory()->create(); + + $cartItem = CartItem::create([ + 'cart_id' => $cart->id, + 'product_id' => $product->id, + 'quantity' => 1, + 'price' => 50.00, + 'subtotal' => 50.00, + ]); + + $cartItemId = $cartItem->id; + + $cart->delete(); + + $this->assertDatabaseMissing('cart_items', ['id' => $cartItemId]); + } +} diff --git a/tests/Feature/ProductActionTest.php b/tests/Feature/ProductActionTest.php new file mode 100644 index 0000000..b83ab92 --- /dev/null +++ b/tests/Feature/ProductActionTest.php @@ -0,0 +1,342 @@ +create(); + + $action = ProductAction::create([ + 'product_id' => $product->id, + 'event' => 'purchased', + 'action_type' => 'App\\Actions\\SendWelcomeEmail', + 'active' => true, + 'sort_order' => 10, + ]); + + $this->assertDatabaseHas('product_actions', [ + 'id' => $action->id, + 'product_id' => $product->id, + 'event' => 'purchased', + ]); + } + + /** @test */ + public function product_has_many_actions() + { + $product = Product::factory()->create(); + + ProductAction::create([ + 'product_id' => $product->id, + 'event' => 'purchased', + 'action_type' => 'App\\Actions\\SendWelcomeEmail', + 'active' => true, + ]); + + ProductAction::create([ + 'product_id' => $product->id, + 'event' => 'purchased', + 'action_type' => 'App\\Actions\\GrantAccess', + 'active' => true, + ]); + + $this->assertCount(2, $product->fresh()->actions); + } + + /** @test */ + public function action_belongs_to_product() + { + $product = Product::factory()->create(); + + $action = ProductAction::create([ + 'product_id' => $product->id, + 'event' => 'purchased', + 'action_type' => 'App\\Actions\\TestAction', + 'active' => true, + ]); + + $this->assertEquals($product->id, $action->product->id); + } + + /** @test */ + public function it_can_enable_and_disable_actions() + { + $product = Product::factory()->create(); + + $action = ProductAction::create([ + 'product_id' => $product->id, + 'event' => 'purchased', + 'action_type' => 'App\\Actions\\TestAction', + 'active' => true, + ]); + + $this->assertTrue($action->active); + + $action->update(['active' => false]); + + $this->assertFalse($action->fresh()->active); + } + + /** @test */ + public function it_can_store_action_parameters() + { + $product = Product::factory()->create(); + + $action = ProductAction::create([ + 'product_id' => $product->id, + 'event' => 'purchased', + 'action_type' => 'App\\Actions\\SendEmail', + 'parameters' => [ + 'template' => 'welcome', + 'delay' => 60, + 'subject' => 'Welcome to our service', + ], + 'active' => true, + ]); + + $this->assertEquals('welcome', $action->parameters['template']); + $this->assertEquals(60, $action->parameters['delay']); + $this->assertEquals('Welcome to our service', $action->parameters['subject']); + } + + /** @test */ + public function it_can_set_action_priority() + { + $product = Product::factory()->create(); + + $action1 = ProductAction::create([ + 'product_id' => $product->id, + 'event' => 'purchased', + 'action_type' => 'App\\Actions\\FirstAction', + 'sort_order' => 1, + 'active' => true, + ]); + + $action2 = ProductAction::create([ + 'product_id' => $product->id, + 'event' => 'purchased', + 'action_type' => 'App\\Actions\\SecondAction', + 'sort_order' => 2, + 'active' => true, + ]); + + $sorted = ProductAction::where('product_id', $product->id) + ->orderBy('sort_order') + ->get(); + + $this->assertEquals($action1->id, $sorted[0]->id); + $this->assertEquals($action2->id, $sorted[1]->id); + } + + /** @test */ + public function it_can_have_different_events() + { + $product = Product::factory()->create(); + + $purchasedAction = ProductAction::create([ + 'product_id' => $product->id, + 'event' => 'purchased', + 'action_type' => 'App\\Actions\\OnPurchase', + 'active' => true, + ]); + + $refundedAction = ProductAction::create([ + 'product_id' => $product->id, + 'event' => 'refunded', + 'action_type' => 'App\\Actions\\OnRefund', + 'active' => true, + ]); + + $this->assertEquals('purchased', $purchasedAction->event); + $this->assertEquals('refunded', $refundedAction->event); + } + + /** @test */ + public function it_can_get_actions_for_specific_event() + { + $product = Product::factory()->create(); + + ProductAction::create([ + 'product_id' => $product->id, + 'event' => 'purchased', + 'action_type' => 'App\\Actions\\OnPurchase', + 'active' => true, + ]); + + ProductAction::create([ + 'product_id' => $product->id, + 'event' => 'purchased', + 'action_type' => 'App\\Actions\\AnotherPurchase', + 'active' => true, + ]); + + ProductAction::create([ + 'product_id' => $product->id, + 'event' => 'refunded', + 'action_type' => 'App\\Actions\\OnRefund', + 'active' => true, + ]); + + $purchaseActions = ProductAction::where('product_id', $product->id) + ->where('event', 'purchased') + ->get(); + + $this->assertCount(2, $purchaseActions); + } + + /** @test */ + public function it_can_filter_enabled_actions() + { + $product = Product::factory()->create(); + + ProductAction::create([ + 'product_id' => $product->id, + 'event' => 'purchased', + 'action_type' => 'App\\Actions\\EnabledAction', + 'active' => true, + ]); + + ProductAction::create([ + 'product_id' => $product->id, + 'event' => 'purchased', + 'action_type' => 'App\\Actions\\DisabledAction', + 'active' => false, + ]); + + $enabledActions = ProductAction::where('product_id', $product->id) + ->where('active', true) + ->get(); + + $this->assertCount(1, $enabledActions); + } + + /** @test */ + public function multiple_products_can_have_same_action() + { + $product1 = Product::factory()->create(); + $product2 = Product::factory()->create(); + + ProductAction::create([ + 'product_id' => $product1->id, + 'event' => 'purchased', + 'action_type' => 'App\\Actions\\CommonAction', + 'active' => true, + ]); + + ProductAction::create([ + 'product_id' => $product2->id, + 'event' => 'purchased', + 'action_type' => 'App\\Actions\\CommonAction', + 'active' => true, + ]); + + $this->assertCount(1, $product1->actions); + $this->assertCount(1, $product2->actions); + } + + /** @test */ + public function it_can_update_action_parameters() + { + $product = Product::factory()->create(); + + $action = ProductAction::create([ + 'product_id' => $product->id, + 'event' => 'purchased', + 'action_type' => 'App\\Actions\\TestAction', + 'parameters' => ['key' => 'old_value'], + 'active' => true, + ]); + + $action->update([ + 'parameters' => ['key' => 'new_value', 'another_key' => 'another_value'], + ]); + + $fresh = $action->fresh(); + $this->assertEquals('new_value', $fresh->parameters['key']); + $this->assertEquals('another_value', $fresh->parameters['another_key']); + } + + /** @test */ + public function deleting_product_deletes_actions() + { + $product = Product::factory()->create(); + + $action = ProductAction::create([ + 'product_id' => $product->id, + 'event' => 'purchased', + 'action_type' => 'App\\Actions\\TestAction', + 'active' => true, + ]); + + $actionId = $action->id; + + $product->delete(); + + $this->assertDatabaseMissing('product_actions', ['id' => $actionId]); + } + + /** @test */ + public function action_can_have_empty_parameters() + { + $product = Product::factory()->create(); + + $action = ProductAction::create([ + 'product_id' => $product->id, + 'event' => 'purchased', + 'action_type' => 'App\\Actions\\SimpleAction', + 'active' => true, + ]); + + $this->assertNull($action->parameters); + } + + /** @test */ + public function it_can_query_actions_by_priority_order() + { + $product = Product::factory()->create(); + + $high = ProductAction::create([ + 'product_id' => $product->id, + 'event' => 'purchased', + 'action_type' => 'App\\Actions\\HighPriority', + 'sort_order' => 100, + 'active' => true, + ]); + + $medium = ProductAction::create([ + 'product_id' => $product->id, + 'event' => 'purchased', + 'action_type' => 'App\\Actions\\MediumPriority', + 'sort_order' => 50, + 'active' => true, + ]); + + $low = ProductAction::create([ + 'product_id' => $product->id, + 'event' => 'purchased', + 'action_type' => 'App\\Actions\\LowPriority', + 'sort_order' => 10, + 'active' => true, + ]); + + $ordered = ProductAction::where('product_id', $product->id) + ->orderBy('sort_order', 'asc') + ->get(); + + $this->assertEquals($low->id, $ordered[0]->id); + $this->assertEquals($medium->id, $ordered[1]->id); + $this->assertEquals($high->id, $ordered[2]->id); + } +} diff --git a/tests/Feature/ProductCategoryTest.php b/tests/Feature/ProductCategoryTest.php new file mode 100644 index 0000000..0d34a1d --- /dev/null +++ b/tests/Feature/ProductCategoryTest.php @@ -0,0 +1,228 @@ +create([ + 'slug' => 'electronics', + ]); + + $this->assertDatabaseHas('product_categories', [ + 'id' => $category->id, + 'slug' => 'electronics', + ]); + } + + /** @test */ + public function it_automatically_generates_slug_from_name() + { + $category = ProductCategory::create([ + 'slug' => null, + ]); + + $this->assertNotNull($category->slug); + } + + /** @test */ + public function it_can_have_a_parent_category() + { + $parent = ProductCategory::factory()->create([ + 'slug' => 'parent-category', + ]); + + $child = ProductCategory::factory()->create([ + 'slug' => 'child-category', + 'parent_id' => $parent->id, + ]); + + $this->assertEquals($parent->id, $child->parent->id); + } + + /** @test */ + public function it_can_have_multiple_children() + { + $parent = ProductCategory::factory()->create(); + + $child1 = ProductCategory::factory()->create(['parent_id' => $parent->id]); + $child2 = ProductCategory::factory()->create(['parent_id' => $parent->id]); + $child3 = ProductCategory::factory()->create(['parent_id' => $parent->id]); + + $this->assertCount(3, $parent->fresh()->children); + } + + /** @test */ + public function it_can_attach_products_to_category() + { + $category = ProductCategory::factory()->create(); + $product1 = Product::factory()->create(); + $product2 = Product::factory()->create(); + + $category->products()->attach([$product1->id, $product2->id]); + + $this->assertCount(2, $category->fresh()->products); + $this->assertTrue($category->products->contains($product1)); + $this->assertTrue($category->products->contains($product2)); + } + + /** @test */ + public function it_can_count_products_in_category() + { + $category = ProductCategory::factory()->create(); + $product1 = Product::factory()->create(); + $product2 = Product::factory()->create(); + $product3 = Product::factory()->create(); + + $category->products()->attach([$product1->id, $product2->id, $product3->id]); + + $this->assertEquals(3, $category->products()->count()); + } + + /** @test */ + public function it_can_check_visibility() + { + $visibleCategory = ProductCategory::factory()->create([ + 'visible' => true, + ]); + + $hiddenCategory = ProductCategory::factory()->create([ + 'visible' => false, + ]); + + $this->assertTrue($visibleCategory->is_visible); + $this->assertFalse($hiddenCategory->is_visible); + } + + /** @test */ + public function it_can_have_a_sort_order() + { + $category1 = ProductCategory::factory()->create(['sort_order' => 1]); + $category2 = ProductCategory::factory()->create(['sort_order' => 2]); + $category3 = ProductCategory::factory()->create(['sort_order' => 3]); + + $sorted = ProductCategory::orderBy('sort_order')->get(); + + $this->assertEquals($category1->id, $sorted[0]->id); + $this->assertEquals($category2->id, $sorted[1]->id); + $this->assertEquals($category3->id, $sorted[2]->id); + } + + /** @test */ + public function it_can_store_meta_data() + { + $category = ProductCategory::factory()->create([ + 'meta' => [ + 'description' => 'Test description', + 'keywords' => ['test', 'category'], + ], + ]); + + $this->assertEquals('Test description', $category->meta->description); + $this->assertEquals(['test', 'category'], $category->meta->keywords); + } + + /** @test */ + public function product_can_belong_to_multiple_categories() + { + $product = Product::factory()->create(); + $category1 = ProductCategory::factory()->create(['slug' => 'electronics']); + $category2 = ProductCategory::factory()->create(['slug' => 'gadgets']); + $category3 = ProductCategory::factory()->create(['slug' => 'accessories']); + + $product->categories()->attach([$category1->id, $category2->id, $category3->id]); + + $this->assertCount(3, $product->fresh()->categories); + } + + /** @test */ + public function it_can_get_all_products_from_category_hierarchy() + { + $parent = ProductCategory::factory()->create(); + $child = ProductCategory::factory()->create(['parent_id' => $parent->id]); + + $parentProduct = Product::factory()->create(); + $childProduct = Product::factory()->create(); + + $parent->products()->attach($parentProduct->id); + $child->products()->attach($childProduct->id); + + $this->assertCount(1, $parent->products); + $this->assertCount(1, $child->products); + } + + /** @test */ + public function it_can_detach_products_from_category() + { + $category = ProductCategory::factory()->create(); + $product = Product::factory()->create(); + + $category->products()->attach($product->id); + $this->assertCount(1, $category->fresh()->products); + + $category->products()->detach($product->id); + $this->assertCount(0, $category->fresh()->products); + } + + /** @test */ + public function deleting_category_does_not_delete_products() + { + $category = ProductCategory::factory()->create(); + $product = Product::factory()->create(); + + $category->products()->attach($product->id); + $productId = $product->id; + + $category->delete(); + + $this->assertDatabaseHas('products', ['id' => $productId]); + } + + /** @test */ + public function it_can_scope_visible_categories() + { + ProductCategory::factory()->create(['is_visible' => true]); + ProductCategory::factory()->create(['is_visible' => true]); + ProductCategory::factory()->create(['is_visible' => false]); + + $visible = ProductCategory::where('is_visible', true)->get(); + + $this->assertCount(2, $visible); + } + + /** @test */ + public function it_can_get_root_categories() + { + $root1 = ProductCategory::factory()->create(['parent_id' => null]); + $root2 = ProductCategory::factory()->create(['parent_id' => null]); + $child = ProductCategory::factory()->create(['parent_id' => $root1->id]); + + $roots = ProductCategory::whereNull('parent_id')->get(); + + $this->assertCount(2, $roots); + $this->assertTrue($roots->contains($root1)); + $this->assertTrue($roots->contains($root2)); + $this->assertFalse($roots->contains($child)); + } + + /** @test */ + public function it_maintains_category_hierarchy_integrity() + { + $grandparent = ProductCategory::factory()->create(); + $parent = ProductCategory::factory()->create(['parent_id' => $grandparent->id]); + $child = ProductCategory::factory()->create(['parent_id' => $parent->id]); + + $this->assertEquals($grandparent->id, $parent->parent->id); + $this->assertEquals($parent->id, $child->parent->id); + $this->assertNull($grandparent->parent); + } +} diff --git a/tests/Feature/ProductManagementTest.php b/tests/Feature/ProductManagementTest.php new file mode 100644 index 0000000..eb16e68 --- /dev/null +++ b/tests/Feature/ProductManagementTest.php @@ -0,0 +1,337 @@ +create([ + 'slug' => 'test-product', + 'type' => 'simple', + 'price' => 99.99, + 'regular_price' => 99.99, + ]); + + $this->assertDatabaseHas('products', [ + 'id' => $product->id, + 'slug' => 'test-product', + 'price' => 99.99, + ]); + } + + /** @test */ + public function it_automatically_generates_slug_if_not_provided() + { + $product = Product::factory()->create(['slug' => null]); + + $this->assertNotNull($product->slug); + $this->assertStringStartsWith('new-product-', $product->slug); + } + + /** @test */ + public function it_returns_current_price_correctly() + { + $product = Product::factory()->create([ + 'regular_price' => 100, + 'sale_price' => null, + ]); + + $this->assertEquals(100, $product->getCurrentPrice()); + } + + /** @test */ + public function it_applies_sale_price_when_active() + { + $product = Product::factory()->create([ + 'regular_price' => 100, + 'sale_price' => 75, + 'sale_start' => now()->subDay(), + 'sale_end' => now()->addDay(), + ]); + + $this->assertEquals(75, $product->getCurrentPrice()); + } + + /** @test */ + public function it_ignores_sale_price_when_not_started() + { + $product = Product::factory()->create([ + 'regular_price' => 100, + 'sale_price' => 75, + 'sale_start' => now()->addDay(), + 'sale_end' => now()->addWeek(), + ]); + + $this->assertEquals(100, $product->getCurrentPrice()); + } + + /** @test */ + public function it_ignores_sale_price_when_ended() + { + $product = Product::factory()->create([ + 'regular_price' => 100, + 'sale_price' => 75, + 'sale_start' => now()->subWeek(), + 'sale_end' => now()->subDay(), + ]); + + $this->assertEquals(100, $product->getCurrentPrice()); + } + + /** @test */ + public function it_can_manage_stock() + { + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 50, + 'in_stock' => true, + ]); + + $this->assertTrue($product->increaseStock(10)); + $this->assertEquals(60, $product->fresh()->stock_quantity); + + $this->assertTrue($product->decreaseStock(5)); + $this->assertEquals(55, $product->fresh()->stock_quantity); + } + + /** @test */ + public function it_cannot_decrease_stock_below_zero() + { + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 5, + ]); + + $this->assertFalse($product->decreaseStock(10)); + $this->assertEquals(5, $product->fresh()->stock_quantity); + } + + /** @test */ + public function it_returns_available_stock() + { + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 100, + ]); + + $this->assertEquals(100, $product->getAvailableStock()); + } + + /** @test */ + public function it_can_check_if_in_stock() + { + $productInStock = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 10, + 'in_stock' => true, + ]); + + $productOutOfStock = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 0, + 'in_stock' => false, + ]); + + $this->assertTrue($productInStock->isInStock()); + $this->assertFalse($productOutOfStock->isInStock()); + } + + /** @test */ + public function it_can_attach_categories() + { + $product = Product::factory()->create(); + $category = ProductCategory::factory()->create(); + + $product->categories()->attach($category); + + $this->assertTrue($product->categories->contains($category)); + } + + /** @test */ + public function it_can_have_attributes() + { + $product = Product::factory()->create(); + + $attribute = ProductAttribute::create([ + 'product_id' => $product->id, + 'key' => 'Color', + 'value' => 'Blue', + ]); + + $this->assertCount(1, $product->fresh()->attributes); + $this->assertEquals('Color', $product->attributes->first()->key); + $this->assertEquals('Blue', $product->attributes->first()->value); + } + + /** @test */ + public function it_can_have_multiple_prices() + { + $product = Product::factory()->create(); + + ProductPrice::create([ + 'product_id' => $product->id, + 'type' => 'one-time', + 'price' => 9999, + 'currency' => 'USD', + 'active' => true, + ]); + + ProductPrice::create([ + 'product_id' => $product->id, + 'type' => 'recurring', + 'price' => 1999, + 'currency' => 'USD', + 'interval' => 'month', + 'active' => true, + ]); + + $this->assertCount(2, $product->fresh()->prices); + } + + /** @test */ + public function it_can_have_related_products() + { + $product = Product::factory()->create(); + $relatedProduct = Product::factory()->create(); + + $product->relatedProducts()->attach($relatedProduct->id, [ + 'type' => 'related', + ]); + + $this->assertTrue($product->relatedProducts->contains($relatedProduct)); + } + + /** @test */ + public function it_can_have_upsell_products() + { + $product = Product::factory()->create(); + $upsellProduct = Product::factory()->create(); + + $product->relatedProducts()->attach($upsellProduct->id, [ + 'type' => 'upsell', + ]); + + $this->assertTrue($product->upsells->contains($upsellProduct)); + } + + /** @test */ + public function it_can_have_cross_sell_products() + { + $product = Product::factory()->create(); + $crossSellProduct = Product::factory()->create(); + + $product->relatedProducts()->attach($crossSellProduct->id, [ + 'type' => 'cross-sell', + ]); + + $this->assertTrue($product->crossSells->contains($crossSellProduct)); + } + + /** @test */ + public function it_can_scope_published_products() + { + Product::factory()->create(['status' => 'published']); + Product::factory()->create(['status' => 'draft']); + + $published = Product::published()->get(); + + $this->assertCount(1, $published); + $this->assertEquals('published', $published->first()->status); + } + + /** @test */ + public function it_can_scope_in_stock_products() + { + Product::factory()->create([ + 'in_stock' => true, + 'manage_stock' => true, + 'stock_quantity' => 10, + ]); + + Product::factory()->create([ + 'in_stock' => false, + 'manage_stock' => true, + 'stock_quantity' => 0, + ]); + + $inStock = Product::inStock()->get(); + + $this->assertCount(1, $inStock); + $this->assertTrue($inStock->first()->in_stock); + } + + /** @test */ + public function it_can_scope_visible_products() + { + Product::factory()->create([ + 'visible' => true, + 'status' => 'published', + ]); + + Product::factory()->create([ + 'visible' => false, + 'status' => 'published', + ]); + + $visible = Product::visible()->get(); + + $this->assertCount(1, $visible); + $this->assertTrue($visible->first()->visible); + } + + /** @test */ + public function it_can_have_parent_child_relationships() + { + $parent = Product::factory()->create([ + 'type' => 'variable', + ]); + + $child = Product::factory()->create([ + 'type' => 'variation', + 'parent_id' => $parent->id, + ]); + + $this->assertTrue($parent->children->contains($child)); + $this->assertEquals($parent->id, $child->parent->id); + } + + /** @test */ + public function it_validates_virtual_and_downloadable_flags() + { + $virtualProduct = Product::factory()->create([ + 'virtual' => true, + 'downloadable' => false, + ]); + + $downloadableProduct = Product::factory()->create([ + 'virtual' => false, + 'downloadable' => true, + ]); + + $this->assertTrue($virtualProduct->virtual); + $this->assertFalse($virtualProduct->downloadable); + $this->assertTrue($downloadableProduct->downloadable); + $this->assertFalse($downloadableProduct->virtual); + } + + /** @test */ + public function it_can_check_featured_status() + { + $featured = Product::factory()->create(['featured' => true]); + $regular = Product::factory()->create(['featured' => false]); + + $this->assertTrue($featured->featured); + $this->assertFalse($regular->featured); + } +} diff --git a/tests/Feature/PurchaseFlowTest.php b/tests/Feature/PurchaseFlowTest.php new file mode 100644 index 0000000..d85514e --- /dev/null +++ b/tests/Feature/PurchaseFlowTest.php @@ -0,0 +1,322 @@ +create(); + $product = Product::factory()->create([ + 'price' => 99.99, + 'manage_stock' => false, + ]); + + $purchase = $user->purchase($product, quantity: 1); + + $this->assertInstanceOf(ProductPurchase::class, $purchase); + $this->assertEquals($product->id, $purchase->product_id); + $this->assertEquals($user->id, $purchase->user_id); + $this->assertEquals(1, $purchase->quantity); + $this->assertEquals('completed', $purchase->status); + } + + /** @test */ + public function user_can_add_product_to_cart() + { + $user = User::factory()->create(); + $product = Product::factory()->create([ + 'price' => 49.99, + 'manage_stock' => false, + ]); + + $cartItem = $user->addToCart($product, quantity: 2); + + $this->assertInstanceOf(ProductPurchase::class, $cartItem); + $this->assertEquals('cart', $cartItem->status); + $this->assertEquals(2, $cartItem->quantity); + $this->assertEquals($product->id, $cartItem->product_id); + } + + /** @test */ + public function user_can_get_cart_items() + { + $user = User::factory()->create(); + $product1 = Product::factory()->create(['price' => 20.00]); + $product2 = Product::factory()->create(['price' => 30.00]); + + $user->addToCart($product1, quantity: 1); + $user->addToCart($product2, quantity: 2); + + $cartItems = $user->cartItems; + + $this->assertCount(2, $cartItems); + } + + /** @test */ + public function user_can_update_cart_item_quantity() + { + $user = User::factory()->create(); + $product = Product::factory()->create([ + 'price' => 50.00, + 'manage_stock' => false, + ]); + + $cartItem = $user->addToCart($product, quantity: 1); + + $user->updateCartQuantity($cartItem, quantity: 5); + + $this->assertEquals(5, $cartItem->fresh()->quantity); + } + + /** @test */ + public function user_can_remove_item_from_cart() + { + $user = User::factory()->create(); + $product = Product::factory()->create(); + + $cartItem = $user->addToCart($product, quantity: 1); + $this->assertCount(1, $user->fresh()->cartItems); + + $user->removeFromCart($cartItem); + + $this->assertCount(0, $user->fresh()->cartItems); + } + + /** @test */ + public function user_can_checkout_cart() + { + $user = User::factory()->create(); + $product1 = Product::factory()->create([ + 'price' => 25.00, + 'manage_stock' => false, + ]); + $product2 = Product::factory()->create([ + 'price' => 35.00, + 'manage_stock' => false, + ]); + + $user->addToCart($product1, quantity: 2); + $user->addToCart($product2, quantity: 1); + + $purchases = $user->checkout(); + + $this->assertCount(2, $purchases); + $this->assertEquals('completed', $purchases[0]->status); + $this->assertEquals('completed', $purchases[1]->status); + } + + /** @test */ + public function user_can_get_cart_total() + { + $user = User::factory()->create(); + $product1 = Product::factory()->create(['price' => 40.00]); + $product2 = Product::factory()->create(['price' => 60.00]); + + $user->addToCart($product1, quantity: 2); // 80.00 + $user->addToCart($product2, quantity: 1); // 60.00 + + $total = $user->getCartTotal(); + + $this->assertEquals(140.00, $total); + } + + /** @test */ + public function user_can_get_cart_items_count() + { + $user = User::factory()->create(); + $product1 = Product::factory()->create(); + $product2 = Product::factory()->create(); + + $user->addToCart($product1, quantity: 3); + $user->addToCart($product2, quantity: 2); + + $count = $user->getCartItemsCount(); + + $this->assertEquals(5, $count); + } + + /** @test */ + public function user_can_clear_cart() + { + $user = User::factory()->create(); + $product1 = Product::factory()->create(); + $product2 = Product::factory()->create(); + + $user->addToCart($product1, quantity: 1); + $user->addToCart($product2, quantity: 1); + + $this->assertCount(2, $user->cartItems); + + $user->clearCart(); + + $this->assertCount(0, $user->fresh()->cartItems); + } + + /** @test */ + public function user_can_check_if_product_was_purchased() + { + $user = User::factory()->create(); + $purchasedProduct = Product::factory()->create(['manage_stock' => false]); + $notPurchasedProduct = Product::factory()->create(); + + $user->purchase($purchasedProduct, quantity: 1); + + $this->assertTrue($user->hasPurchased($purchasedProduct)); + $this->assertFalse($user->hasPurchased($notPurchasedProduct)); + } + + /** @test */ + public function user_can_get_completed_purchases() + { + $user = User::factory()->create(); + $product1 = Product::factory()->create(['manage_stock' => false]); + $product2 = Product::factory()->create(['manage_stock' => false]); + $product3 = Product::factory()->create(); + + $user->purchase($product1, quantity: 1); + $user->purchase($product2, quantity: 1); + $user->addToCart($product3, quantity: 1); + + $completed = $user->completedPurchases; + + $this->assertCount(2, $completed); + } + + /** @test */ + public function purchase_reduces_stock_when_managed() + { + $user = User::factory()->create(); + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 10, + ]); + + $user->purchase($product, quantity: 3); + + $this->assertEquals(7, $product->fresh()->stock_quantity); + } + + /** @test */ + public function cannot_purchase_more_than_available_stock() + { + $user = User::factory()->create(); + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 5, + ]); + + $this->expectException(\Exception::class); + + $user->purchase($product, quantity: 10); + } + + /** @test */ + public function adding_to_cart_checks_stock_availability() + { + $user = User::factory()->create(); + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 3, + ]); + + $this->expectException(\Exception::class); + + $user->addToCart($product, quantity: 5); + } + + /** @test */ + public function purchase_can_store_metadata() + { + $user = User::factory()->create(); + $product = Product::factory()->create(['manage_stock' => false]); + + $purchase = $user->purchase($product, quantity: 1, options: [ + 'meta' => [ + 'gift_message' => 'Happy Birthday!', + 'gift_wrap' => true, + ], + ]); + + $this->assertEquals('Happy Birthday!', $purchase->meta['gift_message'] ?? null); + } + + /** @test */ + public function purchase_can_be_associated_with_cart() + { + $user = User::factory()->create(); + $cart = Cart::create(['user_id' => $user->id]); + $product = Product::factory()->create(['manage_stock' => false]); + + $purchase = ProductPurchase::create([ + 'user_id' => $user->id, + 'purchasable_type' => get_class($user), + 'purchasable_id' => $user->id, + 'product_id' => $product->id, + 'cart_id' => $cart->id, + 'quantity' => 1, + 'amount' => 5000, + 'status' => 'cart', + ]); + + $this->assertEquals($cart->id, $purchase->cart_id); + $this->assertTrue($cart->purchases->contains($purchase)); + } + + /** @test */ + public function checkout_marks_cart_as_converted() + { + $user = User::factory()->create(); + $product = Product::factory()->create(['manage_stock' => false]); + + $cartItem = $user->addToCart($product, quantity: 1); + $cart = Cart::where('user_id', $user->id)->first(); + + if ($cart) { + $this->assertNull($cart->converted_at); + + $user->checkout(); + + $this->assertNotNull($cart->fresh()->converted_at); + } + } + + /** @test */ + public function user_cannot_add_out_of_stock_product_to_cart() + { + $user = User::factory()->create(); + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 0, + 'in_stock' => false, + ]); + + $this->expectException(\Exception::class); + + $user->addToCart($product, quantity: 1); + } + + /** @test */ + public function purchase_stores_amount_correctly() + { + $user = User::factory()->create(); + $product = Product::factory()->create([ + 'price' => 49.99, + 'manage_stock' => false, + ]); + + $purchase = $user->purchase($product, quantity: 2); + + $this->assertGreaterThan(0, $purchase->amount); + } +} diff --git a/tests/Feature/StockManagementTest.php b/tests/Feature/StockManagementTest.php new file mode 100644 index 0000000..2af947a --- /dev/null +++ b/tests/Feature/StockManagementTest.php @@ -0,0 +1,321 @@ +create([ + 'manage_stock' => true, + 'stock_quantity' => 100, + ]); + + $reservation = ProductStock::reserve( + product: $product, + quantity: 10, + type: 'reservation', + until: now()->addHours(2) + ); + + $this->assertNotNull($reservation); + $this->assertEquals(10, $reservation->quantity); + $this->assertEquals(90, $product->fresh()->stock_quantity); + } + + /** @test */ + public function it_cannot_reserve_more_stock_than_available() + { + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 5, + ]); + + $reservation = ProductStock::reserve( + product: $product, + quantity: 10, + type: 'reservation' + ); + + $this->assertNull($reservation); + $this->assertEquals(5, $product->fresh()->stock_quantity); + } + + /** @test */ + public function it_can_release_reserved_stock() + { + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 100, + ]); + + $reservation = ProductStock::reserve( + product: $product, + quantity: 10, + type: 'reservation' + ); + + $this->assertEquals(90, $product->fresh()->stock_quantity); + + $reservation->release(); + + $this->assertEquals(100, $product->fresh()->stock_quantity); + $this->assertNotNull($reservation->fresh()->released_at); + } + + /** @test */ + public function it_can_check_if_stock_is_pending() + { + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 50, + ]); + + $reservation = ProductStock::reserve( + product: $product, + quantity: 5, + type: 'reservation' + ); + + $pending = ProductStock::pending()->where('id', $reservation->id)->first(); + + $this->assertNotNull($pending); + $this->assertNull($pending->released_at); + } + + /** @test */ + public function it_can_check_if_stock_is_released() + { + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 50, + ]); + + $reservation = ProductStock::reserve( + product: $product, + quantity: 5, + type: 'reservation' + ); + + $reservation->release(); + + $released = ProductStock::released()->where('id', $reservation->id)->first(); + + $this->assertNotNull($released); + $this->assertNotNull($released->released_at); + } + + /** @test */ + public function it_can_find_expired_reservations() + { + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 100, + ]); + + $expiredReservation = ProductStock::reserve( + product: $product, + quantity: 10, + type: 'reservation', + until: now()->subHour() + ); + + $activeReservation = ProductStock::reserve( + product: $product, + quantity: 5, + type: 'reservation', + until: now()->addHour() + ); + + $expired = ProductStock::expired()->get(); + + $this->assertTrue($expired->contains($expiredReservation)); + $this->assertFalse($expired->contains($activeReservation)); + } + + /** @test */ + public function it_can_distinguish_temporary_and_permanent_reservations() + { + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 100, + ]); + + $temporary = ProductStock::reserve( + product: $product, + quantity: 10, + type: 'reservation', + until: now()->addHours(2) + ); + + $permanent = ProductStock::reserve( + product: $product, + quantity: 5, + type: 'sold' + ); + + $temporaryReservations = ProductStock::temporary()->get(); + $permanentReservations = ProductStock::permanent()->get(); + + $this->assertTrue($temporaryReservations->contains($temporary)); + $this->assertFalse($temporaryReservations->contains($permanent)); + $this->assertTrue($permanentReservations->contains($permanent)); + $this->assertFalse($permanentReservations->contains($temporary)); + } + + /** @test */ + public function it_belongs_to_a_product() + { + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 50, + ]); + + $stock = ProductStock::reserve( + product: $product, + quantity: 5, + type: 'reservation' + ); + + $this->assertEquals($product->id, $stock->product->id); + } + + /** @test */ + public function product_has_many_stock_records() + { + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 100, + ]); + + ProductStock::reserve($product, quantity: 10, type: 'reservation'); + ProductStock::reserve($product, quantity: 5, type: 'reservation'); + ProductStock::reserve($product, quantity: 3, type: 'sold'); + + $this->assertCount(3, $product->fresh()->stocks); + } + + /** @test */ + public function it_can_get_active_stock_reservations() + { + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 100, + ]); + + $active1 = ProductStock::reserve($product, quantity: 10, type: 'reservation'); + $active2 = ProductStock::reserve($product, quantity: 5, type: 'reservation'); + $released = ProductStock::reserve($product, quantity: 3, type: 'sold'); + $released->release(); + + $activeStocks = $product->fresh()->activeStocks; + + $this->assertCount(2, $activeStocks); + $this->assertTrue($activeStocks->contains($active1)); + $this->assertTrue($activeStocks->contains($active2)); + $this->assertFalse($activeStocks->contains($released)); + } + + /** @test */ + public function it_cannot_release_stock_twice() + { + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 50, + ]); + + $reservation = ProductStock::reserve($product, quantity: 10, type: 'reservation'); + + $this->assertTrue($reservation->release()); + $this->assertFalse($reservation->release()); + } + + /** @test */ + public function it_can_store_reservation_note() + { + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 50, + ]); + + $reservation = ProductStock::reserve( + product: $product, + quantity: 5, + type: 'reservation', + note: 'Reserved for order #12345' + ); + + $this->assertEquals('Reserved for order #12345', $reservation->note); + } + + /** @test */ + public function it_handles_stock_transactions_atomically() + { + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 10, + ]); + + // Try to reserve more than available + $reservation = ProductStock::reserve($product, quantity: 15, type: 'reservation'); + + // Should fail and not change stock + $this->assertNull($reservation); + $this->assertEquals(10, $product->fresh()->stock_quantity); + } + + /** @test */ + public function it_calculates_available_stock_correctly() + { + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 100, + ]); + + // Reserve some stock + ProductStock::reserve($product, quantity: 20, type: 'reservation'); + ProductStock::reserve($product, quantity: 10, type: 'reservation'); + + $available = $product->fresh()->stock_quantity; + + $this->assertEquals(70, $available); + } + + /** @test */ + public function product_tracks_low_stock_threshold() + { + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 15, + 'low_stock_threshold' => 10, + ]); + + $this->assertFalse($product->isLowStock()); + + $product->decreaseStock(8); + + $this->assertTrue($product->fresh()->isLowStock()); + } + + /** @test */ + public function it_updates_in_stock_status_automatically() + { + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 5, + 'in_stock' => true, + ]); + + $product->decreaseStock(5); + + $this->assertFalse($product->fresh()->in_stock); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..e71695b --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,61 @@ + match (true) { + str_starts_with($modelName, 'Workbench\\App\\') => 'Workbench\\Database\\Factories\\' . class_basename($modelName) . 'Factory', + default => 'Blax\\Shop\\Database\\Factories\\' . class_basename($modelName) . 'Factory' + } + ); + } + + protected function getPackageProviders($app) + { + return [ + ShopServiceProvider::class, + ]; + } + + public function getEnvironmentSetUp($app) + { + config()->set('database.default', 'testing'); + config()->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + + // Set up i18n config for HasMetaTranslation trait + config()->set('app.i18n.supporting', [ + 'en' => 'English', + 'es' => 'Spanish', + 'fr' => 'French', + ]); + + // Create users table for testing + $app['db']->connection()->getSchemaBuilder()->create('users', function ($table) { + $table->uuid('id')->primary(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + // Run package migrations + $migration = include __DIR__ . '/../database/migrations/create_blax_shop_tables.php.stub'; + $migration->up(); + } +} diff --git a/tests/Unit/ProductPricingTest.php b/tests/Unit/ProductPricingTest.php new file mode 100644 index 0000000..9014f20 --- /dev/null +++ b/tests/Unit/ProductPricingTest.php @@ -0,0 +1,75 @@ +create([ + 'regular_price' => 100, + 'sale_price' => null, + ]); + + $this->assertEquals(100, $product->getCurrentPrice()); + } + + /** @test */ + public function it_returns_sale_price_when_on_sale() + { + $product = Product::factory()->create([ + 'regular_price' => 100, + 'sale_price' => 80, + 'sale_start' => now()->subDay(), + 'sale_end' => now()->addDay(), + ]); + + $this->assertEquals(80, $product->getCurrentPrice()); + } + + /** @test */ + public function it_returns_regular_price_when_sale_has_ended() + { + $product = Product::factory()->create([ + 'regular_price' => 100, + 'sale_price' => 80, + 'sale_start' => now()->subDays(7), + 'sale_end' => now()->subDay(), + ]); + + $this->assertEquals(100, $product->getCurrentPrice()); + } + + /** @test */ + public function it_returns_regular_price_when_sale_hasnt_started() + { + $product = Product::factory()->create([ + 'regular_price' => 100, + 'sale_price' => 80, + 'sale_start' => now()->addDay(), + 'sale_end' => now()->addWeek(), + ]); + + $this->assertEquals(100, $product->getCurrentPrice()); + } + + /** @test */ + public function it_calculates_discount_percentage() + { + $product = Product::factory()->create([ + 'regular_price' => 100, + 'sale_price' => 75, + ]); + + $discount = (($product->regular_price - $product->sale_price) / $product->regular_price) * 100; + + $this->assertEquals(25, $discount); + } +} diff --git a/tests/Unit/StockManagementTest.php b/tests/Unit/StockManagementTest.php new file mode 100644 index 0000000..1a0ec5c --- /dev/null +++ b/tests/Unit/StockManagementTest.php @@ -0,0 +1,62 @@ +create([ + 'manage_stock' => true, + 'stock_quantity' => 5, + 'low_stock_threshold' => 10, + ]); + + $this->assertTrue($product->isLowStock()); + } + + /** @test */ + public function it_detects_sufficient_stock() + { + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 50, + 'low_stock_threshold' => 10, + ]); + + $this->assertFalse($product->isLowStock()); + } + + /** @test */ + public function it_marks_product_as_out_of_stock() + { + $product = Product::factory()->create([ + 'manage_stock' => true, + 'stock_quantity' => 0, + 'in_stock' => false, + 'stock_status' => 'outofstock', + ]); + + $this->assertFalse($product->in_stock); + $this->assertEquals('outofstock', $product->stock_status); + } + + /** @test */ + public function products_without_stock_management_are_always_in_stock() + { + $product = Product::factory()->create([ + 'manage_stock' => false, + 'stock_quantity' => 0, + ]); + + // When stock management is disabled, product should be considered in stock + $this->assertFalse($product->manage_stock); + } +}