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

Merge branch 'monthly-payment' into staging

This commit is contained in:
Sylvain 2020-11-18 16:01:18 +01:00
commit d24757ce1c
54 changed files with 796 additions and 240 deletions

View File

@ -4,9 +4,11 @@
- Fix a bug: unable to build homepage custom stylesheet
- Fix a bug: unable to access embedded plan views
- Fix a bug: warning message overflow in credit wallet modal
- Fix a bug: when a cash coupon was used, an invalid amount is shown in the statistics
- [TODO DEPLOY] `rails fablab:stripe:plans_prices`
- Fix a bug: when using a cash coupon, the amount shown in the statistics is invalid
- Fix a bug: unable to create a coupon on stripe
- [TODO DEPLOY] `rails fablab:maintenance:rebuild_stylesheet`
- [TODO DEPLOY] `rails fablab:stripe:set_product_id`
- [TODO DEPLOY] `rails fablab:setup:add_schedule_reference`
## v4.6.3 2020 October 28

View File

@ -20,7 +20,9 @@ class API::SubscriptionsController < API::ApiController
@subscription = Subscription.new(subscription_params)
is_subscribe = Subscriptions::Subscribe.new(current_user.invoicing_profile.id, user_id)
.pay_and_save(@subscription, coupon: coupon_params[:coupon_code], invoice: true)
.pay_and_save(@subscription, coupon: coupon_params[:coupon_code],
invoice: true,
schedule: params[:subscription][:payment_schedule])
if is_subscribe
render :show, status: :created, location: @subscription

View File

@ -40,7 +40,7 @@ const PaymentScheduleSummary: React.FC<PaymentScheduleSummaryProps> = ({ schedul
* Test if all payment deadlines have the same amount
*/
const hasEqualDeadlines = (): boolean => {
const prices = schedule.items.map(i => i.price);
const prices = schedule.items.map(i => i.amount);
return prices.every(p => p === prices[0]);
}
/**
@ -57,7 +57,7 @@ const PaymentScheduleSummary: React.FC<PaymentScheduleSummaryProps> = ({ schedul
{hasEqualDeadlines() && <ul>
<li>
<span className="schedule-item-info">
{t('app.shared.cart.NUMBER_monthly_payment_of_AMOUNT', { NUMBER: schedule.items.length, AMOUNT: formatPrice(schedule.items[0].price) })}
{t('app.shared.cart.NUMBER_monthly_payment_of_AMOUNT', { NUMBER: schedule.items.length, AMOUNT: formatPrice(schedule.items[0].amount) })}
</span>
<span className="schedule-item-date">{t('app.shared.cart.first_debit')}</span>
</li>
@ -65,12 +65,12 @@ const PaymentScheduleSummary: React.FC<PaymentScheduleSummaryProps> = ({ schedul
{!hasEqualDeadlines() && <ul>
<li>
<span className="schedule-item-info">{t('app.shared.cart.monthly_payment_NUMBER', { NUMBER: 1 })}</span>
<span className="schedule-item-price">{formatPrice(schedule.items[0].price)}</span>
<span className="schedule-item-price">{formatPrice(schedule.items[0].amount)}</span>
<span className="schedule-item-date">{t('app.shared.cart.debit')}</span>
</li>
<li>
<span className="schedule-item-info">
{t('app.shared.cart.NUMBER_monthly_payment_of_AMOUNT', { NUMBER: schedule.items.length - 1, AMOUNT: formatPrice(schedule.items[1].price) })}
{t('app.shared.cart.NUMBER_monthly_payment_of_AMOUNT', { NUMBER: schedule.items.length - 1, AMOUNT: formatPrice(schedule.items[1].amount) })}
</span>
</li>
</ul>}
@ -81,7 +81,7 @@ const PaymentScheduleSummary: React.FC<PaymentScheduleSummaryProps> = ({ schedul
<li key={String(item.due_date)}>
<span className="schedule-item-date">{formatDate(item.due_date)}</span>
<span> </span>
<span className="schedule-item-price">{formatPrice(item.price)}</span>
<span className="schedule-item-price">{formatPrice(item.amount)}</span>
</li>
))}
</ul>

View File

@ -0,0 +1,124 @@
/**
* This component displays a summary of the amount paid with the virtual wallet, for the current transaction
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import moment from 'moment';
import { IApplication } from '../models/application';
import '../lib/i18n';
import { IFilterService } from 'angular';
import { Loader } from './loader';
import { Reservation } from '../models/reservation';
import { User } from '../models/user';
import { Wallet } from '../models/wallet';
declare var Application: IApplication;
interface WalletInfoProps {
reservation: Reservation,
$filter: IFilterService,
currentUser: User,
wallet: Wallet,
price: number,
remainingPrice: number,
}
const WalletInfo: React.FC<WalletInfoProps> = ({ reservation, currentUser, wallet, price, remainingPrice, $filter }) => {
const { t } = useTranslation('shared');
/**
* Return the formatted localized amount for the given price (eg. 20.5 => "20,50 €")
*/
const formatPrice = (price: number): string => {
return $filter('currency')(price);
}
/**
* Check if the currently connected used is also the person making the reservation.
* If the currently connected user (ie. the operator), is an admin or a manager, he may book the reservation for someone else.
*/
const isOperatorAndClient = (): boolean => {
return currentUser.id == reservation.user_id;
}
/**
* If the client has some money in his wallet & the price is not zero, then we should display this component.
*/
const shouldBeShown = (): boolean => {
return wallet.amount > 0 && price > 0;
}
/**
* If the amount in the wallet is not enough to cover the whole price, then the user must pay the remaining price
* using another payment mean.
*/
const hasRemainingPrice = (): boolean => {
return remainingPrice > 0;
}
/**
* Does the current cart contains a payment schedule?
*/
const isPaymentSchedule = (): boolean => {
return reservation.plan_id && reservation.payment_schedule;
}
/**
* Return the human-readable name of the item currently bought with the wallet
*/
const getPriceItem = (): string => {
let item = 'other';
if (reservation.slots_attributes.length > 0) {
item = 'reservation';
} else if (reservation.plan_id) {
if (reservation.payment_schedule) {
item = 'first_deadline';
}
else item = 'subscription';
}
return t(`app.shared.wallet.wallet_info.item_${item}`);
}
return (
<div className="wallet-info">
{shouldBeShown() && <div>
{isOperatorAndClient() && <div>
<h3>{t('app.shared.wallet.wallet_info.you_have_AMOUNT_in_wallet', {AMOUNT : formatPrice(wallet.amount)})}</h3>
{!hasRemainingPrice() &&<p>
{t('app.shared.wallet.wallet_info.wallet_pay_ITEM', {ITEM: getPriceItem()})}
</p>}
{hasRemainingPrice() &&<p>
{t('app.shared.wallet.wallet_info.credit_AMOUNT_for_pay_ITEM', {
AMOUNT: formatPrice(remainingPrice),
ITEM: getPriceItem()
})}
</p>}
</div>}
{!isOperatorAndClient() && <div>
<h3>{t('app.shared.wallet.wallet_info.client_have_AMOUNT_in_wallet', {AMOUNT : formatPrice(wallet.amount)})}</h3>
{!hasRemainingPrice() &&<p>
{t('app.shared.wallet.wallet_info.client_wallet_pay_ITEM', {ITEM: getPriceItem()})}
</p>}
{hasRemainingPrice() &&<p>
{t('app.shared.wallet.wallet_info.client_credit_AMOUNT_for_pay_ITEM', {
AMOUNT: formatPrice(remainingPrice),
ITEM: getPriceItem()
})}
</p>}
</div>}
{!hasRemainingPrice() && isPaymentSchedule() &&<p className="info-deadlines">
<i className="fa fa-warning" />
<span>{t('app.shared.wallet.wallet_info.other_deadlines_no_wallet')}</span>
</p>}
</div>}
</div>
);
}
const WalletInfoWrapper: React.FC<WalletInfoProps> = ({ currentUser, reservation, $filter, price, remainingPrice, wallet }) => {
return (
<Loader>
<WalletInfo currentUser={currentUser} reservation={reservation} $filter={$filter} price={price} remainingPrice={remainingPrice} wallet={wallet} />
</Loader>
);
}
Application.Components.component('walletInfo', react2angular(WalletInfoWrapper, ['currentUser', 'price', 'remainingPrice', 'reservation', 'wallet'], ['$filter']));

View File

@ -260,6 +260,8 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
sample = sample.replace(/W\[([^\]]+)\]/g, '');
// information about refunds (R[text]) - does not apply here
sample = sample.replace(/R\[([^\]]+)\]/g, '');
// information about payment schedules (S[text]) -does not apply here
sample = sample.replace(/S\[([^\]]+)\]/g, '');
}
return sample;
};

View File

