1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-20 14:54:15 +01:00

Merge branch 'price' into dev

This commit is contained in:
Sylvain 2020-05-06 16:56:55 +02:00
commit 121d99832b
21 changed files with 285 additions and 200 deletions

View File

@ -5,6 +5,7 @@
- Interface to manage partners
- Ability to define, per availability, a custom duration for the reservation slots
- Ability to promote a user to a higher role (member > manager > admin)
- Ask for confirmation before booking a slot for a member without the required tag
- Corrected the documentation about BOOK_SLOT_AT_SAME_TIME
- Auto-adjusts text colors based on the selected theme colors
- Fix a bug: unable to change group if the previous was deactivated

View File

@ -617,6 +617,22 @@ Application.Controllers.controller('EditPricingController', ['$scope', '$state',
});
}
/**
* Return the exemple price based on the configuration of the default slot duration.
* @param type {string} 'hourly_rate' | *
* @returns {number} price for Fablab.slotDuration minutes.
*/
$scope.examplePrice = function(type) {
const hourlyRate = 10;
if (type === 'hourly_rate') {
return $filter('currency')(hourlyRate);
}
const price = (hourlyRate / 60) * Fablab.slotDuration;
return $filter('currency')(price);
}
/**
* Setup the feature-tour for the admin/pricing page.
* This is intended as a contextual help (when pressing F1)

View File

@ -428,7 +428,7 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$stat
// indicates the state of the current view : calendar or plans information
$scope.plansAreShown = false;
// will store the user's plan if he choosed to buy one
// will store the user's plan if he chose to buy one
$scope.selectedPlan = null;
// the moment when the plan selection changed for the last time, used to trigger changes in the cart

View File

@ -74,38 +74,12 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs',
* @param slot {Object} fullCalendar event object
*/
$scope.validateSlot = function (slot) {
let sameTimeReservations = [
'training_reservations',
'machine_reservations',
'space_reservations',
'events_reservations'
].map(function (k) {
return _.filter($scope.user[k], function(r) {
return slot.start.isSame(r.start_at) ||
(slot.end.isAfter(r.start_at) && slot.end.isBefore(r.end_at)) ||
(slot.start.isAfter(r.start_at) && slot.start.isBefore(r.end_at)) ||
(slot.start.isBefore(r.start_at) && slot.end.isAfter(r.end_at));
})
});
sameTimeReservations = _.union.apply(null, sameTimeReservations);
if (sameTimeReservations.length > 0) {
const modalInstance = $uibModal.open({
animation: true,
templateUrl: '<%= asset_path "shared/_reserve_slot_same_time.html" %>',
size: 'md',
controller: 'ReserveSlotSameTimeController',
resolve: {
sameTimeReservations: function() { return sameTimeReservations; }
}
});
modalInstance.result.then(function(res) {
validateTags(slot, function () {
validateSameTimeReservations(slot, function () {
slot.isValid = true;
return updateCartPrice();
});
} else {
slot.isValid = true;
return updateCartPrice();
}
updateCartPrice();
})
})
};
/**
@ -285,6 +259,20 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs',
return false;
};
/**
* Check if the currently logged user has the 'admin' role OR the 'manager' role, but is not taking reseravtion for himself
* @returns {boolean}
*/
$scope.isAuthorized = function () {
if (AuthService.isAuthorized('admin')) return true;
if (AuthService.isAuthorized('manager')) {
return ($rootScope.currentUser.id !== $scope.user.id);
}
return false;
}
/* PRIVATE SCOPE */
/**
@ -316,6 +304,74 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs',
});
};
/**
* Validates that the current slot is reserved by a member with an authorized tag. Admin and managers can overpass
* the mismatch.
* @param slot {Object} fullCalendar event object.
* @param callback {function}
*/
const validateTags = function (slot, callback) {
const interTags = _.intersection.apply(null, [slot.tag_ids, $scope.user.tag_ids]);
if (slot.tag_ids.length === 0 || interTags.length > 0) {
if (typeof callback === 'function') callback();
} else {
// ask confirmation
const modalInstance = $uibModal.open({
animation: true,
templateUrl: '<%= asset_path "shared/_reserve_slot_tags_mismatch.html" %>',
size: 'md',
controller: 'ReserveSlotTagsMismatchController',
resolve: {
slotTags: function() { return slot.tags; },
userTags: function () { return $scope.user.tags; },
userName: function () { return $scope.user.name; }
}
});
modalInstance.result.then(function(res) {
if (typeof callback === 'function') callback(res);
});
}
}
/**
* Validates that no other reservations were made that conflict the current slot and alert the user about the conflict.
* If the user is an administrator or a manager, he can overpass the conflict.
* @param slot {Object} fullCalendar event object.
* @param callback {function}
*/
const validateSameTimeReservations = function (slot, callback) {
let sameTimeReservations = [
'training_reservations',
'machine_reservations',
'space_reservations',
'events_reservations'
].map(function (k) {
return _.filter($scope.user[k], function(r) {
return slot.start.isSame(r.start_at) ||
(slot.end.isAfter(r.start_at) && slot.end.isBefore(r.end_at)) ||
(slot.start.isAfter(r.start_at) && slot.start.isBefore(r.end_at)) ||
(slot.start.isBefore(r.start_at) && slot.end.isAfter(r.end_at));
})
});
sameTimeReservations = _.union.apply(null, sameTimeReservations);
if (sameTimeReservations.length > 0) {
const modalInstance = $uibModal.open({
animation: true,
templateUrl: '<%= asset_path "shared/_reserve_slot_same_time.html" %>',
size: 'md',
controller: 'ReserveSlotSameTimeController',
resolve: {
sameTimeReservations: function() { return sameTimeReservations; }
}
});
modalInstance.result.then(function(res) {
if (typeof callback === 'function') callback(res);
});
} else {
if (typeof callback === 'function') callback();
}
}
/**
* Callback triggered when the selected slot changed
*/
@ -490,12 +546,12 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs',
return Price.compute(mkRequestParams(r, $scope.coupon.applied), function (res) {
$scope.amountTotal = res.price;
$scope.totalNoCoupon = res.price_without_coupon;
return setSlotsDetails(res.details);
setSlotsDetails(res.details);
});
} else {
// otherwise we alert, this error musn't occur when the current user is not admin
growl.warning(_t('app.shared.cart.please_select_a_member_first'));
return $scope.amountTotal = null;
$scope.amountTotal = null;
}
};
@ -504,7 +560,7 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs',
angular.forEach(details.slots, function (s) {
if (moment(s.start_at).isSame(slot.start)) {
slot.promo = s.promo;
return slot.price = s.price;
slot.price = s.price;
}
});
});
@ -725,10 +781,10 @@ Application.Directives.directive('cart', [ '$rootScope', '$uibModal', 'dialogs',
]);
/**
* Controller of modal for show reservations the same date at the same time
* Controller of the modal showing the reservations the same date at the same time
*/
Application.Controllers.controller('ReserveSlotSameTimeController', ['$scope', '$uibModalInstance', 'AuthService', 'sameTimeReservations', 'growl', '_t',
function ($scope, $uibModalInstance, AuthService, sameTimeReservations, growl, _t) {
Application.Controllers.controller('ReserveSlotSameTimeController', ['$scope', '$uibModalInstance', 'AuthService', 'sameTimeReservations',
function ($scope, $uibModalInstance, AuthService, sameTimeReservations) {
$scope.sameTimeReservations = sameTimeReservations;
$scope.bookSlotAtSameTime = Fablab.bookSlotAtSameTime;
$scope.isAuthorized = AuthService.isAuthorized;
@ -747,6 +803,31 @@ Application.Controllers.controller('ReserveSlotSameTimeController', ['$scope', '
}
]);
/**
* Controller of the modal showing the slot tags
*/
Application.Controllers.controller('ReserveSlotTagsMismatchController', ['$scope', '$uibModalInstance', 'AuthService', 'slotTags', 'userTags', 'userName',
function ($scope, $uibModalInstance, AuthService, slotTags, userTags, userName) {
$scope.slotTags = slotTags;
$scope.userTags = userTags;
$scope.userName = userName;
$scope.isAuthorized = AuthService.isAuthorized;
/**
* Confirmation callback
*/
$scope.ok = function () {
$uibModalInstance.close({});
}
/**
* Cancellation callback
*/
$scope.cancel = function () {
$uibModalInstance.dismiss('cancel');
}
}
]);
/**
* Controller used to alert admin reserve slot without plan
*/

View File

@ -1,5 +1,7 @@
<div class="alert alert-warning m-t">
{{ 'app.admin.pricing.these_prices_match_machine_hours_rates_' | translate:{DURATION:slotDuration} }} <span class="font-bold" translate>{{ 'app.admin.pricing._without_subscriptions' }}</span>.
<p ng-bind-html="'app.admin.pricing.these_prices_match_machine_hours_rates_html' | translate"></p>
<p ng-bind-html="'app.admin.pricing.prices_calculated_on_hourly_rate_html' | translate:{ DURATION:slotDuration, RATE: examplePrice('hourly_rate'), PRICE: examplePrice('final_price') }"></p>
<p translate>{{ 'app.admin.pricing.you_can_override' }}</p>
</div>
<table class="table">
<thead>

View File

@ -1,5 +1,7 @@
<div class="alert alert-warning m-t">
{{ 'app.admin.pricing.these_prices_match_space_hours_rates_' | translate:{DURATION:slotDuration} }} <span class="font-bold" translate>{{ 'app.admin.pricing._without_subscriptions' }}</span>.
<p ng-bind-html="'app.admin.pricing.these_prices_match_space_hours_rates_html' | translate"></p>
<p ng-bind-html="'app.admin.pricing.prices_calculated_on_hourly_rate_html' | translate:{ DURATION:slotDuration, RATE: examplePrice('hourly_rate'), PRICE: examplePrice('final_price') }"></p>
<p translate>{{ 'app.admin.pricing.you_can_override' }}</p>
</div>
<table class="table">
<thead>

View File

@ -16,7 +16,7 @@
<div class="panel-body">
<div class="font-sbold text-u-c">{{ 'app.shared.cart.datetime_to_time' | translate:{START_DATETIME:(slot.start | amDateFormat:'LLLL'), END_TIME:(slot.end | amDateFormat:'LT') } }}</div>
<div class="text-base">{{ 'app.shared.cart.cost_of_TYPE' | translate:{TYPE:reservableType} }} <span ng-class="{'text-blue': !slot.promo, 'red': slot.promo}">{{slot.price | currency}}</span></div>
<div ng-show="isAuthorized(['admin', 'manager'])" class="m-t">
<div ng-show="isAuthorized()" class="m-t">
<label for="offerSlot" class="control-label m-r" translate>{{ 'app.shared.cart.offer_this_slot' }}</label>
<input bs-switch
ng-model="slot.offered"

View File

@ -0,0 +1,26 @@
<div class="modal-header">
<img ng-src="{{logoBlack.custom_asset_file_attributes.attachment_url}}" alt="{{logo.custom_asset_file_attributes.attachment}}" class="modal-logo"/>
<h1 translate>{{ 'app.shared.cart.tags_mismatch' }}</h1>
</div>
<div class="modal-body">
<p ng-show="isAuthorized(['admin', 'manager'])" translate translate-values="{USER: userName}">{{ 'app.shared.cart.confirm_book_slot_tags_mismatch' }}</p>
<p ng-hide="isAuthorized(['admin', 'manager'])" translate>{{ 'app.shared.cart.unable_to_book_slot_tags_mismatch' }}</p>
<h3 translate>{{ 'app.shared.cart.slot_tags' }}</h3>
<ul class="list-unstyled" ng-show="slotTags.length > 0">
<li ng-repeat="t in slotTags">
<span class="label label-default">{{t.name}}</span>
</li>
</ul>
<span ng-hide="slotTags.length > 0" translate>{{ 'app.shared.cart.no_tags' }}</span>
<h3 translate>{{ 'app.shared.cart.user_tags' }}</h3>
<ul class="list-unstyled">
<li ng-repeat="t in userTags" ng-show="userTags.length > 0">
<span class="label label-default">{{t.name}}</span>
</li>
</ul>
<span ng-hide="userTags.length > 0" translate>{{ 'app.shared.cart.no_tags' }}</span>
</div>
<div class="modal-footer">
<button ng-if="isAuthorized(['admin', 'manager'])" class="btn btn-info" ng-click="ok()" translate>{{ 'app.shared.buttons.confirm' }}</button>
<button class="btn btn-default" ng-click="cancel()" translate>{{ 'app.shared.buttons.cancel' }}</button>
</div>

View File

@ -51,10 +51,10 @@ class API::PaymentsController < API::ApiController
private
def on_reservation_success(intent)
def on_reservation_success(intent, details)
@reservation = Reservation.new(reservation_params)
is_reserve = Reservations::Reserve.new(current_user.id, current_user.invoicing_profile.id)
.pay_and_save(@reservation, coupon: coupon_params[:coupon_code], payment_intent_id: intent.id)
.pay_and_save(@reservation, payment_details: details, payment_intent_id: intent.id)
Stripe::PaymentIntent.update(
intent.id,
description: "Invoice reference: #{@reservation.invoice.reference}"

View File

@ -45,7 +45,7 @@ class API::PricesController < API::ApiController
@amount = { elements: nil, total: 0, before_coupon: 0 }
else
reservable = price_parameters[:reservable_type].constantize.find(price_parameters[:reservable_id])
@amount = Price.compute(current_user.admin?,
@amount = Price.compute(current_user.admin? || (current_user.manager? && current_user.id != user.id),
user,
reservable,
price_parameters[:slots_attributes] || [],

View File

@ -29,13 +29,13 @@ class API::ReservationsController < API::ApiController
# Managers can create reservations for other users
def create
user_id = current_user.admin? || current_user.manager? ? params[:reservation][:user_id] : current_user.id
amount = transaction_amount(current_user.admin?, user_id)
price = transaction_amount(current_user.admin? || (current_user.manager? && current_user.id != user_id), user_id)
authorize ReservationContext.new(Reservation, amount, user_id)
authorize ReservationContext.new(Reservation, price[:amount], user_id)
@reservation = Reservation.new(reservation_params)
is_reserve = Reservations::Reserve.new(user_id, current_user.invoicing_profile.id)
.pay_and_save(@reservation, coupon: coupon_params[:coupon_code])
.pay_and_save(@reservation, payment_details: price[:price_details])
if is_reserve
SubscriptionExtensionAfterReservation.new(@reservation).extend_subscription_if_eligible
@ -73,7 +73,8 @@ class API::ReservationsController < API::ApiController
# Subtract wallet amount from total
total = price_details[:total]
wallet_debit = get_wallet_debit(user, total)
total - wallet_debit
{ price_details: price_details, amount: (total - wallet_debit) }
end
def get_wallet_debit(user, total_amount)

View File

@ -14,7 +14,7 @@ class API::SubscriptionsController < API::ApiController
# Managers can create subscriptions for other users
def create
user_id = current_user.admin? || current_user.manager? ? params[:subscription][:user_id] : current_user.id
amount = transaction_amount(current_user.admin?, user_id)
amount = transaction_amount(current_user.admin? || (current_user.manager? && current_user.id != user_id), user_id)
authorize SubscriptionContext.new(Subscription, amount, user_id)

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
# Raised when reserving on a locked availability
class LockedError < StandardError
end

View File

@ -1,5 +1,8 @@
# frozen_string_literal: true
MINUTES_PER_HOUR = 60
SECONDS_PER_MINUTE = 60
# Store customized price for various items (Machine, Space), depending on the group and on the plan
# Also provides a static helper method to compute the price details of a shopping cart
class Price < ApplicationRecord
@ -52,16 +55,16 @@ class Price < ApplicationRecord
if space_credit
hours_available = credits_hours(space_credit, user, new_plan_being_bought)
slots.each_with_index do |slot, index|
total_amount += get_slot_price(base_amount, slot, admin, all_elements, (index < hours_available))
total_amount += get_slot_price(base_amount, slot, admin, elements: all_elements, has_credits: (index < hours_available))
end
else
slots.each do |slot|
total_amount += get_slot_price(base_amount, slot, admin, all_elements)
total_amount += get_slot_price(base_amount, slot, admin, elements: all_elements)
end
end
else
slots.each do |slot|
total_amount += get_slot_price(base_amount, slot, admin, all_elements)
total_amount += get_slot_price(base_amount, slot, admin, elements: all_elements)
end
end
@ -83,7 +86,7 @@ class Price < ApplicationRecord
end
end
slots.each do |slot|
total_amount += get_slot_price(amount, slot, admin, all_elements)
total_amount += get_slot_price(amount, slot, admin, elements: all_elements, is_division: false)
end
# Event reservation
@ -93,7 +96,7 @@ class Price < ApplicationRecord
amount += ticket[:booked] * EventPriceCategory.find(ticket[:event_price_category_id]).amount
end
slots.each do |slot|
total_amount += get_slot_price(amount, slot, admin, all_elements)
total_amount += get_slot_price(amount, slot, admin, elements: all_elements, is_division: false)
end
# Space reservation
@ -105,16 +108,16 @@ class Price < ApplicationRecord
if space_credit
hours_available = credits_hours(space_credit, user, new_plan_being_bought)
slots.each_with_index do |slot, index|
total_amount += get_slot_price(base_amount, slot, admin, all_elements, (index < hours_available))
total_amount += get_slot_price(base_amount, slot, admin, elements: all_elements, has_credits: (index < hours_available))
end
else
slots.each do |slot|
total_amount += get_slot_price(base_amount, slot, admin, all_elements)
total_amount += get_slot_price(base_amount, slot, admin, elements: all_elements)
end
end
else
slots.each do |slot|
total_amount += get_slot_price(base_amount, slot, admin, all_elements)
total_amount += get_slot_price(base_amount, slot, admin, elements: all_elements)
end
end
@ -135,29 +138,48 @@ class Price < ApplicationRecord
# === apply Coupon if any ===
_amount_no_coupon = total_amount
total_amount = CouponService.new.apply(total_amount, coupon_code)
cs = CouponService.new
cp = cs.validate(coupon_code, user.id)
total_amount = cs.apply(total_amount, cp)
# return result
{ elements: all_elements, total: total_amount.to_i, before_coupon: _amount_no_coupon.to_i }
{ elements: all_elements, total: total_amount.to_i, before_coupon: _amount_no_coupon.to_i, coupon: cp }
end
private
GET_SLOT_PRICE_DEFAULT_OPTS = { has_credits: false, elements: nil, is_division: true }.freeze
##
# Compute the price of a single slot, according to the base price and the ability for an admin
# to offer the slot.
# @param base_amount {Number} base price of a slot
# @param hourly_rate {Number} base price of a slot
# @param slot {Hash} Slot object
# @param is_admin {Boolean} true if the current user has the 'admin' role
# @param [elements] {Array} optional, if provided the resulting price will be append into elements.slots
# @param [has_credits] {Boolean} true if the user still has credits for the given slot
# @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 an full availability, true if it is a subdivision (default)
# @return {Number} price of the slot
##
def get_slot_price(base_amount, slot, is_admin, elements = nil, has_credits = false)
ii_amount = has_credits || (slot[:offered] && is_admin) ? 0 : base_amount
elements[:slots].push(start_at: slot[:start_at], price: ii_amount, promo: (ii_amount != base_amount)) unless elements.nil?
ii_amount
def get_slot_price(hourly_rate, slot, is_admin, options = {})
options = GET_SLOT_PRICE_DEFAULT_OPTS.merge(options)
slot_rate = options[:has_credits] || (slot[:offered] && is_admin) ? 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
##

View File

@ -33,75 +33,17 @@ class Reservation < ApplicationRecord
##
# Generate an array of {Stripe::InvoiceItem} with the elements in the current reservation, price included.
# The training/machine price is depending of the member's group, subscription and credits already used
# @param on_site {Boolean} true if an admin triggered the call
# @param coupon_code {String} pass a valid code to appy a coupon
# @param payment_details {Hash} as generated by Price.compute
##
def generate_invoice_items(on_site = false, coupon_code = nil)
# prepare the plan
plan = if user.subscribed_plan
user.subscribed_plan
elsif plan_id
Plan.find(plan_id)
else
nil
end
def generate_invoice_items(payment_details = nil)
# check that none of the reserved availabilities was locked
slots.each do |slot|
raise LockedError if slot.availability.lock
end
case reservable
# === Machine reservation ===
when Machine
base_amount = reservable.prices.find_by(group_id: user.group_id, plan_id: plan.try(:id)).amount
users_credits_manager = UsersCredits::Manager.new(reservation: self, plan: plan)
slots.each_with_index do |slot, index|
description = reservable.name +
" #{I18n.l slot.start_at, format: :long} - #{I18n.l slot.end_at, format: :hour_minute}"
ii_amount = base_amount # ii_amount default to base_amount
if users_credits_manager.will_use_credits?
ii_amount = index < users_credits_manager.free_hours_count ? 0 : base_amount
end
ii_amount = 0 if slot.offered && on_site # if it's a local payment and slot is offered free
invoice.invoice_items.push InvoiceItem.new(
amount: ii_amount,
description: description
)
end
# === Training reservation ===
when Training
base_amount = reservable.amount_by_group(user.group_id).amount
# be careful, variable plan can be the user's plan OR the plan user is currently purchasing
users_credits_manager = UsersCredits::Manager.new(reservation: self, plan: plan)
base_amount = 0 if users_credits_manager.will_use_credits?
slots.each do |slot|
description = reservable.name +
" #{I18n.l slot.start_at, format: :long} - #{I18n.l slot.end_at, format: :hour_minute}"
ii_amount = base_amount
ii_amount = 0 if slot.offered && on_site
invoice.invoice_items.push InvoiceItem.new(
amount: ii_amount,
description: description
)
end
# === Event reservation ===
when Event
amount = reservable.amount * nb_reserve_places
tickets.each do |ticket|
amount += ticket.booked * ticket.event_price_category.amount
end
slots.each do |slot|
description = "#{reservable.name}\n"
description += if slot.start_at.to_date != slot.end_at.to_date
@ -115,69 +57,32 @@ class Reservation < ApplicationRecord
"#{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
ii_amount = amount
ii_amount = 0 if slot.offered && on_site
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: ii_amount,
amount: price_slot[:price],
description: description
)
end
# === Space reservation ===
when Space
base_amount = reservable.prices.find_by(group_id: user.group_id, plan_id: plan.try(:id)).amount
users_credits_manager = UsersCredits::Manager.new(reservation: self, plan: plan)
slots.each_with_index do |slot, index|
description = reservable.name + " #{I18n.l slot.start_at, format: :long} - #{I18n.l slot.end_at, format: :hour_minute}"
ii_amount = base_amount # ii_amount default to base_amount
if users_credits_manager.will_use_credits?
ii_amount = index < users_credits_manager.free_hours_count ? 0 : base_amount
end
ii_amount = 0 if slot.offered && on_site # if it's a local payment and slot is offered free
invoice.invoice_items.push InvoiceItem.new(
amount: ii_amount,
description: description
)
end
# === Unknown reservation type ===
# === Space|Machine|Training reservation ===
else
raise NotImplementedError
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 ===
unless coupon_code.nil?
@coupon = Coupon.find_by(code: coupon_code)
raise InvalidCouponError if @coupon.nil? || @coupon.status(user.id) != 'active'
total = cart_total
discount = if @coupon.type == 'percent_off'
(total * @coupon.percent_off / 100).to_i
elsif @coupon.type == 'amount_off'
@coupon.amount_off
else
raise InvalidCouponError
end
end
@coupon = payment_details[:coupon]
# === Wallet ===
@wallet_amount_debit = wallet_amount_debit
# if @wallet_amount_debit != 0 && !on_site
# invoice_items << Stripe::InvoiceItem.create(
# customer: user.stp_customer_id,
# amount: -@wallet_amount_debit.to_i,
# currency: Rails.application.secrets.stripe_currency,
# description: "wallet -#{@wallet_amount_debit / 100.0}"
# )
# end
true
end
# check reservation amount total and strip invoice total to pay is equal
@ -211,7 +116,7 @@ class Reservation < ApplicationRecord
pending_invoice_items.each(&:delete)
end
def save_with_payment(operator_profile_id, coupon_code = nil, payment_intent_id = nil)
def save_with_payment(operator_profile_id, payment_details, payment_intent_id = nil)
operator = InvoicingProfile.find(operator_profile_id)&.user
method = operator&.admin? || (operator&.manager? && operator != user) ? nil : 'stripe'
@ -222,7 +127,7 @@ class Reservation < ApplicationRecord
stp_payment_intent_id: payment_intent_id,
payment_method: method
)
generate_invoice_items(true, coupon_code)
generate_invoice_items(payment_details)
return false unless valid?
@ -231,18 +136,18 @@ class Reservation < ApplicationRecord
subscription.attributes = { plan_id: plan_id, statistic_profile_id: statistic_profile_id, expiration_date: nil }
if subscription.save_with_payment(operator_profile_id, false)
invoice.invoice_items.push InvoiceItem.new(
amount: subscription.plan.amount,
amount: payment_details[:elements][:plan],
description: subscription.plan.name,
subscription_id: subscription.id
)
set_total_and_coupon(coupon_code)
set_total_and_coupon(payment_details[:coupon])
save!
else
errors[:card] << subscription.errors[:card].join
return false
end
else
set_total_and_coupon(coupon_code)
set_total_and_coupon(payment_details[:coupon])
save!
end
@ -355,16 +260,13 @@ class Reservation < ApplicationRecord
##
# 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_code] {String} optional coupon code to apply to the invoice
# @param [coupon] {Coupon} optional coupon to apply to the invoice
##
def set_total_and_coupon(coupon_code = nil)
def set_total_and_coupon(coupon = 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)
unless coupon.nil?
total = CouponService.new.apply(total, coupon, user.id)
invoice.coupon_id = cp.id
end

View File

@ -101,7 +101,7 @@ class Availabilities::AvailabilitiesService
end
def availabilities(reservable, type, user)
if user.admin?
if user.admin? || user.manager?
reservable.availabilities
.includes(:tags)
.where('end_at > ? AND available_type = ?', 1.month.ago, type)

View File

@ -36,6 +36,21 @@ class CouponService
price
end
##
# Find the coupon associated with the given code and check it is valid for the given user
# @param code {String} the literal code of the coupon
# @param user_id {Number} identifier of the user who is applying the coupon
# @return {Coupon}
##
def validate(code, user_id)
return nil unless code && user_id
coupon = Coupon.find_by(code: code)
raise InvalidCouponError if coupon.nil? || coupon.status(user_id) != 'active'
coupon
end
##
# Ventilate the discount of the provided coupon over the given amount proportionately to the invoice's total
# @param total {Number} total amount of the invoice expressed in monetary units

View File

@ -9,8 +9,8 @@ class Reservations::Reserve
@operator_profile_id = operator_profile_id
end
def pay_and_save(reservation, coupon: nil, payment_intent_id: nil)
def pay_and_save(reservation, payment_details: nil, payment_intent_id: nil)
reservation.statistic_profile_id = StatisticProfile.find_by(user_id: user_id).id
reservation.save_with_payment(operator_profile_id, coupon, payment_intent_id)
reservation.save_with_payment(operator_profile_id, payment_details, payment_intent_id)
end
end

View File

@ -1,8 +1,11 @@
# frozen_string_literal: true
require 'forwardable'
module UsersCredits
class AlreadyUpdatedError < StandardError; end
# You must use UsersCredits::Manager to consume the credits of a user or to reset them
class Manager
extend Forwardable
attr_reader :manager
@ -30,6 +33,8 @@ module UsersCredits
def_delegators :@manager, :will_use_credits?, :free_hours_count, :update_credits, :reset_credits
end
# The classes contained in UsersCredits::Managers are used by UsersCredits::Manager (no s) to handle the credits for
# the various kinds of reservations and for the user
module Managers
# that class is responsible for resetting users_credits of a user
class User
@ -44,6 +49,7 @@ module UsersCredits
end
end
# Parent class of all reservations managers
class Reservation
attr_reader :reservation
@ -119,7 +125,7 @@ module UsersCredits
return false, free_hours_count, machine_credit
end
end
return false, 0
[false, 0]
end
end
@ -149,10 +155,11 @@ module UsersCredits
return true, training_credit
end
end
return false, nil
[false, nil]
end
end
# same as class Machine but for Event reservation
class Event < Reservation
def will_use_credits?
false
@ -161,6 +168,7 @@ module UsersCredits
def update_credits; end
end
# same as class Machine but for Space reservation
class Space < Reservation
# to known if a credit will be used in the context of the given reservation
def will_use_credits?
@ -206,7 +214,7 @@ module UsersCredits
return false, free_hours_count, space_credit
end
end
return false, 0
[false, 0]
end
end
end

View File

@ -283,8 +283,9 @@ en:
prominence: "Prominence"
price: "Price"
machine_hours: "Machine slots"
these_prices_match_machine_hours_rates_: "These prices match one slot of machine usage (default {DURATION} minutes), "
_without_subscriptions: "without subscriptions"
these_prices_match_machine_hours_rates_html: "The prices below match one hour of machine usage, <strong>without subscription</strong>."
prices_calculated_on_hourly_rate_html: "All the prices will be automatically calculated based on the hourly rate defined here.<br/><em>For example</em>, if you define an hourly rate at {RATE}: a slot of {DURATION} minutes (default), will be charged <strong>{PRICE}</strong>."
you_can_override: "You can override this duration for each availability you create in the agenda. The price will then be adjusted accordingly."
machines: "Machines"
credits: "Credits"
subscription: "Subscription"
@ -337,7 +338,7 @@ en:
forever: "Each use"
valid_until: "Valid until (included)"
spaces: "Spaces"
these_prices_match_space_hours_rates_: "These prices match one slot of space usage (default {DURATION} minutes), "
these_prices_match_space_hours_rates_html: "The prices below match one hour of space usage, <strong>without subscription</strong>."
add_a_space_credit: "Add a Space credit"
space: "Space"
error_a_credit_linking_this_space_with_that_subscription_already_exists: "Error : a credit linking this space with that subscription already exists."
@ -366,7 +367,7 @@ en:
copy_prices_from: "Copy prices from"
machines: "Machines"
machine: "Machine"
hourly_rate: "Price by slot"
hourly_rate: "Hourly rate"
spaces: "Spaces"
space: "Space"
unable_to_save_subscription_changes_please_try_again: "Unable to save subscription changes. Please try again."

View File

@ -425,6 +425,12 @@ en:
slot_at_same_time: "Conflict with others reservations"
do_you_really_want_to_book_slot_at_same_time: "Do you really want to book this slot? Other bookings take place at the same time"
unable_to_book_slot_because_really_have_reservation_at_same_time: "Unable to book this slot because the following reservation occurs at the same time."
tags_mismatch: "Tags mismatch"
confirm_book_slot_tags_mismatch: "Do you really want to book this slot? {USER} does not have any of the required tags."
unable_to_book_slot_tags_mismatch: "Unable to book this slot because you don't have any of the required tags."
slot_tags: "Slot tags"
user_tags: "User tags"
no_tags: "No tags"
# feature-tour modal
tour:
previous: "Previous"