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

refactoring of reservation:pay_and_save

TODO: debug with tests,
refactor subscription:pay_and_save on the same template
This commit is contained in:
Sylvain 2020-12-16 18:33:43 +01:00
parent 6c8d65fba1
commit 40c78974b8
13 changed files with 287 additions and 178 deletions

View File

@ -106,7 +106,8 @@ class API::PaymentsController < API::ApiController
.pay_and_save(@reservation,
payment_details: details,
payment_intent_id: intent.id,
schedule: params[:cart_items][:reservation][:payment_schedule])
schedule: params[:cart_items][:reservation][:payment_schedule],
payment_method: params[:cart_items][:reservation][:payment_method])
if intent.class == Stripe::PaymentIntent
Stripe::PaymentIntent.update(
intent.id,

View File

@ -37,7 +37,8 @@ class API::ReservationsController < API::ApiController
is_reserve = Reservations::Reserve.new(user_id, current_user.invoicing_profile.id)
.pay_and_save(@reservation,
payment_details: price[:price_details],
schedule: params[:reservation][:payment_schedule])
schedule: params[:reservation][:payment_schedule],
payment_method: params[:reservation][:payment_method])
if is_reserve
SubscriptionExtensionAfterReservation.new(@reservation).extend_subscription_if_eligible

View File

@ -33,6 +33,13 @@ class PaymentSchedule < ApplicationRecord
save
end
def set_wallet_transaction(amount, transaction_id)
raise InvalidFootprintError unless check_footprint
update_columns(wallet_amount: amount, wallet_transaction_id: transaction_id)
chain_record
end
def chain_record
self.footprint = compute_footprint
save!
@ -46,4 +53,8 @@ class PaymentSchedule < ApplicationRecord
def compute_footprint
FootprintService.compute_footprint(PaymentSchedule, self)
end
def check_footprint
payment_schedule_items.map(&:check_footprint).all? && footprint == compute_footprint
end
end

View File

@ -4,4 +4,23 @@
class PaymentScheduleItem < ApplicationRecord
belongs_to :payment_schedule
belongs_to :invoice
after_create :chain_record
def chain_record
self.footprint = compute_footprint
save!
FootprintDebug.create!(
footprint: footprint,
data: FootprintService.footprint_data(PaymentScheduleItem, self),
klass: PaymentScheduleItem.name
)
end
def check_footprint
footprint == compute_footprint
end
def compute_footprint
FootprintService.compute_footprint(PaymentScheduleItem, self)
end
end

View File