@ -353,13 +353,13 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
// the moment when the slot selection changed for the last time, used to trigger changes in the cart
$scope.selectionTime = null;
// the last clicked event in the calender
// the last clicked event in the calendar
$scope.selectedEvent = null;
// 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
@ -390,7 +390,7 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
$scope.spaceExplicationsAlert = settingsPromise.space_explications_alert;
/**
* Change the last selected slot's appearence to looks like 'added to cart'
* Change the last selected slot's appearance to looks like 'added to cart'
*/
$scope.markSlotAsAdded = function () {
$scope.selectedEvent.backgroundColor = SELECTED_EVENT_BG_COLOR;
@ -398,7 +398,7 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
};
/**
* Change the last selected slot's appearence to looks like 'never added to cart'
* Change the last selected slot's appearance to looks like 'never added to cart'
*/
$scope.markSlotAsRemoved = function (slot) {
slot.backgroundColor = 'white';
@ -419,7 +419,7 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
$scope.slotCancelled = function () { $scope.markSlotAsRemoved($scope.selectedEvent); };
/**
* Change the last selected slot's appearence to looks like 'currently looking for a new destination to exchange'
* Change the last selected slot's appearance to looks like 'currently looking for a new destination to exchange'
*/
$scope.markSlotAsModifying = function () {
$scope.selectedEvent.backgroundColor = '#eee';
@ -428,7 +428,7 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
};
/**
* Change the last selected slot's appearence to looks like 'the slot being exchanged will take this place'
* Change the last selected slot's appearance to looks like 'the slot being exchanged will take this place'
*/
$scope.changeModifySpaceSlot = function () {
if ($scope.events.placable) {
@ -538,7 +538,7 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
};
/**
* Changes the user current view from the plan subsription screen to the machine reservation agenda
* Changes the user current view from the plan subscription screen to the machine reservation agenda
* @param e {Object} see https://docs.angularjs.org/guide/expression#-event-
*/
$scope.doNotSubscribePlan = function (e) {
@ -612,7 +612,7 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
/**
* Triggered when the user clicks on a reservation slot in the agenda.
* Defines the behavior to adopt depending on the slot status (already booked, free, ready to be reserved ...),
* the user's subscription (current or about to be took) and the time (the user cannot modify a booked reservation
* the user's subscription (current or about to be took), and the time (the user cannot modify a booked reservation
* if it's too late).
* @see http://fullcalendar.io/docs/mouse/eventClick/
*/
@ -622,7 +622,7 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
};
/**
* Triggered when fullCalendar tries to graphicaly render an event block.
* Triggered when fullCalendar tries to graphically render an event block.
* Append the event tag into the block, just after the event title.
* @see http://fullcalendar.io/docs/event_rendering/eventRender/
*/

View File

@ -312,24 +312,24 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
// What the bound slot
$scope.$watch('slotSelectionTime', function (newValue, oldValue) {
if (newValue !== oldValue) {
return slotSelectionChanged();
slotSelectionChanged();
}
});
$scope.$watch('user', function (newValue, oldValue) {
if (newValue !== oldValue) {
resetCartState();
return updateCartPrice();
updateCartPrice();
}
});
$scope.$watch('planSelectionTime', function (newValue, oldValue) {
if (newValue !== oldValue) {
return planSelectionChanged();
planSelectionChanged();
}
});
// watch when a coupon is applied to re-compute the total price
$scope.$watch('coupon.applied', function (newValue, oldValue) {
if (newValue !== oldValue) {
return updateCartPrice();
updateCartPrice();
}
});
};
@ -524,6 +524,7 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
*/
const resetCartState = function () {
$scope.selectedPlan = null;
$scope.paidPlan = null;
$scope.coupon.applied = null;
$scope.events.moved = null;
$scope.events.paid = [];
@ -579,7 +580,7 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
const updateCartPrice = function () {
if (Object.keys($scope.user).length > 0) {
const r = mkReservation($scope.user, $scope.events.reserved, $scope.selectedPlan);
return Price.compute(mkRequestParams(r, $scope.coupon.applied), function (res) {
return Price.compute(mkRequestParams({ reservation: r }, $scope.coupon.applied), function (res) {
$scope.amountTotal = res.price;
$scope.schedule.payment_schedule = res.schedule;
$scope.totalNoCoupon = res.price_without_coupon;
@ -605,19 +606,18 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
/**
* Format the parameters expected by /api/prices/compute or /api/reservations and return the resulting object
* @param reservation {Object} as returned by mkReservation()
* @param request {{reservation: object}|{subscription: object}} as returned by mkReservation()
* @param coupon {Object} Coupon as returned from the API
* @return {{reservation:Object, coupon_code:string}}
* @return {{reservation:Object, subscription: Object, coupon_code:string}}
*/
const mkRequestParams = function (reservation, coupon) {
return {
reservation,
const mkRequestParams = function (request, coupon) {
return Object.assign({
coupon_code: ((coupon ? coupon.code : undefined))
};
}, request);
};
/**
* Create an hash map implementing the Reservation specs
* Create a hash map implementing the Reservation specs
* @param member {Object} User as retrieved from the API: current user / selected user if current is admin
* @param slots {Array<Object>} Array of fullCalendar events: slots selected on the calendar
* @param [plan] {Object} Plan as retrieved from the API: plan to buy with the current reservation
@ -644,6 +644,23 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
return reservation;
};
/**
* Create a hash map implementing the Subscription specs
* @param planId {number}
* @param userId {number}
* @param schedule {boolean}
* @return {{subscription: {payment_schedule: boolean, user_id: number, plan_id: number}}}
*/
const mkSubscription = function (planId, userId, schedule) {
return {
subscription: {
plan_id: planId,
user_id: userId,
payment_schedule: schedule
}
};
};
/**
* Open a modal window that allows the user to process a credit card payment for his current shopping cart.
*/
@ -656,7 +673,7 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
return reservation;
},
price () {
return Price.compute(mkRequestParams(reservation, $scope.coupon.applied)).$promise;
return Price.compute(mkRequestParams({ reservation }, $scope.coupon.applied)).$promise;
},
wallet () {
return Wallet.getWalletByUser({ user_id: reservation.user_id }).$promise;
@ -668,7 +685,11 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
return $scope.coupon.applied;
},
cartItems () {
return mkRequestParams(reservation, $scope.coupon.applied);
let request = { reservation };
if (reservation.slots_attributes.length === 0 && reservation.plan_id) {
request = mkSubscription($scope.selectedPlan.id, reservation.user_id, $scope.schedule.requested_schedule);
}
return mkRequestParams(request, $scope.coupon.applied);
},
schedule () {
return $scope.schedule.payment_schedule;
@ -717,13 +738,13 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
const payOnSite = function (reservation) {
$uibModal.open({
templateUrl: '/shared/valid_reservation_modal.html',
size: 'sm',
size: $scope.schedule.payment_schedule ? 'lg' : 'sm',
resolve: {
reservation () {
return reservation;
},
price () {
return Price.compute(mkRequestParams(reservation, $scope.coupon.applied)).$promise;
return Price.compute(mkRequestParams({ reservation }, $scope.coupon.applied)).$promise;
},
wallet () {
return Wallet.getWalletByUser({ user_id: reservation.user_id }).$promise;
@ -741,7 +762,7 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
controller: ['$scope', '$uibModalInstance', '$state', 'reservation', 'price', 'Auth', 'Reservation', 'Subscription', 'wallet', 'helpers', '$filter', 'coupon', 'selectedPlan', 'schedule',
function ($scope, $uibModalInstance, $state, reservation, price, Auth, Reservation, Subscription, wallet, helpers, $filter, coupon, selectedPlan, schedule) {
// user wallet amount
$scope.walletAmount = wallet.amount;
$scope.wallet = wallet;
// Global price (total of all items)
$scope.price = price.price;
@ -761,16 +782,13 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
// Shows the schedule info in the modal
$scope.schedule = schedule.payment_schedule;
// Button label
if ($scope.amount > 0) {
$scope.validButtonName = _t('app.shared.cart.confirm_payment_of_html', { ROLE: $rootScope.currentUser.role, AMOUNT: $filter('currency')($scope.amount) });
} else {
if ((price.price > 0) && ($scope.walletAmount === 0)) {
$scope.validButtonName = _t('app.shared.cart.confirm_payment_of_html', { ROLE: $rootScope.currentUser.role, AMOUNT: $filter('currency')(price.price) });
} else {
$scope.validButtonName = _t('app.shared.buttons.confirm');
}
}
// how should we collect payments for the payment schedule
$scope.method = {
payment_method: 'stripe'
};
// "valid" Button label
$scope.validButtonName = '';
/**
* Callback to process the local payment, triggered on button click
@ -779,24 +797,20 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
$scope.attempting = true;
// save subscription (if there's only a subscription selected)
if (reservation.slots_attributes.length === 0 && selectedPlan) {
return Subscription.save({
coupon_code: ((coupon ? coupon.code : undefined)),
subscription: {
plan_id: selectedPlan.id,
user_id: reservation.user_id,
payment_schedule: schedule.requested_schedule
}
}, function (subscription) {
$uibModalInstance.close(subscription);
$scope.attempting = true;
}, function (response) {
$scope.alerts = [];
$scope.alerts.push({ msg: _t('app.shared.cart.a_problem_occurred_during_the_payment_process_please_try_again_later'), type: 'danger' });
$scope.attempting = false;
});
const sub = mkSubscription(selectedPlan.id, reservation.user_id, schedule.requested_schedule);
return Subscription.save(mkRequestParams(sub, coupon),
function (subscription) {
$uibModalInstance.close(subscription);
$scope.attempting = true;
}, function (response) {
$scope.alerts = [];
$scope.alerts.push({ msg: _t('app.shared.cart.a_problem_occurred_during_the_payment_process_please_try_again_later'), type: 'danger' });
$scope.attempting = false;
});
}
// otherwise, save the reservation (may include a subscription)
Reservation.save(mkRequestParams($scope.reservation, coupon), function (reservation) {
Reservation.save(mkRequestParams({ reservation: $scope.reservation }, coupon), function (reservation) {
$uibModalInstance.close(reservation);
$scope.attempting = true;
}, function (response) {
@ -805,7 +819,47 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
$scope.attempting = false;
});
};
/**
* Callback to close the modal without processing the payment
*/
$scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
/* PRIVATE SCOPE */
/**
* Kind of constructor: these actions will be realized first when the directive is loaded
*/
const initialize = function () {
$scope.$watch('method.payment_method', function (newValue, oldValue) {
console.log(`watch triggered: ${newValue}`);
$scope.validButtonName = computeValidButtonName();
});
};
/**
* Compute the Label of the confirmation button
*/
const computeValidButtonName = function () {
let method = '';
if (AuthService.isAuthorized(['admin', 'manager']) && $rootScope.currentUser.id !== reservation.user_id) {
method = $scope.method.payment_method;
} else {
method = 'stripe';
}
console.log(method);
if ($scope.amount > 0) {
return _t('app.shared.cart.confirm_payment_of_html', { METHOD: method, AMOUNT: $filter('currency')($scope.amount) });
} else {
if ((price.price > 0) && ($scope.wallet.amount === 0)) {
return _t('app.shared.cart.confirm_payment_of_html', { METHOD: method, AMOUNT: $filter('currency')(price.price) });
} else {
return _t('app.shared.buttons.confirm');
}
}
};
// # !!! MUST BE CALLED AT THE END of the controller
initialize();
}
]
}).result.finally(null).then(function (reservation) { afterPayment(reservation); });
@ -822,9 +876,16 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
// we call the external callback if present
if (typeof $scope.afterPayment === 'function') { $scope.afterPayment(reservation); }
// we reset the coupon, and the cart content, and we unselect the slot
$scope.events.reserved = [];
$scope.coupon.applied = null;
$scope.slot = null;
if ($scope.slot) {
// reservation (+ subscription)
$scope.slot = null;
$scope.events.reserved = [];
} else {
// subscription only
$scope.events = {};
}
$scope.paidPlan = $scope.selectedPlan;
$scope.selectedPlan = null;
$scope.schedule.requested_schedule = false;
$scope.schedule.payment_schedule = null;
@ -848,13 +909,23 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
} else {
if (AuthService.isAuthorized(['admin']) ||
(AuthService.isAuthorized('manager') && $scope.user.id !== $rootScope.currentUser.id) ||
amountToPay === 0) {
(amountToPay === 0 && !hasOtherDeadlines())) {
return payOnSite(reservation);
}
}
});
};
/**
* Check if the later deadlines of the payment schedule exists and are not equal to zero
* @return {boolean}
*/
const hasOtherDeadlines = function () {
if (!$scope.schedule.payment_schedule) return false;
if ($scope.schedule.payment_schedule.items.length < 2) return false;
return $scope.schedule.payment_schedule.items[1].amount !== 0;
};
// !!! MUST BE CALLED AT THE END of the directive
return initialize();
}

View File

@ -1,7 +1,12 @@
export interface PaymentScheduleItem {
id: number,
price: number,
amount: number,
due_date: Date
details: {
recurring: number,
adjustment: number,
other_items: number
}
}
export interface PaymentSchedule {

View File

@ -0,0 +1,15 @@
export interface ReservationSlot {
start_at: Date,
end_at: Date,
availability_id: number,
offered: boolean
}
export interface Reservation {
user_id: number,
reservable_id: number,
reservable_type: string,
slots_attributes: Array<ReservationSlot>,
plan_id: number
payment_schedule: boolean
}

View File

@ -0,0 +1,6 @@
export interface Wallet {
id: number,
invoicing_profile_id: number,
amount: number,
user_id: number
}

View File

@ -23,5 +23,6 @@
@import "modules/tour";
@import "modules/fab-modal";
@import "modules/payment-schedule-summary";
@import "modules/wallet-info";
@import "app.responsive";

View File

@ -0,0 +1,25 @@
.wallet-info {
margin-left: 15px;
margin-right: 15px;
h3 {
margin-top: 5px;
}
p {
font-style: italic;
}
.info-deadlines {
border: 1px solid #faebcc;
padding: 15px;
color: #8a6d3b;
background-color: #fcf8e3;
border-radius: 4px;
font-style: normal;
display: flex;
i {
vertical-align: middle;
line-height: 2.5em;
margin-right: 0.5em;
}
}
}

View File

@ -137,12 +137,18 @@
<script type="text/ng-template" id="addOnlineInfo.html">
<table class="invoice-element-legend">
<tr><td><strong>X[texte]</strong></td><td>{{ 'app.admin.invoices.add_a_notice_regarding_the_online_sales_only_if_the_invoice_is_concerned' | translate }} <mark translate>{{ 'app.admin.invoices.this_will_never_be_added_when_a_refund_notice_is_present' }}</mark> {{ 'app.admin.invoices.eg_XVL_will_add_VL_to_the_invoices_settled_with_stripe' | translate }}</td></tr>
<tr><td><strong>X[{{ 'app.admin.invoices.text' | translate }}]</strong></td><td>{{ 'app.admin.invoices.add_a_notice_regarding_the_online_sales_only_if_the_invoice_is_concerned' | translate }} <mark translate>{{ 'app.admin.invoices.this_will_never_be_added_when_a_refund_notice_is_present' }}</mark> {{ 'app.admin.invoices.eg_XVL_will_add_VL_to_the_invoices_settled_with_stripe' | translate }}</td></tr>
</table>
</script>
<script type="text/ng-template" id="addRefundInfo.html">
<table class="invoice-element-legend">
<tr><td><strong>R[texte]</strong></td><td>{{ 'app.admin.invoices.add_a_notice_regarding_refunds_only_if_the_invoice_is_concerned' | translate }}<mark translate>{{ 'app.admin.invoices.this_will_never_be_added_when_an_online_sales_notice_is_present' }}</mark> {{ 'app.admin.invoices.eg_RA_will_add_A_to_the_refund_invoices' | translate }}</td></tr>
<tr><td><strong>R[{{ 'app.admin.invoices.text' | translate }}]</strong></td><td>{{ 'app.admin.invoices.add_a_notice_regarding_refunds_only_if_the_invoice_is_concerned' | translate }}<mark translate>{{ 'app.admin.invoices.this_will_never_be_added_when_an_online_sales_notice_is_present' }}</mark> {{ 'app.admin.invoices.eg_RA_will_add_A_to_the_refund_invoices' | translate }}</td></tr>
</table>
</script>
<script type="text/ng-template" id="addPaymentScheduleInfo.html">
<table class="invoice-element-legend">
<tr><td><strong>S[{{ 'app.admin.invoices.text' | translate }}]</strong></td><td>{{ 'app.admin.invoices.add_a_notice_regarding_payment_schedule' | translate }}<mark translate>{{ 'app.admin.invoices.this_will_never_be_added_with_other_notices' }}</mark> {{ 'app.admin.invoices.eg_SE_to_schedules' | translate }}</td></tr>
</table>
</script>

View File

@ -12,6 +12,7 @@
<li ng-click="invoice.reference.help = 'addInvoiceNumber.html'">{{ 'app.admin.invoices.num_of_invoice' | translate }}</li>
<li ng-click="invoice.reference.help = 'addOnlineInfo.html'">{{ 'app.admin.invoices.online_sales' | translate }}</li>
<li ng-click="invoice.reference.help = 'addRefundInfo.html'">{{ 'app.admin.invoices.refund' | translate }}</li>
<li ng-click="invoice.reference.help = 'addPaymentScheduleInfo.html'">{{ 'app.admin.invoices.payment_schedule' | translate }}</li>
</ul>
</div>
<div class="col-md-8">

View File

@ -1,11 +1,14 @@
<div class="widget panel b-a m m-t-lg" ng-if="user && !events.modifiable && !events.moved">
<div class="widget panel b-a m m-t-lg" ng-if="user && !events.modifiable && !events.moved && !paidPlan">
<div class="panel-heading b-b small">
<h3 translate>{{ 'app.shared.cart.summary' }}</h3>
</div>
<div class="widget-content no-bg auto wrapper" ng-show="events.reserved.length == 0 && (!events.paid || events.paid.length == 0)">
<p class="font-felt fleche-left text-lg"><img src="../../images/arrow-left.png" class="fleche-left visible-lg" />
{{ 'app.shared.cart.select_one_or_more_slots_in_the_calendar' | translate:{SINGLE:limitToOneSlot} }}</p>
<div class="widget-content no-bg auto wrapper" ng-show="(!events.reserved && !selectedPlan) || events.reserved.length == 0 && (!events.paid || events.paid.length == 0)">
<p class="font-felt fleche-left text-lg">
<img src="../../images/arrow-left.png" class="fleche-left visible-lg" />
<span ng-show="events.reserved">{{ 'app.shared.cart.select_one_or_more_slots_in_the_calendar' | translate:{SINGLE:limitToOneSlot} }}</span>
<span ng-hide="events.reserved" translate>{{ 'app.shared.cart.select_a_plan' }}</span>
</p>
</div>
<div class="widget-content no-bg auto wrapper" ng-if="events.reserved.length > 0">

View File

@ -1,10 +0,0 @@
<div ng-if="currentUser.role != 'admin'">
<h3 class="m-t-xs" ng-if="walletAmount > 0 && price > 0" ng-bind-html="'app.shared.wallet.you_have_amount_in_wallet' | translate:{ amount: numberFilter(walletAmount, 2), currency: currencySymbol }"></h3>
<p ng-if="walletAmount > 0 && price > 0 && amount === 0" class="text-italic">{{'app.shared.wallet.wallet_pay_reservation' | translate}}</p>
<p ng-if="walletAmount > 0 && amount !== 0" class="text-italic" ng-bind-html="'app.shared.stripe.credit_amount_for_pay_reservation' | translate:{ amount: numberFilter(amount, 2), currency: currencySymbol }"></p>
</div>
<div ng-if="currentUser.role == 'admin'">
<h3 class="m-t-xs" ng-if="walletAmount > 0 && price > 0" ng-bind-html="'app.shared.wallet.client_have_amount_in_wallet' | translate:{ amount: numberFilter(walletAmount, 2), currency: currencySymbol }"></h3>
<p ng-if="walletAmount > 0 && price > 0 && amount === 0" class="text-italic">{{'app.shared.wallet.client_wallet_pay_reservation' | translate}}</p>
<p ng-if="walletAmount > 0 && amount !== 0" class="text-italic" ng-bind-html="'app.shared.stripe.client_credit_amount_for_pay_reservation' | translate:{ amount: numberFilter(amount, 2), currency: currencySymbol }"></p>
</div>

View File

@ -5,20 +5,45 @@
</div>
<div class="modal-body">
<uib-alert ng-repeat="alert in alerts" type="{{alert.type}}" close="closeAlert($index)">{{alert.msg}}</uib-alert>
<ng-include src="'/shared/_wallet_amount_info.html'"></ng-include>
<div class="row">
<div ng-class="{'col-md-6': schedule, 'm-h-sm': !schedule}">
<div ng-if="reservation.slots_attributes.length > 0">
<p translate>{{ 'app.shared.valid_reservation_modal.here_is_the_summary_of_the_slots_to_book_for_the_current_user' }}</p>
<ul ng-repeat="slot in reservation.slots_attributes">
<li><strong>{{slot.start_at | amDateFormat: 'LL'}} : {{slot.start_at | amDateFormat:'LT'}} - {{slot.end_at | amDateFormat:'LT'}}</strong></li>
</ul>
</div>
<div ng-if="reservation.plan_id">
<p translate>{{ 'app.shared.valid_reservation_modal.here_is_the_subscription_summary' }}</p>
<p>{{ plan | humanReadablePlanName }}</p>
</div>
<div ng-if="schedule">
<label for="method" translate>{{ 'app.shared.valid_reservation_modal.payment_method' }}</label>
<select id="method"
class="form-control m-b"
ng-model="method.payment_method">
<option value="stripe" translate>{{ 'app.shared.valid_reservation_modal.method_stripe' }}</option>
<option value="check" translate>{{ 'app.shared.valid_reservation_modal.method_check' }}</option>
</select>
<p ng-show="method.payment_method == 'stripe'" translate>{{ 'app.shared.valid_reservation_modal.stripe_collection_info' }}</p>
<p ng-show="method.payment_method == 'check'" translate translate-values="{DEADLINES: schedule.items.length}">{{ 'app.shared.valid_reservation_modal.check_collection_info' }}</p>
</div>
</div>
<div class="col-md-6" ng-if="schedule">
<ul class="full-schedule">
<li ng-repeat="item in schedule.items">
<span class="schedule-item-date">{{item.due_date | amDateFormat: 'L'}}</span>
<span> </span>
<span class="schedule-item-price">{{item.amount | currency}}</span>
</li>
</ul>
</div>
<div ng-if="reservation.slots_attributes.length > 0">
<p translate>{{ 'app.shared.valid_reservation_modal.here_is_the_summary_of_the_slots_to_book_for_the_current_user' }}</p>
<ul ng-repeat="slot in reservation.slots_attributes">
<li><strong>{{slot.start_at | amDateFormat: 'LL'}} : {{slot.start_at | amDateFormat:'LT'}} - {{slot.end_at | amDateFormat:'LT'}}</strong></li>
</ul>
</div>
<div ng-if="reservation.plan_id">
<p translate>{{ 'app.shared.valid_reservation_modal.here_is_the_subscription_summary' }}</p>
<p>{{ plan | humanReadablePlanName }}</p>
</div>
<div ng-if="schedule">
<p translate translate-values="{DEADLINES: schedule.items.length}">{{ 'app.shared.valid_reservation_modal.payment_schedule' }}</p>
<wallet-info current-user="currentUser"
reservation="reservation"
price="price"
remaining-price="amount"
wallet="wallet"/>
</div>
</div>
<div class="modal-footer">

View File

@ -3,6 +3,7 @@
# Coupon is a textual code associated with a discount rate or an amount of discount
class Coupon < ApplicationRecord
has_many :invoices
has_many :payment_schedule
after_commit :create_stripe_coupon, on: [:create]
after_commit :delete_stripe_coupon, on: [:destroy]

View File

@ -22,6 +22,7 @@ class Invoice < ApplicationRecord
belongs_to :offer_day, foreign_type: 'OfferDay', foreign_key: 'invoiced_id'
has_one :avoir, class_name: 'Invoice', foreign_key: :invoice_id, dependent: :destroy
has_one :payment_schedule_item
belongs_to :operator_profile, foreign_key: :operator_profile_id, class_name: 'InvoicingProfile'
before_create :add_environment

View File

@ -10,6 +10,7 @@ class InvoicingProfile < ApplicationRecord
has_one :organization, dependent: :destroy
accepts_nested_attributes_for :organization, allow_destroy: false
has_many :invoices, dependent: :destroy
has_many :payment_schedules, dependent: :destroy
has_one :wallet, dependent: :destroy
has_many :wallet_transactions, dependent: :destroy
@ -17,6 +18,7 @@ class InvoicingProfile < ApplicationRecord
has_many :history_values, dependent: :nullify
has_many :operated_invoices, foreign_key: :operator_profile_id, class_name: 'Invoice', dependent: :nullify
has_many :operated_payment_schedules, foreign_key: :operator_profile_id, class_name: 'PaymentSchedule', dependent: :nullify
def full_name
# if first_name or last_name is nil, the empty string will be used as a temporary replacement

View File

@ -29,6 +29,8 @@ class Machine < ApplicationRecord
after_create :create_statistic_subtype
after_create :create_machine_prices
after_create :update_stripe_product
after_update :update_stripe_product, if: :saved_change_to_name?
after_update :update_statistic_subtype, if: :saved_change_to_name?
after_destroy :remove_statistic_subtype
@ -72,4 +74,10 @@ class Machine < ApplicationRecord
def destroyable?
reservations.empty?
end
private
def update_stripe_product
StripeWorker.perform_async(:create_or_update_stp_product, Machine.name, id)
end
end

View File

@ -8,4 +8,42 @@ class PaymentSchedule < ApplicationRecord
belongs_to :coupon
belongs_to :invoicing_profile
belongs_to :operator_profile, foreign_key: :operator_profile_id, class_name: 'InvoicingProfile'
belongs_to :subscription, foreign_type: 'Subscription', foreign_key: 'scheduled_id'
belongs_to :reservation, foreign_type: 'Reservation', foreign_key: 'scheduled_id'
has_many :payment_schedule_items
before_create :add_environment
after_create :update_reference, :chain_record
##
# This is useful to check the first item because its amount may be different from the others
##
def ordered_items
payment_schedule_items.order(due_date: :asc)
end
def add_environment
self.environment = Rails.env
end
def update_reference
self.reference = InvoiceReferenceService.generate_reference(self, payment_schedule: true)
save
end
def chain_record
self.footprint = compute_footprint
save!
FootprintDebug.create!(
footprint: footprint,
data: FootprintService.footprint_data(PaymentSchedule, self),
klass: PaymentSchedule.name
)
end
def compute_footprint
FootprintService.compute_footprint(PaymentSchedule, self)
end
end

View File

@ -3,4 +3,5 @@
# Represents a due date and the associated amount for a PaymentSchedule
class PaymentScheduleItem < ApplicationRecord
belongs_to :payment_schedule
belongs_to :invoice
end

View File

@ -23,9 +23,8 @@ class Plan < ApplicationRecord
after_create :create_spaces_prices
after_create :create_statistic_type
after_create :set_name
after_create :update_stripe_price
after_update :update_stripe_price, if: :saved_change_to_amount?
after_create :update_stripe_product
after_update :update_stripe_product, if: :saved_change_to_base_name?
validates :amount, :group, :base_name, presence: true
validates :interval_count, numericality: { only_integer: true, greater_than_or_equal_to: 1 }
@ -111,10 +110,6 @@ class Plan < ApplicationRecord
StatisticType.where(statistic_index_id: stat_index.first.id).where('label LIKE ?', "%#{human_readable_duration}%").first
end
def update_stripe_price
StripeWorker.perform_async(:create_stripe_price, self)
end
private
def create_statistic_subtype
@ -133,4 +128,8 @@ class Plan < ApplicationRecord
def set_name
update_columns(name: human_readable_name)
end
def update_stripe_product
StripeWorker.perform_async(:create_or_update_stp_product, Plan.name, id)
end
end

View File

@ -144,10 +144,9 @@ class Price < ApplicationRecord
cp = cs.validate(options[:coupon_code], user.id)
total_amount = cs.apply(total_amount, cp)
# == generate PaymentSchedule ()if applicable) ===
# == generate PaymentSchedule (if applicable) ===
schedule = if options[:payment_schedule] && plan.monthly_payment
pss = PaymentScheduleService.new
pss.compute(plan, _amount_no_coupon, cp)
PaymentScheduleService.new.compute(plan, _amount_no_coupon, cp)
else
nil
end

View File

@ -18,6 +18,7 @@ class Reservation < ApplicationRecord
accepts_nested_attributes_for :tickets, allow_destroy: false
has_one :invoice, -> { where(type: nil) }, as: :invoiced, dependent: :destroy
has_one :payment_schedule, as: :scheduled, dependent: :destroy
validates_presence_of :reservable_id, :reservable_type
validate :machine_not_already_reserved, if: -> { reservable.is_a?(Machine) }

View File

@ -26,6 +26,8 @@ class Space < ApplicationRecord
after_create :create_statistic_subtype
after_create :create_space_prices
after_create :update_stripe_product
after_update :update_stripe_product, if: :saved_change_to_name?
after_update :update_statistic_subtype, if: :saved_change_to_name?
after_destroy :remove_statistic_subtype
@ -60,4 +62,10 @@ class Space < ApplicationRecord
def destroyable?
reservations.empty?
end
private
def update_stripe_product
StripeWorker.perform_async(:create_or_update_stp_product, Space.name, id)
end
end

View File

@ -7,6 +7,7 @@ class Subscription < ApplicationRecord
belongs_to :plan
belongs_to :statistic_profile
has_one :payment_schedule, as: :scheduled, dependent: :destroy
has_many :invoices, as: :invoiced, dependent: :destroy
has_many :offer_days, dependent: :destroy
@ -20,7 +21,7 @@ class Subscription < ApplicationRecord
# @param invoice if true then only the subscription is payed, without reservation
# if false then the subscription is payed with reservation
def save_with_payment(operator_profile_id, invoice = true, coupon_code = nil, payment_intent_id = nil)
def save_with_payment(operator_profile_id, invoice = true, coupon_code = nil, payment_intent_id = nil, schedule = nil)
return false unless valid?
set_expiration_date
@ -33,16 +34,29 @@ class Subscription < ApplicationRecord
# debit wallet
wallet_transaction = debit_user_wallet
invoc = generate_invoice(operator_profile_id, coupon_code, payment_intent_id)
payment = if schedule
generate_schedule(operator_profile_id, coupon_code, payment_intent_id)
else
generate_invoice(operator_profile_id, coupon_code, payment_intent_id)
end
if wallet_transaction
invoc.wallet_amount = @wallet_amount_debit
invoc.wallet_transaction_id = wallet_transaction.id
payment.wallet_amount = @wallet_amount_debit
payment.wallet_transaction_id = wallet_transaction.id
end
invoc.save
payment.save
end
true
end
def generate_schedule(operator_profile_id, coupon_code = nil, payment_intent_id = nil)
operator = InvoicingProfile.find(operator_profile_id)&.user
method = operator&.admin? || (operator&.manager? && operator != user) ? nil : 'stripe' # FIXME, paiement à l'accueil
coupon = Coupon.find_by(code: coupon_code) unless coupon_code.nil?
schedule = PaymentScheduleService.new.create(self, plan.amount, coupon: coupon, operator: operator, payment_method: method, user: user)
end
def generate_invoice(operator_profile_id, coupon_code = nil, payment_intent_id = nil)
coupon_id = nil
total = plan.amount

View File

@ -28,6 +28,8 @@ class Training < ApplicationRecord
after_create :create_statistic_subtype
after_create :create_trainings_pricings
after_create :update_stripe_product
after_update :update_stripe_product, if: :saved_change_to_name?
after_update :update_statistic_subtype, if: :saved_change_to_name?
after_destroy :remove_statistic_subtype
@ -64,4 +66,8 @@ class Training < ApplicationRecord
TrainingsPricing.create(training: self, group: group, amount: 0)
end
end
def update_stripe_product
StripeWorker.perform_async(:create_or_update_stp_product, Training.name, id)
end
end

View File

@ -9,6 +9,7 @@ class WalletTransaction < ApplicationRecord
belongs_to :reservation
belongs_to :transactable, polymorphic: true
has_one :invoice
has_one :payment_schedule
validates_inclusion_of :transaction_type, in: %w[credit debit]
validates :invoicing_profile, :wallet, presence: true

View File

@ -199,8 +199,8 @@ class PDF::Invoice < Prawn::Document
elsif cp.type == 'amount_off'
# refunds of invoices with cash coupons: we need to ventilate coupons on paid items
if invoice.is_a?(Avoir)
paid_items = invoice.invoice.invoice_items.select{ |ii| ii.amount.positive? }.length
refund_items = invoice.invoice_items.select{ |ii| ii.amount.positive? }.length
paid_items = invoice.invoice.invoice_items.select { |ii| ii.amount.positive? }.length
refund_items = invoice.invoice_items.select { |ii| ii.amount.positive? }.length
discount = ((invoice.coupon.amount_off / paid_items) * refund_items) / 100.00
else

View File

@ -1,12 +1,12 @@
# frozen_string_literal: true
# Provides methods to generate invoice references
# Provides methods to generate Invoice or PaymentSchedule references
class InvoiceReferenceService
class << self
def generate_reference(invoice, date: DateTime.current, avoir: false)
def generate_reference(invoice, date: DateTime.current, avoir: false, payment_schedule: false)
pattern = Setting.get('invoice_reference')
reference = replace_invoice_number_pattern(pattern, invoice)
reference = replace_invoice_number_pattern(pattern)
reference = replace_date_pattern(reference, date)
if avoir
@ -15,6 +15,15 @@ class InvoiceReferenceService
# remove information about online selling (X[text])
reference.gsub!(/X\[([^\]]+)\]/, ''.to_s)
# remove information about payment schedule (S[text])
reference.gsub!(/S\[([^\]]+)\]/, ''.to_s)
elsif payment_schedule
# information about payment schedule
reference.gsub!(/S\[([^\]]+)\]/, '\1')
# remove information about online selling (X[text])
reference.gsub!(/X\[([^\]]+)\]/, ''.to_s)
# remove information about refunds (R[text])
reference.gsub!(/R\[([^\]]+)\]/, ''.to_s)
else
# information about online selling (X[text])
if invoice.paid_with_stripe?
@ -25,6 +34,8 @@ class InvoiceReferenceService
# remove information about refunds (R[text])
reference.gsub!(/R\[([^\]]+)\]/, ''.to_s)
# remove information about payment schedule (S[text])
reference.gsub!(/S\[([^\]]+)\]/, ''.to_s)
end
reference
@ -35,10 +46,10 @@ class InvoiceReferenceService
# global invoice number (nn..nn)
reference = pattern.gsub(/n+(?![^\[]*\])/) do |match|
pad_and_truncate(number_of_invoices(invoice, 'global'), match.to_s.length)
pad_and_truncate(number_of_invoices('global'), match.to_s.length)
end
reference = replace_invoice_number_pattern(reference, invoice)
reference = replace_invoice_number_pattern(reference)
replace_date_pattern(reference, invoice.created_at)
end
@ -57,11 +68,10 @@ class InvoiceReferenceService
##
# Returns the number of current invoices in the given range around the current date.
# If range is invalid or not specified, the total number of invoices is returned.
# @param invoice {Invoice}
# @param range {String} 'day', 'month', 'year'
# @return {Integer}
##
def number_of_invoices(invoice, range)
def number_of_invoices(range)
case range.to_s
when 'day'
start = DateTime.current.beginning_of_day
@ -73,7 +83,7 @@ class InvoiceReferenceService
start = DateTime.current.beginning_of_year
ending = DateTime.current.end_of_year
else
return invoice.id
return get_max_id(Invoice) + get_max_id(PaymentSchedule)
end
return Invoice.count unless defined? start && defined? ending
@ -111,25 +121,32 @@ class InvoiceReferenceService
##
# Replace the invoice number elements in the provided pattern with counts from the database
# @param reference {string}
# @param invoice {Invoice}
##
def replace_invoice_number_pattern(reference, invoice)
def replace_invoice_number_pattern(reference)
copy = reference.dup
# invoice number per year (yy..yy)
copy.gsub!(/y+(?![^\[]*\])/) do |match|
pad_and_truncate(number_of_invoices(invoice, 'year'), match.to_s.length)
pad_and_truncate(number_of_invoices('year'), match.to_s.length)
end
# invoice number per month (mm..mm)
copy.gsub!(/m+(?![^\[]*\])/) do |match|
pad_and_truncate(number_of_invoices(invoice, 'month'), match.to_s.length)
pad_and_truncate(number_of_invoices('month'), match.to_s.length)
end
# invoice number per day (dd..dd)
copy.gsub!(/d+(?![^\[]*\])/) do |match|
pad_and_truncate(number_of_invoices(invoice, 'day'), match.to_s.length)
pad_and_truncate(number_of_invoices('day'), match.to_s.length)
end
copy
end
##
# Return the maximum ID from the database, for the given class
# @param klass {ActiveRecord::Base}
##
def get_max_id(klass)
ActiveRecord::Base.connection.execute("SELECT max(id) FROM #{klass.table_name}").getvalue(0, 0) || 0
end
end
end

View File

@ -28,7 +28,10 @@ class PaymentScheduleService
items = []
(0..deadlines - 1).each do |i|
date = DateTime.current + i.months
details = { recurring: per_month }
amount = if i.zero?
details[:adjustment] = adjustment
details[:other_items] = other_items
per_month + adjustment + other_items
else
per_month
@ -36,9 +39,29 @@ class PaymentScheduleService
items.push PaymentScheduleItem.new(
amount: amount,
due_date: date,
payment_schedule: ps
payment_schedule: ps,
details: details
)
end
{ payment_schedule: ps, items: items }
end
def create(subscription, total, coupon: nil, operator: nil, payment_method: nil, reservation: nil, user: nil)
schedule = compute(subscription.plan, total, coupon)
ps = schedule[:payment_schedule]
items = schedule[:items]
ps.scheduled = subscription
ps.payment_method = payment_method
ps.operator_profile = operator.invoicing_profile
ps.invoicing_profile = user.invoicing_profile
ps.save!
items.each do |item|
item.payment_schedule = ps
item.save!
end
StripeWorker.perform_async(:create_stripe_subscription, ps.id, reservation&.reservable&.stp_product_id)
ps
end
end

View File

@ -9,11 +9,18 @@ class Subscriptions::Subscribe
@operator_profile_id = operator_profile_id
end
def pay_and_save(subscription, coupon: nil, invoice: nil, payment_intent_id: nil)
##
# @param subscription {Subscription}
# @param coupon {String} coupon code
# @param invoice {Boolean}
# @param payment_intent_id {String} from stripe
# @param schedule {Boolean}
##
def pay_and_save(subscription, coupon: nil, invoice: false, payment_intent_id: nil, schedule: false)
return false if user_id.nil?
subscription.statistic_profile_id = StatisticProfile.find_by(user_id: user_id).id
subscription.save_with_payment(operator_profile_id, invoice, coupon, payment_intent_id)
subscription.save_with_payment(operator_profile_id, invoice, coupon, payment_intent_id, schedule)
end
def extend_subscription(subscription, new_expiration_date, free_days)

View File

@ -23,6 +23,10 @@ $primary-decoration-color: <%= Stylesheet.primary_decoration_color %> !default;
background-color: $primary;
}
.header .nav.navbar-nav a.label:hover {
color: $primary;
}
#nav .nav > li > a {
color: $primary-text-color;
}
@ -166,6 +170,14 @@ h5:after {
color: $primary;
}
.modal .modal-dialog .modal-content a {
color: $primary;
&:hover {
color: $primary-dark;
}
}
.modal-header h1,
.fab-modal-header h1,
.custom-invoice .modal-header h1 {

View File

@ -13,7 +13,7 @@ end
if @amount[:schedule]
json.schedule do
json.items @amount[:schedule][:items] do |item|
json.price item.amount / 100.00
json.amount item.amount / 100.00
json.due_date item.due_date
end
end

View File

@ -31,7 +31,7 @@ class StripeWorker
stp_coupon[:percent_off] = coupon.percent_off
elsif coupon.type == 'amount_off'
stp_coupon[:amount_off] = coupon.amount_off
stp_coupon[:currency] = Rails.application.secrets.stripe_currency
stp_coupon[:currency] = Setting.get('stripe_currency')
end
stp_coupon[:redeem_by] = coupon.valid_until.to_i unless coupon.valid_until.nil?
@ -45,32 +45,79 @@ class StripeWorker
cpn.delete
end
def create_stripe_price(plan)
product = if !plan.stp_price_id.nil?
p = Stripe::Price.update(
plan.stp_price_id,
{ metadata: { archived: true } },
{ api_key: Setting.get('stripe_secret_key') }
)
p.product
else
p = Stripe::Product.create(
{
name: plan.name,
metadata: { plan_id: plan.id }
}, { api_key: Setting.get('stripe_secret_key') }
)
p.id
end
def create_or_update_stp_product(class_name, id)
object = class_name.constantize.find(id)
if !object.stp_product_id.nil?
Stripe::Product.update(
object.stp_product_id,
{ name: object.name },
{ api_key: Setting.get('stripe_secret_key') }
)
p.product
else
product = Stripe::Product.create(
{
name: object.name,
metadata: {
id: object.id,
type: class_name
}
}, { api_key: Setting.get('stripe_secret_key') }
)
object.update_attributes(stp_product_id: product.id)
end
end
price = Stripe::Price.create(
{
currency: Setting.get('stripe_currency'),
unit_amount: plan.amount,
product: product
},
{ api_key: Setting.get('stripe_secret_key') }
)
plan.update_columns(stp_price_id: price.id)
def create_stripe_subscription(payment_schedule_id, reservable_stp_id)
payment_schedule = PaymentSchedule.find(payment_schedule_id)
first_item = payment_schedule.ordered_items.first
second_item = payment_schedule.ordered_items[1]
items = []
if first_item.amount != second_item.amount
unless first_item.details['adjustment']&.zero?
# adjustment: when dividing the price of the plan / months, sometimes it forces us to round the amount per month.
# The difference is invoiced here
p1 = Stripe::Price.create({
unit_amount: first_item.details['adjustment'],
currency: Setting.get('stripe_currency'),
product: payment_schedule.scheduled.plan.stp_product_id,
nickname: "Price adjustment for payment schedule #{payment_schedule_id}"
}, { api_key: Setting.get('stripe_secret_key') })
items.push(price: p1[:id])
end
unless first_item.details['other_items']&.zero?
# when taking a subscription at the same time of a reservation (space, machine or training), the amount of the
# reservation is invoiced here.
p2 = Stripe::Price.create({
unit_amount: first_item.details['other_items'],
currency: Setting.get('stripe_currency'),
product: reservable_stp_id,
nickname: "Reservations for payment schedule #{payment_schedule_id}"
}, { api_key: Setting.get('stripe_secret_key') })
items.push(price: p2[:id])
end
end
# subscription (recurring price)
price = Stripe::Price.create({
unit_amount: first_item.details['recurring'],
currency: Setting.get('stripe_currency'),
recurring: { interval: 'month', interval_count: 1 },
product: payment_schedule.scheduled.plan.stp_product_id
},
{ api_key: Setting.get('stripe_secret_key') })
stp_subscription = Stripe::Subscription.create({
customer: payment_schedule.invoicing_profile.user.stp_customer_id,
cancel_at: payment_schedule.scheduled.expiration_date.to_i,
promotion_code: payment_schedule.coupon&.code,
add_invoice_items: items,
items: [
{ price: price[:id] }
]
}, { api_key: Setting.get('stripe_secret_key') })
payment_schedule.update_attributes(stp_subscription_id: stp_subscription.id)
end
end

View File

@ -440,6 +440,7 @@ en:
important_notes: "Important notes"
address_and_legal_information: "Address and legal information"
invoice_reference: "Invoice reference"
text: "text"
year: "Year"
month: "Month"
day: "Day"
@ -447,6 +448,7 @@ en:
online_sales: "Online sales"
wallet: "Wallet"
refund: "Refund"
payment_schedule: "Payment schedule"
model: "Model"
documentation: "Documentation"
2_digits_year: "2 digits year (eg. 70)"
@ -469,9 +471,10 @@ en:
eg_XVL_will_add_VL_to_the_invoices_settled_with_stripe: '(eg. X[/VL] will add "/VL" to the invoices settled with stripe)'
add_a_notice_regarding_refunds_only_if_the_invoice_is_concerned: "Add a notice regarding refunds, only if the invoice is concerned."
this_will_never_be_added_when_an_online_sales_notice_is_present: "This will never be added when an online sales notice is present."
eg_RA_will_add_A_to_the_refund_invoices: '(ed. R[/A] will add "/A" to the refund invoices)'
add_a_notice_regarding_the_wallet_only_if_the_invoice_is_concerned: "Add a notice regarding the wallet, only if the invoice is concerned."
eg_WPM_will_add_PM_to_the_invoices_settled_with_wallet: '(eg. W[/PM] will add "/PM" to the invoices settled with wallet)'
eg_RA_will_add_A_to_the_refund_invoices: '(eg. R[/A] will add "/A" to the refund invoices)'
add_a_notice_regarding_payment_schedule: "Add a notice regarding the payment schedules, only for concerned documents."
this_will_never_be_added_with_other_notices: "This will never be added when any other notice is present."
eg_SE_to_schedules: '(eg. S[/E] will add "/E" to the payment schedules)'
code: "Code"
enable_the_code: "Enable the code"
enabled: "Enabled"

View File

@ -440,6 +440,7 @@ fr:
important_notes: "Informations importantes"
address_and_legal_information: "Adresse et informations légales"
invoice_reference: "Référence facture"
text: "texte"
year: "Année"
month: "Mois"
day: "Jour"
@ -447,6 +448,7 @@ fr:
online_sales: "Vente en ligne"
wallet: "Porte-monnaie"
refund: "Remboursement"
payment_schedule: "Échéancier"
model: "Modèle"
documentation: "Documentation"
2_digits_year: "Année sur 2 chiffres (ex. 70)"
@ -470,8 +472,9 @@ fr:
add_a_notice_regarding_refunds_only_if_the_invoice_is_concerned: "Ajoute une information relative aux remboursements, uniquement si cela concerne la facture."
this_will_never_be_added_when_an_online_sales_notice_is_present: "Ceci ne sera jamais cumulé avec une information de vente en ligne."
eg_RA_will_add_A_to_the_refund_invoices: '(ex. R[/A] ajoutera "/A" aux factures de remboursement)'
add_a_notice_regarding_the_wallet_only_if_the_invoice_is_concerned: "Ajoute une information relative au paiement par le porte-monnaie, uniquement si cela concerne la facture."
eg_WPM_will_add_PM_to_the_invoices_settled_with_wallet: '(ex. W[/PM] ajoutera "/PM" aux factures réglées avec porte-monnaie)'
add_a_notice_regarding_payment_schedule: "Ajoute une information relative aux échéanciers de paiement, uniquement pour les documents concernés."
this_will_never_be_added_with_other_notices: "Ceci ne sera jamais cumulé avec une autre information (remboursement ou vente en ligne)."
eg_SE_to_schedules: '(ex. S[/É] ajoutera "/É" aux échéanciers de paiement)'
code: "Code"
enable_the_code: "Activer le code"
enabled: "Activé"

View File

@ -129,7 +129,11 @@ en:
here_is_the_summary_of_the_slots_to_book_for_the_current_user: "Here is the summary of the slots to book for the current user:"
subscription_confirmation: "Subscription confirmation"
here_is_the_subscription_summary: "Here is the subscription summary:"
payment_schedule: "This subscription is payed with a payment schedule of {DEADLINES} months. By validating, you confirm to collect the first monthly payment."
payment_method: "Payment method"
method_stripe: "Online by card"
method_check: "By check"
stripe_collection_info: "By validating, you'll be prompted for the member's card number. This card will be automatically charged at the deadlines."
check_collection_info: "By validating, you confirm that you have {DEADLINES} checks, allowing you to collect all the monthly payments."
#event edition form
event:
title: "Title"
@ -321,15 +325,23 @@ en:
amount_minimum_1: "The amount minimum is 1"
amount_confirm_is_required: "The amount confirmation is required."
amount_confirm_does_not_match: "The amount confirmation does not match."
you_have_amount_in_wallet: "You have {amount} {currency} in your wallet"
client_have_amount_in_wallet: "Client has {amount} {currency} in wallet"
wallet_pay_reservation: "You can pay your reservation directly"
client_wallet_pay_reservation: "The member can pay his reservation directly"
debit_subscription: "Pay for a subscription"
debit_reservation_training: "Pay for a training reservation"
debit_reservation_machine: "Pay for a machine reservation"
debit_reservation_event: "Pay for an event reservation"
warning_uneditable_credit: "Warning: once validated, the credited amount won't be editable anymore."
wallet_info:
you_have_AMOUNT_in_wallet: "You have {AMOUNT} on your wallet"
wallet_pay_ITEM: "You pay your {ITEM} directly."
item_reservation: "reservation"
item_subscription: "subscription"
item_first_deadline: "first deadline"
item_other: "purchase"
credit_AMOUNT_for_pay_ITEM: "You still have {AMOUNT} to pay to validate your {ITEM}."
client_have_AMOUNT_in_wallet: "The member has {AMOUNT} on his wallet"
client_wallet_pay_ITEM: "The member can directly pay his {ITEM}."
client_credit_AMOUNT_for_pay_ITEM: "{AMOUNT} are remaining to pay to validate the {ITEM}"
other_deadlines_no_wallet: "Warning: the remaining wallet balance cannot be used for the next deadlines."
#coupon (promotional) (creation/edition form)
coupon:
name: "Name"
@ -386,6 +398,7 @@ en:
cart:
summary: "Summary"
select_one_or_more_slots_in_the_calendar: "Select one {SINGLE, select, true{slot} other{or more slots}} in the calendar"
select_a_plan: "Select a plan here"
you_ve_just_selected_the_slot: "You've just selected the slot:"
datetime_to_time: "{START_DATETIME} to {END_TIME}" #eg: Thursday, September 4, 1986 8:30 PM to 10:00 PM
cost_of_TYPE: "Cost of the {TYPE, select, Machine{machine slot} Training{training} Space{space slot} other{element}}"

View File

@ -129,7 +129,11 @@ fr:
here_is_the_summary_of_the_slots_to_book_for_the_current_user: "Voici le récapitulatif des créneaux à réserver pour l'utilisateur courant :"
subscription_confirmation: "Validation de l'abonnement"
here_is_the_subscription_summary: "Voici le récapitulatif de l'abonnement :"
payment_schedule: "Cet abonnement est souscrit par un échéancier de paiement sur {DEADLINES} mois. En validant, vous confirmez encaisser la première mensualité."
payment_method: "Moyen de paiement"
method_stripe: "Carte bancaire en ligne"
method_check: "Par chèques"
stripe_collection_info: "En validant, vous serez invité à saisir les informations de carte bancaire du membre. Cette carte sera prélevée automatiquement aux échéances."
check_collection_info: "En validant, vous confirmez être en possession de {DEADLINES} chèques permettant d'encaisser l'ensemble des mensualité."
#event edition form
event:
title: "Titre"
@ -321,15 +325,23 @@ fr:
amount_minimum_1: "Le montant minimum est de 1"
amount_confirm_is_required: "La confirmation du montant est requise."
amount_confirm_does_not_match: "La confirmation du montant ne correspond pas."
you_have_amount_in_wallet: "Vous avez {amount} {currency} sur votre porte-monnaie"
client_have_amount_in_wallet: "Le client a {amount} {currency} sur son porte-monnaie"
wallet_pay_reservation: "Vous pouvez payer votre réservation directement"
client_wallet_pay_reservation: "Le membre peut directement payer sa réservation"
debit_subscription: "Payer un abonnement"
debit_reservation_training: "Payer une réservation de formation"
debit_reservation_machine: "Payer une réservation de machine"
debit_reservation_event: "Payer une réservation d'événement"
warning_uneditable_credit: "Attention : une fois validé, le montant crédité ne sera plus modifiable."
wallet_info:
you_have_AMOUNT_in_wallet: "Vous avez {AMOUNT} sur votre porte-monnaie"
wallet_pay_ITEM: "Vous pouvez payer votre {ITEM} directement."
item_reservation: "réservation"
item_subscription: "abonnement"
item_first_deadline: "première échéance"
item_other: "achat"
credit_AMOUNT_for_pay_ITEM: "Il vous reste {AMOUNT} à payer pour valider votre {ITEM}."
client_have_AMOUNT_in_wallet: "Le membre a {AMOUNT} sur son porte-monnaie"
client_wallet_pay_ITEM: "Le membre peut directement payer {ITEM, select, abonnement{son} achat{son} other{sa}} {ITEM}."
client_credit_AMOUNT_for_pay_ITEM: "Il reste {AMOUNT} à payer pour valider {ITEM, select, abonnement{l'} achat{l'} other{la }}{ITEM}"
other_deadlines_no_wallet: "Attention : le solde du porte-monnaie ne pourra pas être utilisé pour les échéances suivantes."
#coupon (promotional) (creation/edition form)
coupon:
name: "Nom"
@ -386,6 +398,7 @@ fr:
cart:
summary: "Résumé"
select_one_or_more_slots_in_the_calendar: "Sélectionnez un {SINGLE, select, true{créneau} other{ou plusieurs créneaux}} dans le calendrier"
select_a_plan: "Sélectionnez une formule d'abonnement ici"
you_ve_just_selected_the_slot: "Vous venez de sélectionner le créneau :"
datetime_to_time: "{START_DATETIME} à {END_TIME}" #eg: Thursday, September 4, 1986 8:30 PM to 10:00 PM
cost_of_TYPE: "Coût {TYPE, select, Machine{du créneau machine} Training{de la formation} Space{du créneau espace} other{de l'élément}}"
@ -428,7 +441,7 @@ fr:
do_you_really_want_to_cancel_this_reservation_html: "<p>Êtes-vous sur de vouloir annuler cette réservation ?</p><p>Attention : si cette réservation a été effectuée gratuitement, dans le cadre d'un abonnement, les crédits utilisés ne seront pas re-crédités.</p>"
reservation_was_cancelled_successfully: "La réservation a bien été annulée."
cancellation_failed: "L'annulation a échouée."
confirm_payment_of_html: "{ROLE, select, admin{Paiement sur place} other{Payer}} : {AMOUNT}" #eg. confirm my payment of $20.00
confirm_payment_of_html: "{METHOD, select, stripe{Payer par carte} other{Paiement sur place}} : {AMOUNT}" #eg. confirm my payment of $20.00
a_problem_occurred_during_the_payment_process_please_try_again_later: "Il y a eu un problème lors de la procédure de paiement. Veuillez réessayer plus tard."
none: "Aucune"
online_payment_disabled: "Le paiement par carte bancaire n'est pas disponible. Merci de contacter directement l'accueil du FabLab."

View File

@ -1,3 +1,4 @@
scr:
date:
abbr_day_names:
- Ned
@ -8,7 +9,7 @@
- Pet
- Sub
abbr_month_names:
-
-
- Jan
- Feb
- Mar
@ -34,7 +35,7 @@
long: ! '%B %e, %Y'
short: ! '%e %b'
month_names:
-
-
- Januar
- Februar
- Mart

View File

@ -2,23 +2,20 @@
class MigratePlanStats < ActiveRecord::Migration[4.2]
def up
index = StatisticIndex.where({es_type_key: 'subscription'}).first
if index
StatisticType.where({statistic_index_id: index.id}).destroy_all
index = StatisticIndex.where(es_type_key: 'subscription').first
return unless index
Plan.all.each do |p|
p.create_statistic_type
end
end
StatisticType.where(statistic_index_id: index.id).destroy_all
Plan.all.each(&:create_statistic_type)
end
def down
index = StatisticIndex.where({es_type_key: 'subscription'}).first
if index
StatisticType.where({statistic_index_id: index.id}).destroy_all
index = StatisticIndex.where(es_type_key: 'subscription').first
return unless index
StatisticType.create!({statistic_index_id: index.id, key: 'month', label: 'Abonnements mensuels', graph: true, simple: true})
StatisticType.create!({statistic_index_id: index.id, key: 'year', label: 'Abonnements annuels', graph: true, simple: true})
end
StatisticType.where(statistic_index_id: index.id).destroy_all
StatisticType.create!(statistic_index_id: index.id, key: 'month', label: 'Abonnements mensuels', graph: true, simple: true)
StatisticType.create!(statistic_index_id: index.id, key: 'year', label: 'Abonnements annuels', graph: true, simple: true)
end
end

View File

@ -16,7 +16,7 @@ class CreatePaymentSchedules < ActiveRecord::Migration[5.2]
t.string :footprint
t.string :environment
t.belongs_to :invoicing_profile, foreign_key: true
t.references :operator_profile_id, foreign_key: { to_table: 'invoicing_profiles' }
t.references :operator_profile, foreign_key: { to_table: 'invoicing_profiles' }
t.timestamps
end

View File

@ -6,7 +6,9 @@ class CreatePaymentScheduleItems < ActiveRecord::Migration[5.2]
create_table :payment_schedule_items do |t|
t.integer :amount
t.datetime :due_date
t.jsonb :details, default: '{}'
t.belongs_to :payment_schedule, foreign_key: true
t.belongs_to :invoice, foreign_key: true
t.timestamps
end

View File

@ -1,9 +0,0 @@
# frozen_string_literal: true
# Save the id of the Stripe::Price associated with the current plan.
# This is used for payment schedules
class AddStpPriceIdToPlan < ActiveRecord::Migration[5.2]
def change
add_column :plans, :stp_price_id, :string
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
# Save the id of the Stripe::Product associated with the current plan or reservable object.
# This is used for payment schedules.
# Machines, Trainings and Spaces can be reserved jointly with a subscription that can have a
# payment schedule, so we must associate them with Stripe::Product.
# This is not the case for Events (we can't buy event+subscription) so we dot no associate a
# Stripe::Product with the events.
class AddStpProductIdToObjects < ActiveRecord::Migration[5.2]
def change
add_column :plans, :stp_product_id, :string
add_column :machines, :stp_product_id, :string
add_column :spaces, :stp_product_id, :string
add_column :trainings, :stp_product_id, :string
end
end

View File

@ -588,7 +588,7 @@ unless Setting.find_by(name: 'invoice_logo').try(:value)
setting.save
end
Setting.set('invoice_reference', 'YYMMmmmX[/VL]R[/A]') unless Setting.find_by(name: 'invoice_reference').try(:value)
Setting.set('invoice_reference', 'YYMMmmmX[/VL]R[/A]S[/E]') unless Setting.find_by(name: 'invoice_reference').try(:value)
Setting.set('invoice_code-active', true) unless Setting.find_by(name: 'invoice_code-active').try(:value)

View File

@ -108,8 +108,8 @@ SET default_tablespace = '';
CREATE TABLE public.abuses (
id integer NOT NULL,
signaled_type character varying,
signaled_id integer,
signaled_type character varying,
first_name character varying,
last_name character varying,
email character varying,
@ -187,8 +187,8 @@ CREATE TABLE public.addresses (
locality character varying,
country character varying,
postal_code character varying,
placeable_type character varying,
placeable_id integer,
placeable_type character varying,
created_at timestamp without time zone,
updated_at timestamp without time zone
);
@ -263,8 +263,8 @@ CREATE TABLE public.ar_internal_metadata (
CREATE TABLE public.assets (
id integer NOT NULL,
viewable_type character varying,
viewable_id integer,
viewable_type character varying,
attachment character varying,
type character varying,
created_at timestamp without time zone,
@ -504,8 +504,8 @@ ALTER SEQUENCE public.coupons_id_seq OWNED BY public.coupons.id;
CREATE TABLE public.credits (
id integer NOT NULL,
creditable_type character varying,
creditable_id integer,
creditable_type character varying,
plan_id integer,
hours integer,
created_at timestamp without time zone,
@ -1046,8 +1046,8 @@ ALTER SEQUENCE public.invoice_items_id_seq OWNED BY public.invoice_items.id;
CREATE TABLE public.invoices (
id integer NOT NULL,
invoiced_type character varying,
invoiced_id integer,
invoiced_type character varying,
stp_invoice_id character varying,
total integer,
created_at timestamp without time zone,
@ -1166,7 +1166,8 @@ CREATE TABLE public.machines (
created_at timestamp without time zone,
updated_at timestamp without time zone,
slug character varying,
disabled boolean
disabled boolean,
stp_product_id character varying
);
@ -1226,15 +1227,15 @@ ALTER SEQUENCE public.machines_id_seq OWNED BY public.machines.id;
CREATE TABLE public.notifications (
id integer NOT NULL,
receiver_id integer,
attached_object_type character varying,
attached_object_id integer,
attached_object_type character varying,
notification_type_id integer,
is_read boolean DEFAULT false,
created_at timestamp without time zone,
updated_at timestamp without time zone,
receiver_type character varying,
is_send boolean DEFAULT false,
meta_data jsonb DEFAULT '"{}"'::jsonb
meta_data jsonb DEFAULT '{}'::jsonb
);
@ -1469,7 +1470,9 @@ CREATE TABLE public.payment_schedule_items (
id bigint NOT NULL,
amount integer,
due_date timestamp without time zone,
details jsonb DEFAULT '"{}"'::jsonb,
payment_schedule_id bigint,
invoice_id bigint,
created_at timestamp without time zone NOT NULL,
updated_at timestamp without time zone NOT NULL
);
@ -1512,7 +1515,7 @@ CREATE TABLE public.payment_schedules (
footprint character varying,
environment character varying,
invoicing_profile_id bigint,
operator_profile_id_id bigint,
operator_profile_id bigint,
created_at timestamp without time zone NOT NULL,
updated_at timestamp without time zone NOT NULL
);
@ -1560,7 +1563,7 @@ CREATE TABLE public.plans (
slug character varying,
disabled boolean,
monthly_payment boolean,
stp_price_id character varying
stp_product_id character varying
);
@ -1653,8 +1656,8 @@ CREATE TABLE public.prices (
id integer NOT NULL,
group_id integer,
plan_id integer,
priceable_type character varying,
priceable_id integer,
priceable_type character varying,
amount integer,
created_at timestamp without time zone NOT NULL,
updated_at timestamp without time zone NOT NULL
@ -1969,8 +1972,8 @@ CREATE TABLE public.reservations (
message text,
created_at timestamp without time zone,
updated_at timestamp without time zone,
reservable_type character varying,
reservable_id integer,
reservable_type character varying,
nb_reserve_places integer,
statistic_profile_id integer
);
@ -2002,8 +2005,8 @@ ALTER SEQUENCE public.reservations_id_seq OWNED BY public.reservations.id;
CREATE TABLE public.roles (
id integer NOT NULL,
name character varying,
resource_type character varying,
resource_id integer,
resource_type character varying,
created_at timestamp without time zone,
updated_at timestamp without time zone
);
@ -2149,7 +2152,8 @@ CREATE TABLE public.spaces (
created_at timestamp without time zone NOT NULL,
updated_at timestamp without time zone NOT NULL,
characteristics text,
disabled boolean
disabled boolean,
stp_product_id character varying
);
@ -2679,7 +2683,8 @@ CREATE TABLE public.trainings (
slug character varying,
description text,
public_page boolean DEFAULT true,
disabled boolean
disabled boolean,
stp_product_id character varying
);
@ -2937,8 +2942,8 @@ CREATE TABLE public.users_roles (
CREATE TABLE public.wallet_transactions (
id integer NOT NULL,
wallet_id integer,
transactable_type character varying,
transactable_id integer,
transactable_type character varying,
transaction_type character varying,
amount integer,
created_at timestamp without time zone NOT NULL,
@ -4027,14 +4032,6 @@ ALTER TABLE ONLY public.roles
ADD CONSTRAINT roles_pkey PRIMARY KEY (id);
--
-- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.schema_migrations
ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version);
--
-- Name: settings settings_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@ -4532,6 +4529,13 @@ CREATE INDEX index_open_api_calls_count_tracings_on_open_api_client_id ON public
CREATE INDEX index_organizations_on_invoicing_profile_id ON public.organizations USING btree (invoicing_profile_id);
--
-- Name: index_payment_schedule_items_on_invoice_id; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_payment_schedule_items_on_invoice_id ON public.payment_schedule_items USING btree (invoice_id);
--
-- Name: index_payment_schedule_items_on_payment_schedule_id; Type: INDEX; Schema: public; Owner: -
--
@ -4554,10 +4558,10 @@ CREATE INDEX index_payment_schedules_on_invoicing_profile_id ON public.payment_s
--
-- Name: index_payment_schedules_on_operator_profile_id_id; Type: INDEX; Schema: public; Owner: -
-- Name: index_payment_schedules_on_operator_profile_id; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_payment_schedules_on_operator_profile_id_id ON public.payment_schedules USING btree (operator_profile_id_id);
CREATE INDEX index_payment_schedules_on_operator_profile_id ON public.payment_schedules USING btree (operator_profile_id);
--
@ -5092,6 +5096,29 @@ CREATE INDEX profiles_lower_unaccent_last_name_trgm_idx ON public.profiles USING
CREATE INDEX projects_search_vector_idx ON public.projects USING gin (search_vector);
--
-- Name: unique_schema_migrations; Type: INDEX; Schema: public; Owner: -
--
CREATE UNIQUE INDEX unique_schema_migrations ON public.schema_migrations USING btree (version);
--
-- Name: accounting_periods accounting_periods_del_protect; Type: RULE; Schema: public; Owner: -
--
CREATE RULE accounting_periods_del_protect AS
ON DELETE TO public.accounting_periods DO INSTEAD NOTHING;
--
-- Name: accounting_periods accounting_periods_upd_protect; Type: RULE; Schema: public; Owner: -
--
CREATE RULE accounting_periods_upd_protect AS
ON UPDATE TO public.accounting_periods DO INSTEAD NOTHING;
--
-- Name: projects projects_search_content_trigger; Type: TRIGGER; Schema: public; Owner: -
--
@ -5275,6 +5302,14 @@ ALTER TABLE ONLY public.event_price_categories
ADD CONSTRAINT fk_rails_4dc2c47476 FOREIGN KEY (price_category_id) REFERENCES public.price_categories(id);
--
-- Name: payment_schedule_items fk_rails_4e9d79c566; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.payment_schedule_items
ADD CONSTRAINT fk_rails_4e9d79c566 FOREIGN KEY (invoice_id) REFERENCES public.invoices(id);
--
-- Name: payment_schedules fk_rails_552bc65163; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@ -5347,6 +5382,14 @@ ALTER TABLE ONLY public.projects_machines
ADD CONSTRAINT fk_rails_88b280c24c FOREIGN KEY (machine_id) REFERENCES public.machines(id);
--
-- Name: payment_schedules fk_rails_8b73dd8d7d; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.payment_schedules
ADD CONSTRAINT fk_rails_8b73dd8d7d FOREIGN KEY (operator_profile_id) REFERENCES public.invoicing_profiles(id);
--
-- Name: availability_tags fk_rails_8cb4e921f7; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@ -5411,14 +5454,6 @@ ALTER TABLE ONLY public.projects_themes
ADD CONSTRAINT fk_rails_b021a22658 FOREIGN KEY (theme_id) REFERENCES public.themes(id);
--
-- Name: payment_schedules fk_rails_b38f5b39f6; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.payment_schedules
ADD CONSTRAINT fk_rails_b38f5b39f6 FOREIGN KEY (operator_profile_id_id) REFERENCES public.invoicing_profiles(id);
--
-- Name: statistic_profiles fk_rails_bba64e5eb9; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@ -5618,6 +5653,7 @@ INSERT INTO "schema_migrations" (version) VALUES
('20140605125131'),
('20140605142133'),
('20140605151442'),
('20140606133116'),
('20140609092700'),
('20140609092827'),
('20140610153123'),
@ -5686,12 +5722,14 @@ INSERT INTO "schema_migrations" (version) VALUES
('20150507075620'),
('20150512123546'),
('20150520132030'),
('20150520133409'),
('20150526130729'),
('20150527153312'),
('20150529113555'),
('20150601125944'),
('20150603104502'),
('20150603104658'),
('20150603133050'),
('20150604081757'),
('20150604131525'),
('20150608142234'),
@ -5773,6 +5811,7 @@ INSERT INTO "schema_migrations" (version) VALUES
('20160905142700'),
('20160906094739'),
('20160906094847'),
('20160906145713'),
('20160915105234'),
('20161123104604'),
('20170109085345'),
@ -5841,6 +5880,6 @@ INSERT INTO "schema_migrations" (version) VALUES
('20201027092149'),
('20201027100746'),
('20201027101809'),
('20201027145651');
('20201112092002');

View File

@ -77,5 +77,6 @@ This is currently not supported, because of some PostgreSQL specific instruction
- `app/models/project.rb` is using the `pg_search` gem.
- `db/migrate/20200622135401_add_pg_search_dmetaphone_support_functions.rb` is using [fuzzystrmatch](http://www.postgresql.org/docs/current/static/fuzzystrmatch.html) module and defines a PL/pgSQL function (`pg_search_dmetaphone()`);
- `db/migrate/20200623134900_add_search_vector_to_project.rb` is using [tsvector](https://www.postgresql.org/docs/10/datatype-textsearch.html), a PostgreSQL datatype and [GIN (Generalized Inverted Index)](https://www.postgresql.org/docs/9.1/textsearch-indexes.html) a PostgreSQL index type;
- `db/migrate/20200623141305_update_search_vector_of_projects.rb` is defines a PL/pgSQL function (`fill_search_vector_for_project()`) and create an SQL trigger for this function;
- `db/migrate/20200623141305_update_search_vector_of_projects.rb` defines a PL/pgSQL function (`fill_search_vector_for_project()`) and create an SQL trigger for this function;
- `db/migrate/20200629123011_update_pg_trgm.rb` is using [ALTER EXTENSION](https://www.postgresql.org/docs/10/sql-alterextension.html);
- `db/migrate/20201027101809_create_payment_schedule_items.rb` is using [jsonb](https://www.postgresql.org/docs/9.4/static/datatype-json.html);

View File

@ -136,9 +136,9 @@ docker-compose run --rm -e VAR1=xxx -e VAR2=xxx fabmanager bundle exec rails my:
## Easy upgrade
Starting with Fab-manager v4.5.0, you can upgrade Fab-manager in one single easy command, that automates the procedure below.
To upgrade with ease, using this helper, read the GitHub release notes of the version between your current version and the target version.
To upgrade with ease, using this command, read the GitHub release notes of all versions between your current version and the target version.
**You MUST append all the arguments** of easy upgrade commands, for **each versions**, to the command you run.
**You MUST append all the arguments** of the easy upgrade commands, for **each version**, to the command you run.
Eg.
If you upgrade from 1.2.3 to 1.2.5, with the following release notes:

View File

@ -101,6 +101,13 @@ namespace :fablab do
FileUtils.mv 'tmp/invoices', 'invoices'
end
desc 'add model for payment-schedules reference'
task add_schedule_reference: :environment do
setting = Setting.find_by(name: 'invoice_reference')
current = setting.value
setting.value = "#{current}S[/E]" unless /S\[([^\]]+)\]/.match?(current)
end
desc 'migrate environment variables to the database (settings)'
task env_to_db: :environment do
include ApplicationHelper

View File

@ -53,13 +53,20 @@ namespace :fablab do
puts 'Done'
end
desc 'set stp_price_id to plans'
task plans_prices: :environment do
puts 'No plans, exiting...' and return if Plan.count.zero?
desc 'set stp_product_id to all plans/machines/trainings/spaces'
task set_product_id: :environment do
w = StripeWorker.new
Plan.all.each do |p|
w.perform(:create_stripe_price, p)
w.perform(:create_or_update_stp_product, Plan.name, p.id)
end
Machine.all.each do |m|
w.perform(:create_or_update_stp_product, Machine.name, m.id)
end
Training.all.each do |t|
w.perform(:create_or_update_stp_product, Training.name, t.id)
end
Space.all.each do |s|
w.perform(:create_or_update_stp_product, Space.name, s.id)
end
end

View File

@ -100,7 +100,7 @@ value_history_10:
id: 10
setting_id: 10
invoicing_profile_id: 1
value: YYMMmmmX[/VL]R[/A]
value: YYMMmmmX[/VL]R[/A]S[/E]
created_at: 2018-12-17 11:23:01.603733000 Z
updated_at: 2018-12-17 11:23:01.603733000 Z
footprint: ed23a2eb1903befc977621bc3c3b19aad831fe550ebaa99e9299238b3d93c275