1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-30 19:52:20 +01:00

(feat) save cart items in db

This commit is contained in:
Sylvain 2022-12-28 17:51:27 +01:00
parent 7a1809940c
commit 42d830b4f8
58 changed files with 721 additions and 337 deletions

View File

@ -52,6 +52,6 @@ class API::CartController < API::ApiController
private
def orderable
Product.find(cart_params[:orderable_id])
params[:orderable_type].classify.constantize.find(cart_params[:orderable_id])
end
end

View File

@ -41,6 +41,7 @@ class API::PaymentsController < API::ApiController
{ json: res[:errors].drop_while(&:empty?), status: :unprocessable_entity }
end
rescue StandardError => e
Rails.logger.debug e.backtrace
{ json: e, status: :unprocessable_entity }
end
end

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
# Raised when the added item is not a recognized class
class Cart::UnknownItemError < StandardError
end

View File

@ -1,6 +1,6 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Order, OrderErrors } from '../models/order';
import { Order, OrderableType, OrderErrors } from '../models/order';
export default class CartAPI {
static async create (token?: string): Promise<Order> {
@ -8,28 +8,28 @@ export default class CartAPI {
return res?.data;
}
static async addItem (order: Order, orderableId: number, quantity: number): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/add_item', { order_token: order.token, orderable_id: orderableId, quantity });
static async addItem (order: Order, orderableId: number, orderableType: OrderableType, quantity: number): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/add_item', { order_token: order.token, orderable_id: orderableId, orderable_type: orderableType, quantity });
return res?.data;
}
static async removeItem (order: Order, orderableId: number): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/remove_item', { order_token: order.token, orderable_id: orderableId });
static async removeItem (order: Order, orderableId: number, orderableType: OrderableType): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/remove_item', { order_token: order.token, orderable_id: orderableId, orderable_type: orderableType });
return res?.data;
}
static async setQuantity (order: Order, orderableId: number, quantity: number): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/set_quantity', { order_token: order.token, orderable_id: orderableId, quantity });
static async setQuantity (order: Order, orderableId: number, orderableType: OrderableType, quantity: number): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/set_quantity', { order_token: order.token, orderable_id: orderableId, orderable_type: orderableType, quantity });
return res?.data;
}
static async setOffer (order: Order, orderableId: number, isOffered: boolean): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/set_offer', { order_token: order.token, orderable_id: orderableId, is_offered: isOffered, customer_id: order.user?.id });
static async setOffer (order: Order, orderableId: number, orderableType: OrderableType, isOffered: boolean): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/set_offer', { order_token: order.token, orderable_id: orderableId, orderable_type: orderableType, is_offered: isOffered, customer_id: order.user?.id });
return res?.data;
}
static async refreshItem (order: Order, orderableId: number): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/refresh_item', { order_token: order.token, orderable_id: orderableId });
static async refreshItem (order: Order, orderableId: number, orderableType: OrderableType): Promise<Order> {
const res: AxiosResponse<Order> = await apiClient.put('/api/cart/refresh_item', { order_token: order.token, orderable_id: orderableId, orderable_type: orderableType });
return res?.data;
}

View File