@ -30,133 +30,33 @@ class Reservation < ApplicationRecord
after_commit :notify_member_create_reservation, on: :create
after_commit :notify_admin_member_create_reservation, on: :create
after_save :update_event_nb_free_places, if: proc { |reservation| reservation.reservable_type == 'Event' }
after_create :debit_user_wallet
##
# Generate an array of {Stripe::InvoiceItem} with the elements in the current reservation, price included.
# @param payment_details {Hash} as generated by Price.compute
# These checks will run before the invoice/payment-schedule is generated
##
def generate_invoice_items(payment_details = nil)
def pre_check
# check that none of the reserved availabilities was locked
slots.each do |slot|
raise LockedError if slot.availability.lock
end
case reservable
# === Event reservation ===
when Event
slots.each do |slot|
description = "#{reservable.name}\n"
description += if slot.start_at.to_date != slot.end_at.to_date
I18n.t('events.from_STARTDATE_to_ENDDATE',
STARTDATE: I18n.l(slot.start_at.to_date, format: :long),
ENDDATE: I18n.l(slot.end_at.to_date, format: :long)) + ' ' +
I18n.t('events.from_STARTTIME_to_ENDTIME',
STARTTIME: I18n.l(slot.start_at, format: :hour_minute),
ENDTIME: I18n.l(slot.end_at, format: :hour_minute))
else
"#{I18n.l slot.start_at.to_date, format: :long} #{I18n.l slot.start_at, format: :hour_minute}" \
" - #{I18n.l slot.end_at, format: :hour_minute}"
end
price_slot = payment_details[:elements][:slots].detect { |p_slot| p_slot[:start_at].to_time.in_time_zone == slot[:start_at] }
invoice.invoice_items.push InvoiceItem.new(
amount: price_slot[:price],
description: description
)
end
# === Space|Machine|Training reservation ===
else
slots.each do |slot|
description = reservable.name +
" #{I18n.l slot.start_at, format: :long} - #{I18n.l slot.end_at, format: :hour_minute}"
price_slot = payment_details[:elements][:slots].detect { |p_slot| p_slot[:start_at].to_time.in_time_zone == slot[:start_at] }
invoice.invoice_items.push InvoiceItem.new(
amount: price_slot[:price],
description: description
)
end
end
# === Coupon ===
@coupon = payment_details[:coupon]
# === Wallet ===
@wallet_amount_debit = wallet_amount_debit
end
# check reservation amount total and strip invoice total to pay is equal
# @param stp_invoice[Stripe::Invoice]
# @param coupon_code[String]
# return Boolean
def is_equal_reservation_total_and_stp_invoice_total(stp_invoice, coupon_code = nil)
compute_amount_total_to_pay(coupon_code) == stp_invoice.total
## Generate the subscription associated with for the current reservation
def generate_subscription
return unless plan_id
self.subscription = Subscription.find_or_initialize_by(statistic_profile_id: statistic_profile_id)
subscription.attributes = { plan_id: plan_id, statistic_profile_id: statistic_profile_id, expiration_date: nil }
subscription.init_save
subscription
end
def clear_payment_info(card, invoice)
card&.delete
if invoice
invoice.closed = true
invoice.save
end
rescue Stripe::InvalidRequestError => e
logger.error e
rescue Stripe::AuthenticationError => e
logger.error e
rescue Stripe::APIConnectionError => e
logger.error e
rescue Stripe::StripeError => e
logger.error e
rescue StandardError => e
logger.error e
end
def clean_pending_strip_invoice_items
pending_invoice_items = Stripe::InvoiceItem.list(
{ customer: user.stp_customer_id, limit: 100 },
{ api_key: Setting.get('stripe_secret_key') }
).data.select { |ii| ii.invoice.nil? }
pending_invoice_items.each(&:delete)
end
def save_with_payment(operator_profile_id, payment_details, payment_intent_id = nil, schedule: false)
operator = InvoicingProfile.find(operator_profile_id)&.user
method = operator&.admin? || (operator&.manager? && operator != user) ? nil : 'stripe'
build_invoice(
invoicing_profile: user.invoicing_profile,
statistic_profile: user.statistic_profile,
operator_profile_id: operator_profile_id,
stp_payment_intent_id: payment_intent_id,
payment_method: method
)
generate_invoice_items(payment_details)
return false unless valid?
if plan_id
self.subscription = Subscription.find_or_initialize_by(statistic_profile_id: statistic_profile_id)
subscription.attributes = { plan_id: plan_id, statistic_profile_id: statistic_profile_id, expiration_date: nil }
if subscription.save_with_payment(operator_profile_id, invoice: false, schedule: schedule)
invoice.invoice_items.push InvoiceItem.new(
amount: payment_details[:elements][:plan],
description: subscription.plan.name,
subscription_id: subscription.id
)
set_total_and_coupon(payment_details[:coupon])
save!
else
errors[:card] << subscription.errors[:card].join
return false
end
else
set_total_and_coupon(payment_details[:coupon])
save!
end
##
# These actions will be realized after the reservation is initially saved (on creation)
##
def post_save
UsersCredits::Manager.new(reservation: self).update_credits
true
end
# @param canceled if true, count the number of seats for this reservation, including canceled seats
@ -219,61 +119,4 @@ class Reservation < ApplicationRecord
receiver: User.admins_and_managers,
attached_object: self
end
def cart_total
total = (invoice.invoice_items.map(&:amount).map(&:to_i).reduce(:+) or 0)
if plan_id.present?
plan = Plan.find(plan_id)
total += plan.amount
end
total
end
def wallet_amount_debit
total = cart_total
total = CouponService.new.apply(total, @coupon, user.id) if @coupon
wallet_amount = (user.wallet.amount * 100).to_i
wallet_amount >= total ? total : wallet_amount
end
def debit_user_wallet
return unless @wallet_amount_debit.present? && @wallet_amount_debit != 0
amount = @wallet_amount_debit / 100.0
wallet_transaction = WalletService.new(user: user, wallet: user.wallet).debit(amount, self)
# wallet debit success
raise DebitWalletError unless wallet_transaction
invoice.set_wallet_transaction(@wallet_amount_debit, wallet_transaction.id)
end
# this function only use for compute total of reservation before save
def compute_amount_total_to_pay(coupon_code = nil)
total = invoice.invoice_items.map(&:amount).map(&:to_i).reduce(:+)
unless coupon_code.nil?
cp = Coupon.find_by(code: coupon_code)
raise InvalidCouponError unless !cp.nil? && cp.status(user.id) == 'active'
total = CouponService.new.apply(total, cp, user.id)
end
total - wallet_amount_debit
end
##
# Set the total price to the reservation's invoice, summing its whole items.
# Additionally a coupon may be applied to this invoice to make a discount on the total price
# @param [coupon] {Coupon} optional coupon to apply to the invoice
##
def set_total_and_coupon(coupon = nil)
total = invoice.invoice_items.map(&:amount).map(&:to_i).reduce(:+)
unless coupon.nil?
total = CouponService.new.apply(total, coupon, user.id)
invoice.coupon_id = coupon.id
end
invoice.total = total
end
end

