diff --git a/app/controllers/api/payzen_controller.rb b/app/controllers/api/payzen_controller.rb index ef63c4249..dc7faecf4 100644 --- a/app/controllers/api/payzen_controller.rb +++ b/app/controllers/api/payzen_controller.rb @@ -19,13 +19,19 @@ class API::PayzenController < API::PaymentsController def create_payment amount = card_amount - @id = PayZen::Helper.generate_ref(cart_items_params, params[:customer]) + @id = PayZen::Helper.generate_ref(cart_items_params, params[:customer_id]) client = PayZen::Charge.new @result = client.create_payment(amount: amount[:amount], order_id: @id, customer: PayZen::Helper.generate_customer(params[:customer_id])) - @result + end + + def create_token + @id = PayZen::Helper.generate_ref(cart_items_params, params[:customer_id]) + client = PayZen::Charge.new + @result = client.create_token(order_id: @id, + customer: PayZen::Helper.generate_customer(params[:customer_id])) end def check_hash diff --git a/app/frontend/src/javascript/api/payzen.ts b/app/frontend/src/javascript/api/payzen.ts index 8b5a08ae1..228f7e457 100644 --- a/app/frontend/src/javascript/api/payzen.ts +++ b/app/frontend/src/javascript/api/payzen.ts @@ -7,22 +7,27 @@ import { CheckHashResponse, ConfirmPaymentResponse, CreatePaymentResponse, SdkTe export default class PayzenAPI { static async chargeSDKTest(baseURL: string, username: string, password: string): Promise { - const res: AxiosResponse = await apiClient.post('/api/payzen/sdk_test', { base_url: baseURL, username, password }); + const res: AxiosResponse = await apiClient.post('/api/payzen/sdk_test', { base_url: baseURL, username, password }); return res?.data; } static async chargeCreatePayment(cartItems: CartItems, customer: User): Promise { - const res: AxiosResponse = await apiClient.post('/api/payzen/create_payment', { cart_items: cartItems, customer_id: customer.id }); + const res: AxiosResponse = await apiClient.post('/api/payzen/create_payment', { cart_items: cartItems, customer_id: customer.id }); + return res?.data; + } + + static async chargeCreateToken(cartItems: CartItems, customer: User): Promise { + const res: AxiosResponse = await apiClient.post('/api/payzen/create_token', { cart_items: cartItems, customer_id: customer.id }); return res?.data; } static async checkHash(algorithm: string, hashKey: string, hash: string, data: string): Promise { - const res: AxiosResponse = await apiClient.post('/api/payzen/check_hash', { algorithm, hash_key: hashKey, hash, data }); + const res: AxiosResponse = await apiClient.post('/api/payzen/check_hash', { algorithm, hash_key: hashKey, hash, data }); return res?.data; } static async confirm(orderId: string, cartItems: CartItems): Promise { - const res: AxiosResponse = await apiClient.post('/api/payzen/confirm_payment', { cart_items: cartItems, order_id: orderId }); + const res: AxiosResponse = await apiClient.post('/api/payzen/confirm_payment', { cart_items: cartItems, order_id: orderId }); return res?.data; } } diff --git a/app/frontend/src/javascript/models/payment.ts b/app/frontend/src/javascript/models/payment.ts index 32b641ccd..0958cb9b7 100644 --- a/app/frontend/src/javascript/models/payment.ts +++ b/app/frontend/src/javascript/models/payment.ts @@ -15,14 +15,17 @@ export interface IntentConfirmation { } export enum PaymentMethod { - Stripe = 'stripe', + Card = 'card', Other = '' } export interface CartItems { + customer_id: number, reservation?: Reservation, subscription?: SubscriptionRequest, - coupon_code?: string + coupon_code?: string, + payment_schedule?: boolean, + payment_method: PaymentMethod } export interface UpdateCardResponse { diff --git a/app/frontend/src/javascript/models/reservation.ts b/app/frontend/src/javascript/models/reservation.ts index fd5f5fb28..49dc54115 100644 --- a/app/frontend/src/javascript/models/reservation.ts +++ b/app/frontend/src/javascript/models/reservation.ts @@ -7,13 +7,10 @@ export interface ReservationSlot { } export interface Reservation { - user_id: number, reservable_id: number, reservable_type: string, slots_attributes: Array, - plan_id?: number, nb_reserve_places?: number, - payment_schedule?: boolean, tickets_attributes?: { event_price_category_id: number, booked: boolean, diff --git a/app/frontend/src/javascript/models/subscription.ts b/app/frontend/src/javascript/models/subscription.ts index d7b1191b2..6ac23d88e 100644 --- a/app/frontend/src/javascript/models/subscription.ts +++ b/app/frontend/src/javascript/models/subscription.ts @@ -11,8 +11,5 @@ export interface Subscription { } export interface SubscriptionRequest { - plan_id: number, - user_id: number, - payment_schedule: boolean, - payment_method: PaymentMethod + plan_id: number } diff --git a/app/models/cart_item/base_item.rb b/app/models/cart_item/base_item.rb new file mode 100644 index 000000000..7de83bd18 --- /dev/null +++ b/app/models/cart_item/base_item.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Items that can be added to the shopping cart +module CartItem; end + +# This is an abstract class implemented by classes that can be added to the shopping cart +class CartItem::BaseItem + self.abstract_class = true + + def price + { elements: {}, amount: 0 } + end +end diff --git a/app/models/cart_item/coupon.rb b/app/models/cart_item/coupon.rb new file mode 100644 index 000000000..0a02004fe --- /dev/null +++ b/app/models/cart_item/coupon.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# A discount coupon applied to the whole shopping cart +class CartItem::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 + end + + def coupon + cs = CouponService.new + cs.validate(@coupon, @customer.id) + end + + def price(cart_total = 0) + cs = CouponService.new + new_total = cs.apply(cart_total, coupon) + + amount = new_total - cart_total + + { amount: amount, total_with_coupon: new_total, total_without_coupon: cart_total } + end +end diff --git a/app/models/cart_item/event_reservation.rb b/app/models/cart_item/event_reservation.rb new file mode 100644 index 000000000..6ed683c95 --- /dev/null +++ b/app/models/cart_item/event_reservation.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# 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.class == Event + + super(customer, operator, event, slots) + @normal_tickets = normal_tickets + @other_tickets = other_tickets + end + + def price + amount = @reservable.amount * @normal_tickets + is_privileged = @operator.admin? || (@operator.manager? && @operator.id != @customer.id) + + @other_tickets.each do |ticket| + amount += ticket[:booked] * EventPriceCategory.find(ticket[:event_price_category_id]).amount + end + + elements = { slots: [] } + + @slots.each do |slot| + amount += get_slot_price(amount, + slot, + is_privileged, + elements: elements, + is_division: false) + end + + { elements: elements, amount: amount } + end + +end diff --git a/app/models/cart_item/machine_reservation.rb b/app/models/cart_item/machine_reservation.rb new file mode 100644 index 000000000..ab8982767 --- /dev/null +++ b/app/models/cart_item/machine_reservation.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# 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.class == Machine + + super(customer, operator, machine, slots) + @plan = plan + @new_subscription = new_subscription + end + + protected + + def credits + return 0 if @plan.nil? + + machine_credit = @plan.machine_credits.find { |credit| credit.creditable_id == @reservable.id } + credits_hours(machine_credit, @new_subscription) + end +end diff --git a/app/models/cart_item/payment_schedule.rb b/app/models/cart_item/payment_schedule.rb new file mode 100644 index 000000000..e0da49506 --- /dev/null +++ b/app/models/cart_item/payment_schedule.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# A payment schedule applied to plan in the shopping cart +class CartItem::PaymentSchedule + def initialize(plan, coupon, requested) + raise TypeError unless coupon.class == CartItem::Coupon + + @plan = plan + @coupon = coupon + @requested = requested + end + + def schedule(total, total_without_coupon) + schedule = if @requested && @plan.monthly_payment + PaymentScheduleService.new.compute(@plan, total_without_coupon, coupon: @coupon.coupon) + else + nil + end + + total_amount = if schedule + schedule[:items][0].amount + else + total + end + + { schedule: schedule, total: total_amount } + end +end diff --git a/app/models/cart_item/reservation.rb b/app/models/cart_item/reservation.rb new file mode 100644 index 000000000..004378c37 --- /dev/null +++ b/app/models/cart_item/reservation.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +MINUTES_PER_HOUR = 60.0 +SECONDS_PER_MINUTE = 60.0 + +GET_SLOT_PRICE_DEFAULT_OPTS = { has_credits: false, elements: nil, is_division: true }.freeze + +# 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 + end + + def price + base_amount = @reservable.prices.find_by(group_id: @customer.group_id, plan_id: @plan.try(:id)).amount + is_privileged = @operator.admin? || (@operator.manager? && @operator.id != @customer.id) + + elements = { slots: [] } + amount = 0 + + hours_available = credits + @slots.each_with_index do |slot, index| + amount += get_slot_price(base_amount, slot, is_privileged, elements: elements, has_credits: (index < hours_available)) + end + + { elements: elements, amount: amount } + end + + protected + + def credits + 0 + end + + ## + # 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 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 + # - has_credits {Boolean} true if the user still has credits for the given slot, false if not provided + # - is_division {boolean} false if the slot covers a full availability, true if it is a subdivision (default) + # @return {Number} price of the slot + ## + def get_slot_price(hourly_rate, slot, is_privileged, options = {}) + options = GET_SLOT_PRICE_DEFAULT_OPTS.merge(options) + + slot_rate = options[:has_credits] || (slot[:offered] && is_privileged) ? 0 : hourly_rate + real_price = if options[:is_division] + (slot_rate / MINUTES_PER_HOUR) * ((slot[:end_at].to_time - slot[:start_at].to_time) / SECONDS_PER_MINUTE) + else + slot_rate + end + + unless options[:elements].nil? + options[:elements][:slots].push( + start_at: slot[:start_at], + price: real_price, + promo: (slot_rate != hourly_rate) + ) + end + real_price + end + + ## + # Compute the number of remaining hours in the users current credits (for machine or space) + ## + def credits_hours(credits, new_plan_being_bought = false) + hours_available = credits.hours + unless new_plan_being_bought + 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 +end diff --git a/app/models/cart_item/space_reservation.rb b/app/models/cart_item/space_reservation.rb new file mode 100644 index 000000000..a4745a427 --- /dev/null +++ b/app/models/cart_item/space_reservation.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# 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.class == Space + + super(customer, operator, space, slots) + @plan = plan + @new_subscription = new_subscription + end + + protected + + def credits + return 0 if @plan.nil? + + space_credit = @plan.space_credits.find { |credit| credit.creditable_id == @reservable.id } + credits_hours(space_credit, @new_subscription) + end +end diff --git a/app/models/cart_item/subscription.rb b/app/models/cart_item/subscription.rb new file mode 100644 index 000000000..5abb90342 --- /dev/null +++ b/app/models/cart_item/subscription.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# A subscription added to the shopping cart +class CartItem::Subscription < CartItem::BaseItem + def initialize(plan) + raise TypeError unless plan.class == Plan + + @plan = plan + end + + def price + amount = @plan.amount + elements = { plan: amount } + + { elements: elements, amount: amount } + end +end diff --git a/app/models/cart_item/training_reservation.rb b/app/models/cart_item/training_reservation.rb new file mode 100644 index 000000000..b892f8f4c --- /dev/null +++ b/app/models/cart_item/training_reservation.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# 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.class == Training + + super(customer, operator, training, slots) + @plan = plan + @new_subscription = new_subscription + end + + def price + base_amount = @reservable.amount_by_group(@customer.group_id, plan_id: @plan.try(:id)).amount + is_privileged = @operator.admin? || (@operator.manager? && @operator.id != @customer.id) + + elements = { slots: [] } + amount = 0 + + hours_available = credits + @slots.each do |slot| + amount += get_slot_price(base_amount, + slot, + is_privileged, + elements: elements, + has_credits: (@user.training_credits.size < hours_available), + is_division: false) + end + + { elements: elements, amount: amount } + end + + protected + + def credits + 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 + end +end diff --git a/app/models/shopping_cart.rb b/app/models/shopping_cart.rb new file mode 100644 index 000000000..b803dc4df --- /dev/null +++ b/app/models/shopping_cart.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# Stores data about a shopping data +class ShoppingCart + attr_accessor :customer, :payment_method, :items, :coupon, :payment_schedule + + # @param items {Array} + # @param coupon {CartItem::Coupon} + # @param payment_schedule {CartItem::PaymentSchedule} + # @param customer {User} + def initialize(customer, coupon, payment_method = '', items: [], payment_schedule: nil) + raise TypeError unless customer.class == User + + @customer = customer + @payment_method = payment_method + @items = items + @coupon = coupon + @payment_schedule = payment_schedule + end + + def total + total_amount = 0 + all_elements = { slots: [] } + + @items.map(&:price).each do |price| + total_amount += price[:amount] + all_elements.merge(price[:elements]) do |_key, old_val, new_val| + old_val | new_val + end + end + + coupon_info = @coupon.price(total_amount) + schedule_info = @payment_schedule.schedule(coupon_info[:total_with_coupon], coupon_info[:total_without_coupon]) + + # return result + { + elements: all_elements, + total: schedule_info[:total].to_i, + before_coupon: coupon_info[:total_without_coupon].to_i, + coupon: @coupon.coupon, + schedule: schedule_info[:schedule] + } + end +end diff --git a/app/services/cart_service.rb b/app/services/cart_service.rb new file mode 100644 index 000000000..a25d08a25 --- /dev/null +++ b/app/services/cart_service.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +# Provides methods for working with cart items +class CartService + def initialize(operator) + @operator = operator + end + + def from_hash(cart_items) + items = [] + plan_info = plan(cart_items) + + @customer = User.find(cart_items[:customer_id]) + + items.push(CartItem::Subscription.new(plan_info[:plan])) if cart_items[:subscription] + items.push(reservable_from_hash(cart_items[:reservation], plan_info)) if cart_items[:reservation] + + coupon = CartItem::Coupon.new(@customer, @operator, cart_items[:coupon_code]) + schedule = CartItem::PaymentSchedule.new(plan_info[:plan], coupon, cart_items[:payment_schedule]) + + ShoppingCart.new( + @customer, + coupon, + cart_items[:payment_method], + items: items, + payment_schedule: schedule + ) + end + + private + + def plan(cart_items) + plan = if @customer.subscribed_plan + new_plan_being_bought = false + @customer.subscribed_plan + elsif cart_items[:subscription] + new_plan_being_bought = true + Plan.find(cart_items[:subscription][:plan_id]) + else + new_plan_being_bought = false + nil + end + { plan: plan, new_subscription: new_plan_being_bought } + end + + def reservable_from_hash(cart_item, plan_info) + return nil if cart_item[:reservable_id].blank? + + reservable = cart_item[:reservable_type].constantize.find(cart_item[:reservable_id]) + case reservable + when Machine + CartItem::MachineReservation.new(@customer, + @operator, + reservable, + cart_item[:slots_attributes], + plan: plan_info[:plan], + new_subscription: plan_info[:new_subscription]) + when Training + CartItem::TrainingReservation.new(@customer, + @operator, + reservable, + cart_item[:slots_attributes], + plan: plan_info[:plan], + new_subscription: plan_info[:new_subscription]) + when Event + CartItem::EventReservation.new(@customer, + @operator, + reservable, + cart_item[:slots_attributes], + normal_tickets: cart_item[:nb_reserve_places], + other_tickets: cart_item[:tickets_attributes]) + when Space + CartItem::SpaceReservation.new(@customer, + @operator, + reservable, + cart_item[:slots_attributes], + plan: plan_info[:plan], + new_subscription: plan_info[:new_subscription]) + else + raise NotImplementedError + end + end +end diff --git a/app/services/coupon_service.rb b/app/services/coupon_service.rb index 05f8c9964..e849c1d57 100644 --- a/app/services/coupon_service.rb +++ b/app/services/coupon_service.rb @@ -12,24 +12,25 @@ class CouponService # @param user_id {Number} user's id against the coupon will be tested for usability # @return {Number} ## - def apply(total, coupon, user_id = nil) + def apply(total, coupon = nil, user_id = nil) price = total - coupon_object = nil - if coupon.instance_of? Coupon - coupon_object = coupon - elsif coupon.instance_of? String - coupon_object = Coupon.find_by(code: coupon) - end + coupon_object = if coupon.instance_of? Coupon + coupon + elsif coupon.instance_of? String + Coupon.find_by(code: coupon) + else + nil + end - unless coupon_object.nil? - if coupon_object.status(user_id, total) == 'active' - if coupon_object.type == 'percent_off' - price -= (price * coupon_object.percent_off / 100.00).truncate - elsif coupon_object.type == 'amount_off' - # do not apply cash coupon unless it has a lower amount that the total price - price -= coupon_object.amount_off if coupon_object.amount_off <= price - end + return price if coupon_object.nil? + + if coupon_object.status(user_id, total) == 'active' + if coupon_object.type == 'percent_off' + price -= (price * coupon_object.percent_off / 100.00).truncate + elsif coupon_object.type == 'amount_off' + # do not apply cash coupon unless it has a lower amount that the total price + price -= coupon_object.amount_off if coupon_object.amount_off <= price end end diff --git a/config/routes.rb b/config/routes.rb index ec316ccbe..b8ba23519 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -184,6 +184,7 @@ Rails.application.routes.draw do post 'payzen/create_payment' => 'payzen#create_payment' post 'payzen/confirm_payment' => 'payzen#confirm_payment' post 'payzen/check_hash' => 'payzen#check_hash' + post 'payzen/create_token' => 'payzen#create_token' # FabAnalytics get 'analytics/data' => 'analytics#data' diff --git a/lib/pay_zen/charge.rb b/lib/pay_zen/charge.rb index 720fc31b1..e4498a173 100644 --- a/lib/pay_zen/charge.rb +++ b/lib/pay_zen/charge.rb @@ -32,5 +32,19 @@ class PayZen::Charge < PayZen::Client contrib: contrib, customer: customer) end + + ## + # @see https://payzen.io/en-EN/rest/V4.0/api/playground/Charge/CreateToken/ + ## + def create_token(currency: Setting.get('payzen_currency'), + order_id: nil, + contrib: "fab-manager #{Version.current}", + customer: nil) + post('/Charge/CreateToken', + currency: currency, + orderId: order_id, + contrib: contrib, + customer: customer) + end end diff --git a/lib/pay_zen/helper.rb b/lib/pay_zen/helper.rb index 0f717db72..941d29079 100644 --- a/lib/pay_zen/helper.rb +++ b/lib/pay_zen/helper.rb @@ -52,6 +52,17 @@ class PayZen::Helper } end + ## Generate a hash map compatible with PayZen 'V4/Customer/ShoppingCart' + def generate_shopping_cart(cart_items, customer) + { + cartItemInfo: cart_items.map do |type, value| + { + productAmount: item. + productType: customer.organization? ? 'SERVICE_FOR_BUSINESS' : 'SERVICE_FOR_INDIVIDUAL', + } + } + end + ## Check the PayZen signature for integrity def check_hash(algorithm, hash_key, hash_proof, data, key = nil) supported_hash_algorithm = ['sha256_hmac']