@ -65,7 +65,7 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
if (errors.length === 1 && errors[0].error === 'not_found') {
reloadCart().catch(onError);
} else {
CartAPI.removeItem(cart, item.orderable_id).then(data => {
CartAPI.removeItem(cart, item.orderable_id, item.orderable_type).then(data => {
setCart(data);
}).catch(onError);
}
@ -76,7 +76,7 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
* Change product quantity
*/
const changeProductQuantity = (e: React.BaseSyntheticEvent, item) => {
CartAPI.setQuantity(cart, item.orderable_id, e.target.value)
CartAPI.setQuantity(cart, item.orderable_id, item.orderable_type, e.target.value)
.then(data => {
setCart(data);
})
@ -87,7 +87,7 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
* Increment/decrement product quantity
*/
const increaseOrDecreaseProductQuantity = (item, direction: 'up' | 'down') => {
CartAPI.setQuantity(cart, item.orderable_id, direction === 'up' ? item.quantity + 1 : item.quantity - 1)
CartAPI.setQuantity(cart, item.orderable_id, item.orderable_type, direction === 'up' ? item.quantity + 1 : item.quantity - 1)
.then(data => {
setCart(data);
})
@ -101,7 +101,7 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
return (e: React.BaseSyntheticEvent) => {
e.preventDefault();
e.stopPropagation();
CartAPI.refreshItem(cart, item.orderable_id).then(data => {
CartAPI.refreshItem(cart, item.orderable_id, item.orderable_type).then(data => {
setCart(data);
}).catch(onError);
};
@ -185,7 +185,7 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
// if the selected user is the operator, he cannot offer products to himself
if (user.id === currentUser.id && cart.order_items_attributes.filter(item => item.is_offered).length > 0) {
Promise.all(cart.order_items_attributes.filter(item => item.is_offered).map(item => {
return CartAPI.setOffer(cart, item.orderable_id, false);
return CartAPI.setOffer(cart, item.orderable_id, item.orderable_type, false);
})).then((data) => setCart({ ...data[data.length - 1], user: { id: user.id, role: user.role } }));
} else {
setCart({ ...cart, user: { id: user.id, role: user.role } });
@ -211,7 +211,7 @@ const StoreCart: React.FC<StoreCartProps> = ({ onSuccess, onError, currentUser,
*/
const toggleProductOffer = (item) => {
return (checked: boolean) => {
CartAPI.setOffer(cart, item.orderable_id, checked).then(data => {
CartAPI.setOffer(cart, item.orderable_id, item.orderable_type, checked).then(data => {
setCart(data);
}).catch(e => {
if (e.match(/code 403/)) {

View File

@ -0,0 +1,64 @@
import { IApplication } from '../../models/application';
import React, { useEffect } from 'react';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import type { Slot } from '../../models/slot';
import { useImmer } from 'use-immer';
import FormatLib from '../../lib/format';
import { FabButton } from '../base/fab-button';
import { ShoppingCart } from 'phosphor-react';
import CartAPI from '../../api/cart';
import useCart from '../../hooks/use-cart';
import type { User } from '../../models/user';
declare const Application: IApplication;
interface ReservationsSummaryProps {
slot: Slot,
customer: User,
onError: (error: string) => void,
}
/**
* Display a summary of the selected slots, and ask for confirmation before adding them to the cart
*/
const ReservationsSummary: React.FC<ReservationsSummaryProps> = ({ slot, customer, onError }) => {
const { cart, setCart } = useCart(customer);
const [pendingSlots, setPendingSlots] = useImmer<Array<Slot>>([]);
useEffect(() => {
if (slot) {
if (pendingSlots.find(s => s.slot_id === slot.slot_id)) {
setPendingSlots(draft => draft.filter(s => s.slot_id !== slot.slot_id));
} else {
setPendingSlots(draft => { draft.push(slot); });
}
}
}, [slot]);
/**
* Add the product to cart
*/
const addSlotToCart = (slot: Slot) => {
return () => {
CartAPI.addItem(cart, slot.slot_id, 'Slot', 1).then(setCart).catch(onError);
};
};
return (
<ul>{pendingSlots.map(slot => (
<li key={slot.slot_id}>
<span>{FormatLib.date(slot.start)} {FormatLib.time(slot.start)} - {FormatLib.time(slot.end)}</span>
<FabButton onClick={addSlotToCart(slot)}><ShoppingCart size={24}/> add to cart </FabButton>
</li>
))}</ul>
);
};
const ReservationsSummaryWrapper: React.FC<ReservationsSummaryProps> = (props) => (
<Loader>
<ReservationsSummary {...props} />
</Loader>
);
Application.Components.component('reservationsSummary', react2angular(ReservationsSummaryWrapper, ['slot', 'customer', 'onError']));

View File

@ -40,7 +40,7 @@ export const StoreProductItem: React.FC<StoreProductItemProps> = ({ product, car
const addProductToCart = (e: React.BaseSyntheticEvent) => {
e.preventDefault();
e.stopPropagation();
CartAPI.addItem(cart, product.id, 1).then(onSuccessAddProductToCart).catch(() => {
CartAPI.addItem(cart, product.id, 'Product', 1).then(onSuccessAddProductToCart).catch(() => {
onError(t('app.public.store_product_item.stock_limit'));
});
};

View File

@ -108,7 +108,7 @@ export const StoreProduct: React.FC<StoreProductProps> = ({ productSlug, current
*/
const addToCart = () => {
if (toCartCount <= product.stock.external) {
CartAPI.addItem(cart, product.id, toCartCount).then(data => {
CartAPI.addItem(cart, product.id, 'Product', toCartCount).then(data => {
setCart(data);
onSuccess(t('app.public.store_product.add_to_cart_success'));
}).catch(() => {

View File

@ -393,12 +393,6 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$tran
// Slot free to be booked
const FREE_SLOT_BORDER_COLOR = '<%= AvailabilityHelper::MACHINE_COLOR %>';
// Slot already booked by another user
const UNAVAILABLE_SLOT_BORDER_COLOR = '<%= AvailabilityHelper::MACHINE_IS_RESERVED_BY_USER %>';
// Slot already booked by the current user
const BOOKED_SLOT_BORDER_COLOR = '<%= AvailabilityHelper::IS_RESERVED_BY_CURRENT_USER %>';
/* PUBLIC SCOPE */
// bind the machine availabilities with full-Calendar events
@ -642,7 +636,6 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$tran
* Callback triggered after a successful prepaid-pack purchase
*/
$scope.onSuccess = function (message) {
growl.success(message);
};

View File

@ -5,6 +5,8 @@ import { UserRole } from './user';
import { Coupon } from './coupon';
import { ApiFilter, PaginatedIndex } from './api';
export type OrderableType = 'Product' | 'Slot';
export interface Order {
id: number,
token: string,
@ -28,7 +30,7 @@ export interface Order {
paid_total?: number,
order_items_attributes: Array<{
id: number,
orderable_type: string,
orderable_type: OrderableType,
orderable_id: number,
orderable_name: string,
orderable_slug: string,

View File

@ -0,0 +1,50 @@
import { TDateISO } from '../typings/date-iso';
export interface Slot {
slot_id: number,
can_modify: boolean,
title: string,
start: TDateISO,
end: TDateISO,
is_reserved: boolean,
is_completed: boolean,
backgroundColor: 'white',
availability_id: number,
slots_reservations_ids: Array<number>,
tag_ids: Array<number>,
tags: Array<{
id: number,
name: string,
}>
plan_ids: Array<number>,
// the users who booked on this slot, if any
users: Array<{
id: number,
name: string
}>,
borderColor?: '#eeeeee' | '#b2e774' | '#e4cd78' | '#bd7ae9' | '#dd7e6b' | '#3fc7ff' | '#000',
// machine
machine?: {
id: number,
name: string
},
// training
nb_total_places?: number,
training?: {
id: number,
name: string,
description: string,
machines: Array<{
id: number,
name: string
}>
},
// space
space?: {
id: number,
name: string
}
}

View File

@ -2,16 +2,22 @@
<section class="heading b-b">
<div class="row no-gutter">
<div class="col-xs-2 col-sm-2 col-md-1">
<div class="col-xs-2 col-md-1">
<section class="heading-btn">
<a ng-click="backPrevLocation($event)"><i class="fas fa-long-arrow-alt-left "></i></a>
</section>
</div>
<div class="col-xs-10 col-sm-10 col-md-8 b-l">
<div class="col-xs-8 col-md-10 b-l">
<section class="heading-title">
<h1>{{ 'app.logged.machines_reserve.machine_planning' | translate }} : {{machine.name}}</h1>
</section>
</div>
<div class="col-xs-2 col-md-1 b-t hide-b-md p-none">
<section class="heading-actions">
<cart-button />
</section>
</div>
</div>
</section>
@ -39,6 +45,8 @@
refresh="afterPaymentPromise">
</packs-summary>
<reservations-summary slot="selectedEvent" customer="ctrl.member" on-error="onError"></reservations-summary>
<cart slot="selectedEvent"
slot-selection-time="selectionTime"
events="events"

View File

@ -7,7 +7,6 @@ module AvailabilityHelper
SPACE_COLOR = '#3fc7ff'
EVENT_COLOR = '#dd7e6b'
IS_RESERVED_BY_CURRENT_USER = '#b2e774'
MACHINE_IS_RESERVED_BY_USER = '#1d98ec'
IS_FULL = '#eeeeee'
def availability_border_color(availability)

View File

@ -1,15 +1,15 @@
# frozen_string_literal: true
# Items that can be added to the shopping cart
module CartItem; end
module CartItem
def self.table_name_prefix
'cart_item_'
end
end
# This is an abstract class implemented by classes that can be added to the shopping cart
class CartItem::BaseItem
attr_reader :errors
def initialize(*)
@errors = {}
end
class CartItem::BaseItem < ApplicationRecord
self.abstract_class = true
def price
{ elements: {}, amount: 0 }

View File

@ -1,20 +1,17 @@
# frozen_string_literal: true
# A discount coupon applied to the whole shopping cart
class CartItem::Coupon
attr_reader :errors
class CartItem::Coupon < ApplicationRecord
belongs_to :operator_profile, class_name: 'InvoicingProfile'
belongs_to :customer_profile, class_name: 'InvoicingProfile'
belongs_to :coupon
# @param coupon {String|Coupon} may be nil or empty string if no coupons are applied
def initialize(customer, operator, coupon)
@customer = customer
@operator = operator
@coupon = coupon
@errors = {}
def operator
operator_profile.user
end
def coupon
cs = CouponService.new
cs.validate(@coupon, @customer.id)
def customer
customer_profile.user
end
def price(cart_total = 0)
@ -31,11 +28,10 @@ class CartItem::Coupon
end
def valid?(_all_items)
return true if @coupon.nil?
return true if coupon.nil?
c = ::Coupon.find_by(code: @coupon)
if c.nil? || c.status(@customer.id) != 'active'
@errors[:item] = 'coupon is invalid'
if coupon.status(customer.id) != 'active'
errors.add(:coupon, 'invalid coupon')
return false
end
true

View File

@ -2,30 +2,38 @@
# An event reservation added to the shopping cart
class CartItem::EventReservation < CartItem::Reservation
# @param normal_tickets {Number} number of tickets at the normal price
# @param other_tickets {Array<{booked: Number, event_price_category_id: Number}>}
def initialize(customer, operator, event, slots, normal_tickets: 0, other_tickets: [])
raise TypeError unless event.is_a? Event
has_many :cart_item_event_reservation_tickets, class_name: 'CartItem::EventReservationTicket', dependent: :destroy,
inverse_of: :cart_item_event_reservation,
foreign_key: 'cart_item_event_reservation_id'
accepts_nested_attributes_for :cart_item_event_reservation_tickets
super(customer, operator, event, slots)
@normal_tickets = normal_tickets || 0
@other_tickets = other_tickets || []
has_many :cart_item_reservation_slots, class_name: 'CartItem::ReservationSlot', dependent: :destroy, inverse_of: :cart_item,
foreign_key: 'cart_item_id', foreign_type: 'cart_item_type'
accepts_nested_attributes_for :cart_item_reservation_slots
belongs_to :operator_profile, class_name: 'InvoicingProfile'
belongs_to :customer_profile, class_name: 'InvoicingProfile'
belongs_to :event
def reservable
event
end
def price
amount = @reservable.amount * @normal_tickets
is_privileged = @operator.privileged? && @operator.id != @customer.id
amount = reservable.amount * normal_tickets
is_privileged = operator.privileged? && operator.id != customer.id
@other_tickets.each do |ticket|
amount += ticket[:booked] * EventPriceCategory.find(ticket[:event_price_category_id]).amount
cart_item_event_reservation_tickets.each do |ticket|
amount += ticket.booked * ticket.event_price_category.amount
end
elements = { slots: [] }
total = 0
@slots.each do |slot|
cart_item_reservation_slots.each do |sr|
total += get_slot_price(amount,
slot,
sr,
is_privileged,
elements: elements,
is_division: false)
@ -36,26 +44,25 @@ class CartItem::EventReservation < CartItem::Reservation
def to_object
::Reservation.new(
reservable_id: @reservable.id,
reservable_id: reservable.id,
reservable_type: Event.name,
slots_reservations_attributes: slots_params,
tickets_attributes: tickets_params,
nb_reserve_places: @normal_tickets,
statistic_profile_id: StatisticProfile.find_by(user: @customer).id
tickets_attributes: cart_item_event_reservation_tickets.map do |t|
{
event_price_category_id: t.event_price_category_id,
booked: t.booked
}
end,
nb_reserve_places: normal_tickets,
statistic_profile_id: StatisticProfile.find_by(user: customer).id
)
end
def name
@reservable.title
reservable.title
end
def type
'event'
end
protected
def tickets_params
@other_tickets.map { |ticket| ticket.permit(:event_price_category_id, :booked) }
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
# A relation table between a pending event reservation and a special price for this event
class CartItem::EventReservationTicket < ApplicationRecord
belongs_to :cart_item_event_reservation, class_name: 'CartItem::EventReservation', inverse_of: :cart_item_event_reservation_tickets
belongs_to :event_price_category, inverse_of: :cart_item_event_reservation_tickets
end

View File

@ -2,22 +2,20 @@
# A subscription extended for free, added to the shopping cart
class CartItem::FreeExtension < CartItem::BaseItem
def initialize(customer, subscription, new_expiration_date)
raise TypeError unless subscription.is_a? Subscription
belongs_to :customer_profile, class_name: 'InvoicingProfile'
belongs_to :subscription
@customer = customer
@new_expiration_date = new_expiration_date
@subscription = subscription
super
def customer
statistic_profile.user
end
def start_at
raise InvalidSubscriptionError if @subscription.nil?
if @new_expiration_date.nil? || @new_expiration_date <= @subscription.expired_at
raise InvalidSubscriptionError if subscription.nil?
if new_expiration_date.nil? || new_expiration_date <= subscription.expired_at
raise InvalidSubscriptionError, I18n.t('cart_items.must_be_after_expiration')
end
@subscription.expired_at
subscription.expired_at
end
def price
@ -27,14 +25,14 @@ class CartItem::FreeExtension < CartItem::BaseItem
end
def name
I18n.t('cart_items.free_extension', DATE: I18n.l(@new_expiration_date))
I18n.t('cart_items.free_extension', DATE: I18n.l(new_expiration_date))
end
def to_object
::OfferDay.new(
subscription_id: @subscription.id,
subscription_id: subscription.id,
start_at: start_at,
end_at: @new_expiration_date
end_at: new_expiration_date
)
end

View File

@ -2,46 +2,40 @@
# A machine reservation added to the shopping cart
class CartItem::MachineReservation < CartItem::Reservation
# @param plan {Plan} a subscription bought at the same time of the reservation OR an already running subscription
# @param new_subscription {Boolean} true is new subscription is being bought at the same time of the reservation
def initialize(customer, operator, machine, slots, plan: nil, new_subscription: false)
raise TypeError unless machine.is_a? Machine
self.table_name = 'cart_item_reservations'
super(customer, operator, machine, slots)
@plan = plan
@new_subscription = new_subscription
end
has_many :cart_item_reservation_slots, class_name: 'CartItem::ReservationSlot', dependent: :destroy, inverse_of: :cart_item,
foreign_key: 'cart_item_id', foreign_type: 'cart_item_type'
accepts_nested_attributes_for :cart_item_reservation_slots
def to_object
::Reservation.new(
reservable_id: @reservable.id,
reservable_type: Machine.name,
slots_reservations_attributes: slots_params,
statistic_profile_id: StatisticProfile.find_by(user: @customer).id
)
end
belongs_to :operator_profile, class_name: 'InvoicingProfile'
belongs_to :customer_profile, class_name: 'InvoicingProfile'
belongs_to :reservable, polymorphic: true
belongs_to :plan
def type
'machine'
end
def valid?(all_items)
@slots.each do |slot|
cart_item_reservation_slots.each do |slot|
same_hour_slots = SlotsReservation.joins(:reservation).where(
reservations: { reservable: @reservable },
reservations: { reservable: reservable },
slot_id: slot[:slot_id],
canceled_at: nil
).count
if same_hour_slots.positive?
@errors[:slot] = I18n.t('cart_item_validation.reserved')
errors.add(:slot, I18n.t('cart_item_validation.reserved'))
return false
end
if @reservable.disabled
@errors[:reservable] = I18n.t('cart_item_validation.machine')
if reservable.disabled
errors.add(:reservable, I18n.t('cart_item_validation.machine'))
return false
end
unless @reservable.reservable
@errors[:reservable] = I18n.t('cart_item_validation.reservable')
unless reservable.reservable
errors.add(:reservable, I18n.t('cart_item_validation.reservable'))
return false
end
end
@ -52,9 +46,9 @@ class CartItem::MachineReservation < CartItem::Reservation
protected
def credits
return 0 if @plan.nil?
return 0 if plan.nil?
machine_credit = @plan.machine_credits.find { |credit| credit.creditable_id == @reservable.id }
credits_hours(machine_credit, new_plan_being_bought: @new_subscription)
machine_credit = plan.machine_credits.find { |credit| credit.creditable_id == reservable.id }
credits_hours(machine_credit, new_plan_being_bought: new_subscription)
end
end

View File

@ -1,23 +1,18 @@
# frozen_string_literal: true
# A payment schedule applied to plan in the shopping cart
class CartItem::PaymentSchedule
attr_reader :requested, :errors
class CartItem::PaymentSchedule < ApplicationRecord
belongs_to :customer_profile, class_name: 'InvoicingProfile'
belongs_to :coupon
belongs_to :plan
def initialize(plan, coupon, requested, customer, start_at = nil)
raise TypeError unless coupon.is_a? CartItem::Coupon
@plan = plan
@coupon = coupon
@requested = requested
@customer = customer
@start_at = start_at
@errors = {}
def customer
customer_profile.user
end
def schedule(total, total_without_coupon)
schedule = if @requested && @plan&.monthly_payment
PaymentScheduleService.new.compute(@plan, total_without_coupon, @customer, coupon: @coupon.coupon, start_at: @start_at)
schedule = if requested && plan&.monthly_payment
PaymentScheduleService.new.compute(plan, total_without_coupon, customer, coupon: coupon.coupon, start_at: start_at)
else
nil
end
@ -36,10 +31,10 @@ class CartItem::PaymentSchedule
end
def valid?(_all_items)
return true unless @requested && @plan&.monthly_payment
return true unless requested && plan&.monthly_payment
if @plan&.disabled
@errors[:item] = I18n.t('cart_item_validation.plan')
if plan&.disabled
errors.add(:plan, I18n.t('cart_item_validation.plan'))
return false
end
true

View File

@ -2,18 +2,14 @@
# A prepaid-pack added to the shopping cart
class CartItem::PrepaidPack < CartItem::BaseItem
def initialize(pack, customer)
raise TypeError unless pack.is_a? PrepaidPack
belongs_to :prepaid_pack
@pack = pack
@customer = customer
super
def customer
customer_profile.user
end
def pack
raise InvalidGroupError if @pack.group_id != @customer.group_id
@pack
prepaid_pack
end
def price
@ -24,13 +20,13 @@ class CartItem::PrepaidPack < CartItem::BaseItem
end
def name
"#{@pack.minutes / 60} h"
"#{pack.minutes / 60} h"
end
def to_object
::StatisticProfilePrepaidPack.new(
prepaid_pack_id: @pack.id,
statistic_profile_id: StatisticProfile.find_by(user: @customer).id
prepaid_pack_id: pack.id,
statistic_profile_id: StatisticProfile.find_by(user: customer).id
)
end
@ -39,10 +35,14 @@ class CartItem::PrepaidPack < CartItem::BaseItem
end
def valid?(_all_items)
if @pack.disabled
if pack.disabled
@errors[:item] = 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}"
return false
end
true
end
end

View File

@ -7,17 +7,27 @@ GET_SLOT_PRICE_DEFAULT_OPTS = { has_credits: false, elements: nil, is_division:
# A generic reservation added to the shopping cart
class CartItem::Reservation < CartItem::BaseItem
def initialize(customer, operator, reservable, slots)
@customer = customer
@operator = operator
@reservable = reservable
@slots = slots.map { |s| expand_slot(s) }
super
self.abstract_class = true
def reservable
nil
end
def plan
nil
end
def operator
operator_profile.user
end
def customer
customer_profile.user
end
def price
is_privileged = @operator.privileged? && @operator.id != @customer.id
prepaid = { minutes: PrepaidPackService.minutes_available(@customer, @reservable) }
is_privileged = operator.privileged? && operator.id != customer.id
prepaid = { minutes: PrepaidPackService.minutes_available(customer, reservable) }
raise InvalidGroupError, I18n.t('cart_items.group_subscription_mismatch') if !@plan.nil? && @customer.group_id != @plan.group_id
@ -39,7 +49,7 @@ class CartItem::Reservation < CartItem::BaseItem
end
def name
@reservable.name
reservable&.name
end
def valid?(all_items)
@ -48,39 +58,48 @@ class CartItem::Reservation < CartItem::BaseItem
reservation_deadline_minutes = Setting.get('reservation_deadline').to_i
reservation_deadline = reservation_deadline_minutes.minutes.since
@slots.each do |slot|
slot_db = Slot.find(slot[:slot_id])
if slot_db.nil?
@errors[:slot] = I18n.t('cart_item_validation.slot')
cart_item_reservation_slots.each do |sr|
slot = sr.slot
if slot.nil?
errors.add(:slot, I18n.t('cart_item_validation.slot'))
return false
end
availability = Availability.find_by(id: slot[:slot_attributes][:availability_id])
availability = slot.availability
if availability.nil?
@errors[:availability] = I18n.t('cart_item_validation.availability')
errors.add(:availability, I18n.t('cart_item_validation.availability'))
return false
end
if slot_db.full?
@errors[:slot] = I18n.t('cart_item_validation.full')
if slot.full?
errors.add(:slot, I18n.t('cart_item_validation.full')
return false
end
if slot_db.start_at < reservation_deadline && !@operator.privileged?
@errors[:slot] = I18n.t('cart_item_validation.deadline', { MINUTES: reservation_deadline_minutes })
if slot.start_at < reservation_deadline && !operator.privileged?
errors.add(:slot, I18n.t('cart_item_validation.deadline', { MINUTES: reservation_deadline_minutes }))
return false
end
next if availability.plan_ids.empty?
next if required_subscription?(availability, pending_subscription)
@errors[:availability] = I18n.t('cart_item_validation.restricted')
errors.add(:availability, I18n.t('cart_item_validation.restricted'))
return false
end
true
end
def to_object
::Reservation.new(
reservable_id: reservable_id,
reservable_type: reservable_type,
slots_reservations_attributes: slots_params,
statistic_profile_id: StatisticProfile.find_by(user: customer).id
)
end
protected
def credits
@ -91,13 +110,9 @@ class CartItem::Reservation < CartItem::BaseItem
# Group the slots by date, if the extended_prices_in_same_day option is set to true
##
def grouped_slots
return { all: @slots } unless Setting.get('extended_prices_in_same_day')
return { all: cart_item_reservation_slots } unless Setting.get('extended_prices_in_same_day')
@slots.group_by { |slot| slot[:slot_attributes][:start_at].to_date }
end
def expand_slot(slot)
slot.merge({ slot_attributes: Slot.find(slot[:slot_id]) })
cart_item_reservation_slots.group_by { |slot| slot.slot[:start_at].to_date }
end
##
@ -105,16 +120,16 @@ class CartItem::Reservation < CartItem::BaseItem
# @param prices {{ prices: Array<{price: Price, duration: number}> }} list of prices to use with the current reservation
# @see get_slot_price
##
def get_slot_price_from_prices(prices, slot, is_privileged, options = {})
def get_slot_price_from_prices(prices, slot_reservation, is_privileged, options = {})
options = GET_SLOT_PRICE_DEFAULT_OPTS.merge(options)
slot_minutes = (slot[:slot_attributes][:end_at].to_time - slot[:slot_attributes][:start_at].to_time) / SECONDS_PER_MINUTE
slot_minutes = (slot_reservation.slot[:end_at].to_time - slot_reservation.slot[:start_at].to_time) / SECONDS_PER_MINUTE
price = prices[:prices].find { |p| p[:duration] <= slot_minutes && p[:duration].positive? }
price = prices[:prices].first if price.nil?
hourly_rate = ((Rational(price[:price].amount.to_f) / Rational(price[:price].duration)) * Rational(MINUTES_PER_HOUR)).to_f
# apply the base price to the real slot duration
real_price = get_slot_price(hourly_rate, slot, is_privileged, options)
real_price = get_slot_price(hourly_rate, slot_reservation, is_privileged, options)
price[:duration] -= slot_minutes
@ -125,7 +140,7 @@ class CartItem::Reservation < CartItem::BaseItem
# Compute the price of a single slot, according to the base price and the ability for an admin
# to offer the slot.
# @param hourly_rate {Number} base price of a slot
# @param slot {Hash} Slot object
# @param slot_reservation {CartItem::ReservationSlot}
# @param is_privileged {Boolean} true if the current user has a privileged role (admin or manager)
# @param [options] {Hash} optional parameters, allowing the following options:
# - elements {Array} if provided the resulting price will be append into elements.slots
@ -134,11 +149,11 @@ class CartItem::Reservation < CartItem::BaseItem
# - prepaid_minutes {Number} number of remaining prepaid minutes for the customer
# @return {Number} price of the slot
##
def get_slot_price(hourly_rate, slot, is_privileged, options = {})
def get_slot_price(hourly_rate, slot_reservation, is_privileged, options = {})
options = GET_SLOT_PRICE_DEFAULT_OPTS.merge(options)
slot_rate = options[:has_credits] || (slot[:offered] && is_privileged) ? 0 : hourly_rate
slot_minutes = (slot[:slot_attributes][:end_at].to_time - slot[:slot_attributes][:start_at].to_time) / SECONDS_PER_MINUTE
slot_rate = options[:has_credits] || (slot_reservation[:offered] && is_privileged) ? 0 : hourly_rate
slot_minutes = (slot_reservation.slot[:end_at].to_time - slot_reservation.slot[:start_at].to_time) / SECONDS_PER_MINUTE
# apply the base price to the real slot duration
real_price = if options[:is_division]
((Rational(slot_rate) / Rational(MINUTES_PER_HOUR)) * Rational(slot_minutes)).to_f
@ -155,7 +170,7 @@ class CartItem::Reservation < CartItem::BaseItem
unless options[:elements].nil?
options[:elements][:slots].push(
start_at: slot[:slot_attributes][:start_at],
start_at: slot_reservation.slot[:start_at],
price: real_price,
promo: (slot_rate != hourly_rate)
)
@ -168,19 +183,19 @@ class CartItem::Reservation < CartItem::BaseItem
# Eg. If the reservation is for 12 hours, and there are prices for 3 hours, 7 hours,
# and the base price (1 hours), we use the 7 hours price, then 3 hours price, and finally the base price twice (7+3+1+1 = 12).
# All these prices are returned to be applied to the reservation.
def applicable_prices(slots)
total_duration = slots.map do |slot|
(slot[:slot_attributes][:end_at].to_time - slot[:slot_attributes][:start_at].to_time) / SECONDS_PER_MINUTE
def applicable_prices(slots_reservations)
total_duration = slots_reservations.map do |slot|
(slot.slot[:end_at].to_time - slot.slot[:start_at].to_time) / SECONDS_PER_MINUTE
end.reduce(:+)
rates = { prices: [] }
remaining_duration = total_duration
while remaining_duration.positive?
max_duration = @reservable.prices.where(group_id: @customer.group_id, plan_id: @plan.try(:id))
.where(Price.arel_table[:duration].lteq(remaining_duration))
.maximum(:duration)
max_duration = reservable&.prices&.where(group_id: customer.group_id, plan_id: plan.try(:id))
&.where(Price.arel_table[:duration].lteq(remaining_duration))
&.maximum(:duration)
max_duration = 60 if max_duration.nil?
max_duration_price = @reservable.prices.find_by(group_id: @customer.group_id, plan_id: @plan.try(:id), duration: max_duration)
max_duration_price = reservable&.prices&.find_by(group_id: customer.group_id, plan_id: plan.try(:id), duration: max_duration)
current_duration = [remaining_duration, max_duration].min
rates[:prices].push(price: max_duration_price, duration: current_duration)
@ -200,14 +215,14 @@ class CartItem::Reservation < CartItem::BaseItem
hours_available = credits.hours
unless new_plan_being_bought
user_credit = @customer.users_credits.find_by(credit_id: credits.id)
user_credit = customer.users_credits.find_by(credit_id: credits.id)
hours_available = credits.hours - user_credit.hours_used if user_credit
end
hours_available
end
def slots_params
@slots.map { |slot| slot.permit(:id, :slot_id, :offered) }
cart_item_reservation_slots.map { |sr| { id: sr.slots_reservation_id, slot_id: sr.slot_id, offered: sr.offered } }
end
##
@ -215,9 +230,9 @@ class CartItem::Reservation < CartItem::BaseItem
# has the required susbcription, otherwise, check if the operator is privileged
##
def required_subscription?(availability, pending_subscription)
(@customer.subscribed_plan && availability.plan_ids.include?(@customer.subscribed_plan.id)) ||
(customer.subscribed_plan && availability.plan_ids.include?(customer.subscribed_plan.id)) ||
(pending_subscription && availability.plan_ids.include?(pending_subscription.plan.id)) ||
(@operator.manager? && @customer.id != @operator.id) ||
@operator.admin?
(operator.manager? && customer.id != operator.id) ||
operator.admin?
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
# A relation table between a pending reservation and a slot
class CartItem::ReservationSlot < ApplicationRecord
belongs_to :cart_item, polymorphic: true
belongs_to :slot
belongs_to :slots_reservation
end

View File

@ -2,33 +2,26 @@
# A space reservation added to the shopping cart
class CartItem::SpaceReservation < CartItem::Reservation
# @param plan {Plan} a subscription bought at the same time of the reservation OR an already running subscription
# @param new_subscription {Boolean} true is new subscription is being bought at the same time of the reservation
def initialize(customer, operator, space, slots, plan: nil, new_subscription: false)
raise TypeError unless space.is_a? Space
self.table_name = 'cart_item_reservations'
super(customer, operator, space, slots)
@plan = plan
@space = space
@new_subscription = new_subscription
end
has_many :cart_item_reservation_slots, class_name: 'CartItem::ReservationSlot', dependent: :destroy, inverse_of: :cart_item,
foreign_key: 'cart_item_id', foreign_type: 'cart_item_type'
accepts_nested_attributes_for :cart_item_reservation_slots
def to_object
::Reservation.new(
reservable_id: @reservable.id,
reservable_type: Space.name,
slots_reservations_attributes: slots_params,
statistic_profile_id: StatisticProfile.find_by(user: @customer).id
)
end
belongs_to :operator_profile, class_name: 'InvoicingProfile'
belongs_to :customer_profile, class_name: 'InvoicingProfile'
belongs_to :reservable, polymorphic: true
belongs_to :plan
def type
'space'
end
def valid?(all_items)
if @space.disabled
@errors[:reservable] = I18n.t('cart_item_validation.space')
if reservable.disabled
errors.add(:reservable, I18n.t('cart_item_validation.space'))
return false
end
@ -38,9 +31,9 @@ class CartItem::SpaceReservation < CartItem::Reservation
protected
def credits
return 0 if @plan.nil?
return 0 if plan.nil?
space_credit = @plan.space_credits.find { |credit| credit.creditable_id == @reservable.id }
credits_hours(space_credit, new_plan_being_bought: @new_subscription)
space_credit = plan.space_credits.find { |credit| credit.creditable_id == reservable.id }
credits_hours(space_credit, new_plan_being_bought: new_subscription)
end
end

View File

@ -2,21 +2,11 @@
# A subscription added to the shopping cart
class CartItem::Subscription < CartItem::BaseItem
attr_reader :start_at
belongs_to :plan
belongs_to :customer_profile, class_name: 'InvoicingProfile'
def initialize(plan, customer, start_at = nil)
raise TypeError unless plan.is_a? Plan
@plan = plan
@customer = customer
@start_at = start_at
super
end
def plan
raise InvalidGroupError if @plan.group_id != @customer.group_id
@plan
def customer
customer_profile.user
end
def price
@ -27,14 +17,14 @@ class CartItem::Subscription < CartItem::BaseItem
end
def name
@plan.base_name
plan.base_name
end
def to_object
::Subscription.new(
plan_id: @plan.id,
statistic_profile_id: StatisticProfile.find_by(user: @customer).id,
start_at: @start_at
plan_id: plan.id,
statistic_profile_id: StatisticProfile.find_by(user: customer).id,
start_at: start_at
)
end
@ -43,8 +33,12 @@ class CartItem::Subscription < CartItem::BaseItem
end
def valid?(_all_items)
if @plan.disabled
@errors[:item] = I18n.t('cart_item_validation.plan')
if plan.disabled
errors.add(:plan, I18n.t('cart_item_validation.plan'))
return false
end
if plan.group_id != customer.group_id
errors.add(:group, "plan is reserved for members of group #{plan.group.name}")
return false
end
true

View File

@ -2,45 +2,39 @@
# A training reservation added to the shopping cart
class CartItem::TrainingReservation < CartItem::Reservation
# @param plan {Plan} a subscription bought at the same time of the reservation OR an already running subscription
# @param new_subscription {Boolean} true is new subscription is being bought at the same time of the reservation
def initialize(customer, operator, training, slots, plan: nil, new_subscription: false)
raise TypeError unless training.is_a? Training
self.table_name = 'cart_item_reservations'
super(customer, operator, training, slots)
@plan = plan
@new_subscription = new_subscription
end
has_many :cart_item_reservation_slots, class_name: 'CartItem::ReservationSlot', dependent: :destroy, inverse_of: :cart_item,
foreign_key: 'cart_item_id', foreign_type: 'cart_item_type'
accepts_nested_attributes_for :cart_item_reservation_slots
belongs_to :operator_profile, class_name: 'InvoicingProfile'
belongs_to :customer_profile, class_name: 'InvoicingProfile'
belongs_to :reservable, polymorphic: true
belongs_to :plan
def price
base_amount = @reservable.amount_by_group(@customer.group_id).amount
is_privileged = @operator.admin? || (@operator.manager? && @operator.id != @customer.id)
base_amount = reservable&.amount_by_group(customer.group_id)&.amount
is_privileged = operator.admin? || (operator.manager? && operator.id != customer.id)
elements = { slots: [] }
amount = 0
hours_available = credits
@slots.each do |slot|
cart_item_reservation_slots.each do |sr|
amount += get_slot_price(base_amount,
slot,
sr,
is_privileged,
elements: elements,
has_credits: (@customer.training_credits.size < hours_available),
has_credits: (customer.training_credits.size < hours_available),
is_division: false)
end
{ elements: elements, amount: amount }
end
def to_object
::Reservation.new(
reservable_id: @reservable.id,
reservable_type: Training.name,
slots_reservations_attributes: slots_params,
statistic_profile_id: StatisticProfile.find_by(user: @customer).id
)
end
def type
'training'
end
@ -48,9 +42,9 @@ class CartItem::TrainingReservation < CartItem::Reservation
protected
def credits
return 0 if @plan.nil?
return 0 if plan.nil?
is_creditable = @plan.training_credits.select { |credit| credit.creditable_id == @reservable.id }.any?
is_creditable ? @plan.training_credit_nb : 0
is_creditable = plan&.training_credits&.select { |credit| credit.creditable_id == reservable&.id }&.any?
is_creditable ? plan&.training_credit_nb : 0
end
end

View File

@ -2,16 +2,18 @@
# Coupon is a textual code associated with a discount rate or an amount of discount
class Coupon < ApplicationRecord
has_many :invoices, dependent: :nullify
has_many :payment_schedule, dependent: :nullify
has_many :orders, dependent: :nullify
has_many :invoices, dependent: :restrict_with_error
has_many :payment_schedule, dependent: :restrict_with_error
has_many :orders, dependent: :restrict_with_error
has_many :cart_item_coupons, class_name: 'CartItem::Coupon', dependent: :destroy
after_create :create_gateway_coupon
before_destroy :delete_gateway_coupon
validates :name, presence: true
validates :code, presence: true
validates :code, format: { with: /\A[A-Z0-9\-]+\z/, message: I18n.t('coupon.invalid_format') }
validates :code, format: { with: /\A[A-Z0-9\-]+\z/, message: I18n.t('coupon.code_format_error') }
validates :code, uniqueness: true
validates :validity_per_user, presence: true
validates :validity_per_user, inclusion: { in: %w[once forever] }

View File

@ -31,6 +31,8 @@ class Event < ApplicationRecord
has_one :advanced_accounting, as: :accountable, dependent: :destroy
accepts_nested_attributes_for :advanced_accounting, allow_destroy: true
has_many :cart_item_event_reservations, class_name: 'CartItem::EventReservation', dependent: :destroy
attr_accessor :recurrence, :recurrence_end_at
before_save :update_nb_free_places

View File

@ -5,7 +5,8 @@ class EventPriceCategory < ApplicationRecord
belongs_to :event
belongs_to :price_category
has_many :tickets
has_many :tickets, dependent: :restrict_with_error
has_many :cart_item_event_reservation_tickets, class_name: 'CartItem::EventReservationTicket', dependent: :restrict_with_error
validates :price_category_id, presence: true
validates :amount, presence: true
@ -17,5 +18,4 @@ class EventPriceCategory < ApplicationRecord
def verify_no_associated_tickets
throw(:abort) unless tickets.count.zero?
end
end

View File

@ -27,6 +27,25 @@ class InvoicingProfile < ApplicationRecord
has_many :accounting_lines, dependent: :destroy
# as operator
has_many :operated_cart_item_event_reservations, class_name: 'CartItem::EventReservation', dependent: :nullify, inverse_of: :operator_profile
has_many :operated_cart_item_machine_reservations, class_name: 'CartItem::MachineReservation', dependent: :nullify,
inverse_of: :operator_profile
has_many :operated_cart_item_space_reservations, class_name: 'CartItem::SpaceReservation', dependent: :nullify, inverse_of: :operator_profile
has_many :operated_cart_item_training_reservations, class_name: 'CartItem::TrainingReservation', dependent: :nullify,
inverse_of: :operator_profile
has_many :operated_cart_item_coupon, class_name: 'CartItem::Coupon', dependent: :nullify, inverse_of: :operator_profile
# as customer
has_many :cart_item_event_reservations, class_name: 'CartItem::EventReservation', dependent: :destroy, inverse_of: :customer_profile
has_many :cart_item_machine_reservations, class_name: 'CartItem::MachineReservation', dependent: :destroy, inverse_of: :customer_profile
has_many :cart_item_space_reservations, class_name: 'CartItem::SpaceReservation', dependent: :destroy, inverse_of: :customer_profile
has_many :cart_item_training_reservations, class_name: 'CartItem::TrainingReservation', dependent: :destroy, inverse_of: :customer_profile
has_many :cart_item_free_extensions, class_name: 'CartItem::FreeExtension', dependent: :destroy, inverse_of: :customer_profile
has_many :cart_item_subscriptions, class_name: 'CartItem::Subscription', dependent: :destroy, inverse_of: :customer_profile
has_many :cart_item_prepaid_packs, class_name: 'CartItem::PrepaidPack', dependent: :destroy, inverse_of: :customer_profile
has_many :cart_item_coupons, class_name: 'CartItem::Coupon', dependent: :destroy, inverse_of: :customer_profile
has_many :cart_item_payment_schedules, class_name: 'CartItem::PaymentSchedule', dependent: :destroy, inverse_of: :customer_profile
before_validation :set_external_id_nil
validates :external_id, uniqueness: true, allow_blank: true
validates :address, presence: true, if: -> { Setting.get('address_required') }

View File

@ -37,6 +37,9 @@ class Machine < ApplicationRecord
has_one :advanced_accounting, as: :accountable, dependent: :destroy
accepts_nested_attributes_for :advanced_accounting, allow_destroy: true
has_many :cart_item_machine_reservations, class_name: 'CartItem::MachineReservation', dependent: :destroy, inverse_of: :reservable,
foreign_type: 'reservable_type', foreign_key: 'reservable_id'
belongs_to :category
after_create :create_statistic_subtype

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
# Order is a model for the user hold information of order
# Order is a model used to hold orders data
class Order < PaymentDocument
belongs_to :statistic_profile
belongs_to :operator_profile, class_name: 'InvoicingProfile'

View File

@ -15,6 +15,12 @@ class Plan < ApplicationRecord
has_many :prices, dependent: :destroy
has_one :payment_gateway_object, -> { order id: :desc }, inverse_of: :plan, as: :item, dependent: :destroy
has_many :cart_item_machine_reservations, class_name: 'CartItem::MachineReservation', dependent: :destroy
has_many :cart_item_space_reservations, class_name: 'CartItem::SpaceReservation', dependent: :destroy
has_many :cart_item_training_reservations, class_name: 'CartItem::TrainingReservation', dependent: :destroy
has_many :cart_item_subscriptions, class_name: 'CartItem::Subscription', dependent: :destroy
has_many :cart_item_payment_schedules, class_name: 'CartItem::PaymentSchedule', dependent: :destroy
extend FriendlyId
friendly_id :base_name, use: :slugged

View File

@ -8,12 +8,14 @@
# The number of hours in a pack is stored in minutes.
class PrepaidPack < ApplicationRecord
belongs_to :priceable, polymorphic: true
belongs_to :machine, foreign_type: 'Machine', foreign_key: 'priceable_id'
belongs_to :space, foreign_type: 'Space', foreign_key: 'priceable_id'
belongs_to :machine, foreign_type: 'Machine', foreign_key: 'priceable_id', inverse_of: :prepaid_packs
belongs_to :space, foreign_type: 'Space', foreign_key: 'priceable_id', inverse_of: :prepaid_packs
belongs_to :group
has_many :statistic_profile_prepaid_packs
has_many :statistic_profile_prepaid_packs, dependent: :destroy
has_many :cart_item_prepaid_packs, class_name: 'CartItem::PrepaidPack', dependent: :destroy
validates :amount, :group_id, :priceable_id, :priceable_type, :minutes, presence: true

View File

@ -59,7 +59,7 @@ class Project < ApplicationRecord
scope :published_or_drafts, lambda { |author_profile|
where("state = 'published' OR (state = 'draft' AND author_statistic_profile_id = ?)", author_profile)
}
scope :user_projects, ->(author_profile) { where(author_statistic_profile: author_profile) }
scope :user_projects, ->(author_profile) { where(author_statistic_profile_id: author_profile) }
scope :collaborations, ->(collaborators_ids) { joins(:project_users).where(project_users: { user_id: collaborators_ids }) }
scope :with_machine, ->(machines_ids) { joins(:projects_machines).where(projects_machines: { machine_id: machines_ids }) }
scope :with_theme, ->(themes_ids) { joins(:projects_themes).where(projects_themes: { theme_id: themes_ids }) }

View File

@ -70,7 +70,7 @@ class ShoppingCart
payment.post_save(payment_id, payment_type)
end
success = !payment.nil? && objects.map(&:errors).flatten.map(&:empty?).all? && items.map(&:errors).map(&:empty?).all?
success = !payment.nil? && objects.map(&:errors).flatten.map(&:empty?).all? && items.map(&:errors).map(&:blank?).all?
errors = objects.map(&:errors).flatten.concat(items.map(&:errors))
errors.push('Unable to create the PaymentDocument') if payment.nil?
{ success: success, payment: payment, errors: errors }

View File

@ -10,6 +10,8 @@ class Slot < ApplicationRecord
has_many :reservations, through: :slots_reservations
belongs_to :availability
has_many :cart_item_reservation_slots, class_name: 'CartItem::ReservationSlot', dependent: :destroy
attr_accessor :is_reserved, :machine, :space, :title, :can_modify, :current_user_slots_reservations_ids
def full?(reservable = nil)

View File

@ -5,6 +5,7 @@
class SlotsReservation < ApplicationRecord
belongs_to :slot
belongs_to :reservation
has_one :cart_item_reservation_slot, class_name: 'CartItem::ReservationSlot', dependent: :nullify
after_update :set_ex_start_end_dates_attrs, if: :slot_changed?
after_update :notify_member_and_admin_slot_is_modified, if: :slot_changed?
@ -13,7 +14,7 @@ class SlotsReservation < ApplicationRecord
after_update :update_event_nb_free_places, if: :canceled?
def set_ex_start_end_dates_attrs
update_columns(ex_start_at: previous_slot.start_at, ex_end_at: previous_slot.end_at)
update_columns(ex_start_at: previous_slot.start_at, ex_end_at: previous_slot.end_at) # rubocop:disable Rails/SkipsModelValidations
end
private

View File

@ -31,6 +31,9 @@ class Space < ApplicationRecord
has_one :advanced_accounting, as: :accountable, dependent: :destroy
accepts_nested_attributes_for :advanced_accounting, allow_destroy: true
has_many :cart_item_space_reservations, class_name: 'CartItem::SpaceReservation', dependent: :destroy, inverse_of: :reservable,
foreign_type: 'reservable_type', foreign_key: 'reservable_id'
after_create :create_statistic_subtype
after_create :create_space_prices
after_create :update_gateway_product

View File

@ -12,6 +12,8 @@ class Subscription < ApplicationRecord
has_many :invoice_items, as: :object, dependent: :destroy
has_many :offer_days, dependent: :destroy
has_many :cart_item_free_extensions, class_name: 'CartItem::FreeExtension', dependent: :destroy
validates :plan_id, presence: true
validates_with SubscriptionGroupValidator

View File

@ -32,6 +32,9 @@ class Training < ApplicationRecord
has_one :advanced_accounting, as: :accountable, dependent: :destroy
accepts_nested_attributes_for :advanced_accounting, allow_destroy: true
has_many :cart_item_training_reservations, class_name: 'CartItem::TrainingReservation', dependent: :destroy, inverse_of: :reservable,
foreign_type: 'reservable_type', foreign_key: 'reservable_id'
after_create :create_statistic_subtype
after_create :create_trainings_pricings
after_create :update_gateway_product

View File

@ -5,10 +5,30 @@ class Cart::AddItemService
def call(order, orderable, quantity = 1)
return order if quantity.to_i.zero?
raise Cart::InactiveProductError unless orderable.is_active
item = case orderable
when Product
add_product(order, orderable, quantity)
when Slot
add_slot(order, orderable)
else
raise Cart::UnknownItemError
end
order.created_at = Time.current if order.order_items.length.zero?
ActiveRecord::Base.transaction do
item.save
Cart::UpdateTotalService.new.call(order)
order.save
end
order.reload
end
private
def add_product(order, orderable, quantity)
raise Cart::InactiveProductError unless orderable.is_active
item = order.order_items.find_by(orderable: orderable)
quantity = orderable.quantity_min > quantity.to_i && item.nil? ? orderable.quantity_min : quantity.to_i
@ -19,11 +39,14 @@ class Cart::AddItemService
end
raise Cart::OutStockError if item.quantity > orderable.stock['external']
ActiveRecord::Base.transaction do
item.save
Cart::UpdateTotalService.new.call(order)
order.save
item
end
order.reload
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
end
end

View File

@ -11,25 +11,46 @@ class CartService
# @see app/frontend/src/javascript/models/payment.ts > interface ShoppingCart
##
def from_hash(cart_items)
cart_items.permit! if cart_items.is_a? ActionController::Parameters
@customer = customer(cart_items)
plan_info = plan(cart_items)
items = []
cart_items[:items].each do |item|
if ['subscription', :subscription].include?(item.keys.first)
items.push(CartItem::Subscription.new(plan_info[:plan], @customer, item[:subscription][:start_at])) if plan_info[:new_subscription]
if ['subscription', :subscription].include?(item.keys.first) && plan_info[:new_subscription]
items.push(CartItem::Subscription.new(
plan: plan_info[:plan],
customer_profile: @customer.invoicing_profile,
start_at: item[:subscription][:start_at]
))
elsif ['reservation', :reservation].include?(item.keys.first)
items.push(reservable_from_hash(item[:reservation], plan_info))
elsif ['prepaid_pack', :prepaid_pack].include?(item.keys.first)
items.push(CartItem::PrepaidPack.new(PrepaidPack.find(item[:prepaid_pack][:id]), @customer))
items.push(CartItem::PrepaidPack.new(
prepaid_pack: PrepaidPack.find(item[:prepaid_pack][:id]),
customer_profile: @customer.invoicing_profile
))
elsif ['free_extension', :free_extension].include?(item.keys.first)
items.push(CartItem::FreeExtension.new(@customer, plan_info[:subscription], item[:free_extension][:end_at]))
items.push(CartItem::FreeExtension.new(
customer_profile: @customer.invoicing_profile,
subscription: plan_info[:subscription],
new_expiration_date: item[:free_extension][:end_at]
))
end
end
coupon = CartItem::Coupon.new(@customer, @operator, cart_items[:coupon_code])
coupon = CartItem::Coupon.new(
customer_profile: @customer.invoicing_profile,
operator_profile: @operator.invoicing_profile,
coupon: Coupon.find_by(code: cart_items[:coupon_code])
)
schedule = CartItem::PaymentSchedule.new(
plan_info[:plan], coupon, cart_items[:payment_schedule], @customer, plan_info[:subscription]&.start_at
plan: plan_info[:plan],
coupon: coupon,
requested: cart_items[:payment_schedule],
customer_profile: @customer.invoicing_profile,
start_at: plan_info[:subscription]&.start_at
)
ShoppingCart.new(
@ -47,19 +68,40 @@ class CartService
subscription = payment_schedule.payment_schedule_objects.find { |pso| pso.object_type == Subscription.name }&.subscription
plan = subscription&.plan
coupon = CartItem::Coupon.new(@customer, @operator, payment_schedule.coupon&.code)
schedule = CartItem::PaymentSchedule.new(plan, coupon, true, @customer, subscription.start_at)
coupon = CartItem::Coupon.new(
customer_profile: @customer.invoicing_profile,
operator_profile: @operator.invoicing_profile,
coupon: payment_schedule.coupon
)
schedule = CartItem::PaymentSchedule.new(
plan: plan,
coupon: coupon,
requested: true,
customer_profile: @customer.invoicing_profile,
start_at: subscription.start_at
)
items = []
payment_schedule.payment_schedule_objects.each do |object|
if object.object_type == Subscription.name
items.push(CartItem::Subscription.new(object.subscription.plan, @customer, object.subscription.start_at))
items.push(CartItem::Subscription.new(
plan: object.subscription.plan,
customer_profile: @customer.invoicing_profile,
start_at: object.subscription.start_at
))
elsif object.object_type == Reservation.name
items.push(reservable_from_payment_schedule_object(object, plan))
elsif object.object_type == PrepaidPack.name
items.push(CartItem::PrepaidPack.new(object.statistic_profile_prepaid_pack.prepaid_pack_id, @customer))
items.push(CartItem::PrepaidPack.new(
prepaid_pack_id: object.statistic_profile_prepaid_pack.prepaid_pack_id,
customer_profile: @customer.invoicing_profile
))
elsif object.object_type == OfferDay.name
items.push(CartItem::FreeExtension.new(@customer, object.offer_day.subscription, object.offer_day.end_date))
items.push(CartItem::FreeExtension.new(
customer_profile: @customer.invoicing_profile,
subscription: object.offer_day.subscription,
new_expiration_date: object.offer_day.end_date
))
end
end
@ -83,7 +125,11 @@ class CartService
if cart_items[:items][index][:subscription][:plan_id]
new_plan_being_bought = true
plan = Plan.find(cart_items[:items][index][:subscription][:plan_id])
subscription = CartItem::Subscription.new(plan, @customer, cart_items[:items][index][:subscription][:start_at]).to_object
subscription = CartItem::Subscription.new(
plan: plan,
customer_profile: @customer.invoicing_profile,
start_at: cart_items[:items][index][:subscription][:start_at]
).to_object
plan
end
elsif @customer.subscribed_plan
@ -107,31 +153,31 @@ class CartService
reservable = cart_item[:reservable_type]&.constantize&.find(cart_item[:reservable_id])
case reservable
when Machine
CartItem::MachineReservation.new(@customer,
@operator,
reservable,
cart_item[:slots_reservations_attributes],
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[:plan],
new_subscription: plan_info[:new_subscription])
when Training
CartItem::TrainingReservation.new(@customer,
@operator,
reservable,
cart_item[:slots_reservations_attributes],
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[:plan],
new_subscription: plan_info[:new_subscription])
when Event
CartItem::EventReservation.new(@customer,
@operator,
reservable,
cart_item[:slots_reservations_attributes],
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],
other_tickets: cart_item[:tickets_attributes])
cart_item_event_reservation_tickets_attributes: cart_item[:tickets_attributes] || {})
when Space
CartItem::SpaceReservation.new(@customer,
@operator,
reservable,
cart_item[:slots_reservations_attributes],
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[:plan],
new_subscription: plan_info[:new_subscription])
else
@ -144,31 +190,31 @@ class CartService
reservable = object.reservation.reservable
case reservable
when Machine
CartItem::MachineReservation.new(@customer,
@operator,
reservable,
object.reservation.slots_reservations,
CartItem::MachineReservation.new(customer_profile: @customer.invoicing_profile,
operator_profile: @operator.invoicing_profile,
reservable: reservable,
cart_item_reservation_slots_attributes: object.reservation.slots_reservations,
plan: plan,
new_subscription: true)
when Training
CartItem::TrainingReservation.new(@customer,
@operator,
reservable,
object.reservation.slots_reservations,
CartItem::TrainingReservation.new(customer_profile: @customer.invoicing_profile,
operator_profile: @operator.invoicing_profile,
reservable: reservable,
cart_item_reservation_slots_attributes: object.reservation.slots_reservations,
plan: plan,
new_subscription: true)
when Event
CartItem::EventReservation.new(@customer,
@operator,
reservable,
object.reservation.slots_reservations,
CartItem::EventReservation.new(customer_profile: @customer.invoicing_profile,
operator_profile: @operator.invoicing_profile,
event: reservable,
cart_item_reservation_slots_attributes: object.reservation.slots_reservation,
normal_tickets: object.reservation.nb_reserve_places,
other_tickets: object.reservation.tickets)
cart_item_event_reservation_tickets_attributes: object.reservation.tickets)
when Space
CartItem::SpaceReservation.new(@customer,
@operator,
reservable,
object.reservation.slots_reservations,
CartItem::SpaceReservation.new(customer_profile: @customer.invoicing_profile,
operator_profile: @operator.invoicing_profile,
reservable: reservable,
cart_item_reservation_slots_attributes: object.reservation.slots_reservations,
plan: plan,
new_subscription: true)
else

View File

@ -7,9 +7,9 @@ class StripeCardTokenValidator
res = Stripe::Token.retrieve(options[:token], api_key: Setting.get('stripe_secret_key'))
if res[:id] != options[:token]
record.errors[:card_token] << "A problem occurred while retrieving the card with the specified token: #{res.id}"
record.errors.add(:card_token, "A problem occurred while retrieving the card with the specified token: #{res.id}")
end
rescue Stripe::InvalidRequestError => e
record.errors[:card_token] << e
record.errors.add(:card_token, e)
end
end

View File

@ -67,6 +67,8 @@ en:
length_must_be_slot_multiple: "must be at least %{MIN} minutes after the start date"
must_be_associated_with_at_least_1_machine: "must be associated with at least 1 machine"
deleted_user: "Deleted user"
coupon:
code_format_error: "only caps letters, numbers, and dashes are allowed"
#members management
members:
unable_to_change_the_group_while_a_subscription_is_running: "Unable to change the group while a subscription is running"

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
# From this migration, we save the pending event reservations in database, instead of just creating them on the fly
class CreateCartItemEventReservation < ActiveRecord::Migration[5.2]
def change
create_table :cart_item_event_reservations do |t|
t.integer :normal_tickets
t.references :event, foreign_key: true
t.references :operator_profile, foreign_key: { to_table: 'invoicing_profiles' }
t.references :customer_profile, foreign_key: { to_table: 'invoicing_profiles' }
t.timestamps
end
end
end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
# A relation table between a pending event reservation and a special price for this event
class CreateCartItemEventReservationTicket < ActiveRecord::Migration[5.2]
def change
create_table :cart_item_event_reservation_tickets do |t|
t.integer :booked
t.references :event_price_category, foreign_key: true, index: { name: 'index_cart_item_tickets_on_event_price_category' }
t.references :cart_item_event_reservation, foreign_key: true, index: { name: 'index_cart_item_tickets_on_cart_item_event_reservation' }
t.timestamps
end
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
# A relation table between a pending reservation and a slot
class CreateCartItemReservationSlot < ActiveRecord::Migration[5.2]
def change
create_table :cart_item_reservation_slots do |t|
t.references :cart_item, polymorphic: true, index: { name: 'index_cart_item_slots_on_cart_item' }
t.references :slot, foreign_key: true
t.references :slots_reservation, foreign_key: true
t.boolean :offered, default: false
t.timestamps
end
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
# From this migration, we save the pending machine/space/training reservations in database, instead of just creating them on the fly
class CreateCartItemReservation < ActiveRecord::Migration[5.2]
def change
create_table :cart_item_reservations do |t|
t.references :reservable, polymorphic: true, index: { name: 'index_cart_item_reservations_on_reservable' }
t.references :plan, foreign_key: true
t.boolean :new_subscription
t.references :customer_profile, foreign_key: { to_table: 'invoicing_profiles' }
t.references :operator_profile, foreign_key: { to_table: 'invoicing_profiles' }
t.string :type
t.timestamps
end
end
end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
# From this migration, we save the pending free-extensions of subscriptions in database, instead of just creating them on the fly
class CreateCartItemFreeExtension < ActiveRecord::Migration[5.2]
def change
create_table :cart_item_free_extensions do |t|
t.references :subscription, foreign_key: true
t.datetime :new_expiration_date
t.references :customer_profile, foreign_key: { to_table: 'invoicing_profiles' }
t.timestamps
end
end
end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
# From this migration, we save the pending subscriptions in database, instead of just creating them on the fly
class CreateCartItemSubscription < ActiveRecord::Migration[5.2]
def change
create_table :cart_item_subscriptions do |t|
t.references :plan, foreign_key: true
t.datetime :start_at
t.references :customer_profile, foreign_key: { to_table: 'invoicing_profiles' }
t.timestamps
end
end
end

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
# From this migration, we save the pending prepaid-packs in database, instead of just creating them on the fly
class CreateCartItemPrepaidPack < ActiveRecord::Migration[5.2]
def change
create_table :cart_item_prepaid_packs do |t|
t.references :prepaid_pack, foreign_key: true
t.references :customer_profile, foreign_key: { to_table: 'invoicing_profiles' }
t.timestamps
end
end
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
# Coupon's codes should validate uniqness in database
class AddUniqueIndexOnCouponCode < ActiveRecord::Migration[5.2]
def change
add_index :coupons, :code, unique: true
end
end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
# From this migration, we save the pending coupons in database, instead of just creating them on the fly
class CreateCartItemCoupon < ActiveRecord::Migration[5.2]
def change
create_table :cart_item_coupons do |t|
t.references :coupon, foreign_key: true
t.references :customer_profile, foreign_key: { to_table: 'invoicing_profiles' }
t.references :operator_profile, foreign_key: { to_table: 'invoicing_profiles' }
t.timestamps
end
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
# From this migration, we save the pending payment schedules in database, instead of just creating them on the fly
class CreateCartItemPaymentSchedule < ActiveRecord::Migration[5.2]
def change
create_table :cart_item_payment_schedules do |t|
t.references :plan, foreign_key: true
t.references :coupon, foreign_key: true
t.boolean :requested
t.datetime :start_at
t.references :customer_profile, foreign_key: { to_table: 'invoicing_profiles' }
t.timestamps
end
end
end

View File

@ -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.integer "signaled_id"
t.string "signaled_type"
t.integer "signaled_id"
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.integer "placeable_id"
t.string "placeable_type"
t.integer "placeable_id"
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.integer "viewable_id"
t.string "viewable_type"
t.integer "viewable_id"
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.integer "creditable_id"
t.string "creditable_type"
t.integer "creditable_id"
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.integer "attached_object_id"
t.string "attached_object_type"
t.integer "attached_object_id"
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.integer "priceable_id"
t.string "priceable_type"
t.integer "priceable_id"
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.integer "reservable_id"
t.string "reservable_type"
t.integer "reservable_id"
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.integer "resource_id"
t.string "resource_type"
t.integer "resource_id"
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"

View File

@ -2,6 +2,8 @@
require 'test_helper'
module Subscriptions; end
class Subscriptions::CreateAsUserTest < ActionDispatch::IntegrationTest
setup do
@user = User.find_by(username: 'jdupond')
@ -57,7 +59,7 @@ class Subscriptions::CreateAsUserTest < ActionDispatch::IntegrationTest
# Check notifications were sent for every admins
notifications = Notification.where(
notification_type_id: NotificationType.find_by_name('notify_admin_subscribed_plan'),
notification_type_id: NotificationType.find_by_name('notify_admin_subscribed_plan'), # rubocop:disable Rails/DynamicFindBy
attached_object_type: 'Subscription',
attached_object_id: subscription[:id]
)
@ -100,7 +102,7 @@ class Subscriptions::CreateAsUserTest < ActionDispatch::IntegrationTest
assert_equal Mime[:json], response.content_type
# Check the error was handled
assert_match(/plan is not compatible/, response.body)
assert_match(/plan is reserved for members of group/, response.body)
# Check that the user has no subscription
assert_nil @user.subscription, "user's subscription was found"
@ -162,7 +164,7 @@ class Subscriptions::CreateAsUserTest < ActionDispatch::IntegrationTest
# Check notifications were sent for every admins
notifications = Notification.where(
notification_type_id: NotificationType.find_by_name('notify_admin_subscribed_plan'),
notification_type_id: NotificationType.find_by_name('notify_admin_subscribed_plan'), # rubocop:disable Rails/DynamicFindBy
attached_object_type: 'Subscription',
attached_object_id: subscription[:id]
)

View File

@ -2,6 +2,8 @@
require 'test_helper'
module Subscriptions; end
class Subscriptions::RenewAsUserTest < ActionDispatch::IntegrationTest
setup do
@user = User.find_by(username: 'atiermoulin')
@ -60,7 +62,7 @@ class Subscriptions::RenewAsUserTest < ActionDispatch::IntegrationTest
# Check notifications were sent for every admins
notifications = Notification.where(
notification_type_id: NotificationType.find_by_name('notify_admin_subscribed_plan'),
notification_type_id: NotificationType.find_by_name('notify_admin_subscribed_plan'), # rubocop:disable Rails/DynamicFindBy
attached_object_type: 'Subscription',
attached_object_id: subscription[:id]
)