View File

@ -19,6 +19,22 @@ class Subscription < ApplicationRecord
after_save :notify_admin_subscribed_plan
after_save :notify_partner_subscribed_plan, if: :of_partner_plan?
##
# Set the inner properties of the subscription, init the user's credits and save the subscription
# into the DB
# @return {boolean} true, if the operation succeeded
##
def init_save
return false unless valid?
set_expiration_date
return false unless save
UsersCredits::Manager.new(user: user).reset_credits
true
end
# TODO, remove this method, refactor like services/Reservations::Reserve
# @param invoice if true then only the subscription is payed, without reservation
# if false then the subscription is payed with reservation
# @param payment_method is only used for schedules

View File

@ -59,4 +59,137 @@ class InvoicesService
end
{ direction: direction, order_key: order_key }
end
##
# Create a Stripe::Invoice with an associated array of Stripe::InvoiceItem matching the given parameters
# @param payment_details {Hash} as generated by Price.compute
# @param operator_profile_id {Number} ID of the user that operates the invoice generation (may be an admin, a manager or the customer himself)
# @param reservation {Reservation} the booking reservation, if any
# @param subscription {Subscription} the booking subscription, if any
# @param payment_intent_id {String} ID of the Stripe::PaymentIntend, if the current invoice is paid by stripe
##
def self.create(payment_details, operator_profile_id, reservation: nil, subscription: nil, payment_intent_id: nil)
user = reservation&.user || subscription&.user
operator = InvoicingProfile.find(operator_profile_id)&.user
method = operator&.admin? || (operator&.manager? && operator != user) ? nil : 'stripe'
invoice = Invoice.new(
invoiced: subscription || reservation,
invoicing_profile: user.invoicing_profile,
statistic_profile: user.statistic_profile,
operator_profile_id: operator_profile_id,
stp_payment_intent_id: payment_intent_id,
payment_method: method
)
InvoicesService.generate_invoice_items(invoice, payment_details, reservation: reservation, subscription: subscription)
InvoicesService.set_total_and_coupon(invoice, user, payment_details[:coupon])
invoice
end
##
# Generate an array of {Stripe::InvoiceItem} with the elements in provided reservation, price included.
# @param invoice {Invoice} the parent invoice
# @param payment_details {Hash} as generated by Price.compute
##
def self.generate_invoice_items(invoice, payment_details, reservation: nil, subscription: nil)
if reservation
case reservation.reservable
# === Event reservation ===
when Event
InvoicesService.generate_event_item(invoice, reservation, payment_details)
# === Space|Machine|Training reservation ===
else
InvoicesService.generate_generic_item(invoice, reservation, payment_details)
end
end
return unless subscription || reservation&.plan_id
subscription = reservation.generate_subscription if !subscription && reservation.plan_id
InvoicesService.generate_subscription_item(invoice, subscription, payment_details)
end
##
# Generate Stripe::InvoiceItem for each slot in the given reservation and save them in invoice.invoice_items.
# This method must be called if reservation.reservable is an Event
##
def self.generate_event_item(invoice, reservation, payment_details)
raise TypeError unless reservation.reservable.class == Event
reservation.slots.each do |slot|
description = "#{reservation.reservable.name}\n"
description += if slot.start_at.to_date != slot.end_at.to_date
I18n.t('events.from_STARTDATE_to_ENDDATE',
STARTDATE: I18n.l(slot.start_at.to_date, format: :long),
ENDDATE: I18n.l(slot.end_at.to_date, format: :long)) + ' ' +
I18n.t('events.from_STARTTIME_to_ENDTIME',
STARTTIME: I18n.l(slot.start_at, format: :hour_minute),
ENDTIME: I18n.l(slot.end_at, format: :hour_minute))
else
"#{I18n.l slot.start_at.to_date, format: :long} #{I18n.l slot.start_at, format: :hour_minute}" \
" - #{I18n.l slot.end_at, format: :hour_minute}"
end
price_slot = payment_details[:elements][:slots].detect { |p_slot| p_slot[:start_at].to_time.in_time_zone == slot[:start_at] }
invoice.invoice_items.push InvoiceItem.new(
amount: price_slot[:price],
description: description
)
end
end
##
# Generate Stripe::InvoiceItem for each slot in the given reservation and save them in invoice.invoice_items.
# This method must be called if reservation.reservable is a Space, a Machine or a Training
##
def self.generate_generic_item(invoice, reservation, payment_details)
raise TypeError unless [Space, Machine, Training].include? reservation.reservable.class
reservation.slots.each do |slot|
description = reservation.reservable.name +
" #{I18n.l slot.start_at, format: :long} - #{I18n.l slot.end_at, format: :hour_minute}"
price_slot = payment_details[:elements][:slots].detect { |p_slot| p_slot[:start_at].to_time.in_time_zone == slot[:start_at] }
invoice.invoice_items.push InvoiceItem.new(
amount: price_slot[:price],
description: description
)
end
end
##
# Generate a Stripe::InvoiceItem for the given subscription and save it in invoice.invoice_items.
# This method must be called only with a valid subscription
##
def self.generate_subscription_item(invoice, subscription, payment_details)
raise TypeError unless subscription
invoice.invoice_items.push InvoiceItem.new(
amount: payment_details[:elements][:plan],
description: subscription.plan.name,
subscription_id: subscription.id
)
end
##
# Set the total price to the reservation's invoice, summing its whole items.
# Additionally a coupon may be applied to this invoice to make a discount on the total price
# @param invoice {Invoice} the invoice to fill
# @param user {User} the customer
# @param [coupon] {Coupon} optional coupon to apply to the invoice
##
def self.set_total_and_coupon(invoice, user, coupon = nil)
return unless invoice
total = invoice.invoice_items.map(&:amount).map(&:to_i).reduce(:+)
unless coupon.nil?
total = CouponService.new.apply(total, coupon, user.id)
invoice.coupon_id = coupon.id
end
invoice.total = total
end
end

