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:
commit
121d99832b
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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>
|
@ -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}"
|
||||
|
@ -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] || [],
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
@ -1,3 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Raised when reserving on a locked availability
|
||||
class LockedError < StandardError
|
||||
end
|
||||
|
@ -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
|
||||
|
||||
##
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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."
|
||||
|
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user