diff --git a/app/controllers/api/cart_controller.rb b/app/controllers/api/cart_controller.rb index 829bd29bb..4a6b40e6a 100644 --- a/app/controllers/api/cart_controller.rb +++ b/app/controllers/api/cart_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# API Controller for manage user's cart +# API Controller to manage user's cart class API::CartController < API::ApiController include API::OrderConcern @@ -13,6 +13,17 @@ class API::CartController < API::ApiController render 'api/orders/show' end + def create_item + authorize @current_order, policy_class: CartPolicy + service = Cart::CreateCartItemService.new(@current_order) + @item = service.create(params) + if @item.save({ context: @current_order.order_items }) + render 'api/orders/item', status: :created + else + render json: @item.errors.full_messages, status: :unprocessable_entity + end + end + def add_item authorize @current_order, policy_class: CartPolicy @order = Cart::AddItemService.new.call(@current_order, orderable, cart_params[:quantity]) diff --git a/app/models/cart_item/machine_reservation.rb b/app/models/cart_item/machine_reservation.rb index b85d71e15..790244d8a 100644 --- a/app/models/cart_item/machine_reservation.rb +++ b/app/models/cart_item/machine_reservation.rb @@ -19,7 +19,7 @@ class CartItem::MachineReservation < CartItem::Reservation 'machine' end - def valid?(all_items) + def valid?(all_items = []) cart_item_reservation_slots.each do |slot| same_hour_slots = SlotsReservation.joins(:reservation).where( reservations: { reservable: reservable }, diff --git a/app/models/cart_item/prepaid_pack.rb b/app/models/cart_item/prepaid_pack.rb index 8e1053435..73333f536 100644 --- a/app/models/cart_item/prepaid_pack.rb +++ b/app/models/cart_item/prepaid_pack.rb @@ -3,6 +3,7 @@ # A prepaid-pack added to the shopping cart class CartItem::PrepaidPack < CartItem::BaseItem belongs_to :prepaid_pack + belongs_to :customer_profile, class_name: 'InvoicingProfile' def customer customer_profile.user @@ -36,11 +37,11 @@ class CartItem::PrepaidPack < CartItem::BaseItem def valid?(_all_items) if pack.disabled - @errors[:item] = I18n.t('cart_item_validation.pack') + errors.add(:prepaid_pack, I18n.t('cart_item_validation.pack')) return false end if pack.group_id != customer.group_id - @errors[:group] = "pack is reserved for members of group #{pack.group.name}" + errors.add(:group, "pack is reserved for members of group #{pack.group.name}") return false end true diff --git a/app/models/cart_item/reservation.rb b/app/models/cart_item/reservation.rb index 7e792a1ee..68ad770a0 100644 --- a/app/models/cart_item/reservation.rb +++ b/app/models/cart_item/reservation.rb @@ -52,7 +52,7 @@ class CartItem::Reservation < CartItem::BaseItem reservable&.name end - def valid?(all_items) + def valid?(all_items = []) pending_subscription = all_items.find { |i| i.is_a?(CartItem::Subscription) } reservation_deadline_minutes = Setting.get('reservation_deadline').to_i diff --git a/app/policies/cart_policy.rb b/app/policies/cart_policy.rb index b63dcbcac..db442a808 100644 --- a/app/policies/cart_policy.rb +++ b/app/policies/cart_policy.rb @@ -6,7 +6,7 @@ class CartPolicy < ApplicationPolicy !Setting.get('store_hidden') || user&.privileged? end - %w[add_item remove_item set_quantity refresh_item validate].each do |action| + %w[add_item remove_item set_quantity refresh_item validate create_item].each do |action| define_method "#{action}?" do return user.privileged? || (record.statistic_profile_id == user.statistic_profile.id) if user diff --git a/app/services/cart/add_item_service.rb b/app/services/cart/add_item_service.rb index 6f868d0fd..952cc7b02 100644 --- a/app/services/cart/add_item_service.rb +++ b/app/services/cart/add_item_service.rb @@ -8,8 +8,8 @@ class Cart::AddItemService item = case orderable when Product add_product(order, orderable, quantity) - when Slot - add_slot(order, orderable) + when /^CartItem::/ + add_cart_item(order, orderable) else raise Cart::UnknownItemError end @@ -42,11 +42,7 @@ class Cart::AddItemService item end - def add_slot(order, orderable) - item = order.order_items.find_by(orderable: orderable) - - item = order.order_items.new(quantity: 1, orderable: orderable, amount: orderable.amount || 0) if item.nil? - - item + def add_cart_item(order, orderable) + order.order_items.new(quantity: 1, orderable: orderable, amount: orderable.price.amount || 0) end end diff --git a/app/services/cart/create_cart_item_service.rb b/app/services/cart/create_cart_item_service.rb new file mode 100644 index 000000000..2c59b50d0 --- /dev/null +++ b/app/services/cart/create_cart_item_service.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +# Provides methods to create new cart items, based on an existing Order +class Cart::CreateCartItemService + def initialize(order) + @order = order + @customer = order.user + @operator = order.user.privileged? ? order.operator_profile.user : order.user + end + + def create(item) + key = item.keys.filter { |k| %w[subscription reservation prepaid_pack free_extension].include?(k) }.first + case key + when 'subscription', :subscription + subscription = create_subscription(item.require(:subscription).permit!) + update_reservations(subscription) + subscription + when 'reservation', :reservation + create_reservation(item.require(:reservation).permit!) + when 'prepaid_pack', :prepaid_pack + create_prepaid_pack(item.require(:prepaid_pack).permit!) + when 'free_extension', :free_extension + create_free_extension(item.require(:free_extension).permit!) + else + raise NotImplementedError, "unknown item type #{item.keys.first}" + end + end + + private + + def create_subscription(cart_item) + CartItem::Subscription.new( + plan: Plan.find(cart_item[:plan_id]), + customer_profile: @customer.invoicing_profile, + start_at: cart_item[:start_at] + ) + end + + def update_reservations(new_subscription) + @order.order_items + .where(orderable_type: %w[CartItem::MachineReservation CartItem::SpaceReservation CartItem::TrainingReservation]) + .find_each do |reserv| + reserv.update(plan: new_subscription.plan, new_subscription: true) + end + end + + def create_reservation(cart_item) + plan_info = subscription_info + reservable = cart_item[:reservable_type]&.constantize&.find(cart_item[:reservable_id]) + case reservable + when Machine + CartItem::MachineReservation.new(customer_profile: @customer.invoicing_profile, + operator_profile: @operator.invoicing_profile, + reservable: reservable, + cart_item_reservation_slots_attributes: cart_item[:slots_reservations_attributes], + plan: plan_info[:subscription]&.plan, + new_subscription: plan_info[:new_subscription]) + when Training + CartItem::TrainingReservation.new(customer_profile: @customer.invoicing_profile, + operator_profile: @operator.invoicing_profile, + reservable: reservable, + cart_item_reservation_slots_attributes: cart_item[:slots_reservations_attributes], + plan: plan_info[:subscription]&.plan, + new_subscription: plan_info[:new_subscription]) + when Event + CartItem::EventReservation.new(customer_profile: @customer.invoicing_profile, + operator_profile: @operator.invoicing_profile, + event: reservable, + cart_item_reservation_slots_attributes: cart_item[:slots_reservations_attributes], + normal_tickets: cart_item[:nb_reserve_places], + cart_item_event_reservation_tickets_attributes: cart_item[:tickets_attributes] || {}) + when Space + CartItem::SpaceReservation.new(customer_profile: @customer.invoicing_profile, + operator_profile: @operator.invoicing_profile, + reservable: reservable, + cart_item_reservation_slots_attributes: cart_item[:slots_reservations_attributes], + plan: plan_info[:subscription]&.plan, + new_subscription: plan_info[:new_subscription]) + else + raise NotImplementedError, "unknown reservable type #{reservable}" + end + end + + def create_prepaid_pack(cart_item) + CartItem::PrepaidPack.new( + prepaid_pack: PrepaidPack.find(cart_item[:id]), + customer_profile: @customer.invoicing_profile + ) + end + + def create_free_extension(cart_item) + plan_info = subscription_info + CartItem::FreeExtension.new( + customer_profile: @customer.invoicing_profile, + subscription: plan_info[:subscription], + new_expiration_date: cart_item[:end_at] + ) + end + + def subscription_info + cart_subscription = @order.order_items.find_by(orderable_type: 'CartItem::Subscription') + if cart_subscription + { subscription: cart_subscription, new_subscription: true } + elsif @customer.subscribed_plan + { subscription: @customer.subscription, new_subscription: false } unless @customer.subscription.expired_at < DateTime.current + else + { subscription: nil, new_subscription: false } + end + end +end diff --git a/app/views/api/orders/item.json.jbuilder b/app/views/api/orders/item.json.jbuilder new file mode 100644 index 000000000..7c3963b94 --- /dev/null +++ b/app/views/api/orders/item.json.jbuilder @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +json.id @item.id +json.type @item.class.name diff --git a/config/routes.rb b/config/routes.rb index 31823d03f..2f620d93b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -168,6 +168,7 @@ Rails.application.routes.draw do put 'set_offer', on: :collection put 'refresh_item', on: :collection post 'validate', on: :collection + post 'create_item', on: :collection end resources :checkout, only: %i[] do post 'payment', on: :collection diff --git a/db/schema.rb b/db/schema.rb index 4ed4819f2..4ba30590d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -19,8 +19,8 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do enable_extension "unaccent" create_table "abuses", id: :serial, force: :cascade do |t| - t.string "signaled_type" t.integer "signaled_id" + t.string "signaled_type" t.string "first_name" t.string "last_name" t.string "email" @@ -68,8 +68,8 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do t.string "locality" t.string "country" t.string "postal_code" - t.string "placeable_type" t.integer "placeable_id" + t.string "placeable_type" t.datetime "created_at" t.datetime "updated_at" end @@ -93,8 +93,8 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do end create_table "assets", id: :serial, force: :cascade do |t| - t.string "viewable_type" t.integer "viewable_id" + t.string "viewable_type" t.string "attachment" t.string "type" t.datetime "created_at" @@ -281,8 +281,8 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do end create_table "credits", id: :serial, force: :cascade do |t| - t.string "creditable_type" t.integer "creditable_id" + t.string "creditable_type" t.integer "plan_id" t.integer "hours" t.datetime "created_at" @@ -524,15 +524,15 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do create_table "notifications", id: :serial, force: :cascade do |t| t.integer "receiver_id" - t.string "attached_object_type" t.integer "attached_object_id" + t.string "attached_object_type" t.integer "notification_type_id" t.boolean "is_read", default: false t.datetime "created_at" t.datetime "updated_at" t.string "receiver_type" t.boolean "is_send", default: false - t.jsonb "meta_data", default: "{}" + t.jsonb "meta_data", default: {} t.index ["notification_type_id"], name: "index_notifications_on_notification_type_id" t.index ["receiver_id"], name: "index_notifications_on_receiver_id" end @@ -772,8 +772,8 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do create_table "prices", id: :serial, force: :cascade do |t| t.integer "group_id" t.integer "plan_id" - t.string "priceable_type" t.integer "priceable_id" + t.string "priceable_type" t.integer "amount" t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -976,8 +976,8 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do t.text "message" t.datetime "created_at" t.datetime "updated_at" - t.string "reservable_type" t.integer "reservable_id" + t.string "reservable_type" t.integer "nb_reserve_places" t.integer "statistic_profile_id" t.index ["reservable_type", "reservable_id"], name: "index_reservations_on_reservable_type_and_reservable_id" @@ -986,8 +986,8 @@ ActiveRecord::Schema.define(version: 2023_01_31_104958) do create_table "roles", id: :serial, force: :cascade do |t| t.string "name" - t.string "resource_type" t.integer "resource_id" + t.string "resource_type" t.datetime "created_at" t.datetime "updated_at" t.index ["name", "resource_type", "resource_id"], name: "index_roles_on_name_and_resource_type_and_resource_id" diff --git a/test/integration/order/create_cart_item_test.rb b/test/integration/order/create_cart_item_test.rb new file mode 100644 index 000000000..7fba81874 --- /dev/null +++ b/test/integration/order/create_cart_item_test.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require 'test_helper' + +class CreateCartItemTest < ActionDispatch::IntegrationTest + setup do + @user = User.find_by(username: 'pdurand') + login_as(@user, scope: :user) + @order = Cart::FindOrCreateService.new(@user).call(nil) + end + + test 'create a subscription' do + plan = Plan.first + post '/api/cart/create_item', + params: { + order_token: @order.token, + subscription: { + plan_id: plan.id + } + } + # general assertions + assert_equal 201, response.status + assert_equal Mime[:json], response.content_type + + # Check the cart item was created correctly + res = json_response(response.body) + resource = res[:type].classify.constantize.find(res[:id]) + assert resource.is_a? CartItem::Subscription + assert_equal plan, resource.plan + end + + test 'create a machine reservation' do + machine = Machine.first + post '/api/cart/create_item', + params: { + order_token: @order.token, + reservation: { + reservable_id: machine.id, + reservable_type: 'Machine', + slots_reservations_attributes: [ + { slot_id: machine.availabilities.last&.slots&.last&.id } + ] + } + } + # general assertions + assert_equal 201, response.status + assert_equal Mime[:json], response.content_type + + # Check the cart item was created correctly + res = json_response(response.body) + resource = res[:type].classify.constantize.find(res[:id]) + assert resource.is_a? CartItem::MachineReservation + assert_equal machine, resource.reservable + end + + test 'create a space reservation' do + space = Space.first + post '/api/cart/create_item', + params: { + order_token: @order.token, + reservation: { + reservable_id: space.id, + reservable_type: 'Space', + slots_reservations_attributes: [ + { slot_id: space.availabilities.last&.slots&.last&.id } + ] + } + } + # general assertions + assert_equal 201, response.status + assert_equal Mime[:json], response.content_type + + # Check the cart item was created correctly + res = json_response(response.body) + resource = res[:type].classify.constantize.find(res[:id]) + assert resource.is_a? CartItem::SpaceReservation + assert_equal space, resource.reservable + end + + test 'create a training reservation' do + training = Training.first + post '/api/cart/create_item', + params: { + order_token: @order.token, + reservation: { + reservable_id: training.id, + reservable_type: 'Training', + slots_reservations_attributes: [ + { slot_id: training.availabilities.last&.slots&.last&.id } + ] + } + } + # general assertions + assert_equal 201, response.status + assert_equal Mime[:json], response.content_type + + # Check the cart item was created correctly + res = json_response(response.body) + resource = res[:type].classify.constantize.find(res[:id]) + assert resource.is_a? CartItem::TrainingReservation + assert_equal training, resource.reservable + end + + test 'create an event reservation' do + event = Event.find(4) + post '/api/cart/create_item', + params: { + order_token: @order.token, + reservation: { + reservable_id: event.id, + reservable_type: 'Event', + slots_reservations_attributes: [ + { slot_id: event.availability.slots.last&.id } + ], + nb_reserve_places: 2 + } + } + # general assertions + assert_equal 201, response.status + assert_equal Mime[:json], response.content_type + + # Check the cart item was created correctly + res = json_response(response.body) + resource = res[:type].classify.constantize.find(res[:id]) + assert resource.is_a? CartItem::EventReservation + assert_equal event, resource.event + assert_equal 2, resource.normal_tickets + end + + test 'create a prepaid-pack' do + pack = PrepaidPack.first + post '/api/cart/create_item', + params: { + order_token: @order.token, + prepaid_pack: { id: pack.id } + } + # general assertions + assert_equal 201, response.status + assert_equal Mime[:json], response.content_type + + # Check the cart item was created correctly + res = json_response(response.body) + resource = res[:type].classify.constantize.find(res[:id]) + assert resource.is_a? CartItem::PrepaidPack + assert_equal pack.id, resource.prepaid_pack_id + end + + test 'create a free-extension for a subscription' do + subscription = @user.subscription + post '/api/cart/create_item', + params: { + order_token: @order.token, + free_extension: { end_at: subscription.expiration_date + 1.month } + } + # general assertions + assert_equal 201, response.status + assert_equal Mime[:json], response.content_type + + # Check the cart item was created correctly + res = json_response(response.body) + resource = res[:type].classify.constantize.find(res[:id]) + assert resource.is_a? CartItem::FreeExtension + assert_equal subscription.id, resource.subscription_id + end +end