View File

@ -47,11 +47,11 @@ class PaymentScheduleService
end
def create(subscription, total, coupon: nil, operator: nil, payment_method: nil, reservation: nil, user: nil)
schedule = compute(subscription.plan, total, coupon)
schedule = compute(reservation ? reservation.subscription.plan : subscription.plan, total, coupon)
ps = schedule[:payment_schedule]
items = schedule[:items]
ps.scheduled = subscription
ps.scheduled = reservation || subscription
ps.payment_method = payment_method
ps.operator_profile = operator.invoicing_profile
ps.invoicing_profile = user.invoicing_profile

View File

@ -9,8 +9,87 @@ class Reservations::Reserve
@operator_profile_id = operator_profile_id
end
def pay_and_save(reservation, payment_details: nil, payment_intent_id: nil, schedule: false)
##
# Confirm the payment of the given reservation, generate the associated documents and save teh record into
# the database.
##
def pay_and_save(reservation, payment_details: nil, payment_intent_id: nil, schedule: false, payment_method: nil)
user = User.find(user_id)
reservation.statistic_profile_id = StatisticProfile.find_by(user_id: user_id).id
reservation.save_with_payment(operator_profile_id, payment_details, payment_intent_id, schedule: schedule)
reservation.pre_check
payment = if schedule
generate_schedule(reservation: reservation,
total: payment_details[:before_coupon],
operator_profile_id: operator_profile_id,
user: user,
payment_method: payment_method,
coupon_code: payment_details[:coupon])
else
generate_invoice(reservation, operator_profile_id, payment_details, payment_intent_id)
end
payment.save
debit_user_wallet(payment, user, reservation)
reservation.post_save
end
##
# Generate the invoice for the given reservation+subscription
##
def generate_schedule(reservation: nil, total: nil, operator_profile_id: nil, user: nil, payment_method: nil, coupon_code: nil)
operator = InvoicingProfile.find(operator_profile_id)&.user
coupon = Coupon.find_by(code: coupon_code) unless coupon_code.nil?
PaymentScheduleService.new.create(
nil,
total,
coupon: coupon,
operator: operator,
payment_method: payment_method,
user: user,
reservation: reservation
)
end
##
# Generate the invoice for the given reservation
##
def generate_invoice(reservation, operator_profile_id, payment_details, payment_intent_id = nil)
InvoicesService.create(
payment_details,
operator_profile_id,
reservation: reservation,
payment_intent_id: payment_intent_id
)
end
##
# Compute the amount decreased from the user's wallet, if applicable
# @param payment {Invoice|PaymentSchedule}
# @param user {User} the customer
# @param coupon {Coupon|String} Coupon object or code
##
def wallet_amount_debit(payment, user, coupon = nil)
total = payment.total
total = CouponService.new.apply(total, coupon, user.id) if coupon
wallet_amount = (user.wallet.amount * 100).to_i
wallet_amount >= total ? total : wallet_amount
end
##
# Subtract the amount of the current reservation from the customer's wallet
##
def debit_user_wallet(payment, user, reservation)
wallet_amount = wallet_amount_debit(payment, user)
return unless wallet_amount.present? && wallet_amount != 0
amount = wallet_amount / 100.0
wallet_transaction = WalletService.new(user: user, wallet: user.wallet).debit(amount, reservation)
# wallet debit success
raise DebitWalletError unless wallet_transaction
payment.set_wallet_transaction(wallet_amount, wallet_transaction.id)
end
end

