1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-19 13:54:25 +01:00

WIP: refactor the price computation system

This commit is contained in:
Sylvain 2021-04-22 19:24:08 +02:00
parent 26dfbef5e1
commit e456ddc7c9
20 changed files with 482 additions and 30 deletions

View File

@ -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

View File

@ -7,22 +7,27 @@ import { CheckHashResponse, ConfirmPaymentResponse, CreatePaymentResponse, SdkTe
export default class PayzenAPI {
static async chargeSDKTest(baseURL: string, username: string, password: string): Promise<SdkTestResponse> {
const res: AxiosResponse = await apiClient.post('/api/payzen/sdk_test', { base_url: baseURL, username, password });
const res: AxiosResponse<SdkTestResponse> = await apiClient.post('/api/payzen/sdk_test', { base_url: baseURL, username, password });
return res?.data;
}
static async chargeCreatePayment(cartItems: CartItems, customer: User): Promise<CreatePaymentResponse> {
const res: AxiosResponse = await apiClient.post('/api/payzen/create_payment', { cart_items: cartItems, customer_id: customer.id });
const res: AxiosResponse<CreatePaymentResponse> = 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<any> {
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<CheckHashResponse> {
const res: AxiosResponse = await apiClient.post('/api/payzen/check_hash', { algorithm, hash_key: hashKey, hash, data });
const res: AxiosResponse<CheckHashResponse> = await apiClient.post('/api/payzen/check_hash', { algorithm, hash_key: hashKey, hash, data });
return res?.data;
}
static async confirm(orderId: string, cartItems: CartItems): Promise<ConfirmPaymentResponse> {
const res: AxiosResponse = await apiClient.post('/api/payzen/confirm_payment', { cart_items: cartItems, order_id: orderId });
const res: AxiosResponse<ConfirmPaymentResponse> = await apiClient.post('/api/payzen/confirm_payment', { cart_items: cartItems, order_id: orderId });
return res?.data;
}
}

View File

@ -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 {

View File

@ -7,13 +7,10 @@ export interface ReservationSlot {
}
export interface Reservation {
user_id: number,
reservable_id: number,
reservable_type: string,
slots_attributes: Array<ReservationSlot>,
plan_id?: number,
nb_reserve_places?: number,
payment_schedule?: boolean,
tickets_attributes?: {
event_price_category_id: number,
booked: boolean,

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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<CartItem::BaseItem>}
# @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

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -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']