View File

@ -1,5 +1,7 @@
# frozen_string_literal:true
# From this migration, if the current Invoice is payed with Stripe, it will be stored in database
# using stp_payment_intent_id instead of stp_invoice_id
class AddStpPaymentIntentIdToInvoices < ActiveRecord::Migration[4.2]
def change
add_column :invoices, :stp_payment_intent_id, :string

View File

@ -9,6 +9,7 @@ class CreatePaymentScheduleItems < ActiveRecord::Migration[5.2]
t.jsonb :details, default: '{}'
t.belongs_to :payment_schedule, foreign_key: true
t.belongs_to :invoice, foreign_key: true
t.string :footprint
t.timestamps
end

View File

@ -1473,6 +1473,7 @@ CREATE TABLE public.payment_schedule_items (
details jsonb DEFAULT '"{}"'::jsonb,
payment_schedule_id bigint,
invoice_id bigint,
footprint character varying,
created_at timestamp without time zone NOT NULL,
updated_at timestamp without time zone NOT NULL
);

View File

@ -60,6 +60,7 @@ class Subscriptions::CreateAsAdminTest < ActionDispatch::IntegrationTest
test 'admin takes a subscription with a payment schedule' do
user = User.find_by(username: 'jdupond')
plan = Plan.find_by(group_id: user.group.id, type: 'Plan', base_name: 'Abonnement mensualisable')
invoice_count = Invoice.count
payment_schedule_count = PaymentSchedule.count
payment_schedule_items_count = PaymentScheduleItem.count
@ -106,6 +107,7 @@ class Subscriptions::CreateAsAdminTest < ActionDispatch::IntegrationTest
# Check generalities
assert_equal 201, response.status, response.body
assert_equal Mime[:json], response.content_type
assert_equal invoice_count, Invoice.count, "an invoice was generated but it shouldn't"
assert_equal payment_schedule_count + 1, PaymentSchedule.count, 'missing the payment schedule'
assert_equal payment_schedule_items_count + 12, PaymentScheduleItem.count, 'missing some payment schedule items'