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

Merge branch 'dev' into wip-demo-update

This commit is contained in:
vincent 2022-01-07 08:23:34 +01:00
commit f15907c405
140 changed files with 2802 additions and 745 deletions

View File

@ -1,5 +1,30 @@
# Changelog Fab-manager
- Ability to cancel a payement schedule from the interface
- Ability to create slots in the past
- Ability to disable public account creation
- Ability to select "bank transfer" as the payment mean for a payment schedule
- When a payment schedule was canceled by the payment gateway, inform the user in the interface
- Updated caniuse db
- Optimized the load time of the payment schedules list
- Fix a bug: do not load Stripe if no keys were defined
- [TODO DEPLOY] `rails db:seed`
# v5.3.0 2021 December 29
- Ability to configure multiple VAT rates, per kind of invoiced item
- Ability to export the collected VAT, by rates, to a CSV file
- Refactored the extended prices' frontend code to allow future customization
- Fix a bug: the amount label in not correctly shown in the extended prices modal
- Fix a bug: `extended_prices_in_same_day` apply the extended prices to each day
## v5.2.0 2021 December 23
- Ability to configure prices for spaces, by time slots different from the default hourly rate
- Updated portuguese translation
- Refactored the ReserveButton component to use the same user's data across all the component
- First optimization the load time of the payment schedules list
## v5.1.13 2021 November 16
- Fix a bug: unable to run the setup/upgrade scripts as root
@ -12,21 +37,21 @@
## v5.1.11 2021 October 22
- Refactored subscription new/renew/free extend interfaces and API
- Ability to configure data sources for preventing booking on overlapping slots
- Updated production documentation
- Updated SSO documentation
- Improved stripe subscription process with better error handling
- Ability to configure the data sources of the booking prevention on overlapping slots
- Updated the production documentation
- Updated the SSO documentation
- Improved the stripe subscription process with better error handling
- The upgrade script will check and report the ability to access the hub API
- Fix a bug: canceled training reservation is not marked as this in admin/edit members/trainings
- Fix a bug: canceled training reservation is not marked as this in admin > edit members > trainings
- Fix a bug: users can set their birthdate in the future
- Fix a bug: the upgrade script won't add environment variables that are already present anymore
- Fix a bug: the upgrade script won't add anymore the environment variables that are already present
- Fix a bug: admin cannot take or renew a subscription for a member from member/edit interface
- Fix a bug: missing translations
- Fix a bug: the upgrade script report an invalid version to upgrade to
- Fix a bug: invalid amount provided to the PayZen payment gateway when using a currency with anything else than 2 decimals
- Fix a bug: invalid amount provided to the PayZen payment gateway, when using a 0-decimal or a 3-decimal currency
- Fix a bug: incorrect behavior for the setting "email confirmation required"
- Fix a bug: invalid text shown when a member confirms a free cart
- Fix a bug: 3DS confirmation is not asked when an admin is subscribing a user through a payment schedule using PayZen
- Fix a bug: 3DS confirmation is not asked when an admin is subscribing a user through a payment schedule, using PayZen
- Updated @rails/webpacker to 5.4.3
- Updated react-refresh-webpack-plugin to 0.5.1
- Updated react-refresh to 0.10.0
@ -41,17 +66,17 @@
## v5.1.10 2021 October 04
- Fix a bug: the image of the about page is not using the image set in backoffice
- Fix a bug: the image of the about page is not using the image set in the backoffice
- Fix a bug: updated sassc to 2.4.0 to fix ruby runtime error on some CPU architectures (#270)
- Fix a security issue: prevent HTML code edition in projects, to prevent XSS vulnerability (#293)
- Fix a bug : cover image doesn't display in profile
- Fix a bug : it redirects to home when we delete a machine record photo
- Fix a bug: cover image doesn't display in profile
- Fix a bug: fab-manager redirects to the home page when we delete a machine photo
## v5.1.9 2021 September 21
- Add a setting for the purchase and use of a prepaid pack is only possible for the user with a valid subscription
- Fix a bug: unable to show plan name in calendar reservations
- Fix a bug: book overlapping slot setting label name
- Add a setting to restrict the purchase and use of a prepaid pack to users with a valid subscription
- Fix a bug: unable to view the plans names in the reservation calendar
- Fix a bug: label name of the book overlapping slot setting
## v5.1.8 2021 September 13
@ -266,7 +291,11 @@
- [TODO DEPLOY] `rails fablab:maintenance:rebuild_stylesheet`
- [TODO DEPLOY] `\curl -sSL https://raw.githubusercontent.com/sleede/fab-manager/master/scripts/rename-adminsys.sh | bash`
## v4.7.13 2020 June 11
## v4.7.14 2021 September 30
- Fix a bug: updated sassc to 2.4.0 to fix ruby runtime error on some CPU architectures
## v4.7.13 2021 June 11
- Fix a bug: unable to process stripe payments with 3DS authentication

View File

@ -70,6 +70,8 @@ class API::ExportsController < API::ApiController
case type
when 'acd'
export = export.where('created_at > ?', Invoice.maximum('updated_at'))
when 'vat'
export = export.where('created_at > ?', Invoice.maximum('updated_at'))
else
raise ArgumentError, "Unknown type accounting/#{type}"
end

View File

@ -4,8 +4,9 @@
class API::PaymentSchedulesController < API::ApiController
before_action :authenticate_user!
before_action :set_payment_schedule, only: %i[download cancel]
before_action :set_payment_schedule_item, only: %i[cash_check refresh_item pay_item]
before_action :set_payment_schedule_item, only: %i[show_item cash_check confirm_transfer refresh_item pay_item]
# retrieve all payment schedules for the current user, paginated
def index
@payment_schedules = PaymentSchedule.where('invoicing_profile_id = ?', current_user.invoicing_profile.id)
.includes(:invoicing_profile, :payment_schedule_items, :payment_schedule_objects)
@ -15,6 +16,7 @@ class API::PaymentSchedulesController < API::ApiController
.per(params[:size])
end
# retrieve all payment schedules for all users. Filtering is supported
def list
authorize PaymentSchedule
@ -44,6 +46,15 @@ class API::PaymentSchedulesController < API::ApiController
render json: attrs, status: :ok
end
def confirm_transfer
authorize @payment_schedule_item.payment_schedule
PaymentScheduleService.new.generate_invoice(@payment_schedule_item, payment_method: 'transfer')
attrs = { state: 'paid', payment_method: 'transfer' }
@payment_schedule_item.update_attributes(attrs)
render json: attrs, status: :ok
end
def refresh_item
authorize @payment_schedule_item.payment_schedule
PaymentScheduleItemWorker.new.perform(@payment_schedule_item.id)
@ -62,6 +73,11 @@ class API::PaymentSchedulesController < API::ApiController
end
end
def show_item
authorize @payment_schedule_item.payment_schedule
render json: @payment_schedule_item, status: :ok
end
def cancel
authorize @payment_schedule

View File

@ -4,6 +4,20 @@
# Prices are used in reservations (Machine, Space)
class API::PricesController < API::ApiController
before_action :authenticate_user!
before_action :set_price, only: %i[update destroy]
def create
@price = Price.new(price_create_params)
@price.amount *= 100
authorize @price
if @price.save
render json: @price, status: :created
else
render json: @price.errors, status: :unprocessable_entity
end
end
def index
@prices = PriceService.list(params)
@ -11,7 +25,6 @@ class API::PricesController < API::ApiController
def update
authorize Price
@price = Price.find(params[:id])
price_parameters = price_params
price_parameters[:amount] = price_parameters[:amount] * 100
if @price.update(price_parameters)
@ -21,6 +34,12 @@ class API::PricesController < API::ApiController
end
end
def destroy
authorize @price
@price.safe_destroy
head :no_content
end
def compute
cs = CartService.new(current_user)
cart = cs.from_hash(params)
@ -29,7 +48,15 @@ class API::PricesController < API::ApiController
private
def set_price
@price = Price.find(params[:id])
end
def price_create_params
params.require(:price).permit(:amount, :duration, :group_id, :plan_id, :priceable_id, :priceable_type)
end
def price_params
params.require(:price).permit(:amount)
params.require(:price).permit(:amount, :duration)
end
end

View File

@ -4,6 +4,11 @@
class RegistrationsController < Devise::RegistrationsController
# POST /users.json
def create
# Is public registration allowed?
unless Setting.get('public_registrations')
render json: { errors: { signup: [t('errors.messages.registration_disabled')] } }, status: :forbidden and return
end
# first check the recaptcha
check = RecaptchaService.verify(params[:user][:recaptcha])
render json: check['error-codes'], status: :unprocessable_entity and return unless check['success']

View File

@ -2,5 +2,8 @@
# Raised when an an error occurred with the PayZen payment gateway
class PayzenError < PaymentGatewayError
def details
JSON.parse(message.gsub('=>', ':').gsub('nil', 'null'))
end
end

View File

@ -4,7 +4,7 @@ import {
CancelScheduleResponse,
CashCheckResponse, PayItemResponse,
PaymentSchedule,
PaymentScheduleIndexRequest, RefreshItemResponse
PaymentScheduleIndexRequest, PaymentScheduleItem, RefreshItemResponse
} from '../models/payment-schedule';
export default class PaymentScheduleAPI {
@ -23,6 +23,16 @@ export default class PaymentScheduleAPI {
return res?.data;
}
static async confirmTransfer (paymentScheduleItemId: number): Promise<CashCheckResponse> {
const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/items/${paymentScheduleItemId}/confirm_transfer`);
return res?.data;
}
static async getItem (paymentScheduleItemId: number): Promise<PaymentScheduleItem> {
const res: AxiosResponse = await apiClient.get(`/api/payment_schedules/items/${paymentScheduleItemId}`);
return res?.data;
}
static async refreshItem (paymentScheduleItemId: number): Promise<RefreshItemResponse> {
const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/items/${paymentScheduleItemId}/refresh_item`);
return res?.data;

View File

@ -14,11 +14,21 @@ export default class PriceAPI {
return res?.data;
}
static async create (price: Price): Promise<Price> {
const res: AxiosResponse<Price> = await apiClient.post('/api/prices', { price });
return res?.data;
}
static async update (price: Price): Promise<Price> {
const res: AxiosResponse<Price> = await apiClient.patch(`/api/prices/${price.id}`, { price });
return res?.data;
}
static async destroy (priceId: number): Promise<void> {
const res: AxiosResponse<void> = await apiClient.delete(`/api/prices/${priceId}`);
return res?.data;
}
private static filtersToQuery (filters?: PriceIndexFilter): string {
if (!filters) return '';

View File

@ -0,0 +1,16 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Space } from '../models/space';
export default class SpaceAPI {
static async index (): Promise<Array<any>> {
const res: AxiosResponse<Array<Space>> = await apiClient.get('/api/spaces');
return res?.data;
}
static async get (id: number): Promise<Space> {
const res: AxiosResponse<Space> = await apiClient.get(`/api/spaces/${id}`);
return res?.data;
}
}

View File

@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
interface HtmlTranslateProps {
trKey: string,
options?: Record<string, string>
options?: Record<string, string|number>
}
/**

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { DocumentFilters } from '../document-filters';
@ -38,13 +38,6 @@ const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser
// current filter, by date, for the schedules and the deadlines
const [dateFilter, setDateFilter] = useState<Date>(null);
/**
* When the component is loaded first, refresh the list of schedules to fill the first page.
*/
useEffect(() => {
handleRefreshList();
}, []);
/**
* Fetch from the API the payments schedules matching the given filters and reset the results table with the new schedules.
*/

View File

@ -1,4 +1,4 @@
import React, { ReactEventHandler, useState } from 'react';
import React, { ReactElement, ReactEventHandler, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader } from '../base/loader';
import _ from 'lodash';
@ -6,11 +6,11 @@ import { FabButton } from '../base/fab-button';
import { FabModal } from '../base/fab-modal';
import { UpdateCardModal } from '../payment/update-card-modal';
import { StripeElements } from '../payment/stripe/stripe-elements';
import { StripeConfirm } from '../payment/stripe/stripe-confirm';
import { User, UserRole } from '../../models/user';
import { PaymentSchedule, PaymentScheduleItem, PaymentScheduleItemState } from '../../models/payment-schedule';
import PaymentScheduleAPI from '../../api/payment-schedule';
import FormatLib from '../../lib/format';
import { StripeConfirmModal } from '../payment/stripe/stripe-confirm-modal';
interface PaymentSchedulesTableProps {
paymentSchedules: Array<PaymentSchedule>,
@ -31,6 +31,8 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
const [showExpanded, setShowExpanded] = useState<Map<number, boolean>>(new Map());
// is open, the modal dialog to confirm the cashing of a check?
const [showConfirmCashing, setShowConfirmCashing] = useState<boolean>(false);
// is open, the modal dialog to confirm a back transfer?
const [showConfirmTransfer, setShowConfirmTransfer] = useState<boolean>(false);
// is open, the modal dialog the resolve a pending card payment?
const [showResolveAction, setShowResolveAction] = useState<boolean>(false);
// the user cannot confirm the action modal (3D secure), unless he has resolved the pending action
@ -46,6 +48,8 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
// we want to display the card update button, only once. This is an association table keeping when we already shown one
const cardUpdateButton = new Map<number, boolean>();
// we want to display the cancel subscription button, only once. This is an association table keeping when we already shown one
const subscriptionCancelButton = new Map<number, boolean>();
/**
* Check if the requested payment schedule is displayed with its deadlines (PaymentScheduleItem) or without them
@ -110,11 +114,26 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
);
};
/**
* Return a button to cancel the given subscription, if the user is privileged enough
*/
const cancelSubscriptionButton = (schedule: PaymentSchedule): ReactElement => {
if (isPrivileged() && !subscriptionCancelButton.get(schedule.id)) {
subscriptionCancelButton.set(schedule.id, true);
return (
<FabButton onClick={handleCancelSubscription(schedule)}
icon={<i className="fas fa-times" />}>
{t('app.shared.schedules_table.cancel_subscription')}
</FabButton>
);
}
};
/**
* Return the human-readable string for the status of the provided deadline.
*/
const formatState = (item: PaymentScheduleItem): JSX.Element => {
let res = t(`app.shared.schedules_table.state_${item.state}`);
const formatState = (item: PaymentScheduleItem, schedule: PaymentSchedule): JSX.Element => {
let res = t(`app.shared.schedules_table.state_${item.state}${item.state === 'pending' ? '_' + schedule.payment_method : ''}`);
if (item.state === PaymentScheduleItemState.Paid) {
const key = `app.shared.schedules_table.method_${item.payment_method}`;
res += ` (${t(key)})`;
@ -138,12 +157,21 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
return downloadButton(TargetType.Invoice, item.invoice_id);
case PaymentScheduleItemState.Pending:
if (isPrivileged()) {
return (
<FabButton onClick={handleConfirmCheckPayment(item)}
icon={<i className="fas fa-money-check" />}>
{t('app.shared.schedules_table.confirm_payment')}
</FabButton>
);
if (schedule.payment_method === 'transfer') {
return (
<FabButton onClick={handleConfirmTransferPayment(item)}
icon={<i className="fas fa-university"/>}>
{t('app.shared.schedules_table.confirm_payment')}
</FabButton>
);
} else {
return (
<FabButton onClick={handleConfirmCheckPayment(item)}
icon={<i className="fas fa-money-check"/>}>
{t('app.shared.schedules_table.confirm_payment')}
</FabButton>
);
}
} else {
return <span>{t('app.shared.schedules_table.please_ask_reception')}</span>;
}
@ -161,28 +189,29 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
{t('app.shared.schedules_table.update_card')}
</FabButton>
);
case PaymentScheduleItemState.GatewayCanceled:
case PaymentScheduleItemState.Error:
// if the payment schedule was canceled by the gateway, or
// if the payment is in error, the schedule is over, and we can't update the card
cardUpdateButton.set(schedule.id, true);
if (isPrivileged()) {
return (
<FabButton onClick={handleCancelSubscription(schedule)}
icon={<i className="fas fa-times" />}>
{t('app.shared.schedules_table.cancel_subscription')}
</FabButton>
);
} else {
if (!isPrivileged()) {
return <span>{t('app.shared.schedules_table.please_ask_reception')}</span>;
}
return cancelSubscriptionButton(schedule);
case PaymentScheduleItemState.New:
if (!cardUpdateButton.get(schedule.id)) {
if (!cardUpdateButton.get(schedule.id) && schedule.payment_method === 'card') {
cardUpdateButton.set(schedule.id, true);
return (
<FabButton onClick={handleUpdateCard(schedule)}
icon={<i className="fas fa-credit-card" />}>
{t('app.shared.schedules_table.update_card')}
</FabButton>
<span>
<FabButton onClick={handleUpdateCard(schedule)}
icon={<i className="fas fa-credit-card" />}>
{t('app.shared.schedules_table.update_card')}
</FabButton>
{cancelSubscriptionButton(schedule)}
</span>
);
} else {
return cancelSubscriptionButton(schedule);
}
return <span />;
default:
@ -200,6 +229,15 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
};
};
/**
* Callback triggered when the user's clicks on the "confirm transfer" button: show a confirmation modal
*/
const handleConfirmTransferPayment = (item: PaymentScheduleItem): ReactEventHandler => {
return (): void => {
setTempDeadline(item);
toggleConfirmTransferModal();
};
};
/**
* After the user has confirmed that he wants to cash the check, update the API, refresh the list and close the modal.
*/
@ -212,6 +250,18 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
});
};
/**
* After the user has confirmed that he validates the tranfer, update the API, refresh the list and close the modal.
*/
const onTransferConfirmed = (): void => {
PaymentScheduleAPI.confirmTransfer(tempDeadline.id).then((res) => {
if (res.state === PaymentScheduleItemState.Paid) {
refreshSchedulesTable();
toggleConfirmTransferModal();
}
});
};
/**
* Refresh all payment schedules in the table
*/
@ -226,6 +276,13 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
setShowConfirmCashing(!showConfirmCashing);
};
/**
* Show/hide the modal dialog that enable to confirm the bank transfer for a given deadline.
*/
const toggleConfirmTransferModal = (): void => {
setShowConfirmTransfer(!showConfirmTransfer);
};
/**
* Show/hide the modal dialog that trigger the card "action".
*/
@ -262,7 +319,8 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
};
/**
* Callback triggered when the user's clicks on the "update card" button: show a modal to input a new card
* Callback triggered when the user's clicks on the "update card" button: show a modal to input a new card.
* If a payementScheduleItem is provided, the payment will be triggered after a successful update (see handleCardUpdateSuccess).
*/
const handleUpdateCard = (paymentSchedule: PaymentSchedule, item?: PaymentScheduleItem): ReactEventHandler => {
return (): void => {
@ -375,7 +433,7 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
{_.orderBy(p.items, 'due_date').map(item => <tr key={item.id}>
<td>{FormatLib.date(item.due_date)}</td>
<td>{FormatLib.price(item.amount)}</td>
<td>{formatState(item)}</td>
<td>{formatState(item, p)}</td>
<td>{itemButtons(item, p)}</td>
</tr>)}
</tbody>
@ -390,6 +448,7 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
</tbody>
</table>
<div className="modals">
{/* Confirm the cashing of the current deadline by check */}
<FabModal title={t('app.shared.schedules_table.confirm_check_cashing')}
isOpen={showConfirmCashing}
toggleModal={toggleConfirmCashingModal}
@ -403,6 +462,21 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
})}
</span>}
</FabModal>
{/* Confirm the bank transfer for the current deadline */}
<FabModal title={t('app.shared.schedules_table.confirm_bank_transfer')}
isOpen={showConfirmTransfer}
toggleModal={toggleConfirmTransferModal}
onConfirm={onTransferConfirmed}
closeButton={true}
confirmButton={t('app.shared.schedules_table.confirm_button')}>
{tempDeadline && <span>
{t('app.shared.schedules_table.confirm_bank_transfer_body', {
AMOUNT: FormatLib.price(tempDeadline.amount),
DATE: FormatLib.date(tempDeadline.due_date)
})}
</span>}
</FabModal>
{/* Cancel the subscription */}
<FabModal title={t('app.shared.schedules_table.cancel_subscription')}
isOpen={showCancelSubscription}
toggleModal={toggleCancelSubscriptionModal}
@ -412,14 +486,12 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
{t('app.shared.schedules_table.confirm_cancel_subscription')}
</FabModal>
<StripeElements>
<FabModal title={t('app.shared.schedules_table.resolve_action')}
isOpen={showResolveAction}
{/* 3D secure confirmation */}
{tempDeadline && <StripeConfirmModal isOpen={showResolveAction}
toggleModal={toggleResolveActionModal}
onConfirm={afterAction}
confirmButton={t('app.shared.schedules_table.ok_button')}
preventConfirm={isConfirmActionDisabled}>
{tempDeadline && <StripeConfirm clientSecret={tempDeadline.client_secret} onResponse={toggleConfirmActionButton} />}
</FabModal>
onSuccess={afterAction}
paymentScheduleItemId={tempDeadline.id} />}
{/* Update credit card */}
{tempSchedule && <UpdateCardModal isOpen={showUpdateCard}
toggleModal={toggleUpdateCardModal}
operator={operator}

View File

@ -8,9 +8,9 @@ import SettingAPI from '../../../api/setting';
import { SettingName } from '../../../models/setting';
import { PaymentModal } from '../payment-modal';
import { PaymentSchedule } from '../../../models/payment-schedule';
import { PaymentMethod } from '../../../models/payment';
import { HtmlTranslate } from '../../base/html-translate';
const ALL_SCHEDULE_METHODS = ['card', 'check'] as const;
const ALL_SCHEDULE_METHODS = ['card', 'check', 'transfer'] as const;
type scheduleMethod = typeof ALL_SCHEDULE_METHODS[number];
/**
@ -31,11 +31,7 @@ export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSucce
const [onlinePaymentModal, setOnlinePaymentModal] = useState<boolean>(false);
useEffect(() => {
if (cart.payment_method === PaymentMethod.Card) {
setMethod('card');
} else {
setMethod('check');
}
setMethod(cart.payment_method || 'check');
}, [cart]);
/**
@ -65,11 +61,7 @@ export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSucce
* Callback triggered when the user selects a payment method for the current payment schedule.
*/
const handleUpdateMethod = (option: selectOption) => {
if (option.value === 'card') {
updateCart(Object.assign({}, cart, { payment_method: PaymentMethod.Card }));
} else {
updateCart(Object.assign({}, cart, { payment_method: PaymentMethod.Other }));
}
updateCart(Object.assign({}, cart, { payment_method: option.value }));
setMethod(option.value);
};
@ -140,6 +132,7 @@ export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSucce
value={methodToOption(method)} />
{method === 'card' && <p>{t('app.admin.local_payment.card_collection_info')}</p>}
{method === 'check' && <p>{t('app.admin.local_payment.check_collection_info', { DEADLINES: paymentSchedule.items.length })}</p>}
{method === 'transfer' && <HtmlTranslate trKey="app.admin.local_payment.transfer_collection_info" options={{ DEADLINES: paymentSchedule.items.length }} />}
</div>
<div className="full-schedule">
<ul>

View File

@ -0,0 +1,55 @@
import { StripeConfirm } from './stripe-confirm';
import { FabModal } from '../../base/fab-modal';
import React, { useEffect, useState } from 'react';
import PaymentScheduleAPI from '../../../api/payment-schedule';
import { PaymentScheduleItem } from '../../../models/payment-schedule';
import { useTranslation } from 'react-i18next';
interface StripeConfirmModalProps {
isOpen: boolean,
toggleModal: () => void,
onSuccess: () => void,
paymentScheduleItemId: number,
}
/**
* Modal dialog that trigger a 3D secure confirmation for the given payment schedule item (deadline for a payment schedule).
*/
export const StripeConfirmModal: React.FC<StripeConfirmModalProps> = ({ isOpen, toggleModal, onSuccess, paymentScheduleItemId }) => {
const { t } = useTranslation('shared');
const [item, setItem] = useState<PaymentScheduleItem>(null);
const [isPending, setIsPending] = useState(false);
useEffect(() => {
PaymentScheduleAPI.getItem(paymentScheduleItemId).then(data => {
setItem(data);
});
}, [paymentScheduleItemId]);
/**
* Callback triggered when the confirm button was clicked in the modal.
*/
const onConfirmed = (): void => {
togglePending();
onSuccess();
};
/**
* Enable/disable the confirm button of the "action" modal
*/
const togglePending = (): void => {
setIsPending(!isPending);
};
return (
<FabModal title={t('app.shared.schedules_table.resolve_action')}
isOpen={isOpen}
toggleModal={toggleModal}
onConfirm={onConfirmed}
confirmButton={t('app.shared.schedules_table.ok_button')}
preventConfirm={isPending}>
{item && <StripeConfirm clientSecret={item.client_secret} onResponse={togglePending} />}
</FabModal>
);
};

View File

@ -1,6 +1,6 @@
import React, { memo, useEffect, useState } from 'react';
import { Elements } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
import { loadStripe, Stripe } from '@stripe/stripe-js';
import { SettingName } from '../../../models/setting';
import SettingAPI from '../../../api/setting';
@ -8,15 +8,17 @@ import SettingAPI from '../../../api/setting';
* This component initializes the stripe's Elements tag with the API key
*/
export const StripeElements: React.FC = memo(({ children }) => {
const [stripe, setStripe] = useState(undefined);
const [stripe, setStripe] = useState<Promise<Stripe | null>>(undefined);
/**
* When this component is mounted, we initialize the <Elements> tag with the Stripe's public key
*/
useEffect(() => {
SettingAPI.get(SettingName.StripePublicKey).then(key => {
const promise = loadStripe(key.value);
setStripe(promise);
if (key?.value) {
const promise = loadStripe(key.value);
setStripe(promise);
}
});
}, []);

View File

@ -24,11 +24,7 @@ const UpdateCardModalComponent: React.FC<UpdateCardModalProps> = ({ isOpen, togg
const [gateway, setGateway] = useState<string>('');
useEffect(() => {
if (schedule.gateway_subscription.classname.match(/^PayZen::/)) {
setGateway('payzen');
} else if (schedule.gateway_subscription.classname.match(/^Stripe::/)) {
setGateway('stripe');
}
setGateway(schedule.gateway);
}, [schedule]);
/**
@ -44,7 +40,7 @@ const UpdateCardModalComponent: React.FC<UpdateCardModalProps> = ({ isOpen, togg
/**
* Render the PayZen update-card modal
*/ // 1
*/
const renderPayZenModal = (): ReactElement => {
return <PayzenCardUpdateModal isOpen={isOpen}
toggleModal={toggleModal}
@ -58,15 +54,16 @@ const UpdateCardModalComponent: React.FC<UpdateCardModalProps> = ({ isOpen, togg
*/
switch (gateway) {
case 'stripe':
case 'Stripe':
return renderStripeModal();
case 'payzen':
case 'PayZen':
return renderPayZenModal();
case '':
case undefined:
return <div/>;
default:
onError(t('app.shared.update_card_modal.unexpected_error'));
console.error(`[UpdateCardModal] unexpected gateway: ${schedule.gateway_subscription?.classname}`);
console.error(`[UpdateCardModal] unexpected gateway: ${schedule.gateway} for schedule ${schedule.id}`);
return <div />;
}
};

View File

@ -35,7 +35,7 @@ const EditPlanCategoryComponent: React.FC<EditPlanCategoryProps> = ({ onSuccess,
/**
* The edit has been confirmed by the user.
* Call the API to trigger the update of the temporary set plan-category
* Call the API to trigger the update of the temporary set plan-category.
*/
const onEditConfirmed = (): void => {
PlanCategoryAPI.update(tempCategory).then((updatedCategory) => {

View File

@ -1,12 +1,12 @@
import React, { ReactNode, useState } from 'react';
import { PrepaidPack } from '../../models/prepaid-pack';
import { PrepaidPack } from '../../../models/prepaid-pack';
import { useTranslation } from 'react-i18next';
import { FabPopover } from '../base/fab-popover';
import { FabPopover } from '../../base/fab-popover';
import { CreatePack } from './create-pack';
import PrepaidPackAPI from '../../api/prepaid-pack';
import PrepaidPackAPI from '../../../api/prepaid-pack';
import { DeletePack } from './delete-pack';
import { EditPack } from './edit-pack';
import FormatLib from '../../lib/format';
import FormatLib from '../../../lib/format';
interface ConfigurePacksButtonProps {
packsData: Array<PrepaidPack>,

View File

@ -1,10 +1,10 @@
import React, { useState } from 'react';
import { FabModal } from '../base/fab-modal';
import { FabModal } from '../../base/fab-modal';
import { PackForm } from './pack-form';
import { PrepaidPack } from '../../models/prepaid-pack';
import PrepaidPackAPI from '../../api/prepaid-pack';
import { PrepaidPack } from '../../../models/prepaid-pack';
import PrepaidPackAPI from '../../../api/prepaid-pack';
import { useTranslation } from 'react-i18next';
import { FabAlert } from '../base/fab-alert';
import { FabAlert } from '../../base/fab-alert';
interface CreatePackProps {
onSuccess: (message: string) => void,

View File

@ -1,10 +1,10 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
import { FabModal } from '../base/fab-modal';
import { Loader } from '../base/loader';
import { PrepaidPack } from '../../models/prepaid-pack';
import PrepaidPackAPI from '../../api/prepaid-pack';
import { FabButton } from '../../base/fab-button';
import { FabModal } from '../../base/fab-modal';
import { Loader } from '../../base/loader';
import { PrepaidPack } from '../../../models/prepaid-pack';
import PrepaidPackAPI from '../../../api/prepaid-pack';
interface DeletePackProps {
onSuccess: (message: string) => void,

View File

@ -1,10 +1,10 @@
import React, { useState } from 'react';
import { FabModal } from '../base/fab-modal';
import { FabModal } from '../../base/fab-modal';
import { PackForm } from './pack-form';
import { PrepaidPack } from '../../models/prepaid-pack';
import PrepaidPackAPI from '../../api/prepaid-pack';
import { PrepaidPack } from '../../../models/prepaid-pack';
import PrepaidPackAPI from '../../../api/prepaid-pack';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
import { FabButton } from '../../base/fab-button';
interface EditPackProps {
pack: PrepaidPack,

View File

@ -1,25 +1,23 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { FabAlert } from '../base/fab-alert';
import { HtmlTranslate } from '../base/html-translate';
import MachineAPI from '../../api/machine';
import GroupAPI from '../../api/group';
import { IFablab } from '../../models/fablab';
import { Machine } from '../../models/machine';
import { Group } from '../../models/group';
import { IApplication } from '../../models/application';
import { EditablePrice } from './editable-price';
import { Loader } from '../../base/loader';
import { FabAlert } from '../../base/fab-alert';
import { HtmlTranslate } from '../../base/html-translate';
import MachineAPI from '../../../api/machine';
import GroupAPI from '../../../api/group';
import { Machine } from '../../../models/machine';
import { Group } from '../../../models/group';
import { IApplication } from '../../../models/application';
import { EditablePrice } from '../editable-price';
import { ConfigurePacksButton } from './configure-packs-button';
import PriceAPI from '../../api/price';
import { Price } from '../../models/price';
import PrepaidPackAPI from '../../api/prepaid-pack';
import { PrepaidPack } from '../../models/prepaid-pack';
import PriceAPI from '../../../api/price';
import { Price } from '../../../models/price';
import PrepaidPackAPI from '../../../api/prepaid-pack';
import { PrepaidPack } from '../../../models/prepaid-pack';
import { useImmer } from 'use-immer';
import FormatLib from '../../lib/format';
import FormatLib from '../../../lib/format';
declare let Fablab: IFablab;
declare const Application: IApplication;
interface MachinesPricingProps {

View File

@ -1,11 +1,11 @@
import React, { BaseSyntheticEvent } from 'react';
import Select from 'react-select';
import Switch from 'react-switch';
import { PrepaidPack } from '../../models/prepaid-pack';
import { PrepaidPack } from '../../../models/prepaid-pack';
import { useTranslation } from 'react-i18next';
import { useImmer } from 'use-immer';
import { FabInput } from '../base/fab-input';
import { IFablab } from '../../models/fablab';
import { FabInput } from '../../base/fab-input';
import { IFablab } from '../../../models/fablab';
declare let Fablab: IFablab;

View File

@ -0,0 +1,86 @@
import React, { ReactNode, useState } from 'react';
import { Price } from '../../../models/price';
import { useTranslation } from 'react-i18next';
import { FabPopover } from '../../base/fab-popover';
import { CreateExtendedPrice } from './create-extended-price';
import PriceAPI from '../../../api/price';
import FormatLib from '../../../lib/format';
import { EditExtendedPrice } from './edit-extended-price';
import { DeleteExtendedPrice } from './delete-extended-price';
interface ConfigureExtendedPriceButtonProps {
prices: Array<Price>,
onError: (message: string) => void,
onSuccess: (message: string) => void,
groupId: number,
priceableId: number,
priceableType: string,
}
/**
* This component is a button that shows the list of extendedPrices.
* It also triggers modal dialogs to configure (add/edit/remove) extendedPrices.
*/
export const ConfigureExtendedPriceButton: React.FC<ConfigureExtendedPriceButtonProps> = ({ prices, onError, onSuccess, groupId, priceableId, priceableType }) => {
const { t } = useTranslation('admin');
const [extendedPrices, setExtendedPrices] = useState<Array<Price>>(prices);
const [showList, setShowList] = useState<boolean>(false);
/**
* Return the number of minutes, user-friendly formatted
*/
const formatDuration = (minutes: number): string => {
return t('app.admin.configure_extended_prices_button.extended_price_DURATION', { DURATION: minutes });
};
/**
* Open/closes the popover listing the existing packs
*/
const toggleShowList = (): void => {
setShowList(!showList);
};
/**
* Callback triggered when the extendedPrice was successfully created/deleted/updated.
* We refresh the list of extendedPrices.
*/
const handleSuccess = (message: string) => {
onSuccess(message);
PriceAPI.index({ group_id: groupId, priceable_id: priceableId, priceable_type: priceableType })
.then(data => setExtendedPrices(data.filter(p => p.duration !== 60)))
.catch(error => onError(error));
};
/**
* Render the button used to trigger the "new extended price" modal
*/
const renderAddButton = (): ReactNode => {
return <CreateExtendedPrice onSuccess={handleSuccess}
onError={onError}
groupId={groupId}
priceableId={priceableId}
priceableType={priceableType} />;
};
return (
<div className="configure-extended-prices-button">
<button className="extended-prices-button" onClick={toggleShowList}>
<i className="fas fa-stopwatch" />
</button>
{showList && <FabPopover title={t('app.admin.configure_extended_prices_button.extended_prices')} headerButton={renderAddButton()} className="fab-popover__right">
<ul>
{extendedPrices?.map(extendedPrice =>
<li key={extendedPrice.id}>
{formatDuration(extendedPrice.duration)} - {FormatLib.price(extendedPrice.amount)}
<span className="extended-prices-actions">
<EditExtendedPrice onSuccess={handleSuccess} onError={onError} price={extendedPrice} />
<DeleteExtendedPrice onSuccess={handleSuccess} onError={onError} price={extendedPrice} />
</span>
</li>)}
</ul>
{extendedPrices?.length === 0 && <span>{t('app.admin.configure_extended_prices_button.no_extended_prices')}</span>}
</FabPopover>}
</div>
);
};

View File

@ -0,0 +1,69 @@
import React, { useState } from 'react';
import { FabModal } from '../../base/fab-modal';
import { ExtendedPriceForm } from './extended-price-form';
import { Price } from '../../../models/price';
import PriceAPI from '../../../api/price';
import { useTranslation } from 'react-i18next';
import { FabAlert } from '../../base/fab-alert';
interface CreateExtendedPriceProps {
onSuccess: (message: string) => void,
onError: (message: string) => void,
groupId: number,
priceableId: number,
priceableType: string,
}
/**
* This component shows a button.
* When clicked, we show a modal dialog handing the process of creating a new extended price
*/
export const CreateExtendedPrice: React.FC<CreateExtendedPriceProps> = ({ onSuccess, onError, groupId, priceableId, priceableType }) => {
const { t } = useTranslation('admin');
const [isOpen, setIsOpen] = useState<boolean>(false);
/**
* Open/closes the "new extended price" modal dialog
*/
const toggleModal = (): void => {
setIsOpen(!isOpen);
};
/**
* Callback triggered when the user has validated the creation of the new extended price
*/
const handleSubmit = (extendedPrice: Price): void => {
// set the already-known attributes of the new extended price
const newExtendedPrice = Object.assign<Price, Price>({} as Price, extendedPrice);
newExtendedPrice.group_id = groupId;
newExtendedPrice.priceable_id = priceableId;
newExtendedPrice.priceable_type = priceableType;
// create it on the API
PriceAPI.create(newExtendedPrice)
.then(() => {
onSuccess(t('app.admin.create_extended_price.extended_price_successfully_created'));
toggleModal();
})
.catch(error => onError(error));
};
return (
<div className="create-extended-price">
<button className="add-price-button" onClick={toggleModal}><i className="fas fa-plus"/></button>
<FabModal isOpen={isOpen}
toggleModal={toggleModal}
title={t('app.admin.create_extended_price.new_extended_price')}
className="new-extended-price-modal"
closeButton
confirmButton={t('app.admin.create_extended_price.create_extended_price')}
onConfirmSendFormId="new-extended-price">
<FabAlert level="info">
{t('app.admin.create_extended_price.new_extended_price_info', { TYPE: priceableType })}
</FabAlert>
<ExtendedPriceForm formId="new-extended-price" onSubmit={handleSubmit} />
</FabModal>
</div>
);
};

View File

@ -0,0 +1,56 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../../base/fab-button';
import { FabModal } from '../../base/fab-modal';
import { Price } from '../../../models/price';
import PriceAPI from '../../../api/price';
interface DeleteExtendedPriceProps {
onSuccess: (message: string) => void,
onError: (message: string) => void,
price: Price,
}
/**
* This component shows a button.
* When clicked, we show a modal dialog to ask the user for confirmation about the deletion of the provided extended price.
*/
export const DeleteExtendedPrice: React.FC<DeleteExtendedPriceProps> = ({ onSuccess, onError, price }) => {
const { t } = useTranslation('admin');
const [deletionModal, setDeletionModal] = useState<boolean>(false);
/**
* Opens/closes the deletion modal
*/
const toggleDeletionModal = (): void => {
setDeletionModal(!deletionModal);
};
/**
* The deletion has been confirmed by the user.
* Call the API to trigger the deletion of the temporary set extended price
*/
const onDeleteConfirmed = (): void => {
PriceAPI.destroy(price.id).then(() => {
onSuccess(t('app.admin.delete_extended_price.extended_price_deleted'));
}).catch((error) => {
onError(t('app.admin.delete_extended_price.unable_to_delete') + error);
});
toggleDeletionModal();
};
return (
<div className="delete-extended-price">
<FabButton type='button' className="remove-price-button" icon={<i className="fa fa-trash" />} onClick={toggleDeletionModal} />
<FabModal title={t('app.admin.delete_extended_price.delete_extended_price')}
isOpen={deletionModal}
toggleModal={toggleDeletionModal}
closeButton={true}
confirmButton={t('app.admin.delete_extended_price.confirm_delete')}
onConfirm={onDeleteConfirmed}>
<span>{t('app.admin.delete_extended_price.delete_confirmation')}</span>
</FabModal>
</div>
);
};

View File

@ -0,0 +1,66 @@
import React, { useState } from 'react';
import { FabModal } from '../../base/fab-modal';
import { ExtendedPriceForm } from './extended-price-form';
import { Price } from '../../../models/price';
import PriceAPI from '../../../api/price';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../../base/fab-button';
interface EditExtendedPriceProps {
price: Price,
onSuccess: (message: string) => void,
onError: (message: string) => void
}
/**
* This component shows a button.
* When clicked, we show a modal dialog handing the process of creating a new extended price
*/
export const EditExtendedPrice: React.FC<EditExtendedPriceProps> = ({ price, onSuccess, onError }) => {
const { t } = useTranslation('admin');
const [isOpen, setIsOpen] = useState<boolean>(false);
const [extendedPriceData, setExtendedPriceData] = useState<Price>(price);
/**
* Open/closes the "edit extended price" modal dialog
*/
const toggleModal = (): void => {
setIsOpen(!isOpen);
};
/**
* When the user clicks on the edition button open te edition modal
*/
const handleRequestEdit = (): void => {
toggleModal();
};
/**
* Callback triggered when the user has validated the changes of the extended price
*/
const handleUpdate = (price: Price): void => {
PriceAPI.update(price)
.then(() => {
onSuccess(t('app.admin.edit_extended_price.extended_price_successfully_updated'));
setExtendedPriceData(price);
toggleModal();
})
.catch(error => onError(error));
};
return (
<div className="edit-extended-price">
<FabButton type='button' className="edit-price-button" icon={<i className="fas fa-edit" />} onClick={handleRequestEdit} />
<FabModal isOpen={isOpen}
toggleModal={toggleModal}
title={t('app.admin.edit_extended_price.edit_extended_price')}
className="edit-pack-modal"
closeButton
confirmButton={t('app.admin.edit_extended_price.confirm_changes')}
onConfirmSendFormId="edit-extended-price">
{extendedPriceData && <ExtendedPriceForm formId="edit-extended-price" onSubmit={handleUpdate} price={extendedPriceData} />}
</FabModal>
</div>
);
};

View File

@ -0,0 +1,74 @@
import React, { BaseSyntheticEvent } from 'react';
import { Price } from '../../../models/price';
import { useTranslation } from 'react-i18next';
import { useImmer } from 'use-immer';
import { FabInput } from '../../base/fab-input';
import { IFablab } from '../../../models/fablab';
declare let Fablab: IFablab;
interface ExtendedPriceFormProps {
formId: string,
onSubmit: (price: Price) => void,
price?: Price,
}
/**
* A form component to create/edit a extended price.
* The form validation must be created elsewhere, using the attribute form={formId}.
*/
export const ExtendedPriceForm: React.FC<ExtendedPriceFormProps> = ({ formId, onSubmit, price }) => {
const [extendedPriceData, updateExtendedPriceData] = useImmer<Price>(price || {} as Price);
const { t } = useTranslation('admin');
/**
* Callback triggered when the user sends the form.
*/
const handleSubmit = (event: BaseSyntheticEvent): void => {
event.preventDefault();
onSubmit(extendedPriceData);
};
/**
* Callback triggered when the user inputs an amount for the current extended price.
*/
const handleUpdateAmount = (amount: string) => {
updateExtendedPriceData(draft => {
draft.amount = parseFloat(amount);
});
};
/**
* Callback triggered when the user inputs a number of minutes for the current extended price.
*/
const handleUpdateHours = (minutes: string) => {
updateExtendedPriceData(draft => {
draft.duration = parseInt(minutes, 10);
});
};
return (
<form id={formId} onSubmit={handleSubmit} className="extended-price-form">
<label htmlFor="duration">{t('app.admin.extended_price_form.duration')} *</label>
<FabInput id="duration"
type="number"
defaultValue={extendedPriceData?.duration || ''}
onChange={handleUpdateHours}
step={1}
min={1}
icon={<i className="fas fa-clock" />}
required />
<label htmlFor="amount">{t('app.admin.extended_price_form.amount')} *</label>
<FabInput id="amount"
type="number"
step={0.01}
min={0}
defaultValue={extendedPriceData?.amount || ''}
onChange={handleUpdateAmount}
icon={<i className="fas fa-money-bill" />}
addOn={Fablab.intl_currency}
required />
</form>
);
};

View File

@ -0,0 +1,146 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { Loader } from '../../base/loader';
import { FabAlert } from '../../base/fab-alert';
import { HtmlTranslate } from '../../base/html-translate';
import SpaceAPI from '../../../api/space';
import GroupAPI from '../../../api/group';
import { Group } from '../../../models/group';
import { IApplication } from '../../../models/application';
import { Space } from '../../../models/space';
import { EditablePrice } from '../editable-price';
import { ConfigureExtendedPriceButton } from './configure-extended-price-button';
import PriceAPI from '../../../api/price';
import { Price } from '../../../models/price';
import { useImmer } from 'use-immer';
import FormatLib from '../../../lib/format';
declare const Application: IApplication;
interface SpacesPricingProps {
onError: (message: string) => void,
onSuccess: (message: string) => void,
}
/**
* Interface to set and edit the prices of spaces-hours, per group
*/
const SpacesPricing: React.FC<SpacesPricingProps> = ({ onError, onSuccess }) => {
const { t } = useTranslation('admin');
const [spaces, setSpaces] = useState<Array<Space>>(null);
const [groups, setGroups] = useState<Array<Group>>(null);
const [prices, updatePrices] = useImmer<Array<Price>>([]);
// retrieve the initial data
useEffect(() => {
SpaceAPI.index()
.then(data => setSpaces(data))
.catch(error => onError(error));
GroupAPI.index({ disabled: false, admins: false })
.then(data => setGroups(data))
.catch(error => onError(error));
PriceAPI.index({ priceable_type: 'Space', plan_id: null })
.then(data => updatePrices(data))
.catch(error => onError(error));
}, []);
// duration of the example slot
const EXEMPLE_DURATION = 20;
/**
* Return the exemple price, formatted
*/
const examplePrice = (type: 'hourly_rate' | 'final_price'): string => {
const hourlyRate = 10;
if (type === 'hourly_rate') {
return FormatLib.price(hourlyRate);
}
const price = (hourlyRate / 60) * EXEMPLE_DURATION;
return FormatLib.price(price);
};
/**
* Find the default price (hourly rate) matching the given criterion
*/
const findPriceBy = (spaceId, groupId): Price => {
return prices.find(price => price.priceable_id === spaceId && price.group_id === groupId && price.duration === 60);
};
/**
* Find prices matching the given criterion, except the default hourly rate
*/
const findExtendedPricesBy = (spaceId, groupId): Array<Price> => {
return prices.filter(price => price.priceable_id === spaceId && price.group_id === groupId && price.duration !== 60);
};
/**
* Update the given price in the internal state
*/
const updatePrice = (price: Price): void => {
updatePrices(draft => {
const index = draft.findIndex(p => p.id === price.id);
draft[index] = price;
return draft;
});
};
/**
* Callback triggered when the user has confirmed to update a price
*/
const handleUpdatePrice = (price: Price): void => {
PriceAPI.update(price)
.then(() => {
onSuccess(t('app.admin.spaces_pricing.price_updated'));
updatePrice(price);
})
.catch(error => onError(error));
};
return (
<div className="spaces-pricing">
<FabAlert level="warning">
<p><HtmlTranslate trKey="app.admin.spaces_pricing.prices_match_space_hours_rates_html"/></p>
<p><HtmlTranslate trKey="app.admin.spaces_pricing.prices_calculated_on_hourly_rate_html" options={{ DURATION: `${EXEMPLE_DURATION}`, RATE: examplePrice('hourly_rate'), PRICE: examplePrice('final_price') }} /></p>
<p>{t('app.admin.spaces_pricing.you_can_override')}</p>
<p>{t('app.admin.spaces_pricing.extended_prices')}</p>
</FabAlert>
<table>
<thead>
<tr>
<th>{t('app.admin.spaces_pricing.spaces')}</th>
{groups?.map(group => <th key={group.id} className="group-name">{group.name}</th>)}
</tr>
</thead>
<tbody>
{spaces?.map(space => <tr key={space.id}>
<td>{space.name}</td>
{groups?.map(group => <td key={group.id}>
{prices.length && <EditablePrice price={findPriceBy(space.id, group.id)} onSave={handleUpdatePrice} />}
<ConfigureExtendedPriceButton
prices={findExtendedPricesBy(space.id, group.id)}
onError={onError}
onSuccess={onSuccess}
groupId={group.id}
priceableId={space.id}
priceableType='Space' />
</td>)}
</tr>)}
</tbody>
</table>
</div>
);
};
const SpacesPricingWrapper: React.FC<SpacesPricingProps> = ({ onError, onSuccess }) => {
return (
<Loader>
<SpacesPricing onError={onError} onSuccess={onSuccess} />
</Loader>
);
};
Application.Components.component('spacesPricing', react2angular(SpacesPricingWrapper, ['onError', 'onSuccess']));

View File

@ -407,10 +407,30 @@ Application.Controllers.controller('AdminCalendarController', ['$scope', '$state
// check if slot is not in the past
const today = new Date();
if (Math.trunc((start.valueOf() - today) / (60 * 1000)) < 0) {
growl.warning(_t('app.admin.calendar.event_in_the_past'));
return uiCalendarConfig.calendars.calendar.fullCalendar('unselect');
return dialogs.confirm({
resolve: {
object () {
return {
title: _t('app.admin.calendar.event_in_the_past'),
msg: _t('app.admin.calendar.confirm_create_event_in_the_past')
};
}
}
},
function () { // confirmed
startAvailabilityCreation(start, end);
}, function () { // canceled
uiCalendarConfig.calendars.calendar.fullCalendar('unselect');
});
}
startAvailabilityCreation(start, end);
};
/**
* Start the process to create a new availability between the given start and end datetimes
*/
const startAvailabilityCreation = function (start, end) {
// check that the selected slot is an multiple of SLOT_MULTIPLE (ie. not decimal)
const slots = Math.trunc((end.valueOf() - start.valueOf()) / (60 * 1000)) / SLOT_MULTIPLE;
if (!Number.isInteger(slots)) {

View File

@ -92,6 +92,15 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
active: false,
templateUrl: '/admin/invoices/settings/editVAT.html'
},
multiVAT: {
rateMachine: '',
rateSpace: '',
rateTraining: '',
rateEvent: '',
rateSubscription: '',
editTemplateUrl: '/admin/invoices/settings/editMultiVAT.html',
historyTemplateUrl: '/admin/invoices/settings/multiVATHistory.html'
},
text: {
content: ''
},
@ -217,6 +226,14 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
// Is shown the modal dialog to select a payment gateway
$scope.openSelectGatewayModal = false;
/**
* Return the VAT rate applicable to the machine reservations
* @return {number}
*/
$scope.getMachineExampleRate = function () {
return $scope.invoice.multiVAT.rateMachine || $scope.invoice.VAT.rate;
};
/**
* Change the invoices ordering criterion to the one provided
* @param orderBy {string} ordering criterion
@ -446,6 +463,9 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
active () {
return $scope.invoice.VAT.active;
},
multiVAT () {
return $scope.invoice.multiVAT;
},
rateHistory () {
return Setting.get({ name: 'invoice_VAT-rate', history: true }).$promise;
},
@ -453,13 +473,74 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
return Setting.get({ name: 'invoice_VAT-active', history: true }).$promise;
}
},
controller: ['$scope', '$uibModalInstance', 'rate', 'active', 'rateHistory', 'activeHistory', function ($scope, $uibModalInstance, rate, active, rateHistory, activeHistory) {
controller: ['$scope', '$uibModalInstance', 'rate', 'active', 'rateHistory', 'activeHistory', 'multiVAT', function ($scope, $uibModalInstance, rate, active, rateHistory, activeHistory, multiVAT) {
$scope.rate = rate;
$scope.isSelected = active;
$scope.history = [];
$scope.ok = function () { $uibModalInstance.close({ rate: $scope.rate, active: $scope.isSelected }); };
$scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
$scope.editMultiVAT = function () {
const editMultiVATModalInstance = $uibModal.open({
animation: true,
templateUrl: multiVAT.editTemplateUrl,
size: 'lg',
resolve: {
rate () {
return $scope.rate;
},
multiVAT () {
return multiVAT;
}
},
controller: ['$scope', '$uibModalInstance', 'rate', 'multiVAT', function ($scope, $uibModalInstance, rate, multiVAT) {
$scope.rate = rate;
$scope.multiVAT = multiVAT;
$scope.ok = function () { $uibModalInstance.close({ multiVAT: $scope.multiVAT }); };
$scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
$scope.showMultiRateHistory = function (rateType) {
$uibModal.open({
animation: true,
templateUrl: multiVAT.historyTemplateUrl,
size: 'lg',
resolve: {
rateHistory () {
return Setting.get({ name: `invoice_VAT-rate_${rateType}`, history: true }).$promise;
}
},
controller: ['$scope', '$uibModalInstance', 'rateHistory', function ($scope, $uibModalInstance, rateHistory) {
$scope.history = [];
$scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
const initialize = function () {
rateHistory.setting.history.forEach(function (rate) {
$scope.history.push({ date: rate.created_at, rate: rate.value, user: rate.user });
});
};
initialize();
}]
});
};
}]
});
return editMultiVATModalInstance.result.then(function (result) {
['Machine', 'Space', 'Training', 'Event', 'Subscription'].forEach(rateType => {
Setting.update({ name: `invoice_VAT-rate_${rateType}` }, { value: result.multiVAT[`rate${rateType}`] + '' }, function (data) {
return growl.success(_t('app.admin.invoices.VAT_rate_successfully_saved'));
}
, function (error) {
if (error.status === 304) return;
growl.error(_t('app.admin.invoices.an_error_occurred_while_saving_the_VAT_rate'));
console.error(error);
});
});
});
};
const initialize = function () {
rateHistory.setting.history.forEach(function (rate) {
@ -943,6 +1024,11 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
$scope.invoice.text.content = settings.invoice_text;
$scope.invoice.VAT.rate = parseFloat(settings['invoice_VAT-rate']);
$scope.invoice.VAT.active = (settings['invoice_VAT-active'] === 'true');
$scope.invoice.multiVAT.rateMachine = settings['invoice_VAT-rate_Machine'] ? parseFloat(settings['invoice_VAT-rate_Machine']) : '';
$scope.invoice.multiVAT.rateSpace = settings['invoice_VAT-rate_Space'] ? parseFloat(settings['invoice_VAT-rate_Space']) : '';
$scope.invoice.multiVAT.rateTraining = settings['invoice_VAT-rate_Training'] ? parseFloat(settings['invoice_VAT-rate_Training']) : '';
$scope.invoice.multiVAT.rateEvent = settings['invoice_VAT-rate_Event'] ? parseFloat(settings['invoice_VAT-rate_Event']) : '';
$scope.invoice.multiVAT.rateSubscription = settings['invoice_VAT-rate_Subscription'] ? parseFloat(settings['invoice_VAT-rate_Subscription']) : '';
$scope.invoice.number.model = settings['invoice_order-nb'];
$scope.invoice.code.model = settings['invoice_code-value'];
$scope.invoice.code.active = (settings['invoice_code-active'] === 'true');
@ -1328,6 +1414,16 @@ Application.Controllers.controller('AccountingExportModalController', ['$scope',
decimalSeparator: ',',
exportInvoicesAtZero: false,
columns: ['journal_code', 'date', 'account_code', 'account_label', 'piece', 'line_label', 'debit_origin', 'credit_origin', 'debit_euro', 'credit_euro', 'lettering']
},
vat: {
format: 'csv',
encoding: 'UTF-8',
separator: ';',
dateFormat: '%Y-%m-%d',
labelMaxLength: 'N/A',
decimalSeparator: '.',
exportInvoicesAtZero: false,
columns: ['start_date', 'end_date', 'vat_rate', 'amount']
}
};
@ -1347,6 +1443,7 @@ Application.Controllers.controller('AccountingExportModalController', ['$scope',
// binding to radio button "export to"
$scope.exportTarget = {
type: null,
software: null,
startDate: null,
endDate: null,

View File

@ -461,7 +461,7 @@ Application.Controllers.controller('EditPricingController', ['$scope', '$state',
*/
$scope.findPriceBy = function (prices, machineId, groupId) {
for (const price of Array.from(prices)) {
if ((price.priceable_id === machineId) && (price.group_id === groupId)) {
if ((price.priceable_id === machineId) && (price.group_id === groupId) && (price.duration === 60)) {
return price;
}
}

View File

@ -1,11 +1,18 @@
'use strict';
Application.Controllers.controller('HeaderController', ['$scope', '$rootScope', '$state',
function ($scope, $rootScope, $state) {
Application.Controllers.controller('HeaderController', ['$scope', '$rootScope', '$state', 'settingsPromise',
function ($scope, $rootScope, $state, settingsPromise) {
$scope.aboutPage = ($state.current.name === 'app.public.about');
$rootScope.$on('$stateChangeStart', function (event, toState) {
$scope.aboutPage = (toState.name === 'app.public.about');
});
/**
* Returns the current state of the public registration setting (allowed/blocked).
*/
$scope.registrationEnabled = function () {
return settingsPromise.public_registrations === 'true';
};
}
]);

View File

@ -13,7 +13,7 @@
/**
* Navigation controller. List the links availables in the left navigation pane and their icon.
*/
Application.Controllers.controller('MainNavController', ['$scope', function ($scope) {
Application.Controllers.controller('MainNavController', ['$scope', 'settingsPromise', function ($scope, settingsPromise) {
// Common links (public application)
$scope.navLinks = [
{
@ -172,5 +172,12 @@ Application.Controllers.controller('MainNavController', ['$scope', function ($sc
authorizedRoles: ['admin']
});
}
/**
* Returns the current state of the public registration setting (allowed/blocked).
*/
$scope.registrationEnabled = function () {
return settingsPromise.public_registrations === 'true';
};
}
]);

View File

@ -4,7 +4,8 @@ export enum PaymentScheduleItemState {
RequirePaymentMethod = 'requires_payment_method',
RequireAction = 'requires_action',
Paid = 'paid',
Error = 'error'
Error = 'error',
GatewayCanceled = 'gateway_canceled'
}
export enum PaymentMethod {
@ -26,7 +27,7 @@ export interface PaymentSchedule {
id: number,
total: number,
reference: string,
payment_method: string,
payment_method: 'card' | 'transfer' | '',
items: Array<PaymentScheduleItem>,
created_at: Date,
chained_footprint: boolean,
@ -43,9 +44,7 @@ export interface PaymentSchedule {
first_name: string,
last_name: string,
},
gateway_subscription: {
classname: string
}
gateway: 'PayZen' | 'Stripe',
}
export interface PaymentScheduleIndexRequest {

View File

@ -18,7 +18,8 @@ export interface IntentConfirmation {
export enum PaymentMethod {
Card = 'card',
Other = ''
Check = 'check',
Transfer = 'transfer'
}
export type CartItem = { reservation: Reservation }|

View File

@ -11,7 +11,8 @@ export interface Price {
plan_id: number,
priceable_type: string,
priceable_id: number,
amount: number
amount: number,
duration?: number // in minutes
}
export interface ComputePriceResult {

View File

@ -20,6 +20,11 @@ export enum SettingName {
InvoiceOrderNb = 'invoice_order-nb',
InvoiceVATActive = 'invoice_VAT-active',
InvoiceVATRate = 'invoice_VAT-rate',
InvoiceVATRateMachine = 'invoice_VAT-rate_Machine',
InvoiceVATRateTraining = 'invoice_VAT-rate_Training',
InvoiceVATRateSpace = 'invoice_VAT-rate_Space',
InvoiceVATRateEvent = 'invoice_VAT-rate_Event',
InvoiceVATRateSubscription = 'invoice_VAT-rate_Subscription',
InvoiceText = 'invoice_text',
InvoiceLegals = 'invoice_legals',
BookingWindowStart = 'booking_window_start',
@ -111,7 +116,9 @@ export enum SettingName {
PublicAgendaModule = 'public_agenda_module',
RenewPackThreshold = 'renew_pack_threshold',
PackOnlyForSubscription = 'pack_only_for_subscription',
OverlappingCategories = 'overlapping_categories'
OverlappingCategories = 'overlapping_categories',
ExtendedPricesInSameDay = 'extended_prices_in_same_day',
PublicRegistrations = 'public_registrations'
}
export type SettingValue = string|boolean|number;

View File

@ -0,0 +1,15 @@
export interface Space {
id: number,
name: string,
description: string,
slug: string,
default_places: number,
disabled: boolean,
space_image: string,
space_file_attributes?: {
id: number,
attachment: string,
attachement_url: string,
}
}

View File

@ -38,7 +38,8 @@ angular.module('application.router', ['ui.router'])
logoFile: ['CustomAsset', function (CustomAsset) { return CustomAsset.get({ name: 'logo-file' }).$promise; }],
logoBlackFile: ['CustomAsset', function (CustomAsset) { return CustomAsset.get({ name: 'logo-black-file' }).$promise; }],
sharedTranslations: ['Translations', function (Translations) { return Translations.query(['app.shared', 'app.public.common']).$promise; }],
modulesPromise: ['Setting', function (Setting) { return Setting.query({ names: "['spaces_module', 'plans_module', 'invoicing_module', 'wallet_module', 'statistics_module', 'trainings_module', 'public_agenda_module']" }).$promise; }]
modulesPromise: ['Setting', function (Setting) { return Setting.query({ names: "['spaces_module', 'plans_module', 'invoicing_module', 'wallet_module', 'statistics_module', 'trainings_module', 'public_agenda_module']" }).$promise; }],
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['public_registrations']" }).$promise; }]
},
onEnter: ['$rootScope', 'logoFile', 'logoBlackFile', 'modulesPromise', 'CSRF', function ($rootScope, logoFile, logoBlackFile, modulesPromise, CSRF) {
// Retrieve Anti-CSRF tokens from cookies
@ -869,7 +870,8 @@ angular.module('application.router', ['ui.router'])
resolve: {
settings: ['Setting', function (Setting) {
return Setting.query({
names: "['invoice_legals', 'invoice_text', 'invoice_VAT-rate', 'invoice_VAT-active', 'invoice_order-nb', 'invoice_code-value', " +
names: "['invoice_legals', 'invoice_text', 'invoice_VAT-rate', 'invoice_VAT-rate_Machine', 'invoice_VAT-rate_Training', 'invoice_VAT-rate_Space', " +
"'invoice_VAT-rate_Event', 'invoice_VAT-rate_Subscription', 'invoice_VAT-active', 'invoice_order-nb', 'invoice_code-value', " +
"'invoice_code-active', 'invoice_reference', 'invoice_logo', 'accounting_journal_code', 'accounting_card_client_code', " +
"'accounting_card_client_label', 'accounting_wallet_client_code', 'accounting_wallet_client_label', 'invoicing_module', " +
"'accounting_other_client_code', 'accounting_other_client_label', 'accounting_wallet_code', 'accounting_wallet_label', " +
@ -1080,7 +1082,8 @@ angular.module('application.router', ['ui.router'])
"'reminder_delay', 'visibility_yearly', 'visibility_others', 'wallet_module', 'trainings_module', " +
"'display_name_enable', 'machines_sort_by', 'fab_analytics', 'statistics_module', 'address_required', " +
"'link_name', 'home_content', 'home_css', 'phone_required', 'upcoming_events_shown', 'public_agenda_module'," +
"'renew_pack_threshold', 'pack_only_for_subscription', 'overlapping_categories']"
"'renew_pack_threshold', 'pack_only_for_subscription', 'overlapping_categories', 'public_registrations'," +
"'extended_prices_in_same_day']"
}).$promise;
}],
privacyDraftsPromise: ['Setting', function (Setting) { return Setting.get({ name: 'privacy_draft', history: true }).$promise; }],

View File

@ -57,12 +57,18 @@
@import "modules/machines/machines-filters";
@import "modules/machines/required-training-modal";
@import "modules/user/avatar";
@import "modules/pricing/machines-pricing";
@import "modules/pricing/editable-price";
@import "modules/pricing/configure-packs-button";
@import "modules/pricing/pack-form";
@import "modules/pricing/delete-pack";
@import "modules/pricing/edit-pack";
@import "modules/pricing/machines/machines-pricing";
@import "modules/pricing/machines/configure-packs-button";
@import "modules/pricing/machines/pack-form";
@import "modules/pricing/machines/delete-pack";
@import "modules/pricing/machines/edit-pack";
@import "modules/pricing/machines/create-pack";
@import "modules/pricing/spaces/configure-extended-prices-button";
@import "modules/pricing/spaces/create-extended-price";
@import "modules/pricing/spaces/delete-extended-price";
@import "modules/pricing/spaces/edit-extended-price";
@import "modules/pricing/spaces/spaces-pricing";
@import "modules/settings/check-list-setting";
@import "modules/prepaid-packs/propose-packs-modal";
@import "modules/prepaid-packs/packs-summary";

View File

@ -367,3 +367,7 @@ table.export-table-template {
height: 30px;
}
}
.multi-vat-rate-input {
width: 90% !important;
}

View File

@ -18,13 +18,6 @@
color: white;
}
}
.popover-title {
.add-pack-button {
position: absolute;
right: 5px;
top: 10px;
}
}
.popover-content {
ul {

View File

@ -0,0 +1,7 @@
.create-pack {
.add-pack-button {
position: absolute;
right: 5px;
top: 10px;
}
}

View File

@ -0,0 +1,49 @@
.configure-extended-prices-button {
display: inline-block;
margin-left: 6px;
position: relative;
.extended-prices-button {
border: 1px solid #d0cccc;
border-radius: 50%;
cursor: pointer;
width: 30px;
height: 30px;
display: inline-block;
padding: 2px 6px;
box-shadow: 0 1px 1px 0 #abaaaa;
&:hover {
background-color: #b9b9b9;
color: white;
}
}
.popover-content {
ul {
padding-left: 19px;
li {
display: flex;
justify-content: space-between;
&::before {
content: '\f466';
font-family: 'Font Awesome 5 Free';
position: absolute;
left: 11px;
font-weight: 800;
font-size: 12px;
vertical-align: middle;
line-height: 24px;
}
.extended-prices-actions button {
font-size: 10px;
vertical-align: middle;
line-height: 10px;
height: auto;
}
}
}
}
}

View File

@ -0,0 +1,7 @@
.create-extended-price {
.add-price-button {
position: absolute;
right: 5px;
top: 10px;
}
}

View File

@ -0,0 +1,8 @@
.delete-extended-price {
display: inline;
.remove-price-button {
background-color: #cb1117;
color: white;
}
}

View File

@ -0,0 +1,3 @@
.edit-extended-price {
display: inline-block;
}

View File

@ -0,0 +1,10 @@
.extended-price-form {
.interval-inputs {
display: flex;
.select-interval {
min-width: 49%;
margin-left: 4px;
}
}
}

View File

@ -0,0 +1,31 @@
.spaces-pricing {
.fab-alert {
margin: 15px 0;
}
table {
overflow-y: scroll;
thead > tr > th:first-child {
width: 20%;
}
thead > tr > th.group-name {
width: 20%;
text-transform: uppercase;
font-size: 1.4rem;
}
thead > tr > th {
vertical-align: bottom;
border-bottom: 2px solid #ddd;
padding: 8px;
line-height: 1.5;
}
tbody > tr > td {
padding: 8px;
line-height: 1.5;
vertical-align: top;
border-top: 1px solid #ddd;
}
}
}

View File

@ -42,11 +42,15 @@
</div>
</div>
<div class="row">
<h4 class="control-label m-l" translate>{{ 'app.admin.invoices.export_to' }}</h4>
<h4 class="control-label m-l" translate>{{ 'app.admin.invoices.export_what' }}</h4>
<div class="form-group m-l-lg">
<label for="acd">
<label for="vat" class="block">
<input type="radio" name="vat" id="vat" ng-model="exportTarget.software" ng-value="'vat'" ng-click="fillSettings('vat')" required/>
{{ 'app.admin.invoices.export_VAT' | translate }}
</label>
<label for="acd" class="block">
<input type="radio" name="acd" id="acd" ng-model="exportTarget.software" ng-value="'acd'" ng-click="fillSettings('acd')" required/>
{{ 'app.admin.invoices.acd' | translate }}
{{ 'app.admin.invoices.export_to_ACD' | translate }}
</label>
</div>
</div>

View File

@ -54,12 +54,12 @@
</tr>
<tr class="invoice-vat invoice-editable vat-line italic" ng-click="openEditVAT()" ng-show="invoice.VAT.active">
<td>{{ 'app.admin.invoices.including_VAT' | translate }} {{invoice.VAT.rate}} %</td>
<td>{{30-(30/(invoice.VAT.rate/100+1)) | currency}}</td>
<td translate translate-values="{RATE:getMachineExampleRate(), AMOUNT:(30.0 | currency)}">{{ 'app.admin.invoices.including_VAT' }}</td>
<td>{{30-(30/(getMachineExampleRate()/100+1)) | currency}}</td>
</tr>
<tr class="invoice-ht vat-line italic" ng-show="invoice.VAT.active">
<td translate>{{ 'app.admin.invoices.including_total_excluding_taxes' }}</td>
<td>{{30/(invoice.VAT.rate/100+1) | currency}}</td>
<td>{{30/(getMachineExampleRate()/100+1) | currency}}</td>
</tr>
<tr class="invoice-payed vat-line bold" ng-show="invoice.VAT.active">
<td translate>{{ 'app.admin.invoices.including_amount_payed_on_ordering' }}</td>

View File

@ -0,0 +1,57 @@
<div class="custom-invoice">
<div class="modal-header">
<h3 class="modal-title" translate>{{ 'app.admin.invoices.multiVAT' }}</h3>
</div>
<div class="modal-body">
<uib-alert type="warning">
<p class="text-sm">
<i class="fa fa-warning"></i>
<span ng-bind-html="'app.admin.invoices.multi_VAT_notice' | translate:{ RATE: rate }"></span>
</p>
</uib-alert>
<div class="form-group">
<label for="vatRateMachine" class="control-label" translate>{{ 'app.admin.invoices.VAT_rate_machine' }}</label>
<div class="input-group">
<span class="input-group-addon">% </span>
<input id="vatRateMachine" type="number" ng-model="multiVAT.rateMachine" class="form-control multi-vat-rate-input" min="0" max="100"/>
<button class="btn pull-right" ng-click="showMultiRateHistory('Machine')"><i class="fa fa-history"></i></button>
</div>
</div>
<div class="form-group">
<label for="vatRateSpace" class="control-label" translate>{{ 'app.admin.invoices.VAT_rate_space' }}</label>
<div class="input-group">
<span class="input-group-addon">% </span>
<input id="vatRateSpace" type="number" ng-model="multiVAT.rateSpace" class="form-control multi-vat-rate-input" min="0" max="100"/>
<button class="btn pull-right" ng-click="showMultiRateHistory('Space')"><i class="fa fa-history"></i></button>
</div>
</div>
<div class="form-group">
<label for="vatRateTraining" class="control-label" translate>{{ 'app.admin.invoices.VAT_rate_training' }}</label>
<div class="input-group">
<span class="input-group-addon">% </span>
<input id="vatRateTraining" type="number" ng-model="multiVAT.rateTraining" class="form-control multi-vat-rate-input" min="0" max="100"/>
<button class="btn pull-right" ng-click="showMultiRateHistory('Training')"><i class="fa fa-history"></i></button>
</div>
</div>
<div class="form-group">
<label for="vatRateEvent" class="control-label" translate>{{ 'app.admin.invoices.VAT_rate_event' }}</label>
<div class="input-group">
<span class="input-group-addon">% </span>
<input id="vatRateEvent" type="number" ng-model="multiVAT.rateEvent" class="form-control multi-vat-rate-input" min="0" max="100"/>
<button class="btn pull-right" ng-click="showMultiRateHistory('Event')"><i class="fa fa-history"></i></button>
</div>
</div>
<div class="form-group">
<label for="vatRateSubscription" class="control-label" translate>{{ 'app.admin.invoices.VAT_rate_subscription' }}</label>
<div class="input-group">
<span class="input-group-addon">% </span>
<input id="vatRateSubscription" type="number" ng-model="multiVAT.rateSubscription" class="form-control multi-vat-rate-input" min="0" max="100"/>
<button class="btn pull-right" ng-click="showMultiRateHistory('Subscription')"><i class="fa fa-history"></i></button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-warning" ng-click="ok()" translate>{{ 'app.shared.buttons.confirm' }}</button>
<button class="btn btn-default" ng-click="cancel()" translate>{{ 'app.shared.buttons.cancel' }}</button>
</div>
</div>

View File

@ -22,6 +22,12 @@
<input id="vatRate" type="number" ng-model="rate" class="form-control" min="0" max="100"/>
</div>
</div>
<uib-alert type="warning" ng-show="isSelected">
<p class="text-sm">
<i class="fa fa-warning"></i>
<span>{{ 'app.admin.invoices.VAT_notice' | translate }}</span>
</p>
</uib-alert>
<div class="m-t-lg">
<h4 translate>{{ 'app.admin.invoices.VAT_history' }}</h4>
@ -48,6 +54,7 @@
</div>
</div>
<div class="modal-footer">
<button class="btn btn-warning pull-left" ng-click="editMultiVAT()" ng-show="isSelected" translate>{{ 'app.admin.invoices.edit_multi_VAT_button' }}</button>
<button class="btn btn-warning" ng-click="ok()" translate>{{ 'app.shared.buttons.confirm' }}</button>
<button class="btn btn-default" ng-click="cancel()" translate>{{ 'app.shared.buttons.cancel' }}</button>
</div>

View File

@ -0,0 +1,32 @@
<div class="custom-invoice">
<div class="modal-header">
<h3 class="modal-title" translate>{{ 'app.admin.invoices.VAT_history' }}</h3>
</div>
<div class="modal-body">
<div>
<table class="table scrollable-3-cols">
<thead>
<tr>
<th translate>{{ 'app.admin.invoices.VAT_rate' }}</th>
<th translate>{{ 'app.admin.invoices.changed_at' }}</th>
<th translate>{{ 'app.admin.invoices.changed_by' }}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="value in history | orderBy:'-date'">
<td>
<span class="no-user-label" ng-show="value.enabled === false" translate>{{'app.admin.invoices.VAT_disabled'}}</span>
<span class="no-user-label" ng-show="value.enabled === true" translate>{{'app.admin.invoices.VAT_enabled'}}</span>
<span ng-show="value.rate">{{value.rate}}</span>
</td>
<td>{{value.date | amDateFormat:'L LT'}}</td>
<td>{{value.user.name}}<span class="no-user-label" ng-hide="value.user" translate>{{ 'app.admin.invoices.deleted_user' }}</span></td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-default" ng-click="cancel()" translate>{{ 'app.shared.buttons.cancel' }}</button>
</div>
</div>

View File

@ -1,29 +1 @@
<div class="alert alert-warning m-t">
<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>
<tr>
<th style="width:20%" translate>{{ 'app.admin.pricing.spaces' }}</th>
<th style="width:20%" ng-repeat="group in enabledGroups">
<span class="text-u-c text-sm">{{group.name}}</span>
</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="space in enabledSpaces">
<td>
{{ space.name }}
</td>
<td ng-repeat="group in enabledGroups">
<span editable-number="findPriceBy(spacesPrices, space.id, group.id).amount"
e-step="any"
onbeforesave="updatePrice($data, findPriceBy(spacesPrices, space.id, group.id))">
{{ findPriceBy(spacesPrices, space.id, group.id).amount | currency}}
</span>
</td>
</tr>
</tbody>
</table>
<spaces-pricing on-success="onSuccess" on-error="onError">

View File

@ -412,6 +412,18 @@
<span class="font-sbold" translate>{{ 'app.admin.settings.account_creation' }}</span>
</div>
<div class="panel-body">
<div class="row">
<h3 class="m-l" translate>{{ 'app.admin.settings.general.public_registrations' }}</h3>
<p class="alert alert-warning m-h-md" translate>
{{ 'app.admin.settings.general.public_registrations_info' }}
</p>
<div class="col-md-10 col-md-offset-1">
<boolean-setting name="public_registrations"
settings="allSettings"
label="app.admin.settings.general.public_registrations_allowed">
</boolean-setting>
</div>
</div>
<div class="row">
<h3 class="m-l" translate>{{ 'app.admin.settings.phone' }}</h3>
<p class="alert alert-warning m-h-md" translate>

View File

@ -117,6 +117,28 @@
required="true">
</number-setting>
</div>
<div class="section-separator"></div>
<div class="row">
<h3 class="m-l" translate>{{ 'app.admin.settings.pack_only_for_subscription_info' }}</h3>
<p class="alert alert-warning m-h-md" ng-bind-html="'app.admin.settings.pack_only_for_subscription_info_html' | translate"></p>
<boolean-setting name="pack_only_for_subscription"
settings="allSettings"
label="app.admin.settings.pack_only_for_subscription"
classes="m-l">
</boolean-setting>
</div>
<div class="section-separator"></div>
<div class="row">
<h3 class="m-l" translate>{{ 'app.admin.settings.extended_prices' }}</h3>
<p class="alert alert-warning m-h-md" ng-bind-html="'app.admin.settings.extended_prices_info_html' | translate"></p>
<boolean-setting name="extended_prices_in_same_day"
settings="allSettings"
label="app.admin.settings.extended_prices_in_same_day"
classes="m-l">
</boolean-setting>
</div>
</div>
</div>
</div>
@ -170,6 +192,8 @@
label="app.admin.settings.show_event"
classes="m-l"></boolean-setting>
</div>
<div class="section-separator"></div>
<div class="row">
<h3 class="m-l" translate>{{ 'app.admin.settings.display_invite_to_renew_pack' }}</h3>
<p class="alert alert-warning m-h-md" ng-bind-html="'app.admin.settings.packs_threshold_info_html' | translate"></p>
@ -182,14 +206,5 @@
step="0.01">
</number-setting>
</div>
<div class="row">
<h3 class="m-l" translate>{{ 'app.admin.settings.pack_only_for_subscription_info' }}</h3>
<p class="alert alert-warning m-h-md" ng-bind-html="'app.admin.settings.pack_only_for_subscription_info_html' | translate"></p>
<boolean-setting name="pack_only_for_subscription"
settings="allSettings"
label="app.admin.settings.pack_only_for_subscription"
classes="m-l">
</boolean-setting>
</div>
</div>
</div>

View File

@ -53,7 +53,7 @@
<li><a class="text-black pointer" ng-click="logout($event)"><i class="fa fa-power-off"></i> {{ 'app.public.common.sign_out' | translate }}</a></li>
</ul>
</li>
<li ng-if="!isAuthenticated()"><a class="font-sbold label text-md pointer" ng-click="signup($event)"><i class="fa fa-rocket"></i> {{ 'app.public.common.sign_up' | translate }}</a></li>
<li ng-if="!isAuthenticated() && registrationEnabled()"><a class="font-sbold label text-md pointer" ng-click="signup($event)"><i class="fa fa-rocket"></i> {{ 'app.public.common.sign_up' | translate }}</a></li>
<li ng-if="!isAuthenticated()">
<a class="font-sbold label text-md pointer" ng-click="login($event)"><i class="fa fa-sign-in"></i> {{ 'app.public.common.sign_in' | translate }}</a>
</li>

View File

@ -7,12 +7,12 @@
<nav class="nav-primary hidden-xs">
<ul class="nav nav-main m-t-xs" data-ride="collapse">
<!-- Disconnected user menu for small devices -->
<li class="hidden-sm hidden-md hidden-lg" ng-if-start="!isAuthenticated()">
<li class="hidden-sm hidden-md hidden-lg" ng-if="!isAuthenticated() && registrationEnabled()">
<a class="auto pointer" ng-click="signup($event)">
<i class="fa fa-rocket"></i> <span translate>{{ 'app.public.common.sign_up' }}</span>
</a>
</li>
<li class="hidden-sm hidden-md hidden-lg" ng-if-end>
<li class="hidden-sm hidden-md hidden-lg" ng-if="!isAuthenticated()">
<a class="auto pointer" ng-click="login($event)">
<i class="fa fa-sign-in"></i> <span translate>{{ 'app.public.common.sign_in' }}</span>
</a>

View File

@ -34,7 +34,12 @@ class AccountingPeriod < ApplicationRecord
def invoices_with_vat(invoices)
vat_service = VatHistoryService.new
invoices.map do |i|
{ invoice: i, vat_rate: vat_service.invoice_vat(i) / 100.0 }
vat_rate_group = {}
i.invoice_items.each do |item|
vat_type = item.invoice_item_type
vat_rate_group[vat_type] = vat_service.invoice_item_vat(item) / 100.0 unless vat_rate_group[vat_type]
end
{ invoice: i, vat_rate: vat_rate_group }
end
end
@ -70,7 +75,7 @@ class AccountingPeriod < ApplicationRecord
end
def price_without_taxe(invoice)
invoice[:invoice].total - (invoice[:invoice].total * invoice[:vat_rate])
invoice[:invoice].invoice_items.map(&:net_amount).sum
end
def compute_totals

View File

@ -3,7 +3,7 @@
MINUTES_PER_HOUR = 60.0
SECONDS_PER_MINUTE = 60.0
GET_SLOT_PRICE_DEFAULT_OPTS = { has_credits: false, elements: nil, is_division: true, prepaid: { minutes: 0 } }.freeze
GET_SLOT_PRICE_DEFAULT_OPTS = { has_credits: false, elements: nil, is_division: true, prepaid: { minutes: 0 }, custom_duration: nil }.freeze
# A generic reservation added to the shopping cart
class CartItem::Reservation < CartItem::BaseItem
@ -16,7 +16,6 @@ class CartItem::Reservation < CartItem::BaseItem
end
def price
base_amount = @reservable.prices.find_by(group_id: @customer.group_id, plan_id: @plan.try(:id)).amount
is_privileged = @operator.privileged? && @operator.id != @customer.id
prepaid = { minutes: PrepaidPackService.minutes_available(@customer, @reservable) }
@ -24,11 +23,14 @@ class CartItem::Reservation < CartItem::BaseItem
amount = 0
hours_available = credits
@slots.each_with_index do |slot, index|
amount += get_slot_price(base_amount, slot, is_privileged,
elements: elements,
has_credits: (index < hours_available),
prepaid: prepaid)
grouped_slots.values.each do |slots|
prices = applicable_prices(slots)
slots.each_with_index do |slot, index|
amount += get_slot_price_from_prices(prices, slot, is_privileged,
elements: elements,
has_credits: (index < hours_available),
prepaid: prepaid)
end
end
{ elements: elements, amount: amount }
@ -61,6 +63,36 @@ class CartItem::Reservation < CartItem::BaseItem
0
end
##
# Group the slots by date, if the extended_prices_in_same_day option is set to true
##
def grouped_slots
return { all: @slots } unless Setting.get('extended_prices_in_same_day')
@slots.group_by { |slot| slot[:start_at].to_date }
end
##
# Compute the price of a single slot, according to the list of applicable prices.
# @param prices {{ prices: Array<{price: Price, duration: number}> }} list of prices to use with the current reservation
# @see get_slot_price
##
def get_slot_price_from_prices(prices, slot, is_privileged, options = {})
options = GET_SLOT_PRICE_DEFAULT_OPTS.merge(options)
slot_minutes = (slot[:end_at].to_time - slot[:start_at].to_time) / SECONDS_PER_MINUTE
price = prices[:prices].find { |p| p[:duration] <= slot_minutes && p[:duration].positive? }
price = prices[:prices].first if price.nil?
hourly_rate = (price[:price].amount.to_f / price[:price].duration) * MINUTES_PER_HOUR
# apply the base price to the real slot duration
real_price = get_slot_price(hourly_rate, slot, is_privileged, options)
price[:duration] -= slot_minutes
real_price
end
##
# Compute the price of a single slot, according to the base price and the ability for an admin
# to offer the slot.
@ -103,6 +135,33 @@ class CartItem::Reservation < CartItem::BaseItem
real_price
end
# We determine the list of prices applicable to current reservation
# The longest available price is always used in priority.
# Eg. If the reservation is for 12 hours, and there are prices for 3 hours, 7 hours,
# and the base price (1 hours), we use the 7 hours price, then 3 hours price, and finally the base price twice (7+3+1+1 = 12).
# All these prices are returned to be applied to the reservation.
def applicable_prices(slots)
total_duration = slots.map { |slot| (slot[:end_at].to_time - slot[:start_at].to_time) / SECONDS_PER_MINUTE }.reduce(:+)
rates = { prices: [] }
remaining_duration = total_duration
while remaining_duration.positive?
max_duration = @reservable.prices.where(group_id: @customer.group_id, plan_id: @plan.try(:id))
.where(Price.arel_table[:duration].lteq(remaining_duration))
.maximum(:duration)
max_duration = 60 if max_duration.nil?
max_duration_price = @reservable.prices.find_by(group_id: @customer.group_id, plan_id: @plan.try(:id), duration: max_duration)
current_duration = [remaining_duration, max_duration].min
rates[:prices].push(price: max_duration_price, duration: current_duration)
remaining_duration -= current_duration
end
rates[:prices].sort! { |a, b| b[:duration] <=> a[:duration] }
rates
end
##
# Compute the number of remaining hours in the users current credits (for machine or space)
##

View File

@ -27,7 +27,7 @@ class InvoiceItem < Footprintable
def net_amount
# deduct VAT
vat_service = VatHistoryService.new
vat_rate = vat_service.invoice_vat(invoice)
vat_rate = vat_service.invoice_item_vat(self)
Rational(amount_after_coupon / (vat_rate / 100.00 + 1)).round.to_f
end
@ -36,6 +36,19 @@ class InvoiceItem < Footprintable
amount_after_coupon - net_amount
end
# return invoice item type (Machine/Training/Space/Event/Subscription) used to determine the VAT rate
def invoice_item_type
if object_type == Reservation.name
object.try(:reservable_type) || ''
elsif [Subscription.name, OfferDay.name].include? object_type
Subscription.name
elsif object_type == StatisticProfilePrepaidPack.name
object.prepaid_pack.priceable_type
else
''
end
end
private
def log_changes

View File

@ -58,6 +58,7 @@ class NotificationType
notify_admin_payment_schedule_failed
notify_member_payment_schedule_failed
notify_admin_payment_schedule_check_deadline
notify_admin_payment_schedule_transfer_deadline
]
# deprecated:
# - notify_member_subscribed_plan_is_changed

View File

@ -14,9 +14,10 @@ class PaymentScheduleItem < Footprintable
def payment_intent
return unless payment_gateway_object
return unless payment_gateway_object.gateway_object.gateway == 'Stripe'
stp_invoice = payment_gateway_object.gateway_object.retrieve
Stripe::PaymentIntent.retrieve(stp_invoice.payment_intent, api_key: Setting.get('stripe_secret_key')) # FIXME, maybe this is only used for stripe?
Stripe::PaymentIntent.retrieve(stp_invoice.payment_intent, api_key: Setting.get('stripe_secret_key'))
end
def self.columns_out_of_footprint

View File

@ -7,5 +7,9 @@ class Price < ApplicationRecord
belongs_to :priceable, polymorphic: true
validates :priceable, :group_id, :amount, presence: true
validates :priceable_id, uniqueness: { scope: %i[priceable_type plan_id group_id] }
validates :priceable_id, uniqueness: { scope: %i[priceable_type plan_id group_id duration] }
def safe_destroy
destroy unless duration == 60
end
end

View File

@ -28,6 +28,11 @@ class Setting < ApplicationRecord
invoice_order-nb
invoice_VAT-active
invoice_VAT-rate
invoice_VAT-rate_Machine
invoice_VAT-rate_Training
invoice_VAT-rate_Space
invoice_VAT-rate_Event
invoice_VAT-rate_Subscription
invoice_text
invoice_legals
booking_window_start
@ -121,7 +126,9 @@ class Setting < ApplicationRecord
public_agenda_module
renew_pack_threshold
pack_only_for_subscription
overlapping_categories] }
overlapping_categories
extended_prices_in_same_day
public_registrations] }
# WARNING: when adding a new key, you may also want to add it in:
# - config/locales/en.yml#settings
# - app/frontend/src/javascript/models/setting.ts#SettingName

View File

@ -230,10 +230,12 @@ class PDF::Invoice < Prawn::Document
# TVA
vat_service = VatHistoryService.new
vat_rate = vat_service.invoice_vat(invoice)
if vat_rate != 0
vat_rate_group = vat_service.invoice_vat(invoice)
if total_vat != 0
data += [[I18n.t('invoices.total_including_all_taxes'), number_to_currency(total)]]
data += [[I18n.t('invoices.including_VAT_RATE', RATE: vat_rate), number_to_currency(total_vat / 100.00)]]
vat_rate_group.each do |_type, rate|
data += [[I18n.t('invoices.including_VAT_RATE', RATE: rate[:vat_rate], AMOUNT: number_to_currency(rate[:amount] / 100.00)), number_to_currency(rate[:total_vat] / 100.00)]]
end
data += [[I18n.t('invoices.including_total_excluding_taxes'), number_to_currency(total_ht / 100.00)]]
data += [[I18n.t('invoices.including_amount_payed_on_ordering'), number_to_currency(total)]]
@ -252,23 +254,25 @@ class PDF::Invoice < Prawn::Document
row(0).font_style = :bold
column(1).style align: :right
if Setting.get('invoice_VAT-active')
if total_vat != 0
# Total incl. taxes
row(-1).style align: :right
row(-1).background_color = 'E4E4E4'
row(-1).font_style = :bold
# including VAT xx%
row(-2).style align: :right
row(-2).background_color = 'E4E4E4'
row(-2).font_style = :italic
vat_rate_group.size.times do |i|
# including VAT xx%
row(-2 - i).style align: :right
row(-2 - i).background_color = 'E4E4E4'
row(-2 - i).font_style = :italic
end
# including total excl. taxes
row(-3).style align: :right
row(-3).background_color = 'E4E4E4'
row(-3).font_style = :italic
row(-3 - vat_rate_group.size + 1).style align: :right
row(-3 - vat_rate_group.size + 1).background_color = 'E4E4E4'
row(-3 - vat_rate_group.size + 1).font_style = :italic
# including amount payed on ordering
row(-4).style align: :right
row(-4).background_color = 'E4E4E4'
row(-4).font_style = :bold
row(-4 - vat_rate_group.size + 1).style align: :right
row(-4 - vat_rate_group.size + 1).background_color = 'E4E4E4'
row(-4 - vat_rate_group.size + 1).font_style = :bold
end
end

View File

@ -2,13 +2,13 @@
# Check the access policies for API::PaymentSchedulesController
class PaymentSchedulePolicy < ApplicationPolicy
%w[list? cash_check? cancel?].each do |action|
%w[list? cash_check? confirm_transfer? cancel?].each do |action|
define_method action do
user.admin? || user.manager?
end
end
%w[refresh_item? download? pay_item?].each do |action|
%w[refresh_item? download? pay_item? show_item?].each do |action|
define_method action do
user.admin? || user.manager? || (record.invoicing_profile.user_id == user.id)
end

View File

@ -2,6 +2,14 @@
# Check the access policies for API::PricesController
class PricePolicy < ApplicationPolicy
def create?
user.admin? && record.duration != 60
end
def destroy?
user.admin? && record.duration != 60
end
def update?
user.admin?
end

View File

@ -28,7 +28,7 @@ class SettingPolicy < ApplicationPolicy
end
##
# Every settings that anyone can read. The other settings are restricted for admins.
# List of settings that anyone can read. The other settings are restricted for admins.
# This list must be manually updated if a new setting should be world-readable
##
def self.public_whitelist
@ -40,11 +40,11 @@ class SettingPolicy < ApplicationPolicy
recaptcha_site_key feature_tour_display disqus_shortname allowed_cad_extensions openlab_app_id openlab_default
online_payment_module stripe_public_key confirmation_required wallet_module trainings_module address_required
payment_gateway payzen_endpoint payzen_public_key public_agenda_module renew_pack_threshold statistics_module
pack_only_for_subscription overlapping_categories]
pack_only_for_subscription overlapping_categories public_registrations]
end
##
# Every settings that only admins can read.
# List of settings that only admins can read.
# This blacklist is automatically generated from the public_whitelist above.
##
def self.public_blacklist

View File

@ -16,7 +16,6 @@ class AccountingExportService
@label_max_length = 50
@export_zeros = false
@journal_code = Setting.get('accounting_journal_code') || ''
@date_format = date_format
@columns = columns
end
@ -134,9 +133,9 @@ class AccountingExportService
# Generate the "VAT" row, which contains the credit to the VAT account, with VAT amount only
def vat_row(invoice)
rate = VatHistoryService.new.invoice_vat(invoice)
total = invoice.invoice_items.map(&:net_amount).sum
# we do not render the VAT row if it was disabled for this invoice
return nil if rate.zero?
return nil if total == invoice.total
row(
invoice,

View File

@ -23,6 +23,11 @@ class PaymentGatewayService
@gateway.create_subscription(payment_schedule, *args)
end
def cancel_subscription(payment_schedule)
gateway = service_for_payment_schedule(payment_schedule)
gateway.cancel_subscription(payment_schedule)
end
def create_user(user_id)
@gateway.create_user(user_id)
end
@ -52,7 +57,7 @@ class PaymentGatewayService
private
def service_for_payment_schedule(payment_schedule)
service = case payment_schedule.gateway_subscription.klass
service = case payment_schedule.gateway_subscription&.klass
when /^PayZen::/
require 'pay_zen/service'
PayZen::Service

View File

@ -127,7 +127,7 @@ class PaymentScheduleService
# @param filters {Hash} allowed filters: reference, customer, date.
##
def self.list(page, size, filters = {})
ps = PaymentSchedule.includes(:invoicing_profile, :payment_schedule_items)
ps = PaymentSchedule.includes(:invoicing_profile, :payment_schedule_items, :payment_schedule_objects)
.joins(:invoicing_profile)
.order('payment_schedules.created_at DESC')
.page(page)
@ -158,6 +158,8 @@ class PaymentScheduleService
end
def self.cancel(payment_schedule)
PaymentGatewayService.new.cancel_subscription(payment_schedule)
# cancel all item where state != paid
payment_schedule.ordered_items.each do |item|
next if item.state == 'paid'

View File

@ -61,10 +61,7 @@ class PrepaidPackService
## Total number of prepaid minutes available
def minutes_available(user, priceable)
is_pack_only_for_subscription = Setting.find_by(name: "pack_only_for_subscription")&.value
if is_pack_only_for_subscription == 'true' && !user.subscribed_plan
return 0
end
return 0 if Setting.get('pack_only_for_subscription') && !user.subscribed_plan
user_packs = user_packs(user, priceable)
total_available = user_packs.map { |up| up.prepaid_pack.minutes }.reduce(:+) || 0

View File

@ -0,0 +1,97 @@
# frozen_string_literal: false
# Provides the routine to export the collected VAT data to a CSV file.
class VatExportService
include ActionView::Helpers::NumberHelper
attr_reader :encoding, :format, :separator, :date_format, :columns, :decimal_separator
def initialize(columns, encoding: 'UTF-8', format: 'CSV', separator: ';')
@encoding = encoding
@format = format
@separator = separator
@decimal_separator = '.'
@date_format = '%Y-%m-%d'
@columns = columns
end
def set_options(decimal_separator: ',', date_format: '%d/%m/%Y', label_max_length: nil, export_zeros: nil)
@decimal_separator = decimal_separator
@date_format = date_format
end
def export(start_date, end_date, file)
# build CSV content
content = header_row
invoices = Invoice.where('created_at >= ? AND created_at <= ?', start_date, end_date).order('created_at ASC')
vat_totals = compute_vat_totals(invoices)
content << generate_rows(vat_totals, start_date, end_date)
# write content to file
File.open(file, "w:#{encoding}") { |f| f.puts content.encode(encoding, invalid: :replace, undef: :replace) }
end
private
def header_row
row = ''
columns.each do |column|
row << I18n.t("vat_export.#{column}") << separator
end
"#{row}\n"
end
def generate_rows(vat_totals, start_date, end_date)
rows = ''
vat_totals.each do |rate, total|
next if rate.zero?
rows += "#{row(
start_date,
end_date,
rate,
total
)}\n"
end
rows
end
def compute_vat_totals(invoices)
vat_total = []
service = VatHistoryService.new
invoices.each do |i|
puts "processing invoice #{i.id}..." unless Rails.env.test?
vat_total.push service.invoice_vat(i)
end
vat_total.map(&:values).flatten.group_by { |tot| tot[:vat_rate] }.map { |k, v| [k, v.map { |t| t[:total_vat] }.reduce(:+)] }.to_h
end
# Generate a row of the export, filling the configured columns with the provided values
def row(start_date, end_date, vat_rate, amount)
row = ''
columns.each do |column|
case column
when 'start_date'
row << DateTime.parse(start_date).strftime(date_format)
when 'end_date'
row << DateTime.parse(end_date).strftime(date_format)
when 'vat_rate'
row << vat_rate.to_s
when 'amount'
row << format_number(amount / 100.0)
else
puts "Unsupported column: #{column}"
end
row << separator
end
row
end
# Format the given number as a string, using the configured separator
def format_number(num)
number_to_currency(num, unit: '', separator: decimal_separator, delimiter: '', precision: 2)
end
end

View File

@ -2,30 +2,42 @@
# Provides the VAT rate in use at the given date
class VatHistoryService
# return the VAT rate for the given Invoice/Avoir
# @return the VAT rate for the given Invoice
def invoice_vat(invoice)
if invoice.is_a?(Avoir)
vat_rate(invoice.avoir_date)
vat_rate_group = {}
invoice.invoice_items.each do |item|
vat_type = item.invoice_item_type
vat_rate_group[vat_type] = { vat_rate: invoice_item_vat(item), total_vat: 0, amount: 0 } unless vat_rate_group[vat_type]
vat_rate_group[vat_type][:total_vat] += item.vat
vat_rate_group[vat_type][:amount] += item.amount.to_i
end
vat_rate_group
end
# return the VAT rate for the given InvoiceItem
def invoice_item_vat(invoice_item)
if invoice_item.invoice.is_a?(Avoir)
vat_rate(invoice_item.invoice.avoir_date, invoice_item.invoice_item_type)
else
vat_rate(invoice.created_at)
vat_rate(invoice_item.invoice.created_at, invoice_item.invoice_item_type)
end
end
# return the VAT rate for the given date
def vat_rate(date)
@vat_rates = vat_history if @vat_rates.nil?
# return the VAT rate for the given date and vat type
def vat_rate(date, vat_rate_type)
vat_rates = vat_history(vat_rate_type)
first_rate = @vat_rates.first
first_rate = vat_rates.first
return first_rate[:rate] if date < first_rate[:date]
@vat_rates.each_index do |i|
return @vat_rates[i][:rate] if date >= @vat_rates[i][:date] && (@vat_rates[i + 1].nil? || date < @vat_rates[i + 1][:date])
vat_rates.each_index do |i|
return vat_rates[i][:rate] if date >= vat_rates[i][:date] && (vat_rates[i + 1].nil? || date < vat_rates[i + 1][:date])
end
end
private
def vat_history
def vat_history(vat_rate_type)
chronology = []
end_date = DateTime.current
Setting.find_by(name: 'invoice_VAT-active').history_values.order(created_at: 'DESC').each do |v|
@ -33,15 +45,65 @@ class VatHistoryService
end_date = v.created_at
end
chronology.push(start: DateTime.new(0), end: end_date, enabled: false)
# now chronology contains something like one of the following:
# - [{start: 0000-01-01, end: now, enabled: false}] => VAT was never enabled
# - [
# {start: fab-manager initial setup date, end: now, enabled: true},
# {start: 0000-01-01, end: fab-manager initial setup date, enabled: false}
# ] => VAT was enabled from the beginning
# - [
# {start: [date disabled], end: now, enabled: false},
# {start: [date enable], end: [date disabled], enabled: true},
# {start: fab-manager initial setup date, end: [date enabled], enabled: false},
# {start: 0000-01-01, end: fab-manager initial setup date, enabled: false}
# ] => VAT was enabled at some point, and disabled at some other point later
date_rates = []
Setting.find_by(name: 'invoice_VAT-rate').history_values.order(created_at: 'ASC').each do |rate|
range = chronology.select { |p| rate.created_at.to_i.between?(p[:start].to_i, p[:end].to_i) }.first
date = range[:enabled] ? rate.created_at : range[:end]
date_rates.push(date: date, rate: rate.value.to_i)
end
chronology.reverse_each do |period|
date_rates.push(date: period[:start], rate: 0) unless period[:enabled]
if vat_rate_type.present?
vat_rate_by_type = Setting.find_by(name: "invoice_VAT-rate_#{vat_rate_type}")&.history_values&.order(created_at: 'ASC')
first_vat_rate_by_type = vat_rate_by_type&.select { |v| v.value.present? }&.first
if first_vat_rate_by_type
# before the first VAT rate was defined for the given type, the general VAT rate is used
vat_rate_history_values = Setting.find_by(name: 'invoice_VAT-rate')
.history_values.where('created_at < ?', first_vat_rate_by_type.created_at)
.order(created_at: 'ASC').to_a
# after that, the VAT rate for the given type is used
vat_rate_by_type = Setting.find_by(name: "invoice_VAT-rate_#{vat_rate_type}")
.history_values.where('created_at >= ?', first_vat_rate_by_type.created_at)
.order(created_at: 'ASC')
vat_rate_by_type.each do |rate|
if rate.value.blank?
# if, at some point in the history, a blank rate was set, the general VAT rate is used instead
vat_rate = Setting.find_by(name: 'invoice_VAT-rate')
.history_values.where('created_at < ?', rate.created_at)
.order(created_at: 'DESC')
.first
rate.value = vat_rate.value
end
vat_rate_history_values.push(rate)
end
else
# if no VAT rate is defined for the given type, the general VAT rate is always used
vat_rate_history_values = Setting.find_by(name: 'invoice_VAT-rate').history_values.order(created_at: 'ASC').to_a
end
# Now we have all the rates history, we can build the final chronology, depending on whether VAT was enabled or not
vat_rate_history_values.each do |rate|
# when the VAT rate was enabled, set the date it was enabled and the rate
range = chronology.select { |p| rate.created_at.to_i.between?(p[:start].to_i, p[:end].to_i) }.first
date = range[:enabled] ? rate.created_at : range[:end]
date_rates.push(date: date, rate: rate.value.to_i)
end
chronology.reverse_each do |period|
# when the VAT rate was disabled, set the date it was disabled and rate=0
date_rates.push(date: period[:start], rate: 0) unless period[:enabled]
end
else
# if no VAT rate type is given, we return rate=0 from 0000-01-01
date_rates.push(date: chronology[-1][:start], rate: 0)
end
# finally, we return the chronology, sorted by dates (ascending)
date_rates.sort_by { |k| k[:date] }
end
end

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
json.title notification.notification_type
json.description t('.schedule_deadline', DATE: I18n.l(notification.attached_object.due_date.to_date),
REFERENCE: notification.attached_object.payment_schedule.reference)

View File

@ -18,13 +18,9 @@ json.main_object do
json.id payment_schedule.main_object.object_id
end
if payment_schedule.gateway_subscription
json.gateway_subscription do
# this attribute is used to known which gateway should we interact with, in the front-end
json.classname payment_schedule.gateway_subscription.klass
end
# this attribute is used to known which gateway should we interact with, in the front-end
json.gateway json.classname payment_schedule.gateway_subscription.gateway
end
json.items payment_schedule.payment_schedule_items do |item|
json.extract! item, :id, :due_date, :state, :invoice_id, :payment_method
json.amount item.amount / 100.00
json.client_secret item.payment_intent.client_secret if item.payment_gateway_object && item.state == 'requires_action'
json.partial! 'api/payment_schedules/payment_schedule_item', item: item
end

View File

@ -0,0 +1,4 @@
# frozen_string_literal: true
json.extract! item, :id, :due_date, :state, :invoice_id, :payment_method
json.amount item.amount / 100.00

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
json.partial! 'api/payment_schedules/payment_schedule_item', item: @payment_schedule_item
if @payment_schedule_item.payment_gateway_object && @payment_schedule_item.state == 'requires_action'
json.client_secret @payment_schedule_item.payment_intent.client_secret
end

View File

@ -1,2 +1,4 @@
json.extract! price, :id, :group_id, :plan_id, :priceable_type, :priceable_id
# frozen_string_literal: true
json.extract! price, :id, :group_id, :plan_id, :priceable_type, :priceable_id, :duration
json.amount price.amount / 100.0

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
json.price @amount[:total] / 100.00
json.price_without_coupon @amount[:before_coupon] / 100.00
if @amount[:elements]

View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
json.partial! 'api/prices/price', price: @price

View File

@ -1 +1,3 @@
# frozen_string_literal: true
json.partial! 'api/prices/price', collection: @prices, as: :price

View File

@ -1 +1,3 @@
# frozen_string_literal: true
json.partial! 'api/prices/price', price: @price

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
json.array!(@spaces) do |space|
json.extract! space, :id, :name, :description, :slug, :default_places, :disabled
json.space_image space.space_image.attachment.medium.url if space.space_image

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
json.extract! @space, :id, :name, :description, :characteristics, :created_at, :updated_at, :slug, :default_places, :disabled
json.space_image @space.space_image.attachment.large.url if @space.space_image
json.space_files_attributes @space.space_files do |f|
@ -9,4 +11,4 @@ end
# using the space in the space_show screen
# json.space_projects @space.projects do |p|
# json.extract! p, :slug, :name
# end
# end

View File

@ -34,7 +34,7 @@ json.invoices do
json.id item.object_id
json.main item.main
end
json.partial! 'archive/vat', price: item.amount, vat_rate: invoice[:vat_rate]
json.partial! 'archive/vat', price: item.amount, vat_rate: invoice[:vat_rate][item.invoice_item_type]
end
end
end

View File

@ -0,0 +1,10 @@
<%= render 'notifications_mailer/shared/hello', recipient: @recipient %>
<p>
<%= t('.body.remember',
REFERENCE: @attached_object.payment_schedule.reference,
AMOUNT: number_to_currency(@attached_object.amount / 100.00),
DATE: I18n.l(@attached_object.due_date, format: :long)) %>
<%= t('.body.date') %>
</p>
<p><%= t('.body.confirm') %></p>

View File

@ -10,7 +10,8 @@ class AccountingExportWorker
raise SecurityError, 'Not allowed to export' unless export.user.admin?
data = JSON.parse(export.query)
service = AccountingExportService.new(
service = export.export_type == 'vat' ? VatExportService : AccountingExportService
service = service.new(
data['columns'],
encoding: data['encoding'], format: export.extension, separator: export.key
)

View File

@ -22,8 +22,8 @@ class PaymentScheduleItemWorker
### Cards
PaymentGatewayService.new.process_payment_schedule_item(psi)
elsif psi.state == 'new'
### Check (only new deadlines, to prevent spamming)
NotificationCenter.call type: 'notify_admin_payment_schedule_check_deadline',
### Check/Bank transfer (only new deadlines, to prevent spamming)
NotificationCenter.call type: "notify_admin_payment_schedule_#{psi.payment_schedule.payment_method}_deadline",
receiver: User.admins_and_managers,
attached_object: psi
psi.update_attributes(state: 'pending')

View File

@ -98,7 +98,8 @@ de:
delete_this_slot: "Nur diesen Slot"
delete_this_and_next: "Diesen Slot und die folgenden"
delete_all: "Alle Slots"
event_in_the_past: "In der Vergangenheit kann kein Slot erstellt werden."
event_in_the_past: "Create a slot in the past"
confirm_create_event_in_the_past: "You are about to create a slot in the past. Are you sure you want to do this? Members will not be able to book this slot."
edit_event: "Veranstaltung bearbeiten"
view_reservations: "Reservierungen anzeigen"
legend: "Legende"
@ -368,6 +369,13 @@ de:
status_enabled: "Aktiviert"
status_disabled: "Deaktiviert"
status_all: "Alle"
spaces_pricing:
prices_match_space_hours_rates_html: "The prices below match one hour of space reservation, <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, 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."
extended_prices: "Moreover, you can define extended prices which will apply in priority over the hourly rate below. Extended prices allow you, for example, to set a favorable price for a booking of several hours."
spaces: "Spaces"
price_updated: "Price successfully updated"
machines_pricing:
prices_match_machine_hours_rates_html: "Die unten aufgeführten Preise entsprechen einer Stunde Maschinengebrauch, <strong>ohne Abonnement</strong>."
prices_calculated_on_hourly_rate_html: "Alle Preise werden automatisch nach dem hier definierten Stundensatz berechnet.<br/><em>Zum Beispiel</em> wird bei einem veranschlagten Stundensatz von {RATE} ein Slot von {DURATION} Minuten, zum Preis von <strong>{PRICE}</strong> berechnet."
@ -378,6 +386,13 @@ de:
packs: "Prepaid packs"
no_packs: "No packs for now"
pack_DURATION: "{DURATION} hours"
configure_extended_prices_button:
extended_prices: "Extended prices"
no_extended_prices: "No extended price for now"
extended_price_DURATION: "{DURATION} minutes"
extended_price_form:
duration: "Duration (minutes)"
amount: "Price"
pack_form:
hours: "Hours"
amount: "Price"
@ -404,6 +419,21 @@ de:
edit_pack: "Edit the pack"
confirm_changes: "Confirm changes"
pack_successfully_updated: "The prepaid pack was successfully updated."
create_extended_price:
new_extended_price: "New extended price"
new_extended_price_info: "Extended prices allows you to define prices based on custom durations, instead of the default hourly rates."
create_extended_price: "Create extended price"
extended_price_successfully_created: "The new extended price was successfully created."
delete_extended_price:
extended_price_deleted: "The extended price was successfully deleted."
unable_to_delete: "Unable to delete the extended price: "
delete_extended_price: "Delete the extended price"
confirm_delete: "Delete"
delete_confirmation: "Are you sure you want to delete this extended price?"
edit_extended_price:
edit_extended_price: "Edit the extended price"
confirm_changes: "Confirm changes"
extended_price_successfully_updated: "The extended price was successfully updated."
#ajouter un code promotionnel
coupons_new:
add_a_coupon: "Gutschein hinzufügen"
@ -466,13 +496,14 @@ de:
details: "Details"
amount: "Betrag"
machine_booking-3D_printer: "Maschinen-Buchung - 3D-Drucker"
training_booking-3D_print: "Training booking - initiation to 3d printing"
total_amount: "Gesamtbetrag"
total_including_all_taxes: "Gesamtpreis inkl. Steuern"
VAT_disabled: "MwSt. deaktiviert"
VAT_enabled: "MwSt. aktiviert"
including_VAT: "Inklusive MwSt."
including_VAT: "Inklusive MwSt. {RATE}% von {AMOUNT}"
including_total_excluding_taxes: "Gesamtbetrag zzgl. Steuern"
including_amount_payed_on_ordering: "Inklusive bei Bestellung bezahlter Betrag"
including_amount_payed_on_ordering: "Inklusive bei Bestellung gezahlter Betrag"
settlement_by_debit_card_on_DATE_at_TIME_for_an_amount_of_AMOUNT: "Begleichung mit Debitkarte am {DATE} um {TIME}, über den Betrag von {AMOUNT}"
important_notes: "Wichtige Hinweise"
address_and_legal_information: "Adresse und rechtliche Informationen"
@ -522,6 +553,15 @@ de:
enable_VAT: "MwSt. aktivieren"
VAT_rate: "MwSt.-Satz"
VAT_history: "MwSt.-Sätze Historie"
VAT_notice: "This parameter configures the general case of the VAT rate and applies to everything sold by the Fablab. It is possible to override this parameter by setting a specific VAT rate for each object."
edit_multi_VAT_button: "More options"
multiVAT: "Advanced VAT"
multi_VAT_notice: "<strong>Please note</strong>: The current general rate is {RATE}%. Here you can define different VAT rates for each category.</br></br>For example, you can override this value, only for machine reservations, by filling in the corresponding field below. If no value is filled in, the general rate will apply."
VAT_rate_machine: "Machine reservation"
VAT_rate_space: "Space reservation"
VAT_rate_training: "Training reservation"
VAT_rate_event: "Event reservation"
VAT_rate_subscription: "Subscription"
changed_at: "Geändert am"
changed_by: "Von"
deleted_user: "Gelöschter Nutzer"
@ -640,9 +680,10 @@ de:
codes_customization_success: "Anpassung der Abrechnungscodes erfolgreich gespeichert."
unexpected_error_occurred: "Beim Speichern der Codes ist ein unerwarteter Fehler aufgetreten. Bitte versuchen Sie es später erneut."
export_accounting_data: "Abrechnungsdaten exportieren"
export_to: "In die Buchhaltungssoftware exportieren"
export_what: "What do you want to export?"
export_VAT: "Export the collected VAT"
export_to_ACD: "Export all data to the accounting software ACD"
export_is_running: "Export wird ausgeführt. Sie werden nach Fertigstellung benachrichtigt."
acd: "ACD"
export_form_date: "Exportieren ab"
export_to_date: "Exportieren bis"
format: "Dateiformat"
@ -665,6 +706,10 @@ de:
debit_euro: "Soll in Euro"
credit_euro: "Guthaben in Euro"
lettering: "Beschriftung"
start_date: "Start date"
end_date: "End date"
vat_rate: "VAT rate"
amount: "Total amount"
payment:
payment_settings: "Bezahlungseinstellungen"
online_payment: "Online-Bezahlung"
@ -1235,6 +1280,10 @@ de:
pack_only_for_subscription_info_html: "If this option is activated, the purchase and use of a prepaid pack is only possible for the user with a valid subscription."
pack_only_for_subscription: "Subscription valid for purchase and use of a prepaid pack"
pack_only_for_subscription_info: "Make subscription mandatory for prepaid packs"
extended_prices: "Extended prices"
extended_prices_info_html: "Spaces can have different prices depending on the cumulated duration of the booking. You can choose if this apply to all bookings or only to those starting within the same day."
extended_prices_in_same_day: "Extended prices in the same day"
public_registrations: "Public registrations"
overlapping_options:
training_reservations: "Trainings"
machine_reservations: "Machines"
@ -1261,6 +1310,9 @@ de:
name: "Name"
created_at: "Erstellungsdatum"
updated_at: "Datum der letzten Aktualisierung"
public_registrations: "Public registrations"
public_registrations_info: "Allow everyone to register a new account on the platform. If disabled, only administrators and managers can create new accounts."
public_registrations_allowed: "Public registrations allowed"
help: "Hilfe"
feature_tour: "Feature-Tour"
feature_tour_info_html: "<p>Wenn sich ein Administrator oder Manager anmeldet, wird beim jeweils ersten Besuch eines Abschnitts der Anwendung die Feature-Tour ausgelöst. Sie können dieses Verhalten auf einen der folgenden Werte ändern:</p><ul><li>« Einmal », um das Standardverhalten beizubehalten.</li><li>« Pro Sitzung », um die Tour jedes Mal anzuzeigen, wenn die Anwendung erneut geöffnet wird.</li><li>« Nur manuell », deaktiviert die automatische Anzeige der Touren. Es ist weiterhin möglich, sie durch Drücken der F1-Taste oder durch Klicken auf « Hilfe » im Benutzermenu zu starten.</li></ul>"
@ -1399,8 +1451,10 @@ de:
payment_method: "Payment method"
method_card: "Online by card"
method_check: "By check"
method_transfer: "By bank transfer"
card_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."
transfer_collection_info: "<p>By validating, you confirm that you set up {DEADLINES} bank direct debits, allowing you to collect all the monthly payments.</p><p><strong>Please note:</strong> the bank transfers are not automatically handled by Fab-manager.</p>"
online_payment_disabled: "Online payment is not available. You cannot collect this payment schedule by online card."
check_list_setting:
save: 'Save'

View File

@ -98,7 +98,8 @@ en:
delete_this_slot: "Only this slot"
delete_this_and_next: "This slot and the following"
delete_all: "All slots"
event_in_the_past: "Unable to create a slot in the past."
event_in_the_past: "Create a slot in the past"
confirm_create_event_in_the_past: "You are about to create a slot in the past. Are you sure you want to do this? Members will not be able to book this slot."
edit_event: "Edit the event"
view_reservations: "View reservations"
legend: "Legend"
@ -368,6 +369,13 @@ en:
status_enabled: "Enabled"
status_disabled: "Disabled"
status_all: "All"
spaces_pricing:
prices_match_space_hours_rates_html: "The prices below match one hour of space reservation, <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, 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."
extended_prices: "Moreover, you can define extended prices which will apply in priority over the hourly rate below. Extended prices allow you, for example, to set a favorable price for a booking of several hours."
spaces: "Spaces"
price_updated: "Price successfully updated"
machines_pricing:
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, will be charged <strong>{PRICE}</strong>."
@ -378,6 +386,13 @@ en:
packs: "Prepaid packs"
no_packs: "No packs for now"
pack_DURATION: "{DURATION} hours"
configure_extended_prices_button:
extended_prices: "Extended prices"
no_extended_prices: "No extended price for now"
extended_price_DURATION: "{DURATION} minutes"
extended_price_form:
duration: "Duration (minutes)"
amount: "Price"
pack_form:
hours: "Hours"
amount: "Price"
@ -404,6 +419,21 @@ en:
edit_pack: "Edit the pack"
confirm_changes: "Confirm changes"
pack_successfully_updated: "The prepaid pack was successfully updated."
create_extended_price:
new_extended_price: "New extended price"
new_extended_price_info: "Extended prices allows you to define prices based on custom durations, instead of the default hourly rates."
create_extended_price: "Create extended price"
extended_price_successfully_created: "The new extended price was successfully created."
delete_extended_price:
extended_price_deleted: "The extended price was successfully deleted."
unable_to_delete: "Unable to delete the extended price: "
delete_extended_price: "Delete the extended price"
confirm_delete: "Delete"
delete_confirmation: "Are you sure you want to delete this extended price?"
edit_extended_price:
edit_extended_price: "Edit the extended price"
confirm_changes: "Confirm changes"
extended_price_successfully_updated: "The extended price was successfully updated."
#ajouter un code promotionnel
coupons_new:
add_a_coupon: "Add a coupon"
@ -466,13 +496,14 @@ en:
details: "Details"
amount: "Amount"
machine_booking-3D_printer: "Machine booking - 3D printer"
training_booking-3D_print: "Training booking - initiation to 3d printing"
total_amount: "Total amount"
total_including_all_taxes: "Total incl. all taxes"
VAT_disabled: "VAT disabled"
VAT_enabled: "VAT enabled"
including_VAT: "Including VAT"
including_VAT: "Including VAT {RATE}% of {AMOUNT}"
including_total_excluding_taxes: "Including Total excl. taxes"
including_amount_payed_on_ordering: "Including Amount payed on ordering"
including_amount_payed_on_ordering: "Including amount payed on ordering"
settlement_by_debit_card_on_DATE_at_TIME_for_an_amount_of_AMOUNT: "Settlement by debit card on {DATE} at {TIME}, for an amount of {AMOUNT}"
important_notes: "Important notes"
address_and_legal_information: "Address and legal information"
@ -522,6 +553,15 @@ en:
enable_VAT: "Enable VAT"
VAT_rate: "VAT rate"
VAT_history: "VAT rates history"
VAT_notice: "This parameter configures the general case of the VAT rate and applies to everything sold by the Fablab. It is possible to override this parameter by setting a specific VAT rate for each object."
edit_multi_VAT_button: "More options"
multiVAT: "Advanced VAT"
multi_VAT_notice: "<strong>Please note</strong>: The current general rate is {RATE}%. Here you can define different VAT rates for each category.</br></br>For example, you can override this value, only for machine reservations, by filling in the corresponding field below. If no value is filled in, the general rate will apply."
VAT_rate_machine: "Machine reservation"
VAT_rate_space: "Space reservation"
VAT_rate_training: "Training reservation"
VAT_rate_event: "Event reservation"
VAT_rate_subscription: "Subscription"
changed_at: "Changed at"
changed_by: "By"
deleted_user: "Deleted user"
@ -640,9 +680,10 @@ en:
codes_customization_success: "Customization of the accounting codes successfully saved."
unexpected_error_occurred: "An unexpected error occurred while saving the codes. Please try again later."
export_accounting_data: "Export accounting data"
export_to: "Export to the accounting software"
export_what: "What do you want to export?"
export_VAT: "Export the collected VAT"
export_to_ACD: "Export all data to the accounting software ACD"
export_is_running: "Export is running. You'll be notified when it's ready."
acd: "ACD"
export_form_date: "Export from"
export_to_date: "Export until"
format: "File format"
@ -665,6 +706,10 @@ en:
debit_euro: "Euro debit"
credit_euro: "Euro credit"
lettering: "Lettering"
start_date: "Start date"
end_date: "End date"
vat_rate: "VAT rate"
amount: "Total amount"
payment:
payment_settings: "Payment settings"
online_payment: "Online payment"
@ -1235,6 +1280,10 @@ en:
pack_only_for_subscription_info_html: "If this option is activated, the purchase and use of a prepaid pack is only possible for the user with a valid subscription."
pack_only_for_subscription: "Subscription valid for purchase and use of a prepaid pack"
pack_only_for_subscription_info: "Make subscription mandatory for prepaid packs"
extended_prices: "Extended prices"
extended_prices_info_html: "Spaces can have different prices depending on the cumulated duration of the booking. You can choose if this apply to all bookings or only to those starting within the same day."
extended_prices_in_same_day: "Extended prices in the same day"
public_registrations: "Public registrations"
overlapping_options:
training_reservations: "Trainings"
machine_reservations: "Machines"
@ -1261,6 +1310,9 @@ en:
name: "Name"
created_at: "Creation date"
updated_at: "Last update date"
public_registrations: "Public registrations"
public_registrations_info: "Allow everyone to register a new account on the platform. If disabled, only administrators and managers can create new accounts."
public_registrations_allowed: "Public registrations allowed"
help: "Help"
feature_tour: "Feature tour"
feature_tour_info_html: "<p>When an administrator or a manager in logged-in, a feature tour will be triggered the first time he/she visits each section of the application. You can change this behavior to one of the following values:</p><ul><li>« Once » to keep the default behavior.</li><li>« By session » to display the tours each time you reopen the application.</li><li>« Manual trigger » to prevent displaying the tours automatically. It'll still be possible to trigger them by pressing the F1 key or by clicking on « Help » in the user's menu.</li></ul>"
@ -1399,8 +1451,10 @@ en:
payment_method: "Payment method"
method_card: "Online by card"
method_check: "By check"
method_transfer: "By bank transfer"
card_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."
transfer_collection_info: "<p>By validating, you confirm that you set up {DEADLINES} bank direct debits, allowing you to collect all the monthly payments.</p><p><strong>Please note:</strong> the bank transfers are not automatically handled by Fab-manager.</p>"
online_payment_disabled: "Online payment is not available. You cannot collect this payment schedule by online card."
check_list_setting:
save: 'Save'

View File

@ -98,7 +98,8 @@ es:
delete_this_slot: "Only this slot"
delete_this_and_next: "This slot and the following"
delete_all: "All slots"
event_in_the_past: "Unable to create a slot in the past."
event_in_the_past: "Create a slot in the past"
confirm_create_event_in_the_past: "You are about to create a slot in the past. Are you sure you want to do this? Members will not be able to book this slot."
edit_event: "Edit the event"
view_reservations: "Ver reservas"
legend: "Leyenda"
@ -368,6 +369,13 @@ es:
status_enabled: "Enabled"
status_disabled: "Disabled"
status_all: "All"
spaces_pricing:
prices_match_space_hours_rates_html: "The prices below match one hour of space reservation, <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, 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."
extended_prices: "Moreover, you can define extended prices which will apply in priority over the hourly rate below. Extended prices allow you, for example, to set a favorable price for a booking of several hours."
spaces: "Spaces"
price_updated: "Price successfully updated"
machines_pricing:
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, will be charged <strong>{PRICE}</strong>."
@ -378,6 +386,13 @@ es:
packs: "Prepaid packs"
no_packs: "No packs for now"
pack_DURATION: "{DURATION} hours"
configure_extended_prices_button:
extended_prices: "Extended prices"
no_extended_prices: "No extended price for now"
extended_price_DURATION: "{DURATION} minutes"
extended_price_form:
duration: "Duration (minutes)"
amount: "Price"
pack_form:
hours: "Hours"
amount: "Price"
@ -404,6 +419,21 @@ es:
edit_pack: "Edit the pack"
confirm_changes: "Confirm changes"
pack_successfully_updated: "The prepaid pack was successfully updated."
create_extended_price:
new_extended_price: "New extended price"
new_extended_price_info: "Extended prices allows you to define prices based on custom durations, instead of the default hourly rates."
create_extended_price: "Create extended price"
extended_price_successfully_created: "The new extended price was successfully created."
delete_extended_price:
extended_price_deleted: "The extended price was successfully deleted."
unable_to_delete: "Unable to delete the extended price: "
delete_extended_price: "Delete the extended price"
confirm_delete: "Delete"
delete_confirmation: "Are you sure you want to delete this extended price?"
edit_extended_price:
edit_extended_price: "Edit the extended price"
confirm_changes: "Confirm changes"
extended_price_successfully_updated: "The extended price was successfully updated."
#ajouter un code promotionnel
coupons_new:
add_a_coupon: "Añadir un cupón"
@ -466,11 +496,12 @@ es:
details: "Detalles"
amount: "Cantidad"
machine_booking-3D_printer: "Reserva de la máquina- Impresora 3D"
training_booking-3D_print: "Training booking - initiation to 3d printing"
total_amount: "Cantidad total"
total_including_all_taxes: "Total incl. todos los impuestos"
VAT_disabled: "IVA desactivado"
VAT_enabled: "IVA activado"
including_VAT: "Incluido IVA"
including_VAT: "Incluido IVA {RATE}% de {AMOUNT}"
including_total_excluding_taxes: "Incluido Total excl. impuestos"
including_amount_payed_on_ordering: "Incluido el monto pagado en el pedido"
settlement_by_debit_card_on_DATE_at_TIME_for_an_amount_of_AMOUNT: "Liquidación por tarjeta de débito el {DATE} a las {TIME}, por una cantidad de {AMOUNT}"
@ -522,6 +553,15 @@ es:
enable_VAT: "Habilitar IVA"
VAT_rate: "Ratio IVA"
VAT_history: "Historial de ratios de IVA"
VAT_notice: "This parameter configures the general case of the VAT rate and applies to everything sold by the Fablab. It is possible to override this parameter by setting a specific VAT rate for each object."
edit_multi_VAT_button: "More options"
multiVAT: "Advanced VAT"
multi_VAT_notice: "<strong>Please note</strong>: The current general rate is {RATE}%. Here you can define different VAT rates for each category.</br></br>For example, you can override this value, only for machine reservations, by filling in the corresponding field below. If no value is filled in, the general rate will apply."
VAT_rate_machine: "Machine reservation"
VAT_rate_space: "Space reservation"
VAT_rate_training: "Training reservation"
VAT_rate_event: "Event reservation"
VAT_rate_subscription: "Subscription"
changed_at: "Cambiado en"
changed_by: "Por"
deleted_user: "Usario eliminado"
@ -640,9 +680,10 @@ es:
codes_customization_success: "Customization of accounting codes successfully saved."
unexpected_error_occurred: "An unexpected error occurred while saving the codes. Please try again later."
export_accounting_data: "Export accounting data"
export_to: "Export to the accounting software"
export_what: "What do you want to export?"
export_VAT: "Export the collected VAT"
export_to_ACD: "Export all data to the accounting software ACD"
export_is_running: "Exportando, será notificado cuando esté listo."
acd: "ACD"
export_form_date: "Export from"
export_to_date: "Export until"
format: "File format"
@ -665,6 +706,10 @@ es:
debit_euro: "Euro debit"
credit_euro: "Euro credit"
lettering: "Lettering"
start_date: "Start date"
end_date: "End date"
vat_rate: "VAT rate"
amount: "Total amount"
payment:
payment_settings: "Payment settings"
online_payment: "Online payment"
@ -1235,6 +1280,10 @@ es:
pack_only_for_subscription_info_html: "If this option is activated, the purchase and use of a prepaid pack is only possible for the user with a valid subscription."
pack_only_for_subscription: "Subscription valid for purchase and use of a prepaid pack"
pack_only_for_subscription_info: "Make subscription mandatory for prepaid packs"
extended_prices: "Extended prices"
extended_prices_info_html: "Spaces can have different prices depending on the cumulated duration of the booking. You can choose if this apply to all bookings or only to those starting within the same day."
extended_prices_in_same_day: "Extended prices in the same day"
public_registrations: "Public registrations"
overlapping_options:
training_reservations: "Trainings"
machine_reservations: "Machines"
@ -1261,6 +1310,9 @@ es:
name: "Name"
created_at: "Creation date"
updated_at: "Last update date"
public_registrations: "Public registrations"
public_registrations_info: "Allow everyone to register a new account on the platform. If disabled, only administrators and managers can create new accounts."
public_registrations_allowed: "Public registrations allowed"
help: "Help"
feature_tour: "Feature tour"
feature_tour_info_html: "<p>When an administrator or a manager in logged-in, a feature tour will be triggered the first time he/she visits each section of the application. You can change this behavior to one of the following values:</p><ul><li>« Once » to keep the default behavior.</li><li>« By session » to display the tours each time you reopen the application.</li><li>« Manual trigger » to prevent displaying the tours automatically. It'll still be possible to trigger them by pressing the F1 key or by clicking on « Help » in the user's menu.</li></ul>"
@ -1399,8 +1451,10 @@ es:
payment_method: "Payment method"
method_card: "Online by card"
method_check: "By check"
method_transfer: "By bank transfer"
card_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."
transfer_collection_info: "<p>By validating, you confirm that you set up {DEADLINES} bank direct debits, allowing you to collect all the monthly payments.</p><p><strong>Please note:</strong> the bank transfers are not automatically handled by Fab-manager.</p>"
online_payment_disabled: "Online payment is not available. You cannot collect this payment schedule by online card."
check_list_setting:
save: 'Save'

View File

@ -98,7 +98,8 @@ fr:
delete_this_slot: "Uniquement ce créneau"
delete_this_and_next: "Ce créneau et tous les suivants"
delete_all: "Tous les créneaux"
event_in_the_past: "Impossible de créer un créneau dans le passé."
event_in_the_past: "Créer un créneau dans le passé"
confirm_create_event_in_the_past: "Vous êtes sur le point de créer un créneau dans le passé. Êtes-vous sûr de vouloir faire cela ? Les membres ne pourront pas réserver ce créneau."
edit_event: "Modifier l'événement"
view_reservations: "Voir les réservations"
legend: "Légende"
@ -368,6 +369,13 @@ fr:
status_enabled: "Actifs"
status_disabled: "Désactivés"
status_all: "Tous"
spaces_pricing:
prices_match_space_hours_rates_html: "Les tarifs ci-dessous correspondent à une heure de réservation d'espace, <strong>sans abonnement</strong>."
prices_calculated_on_hourly_rate_html: "Tous les prix seront automatiquement calculés par rapport au tarif horaire défini ici.<br/><em>Par exemple</em>, si vous définissez un tarif horaire à {RATE} : un créneau de {DURATION} minutes, sera facturé <strong>{PRICE}</strong>."
you_can_override: "Vous pouvez surcharger cette durée pour chaque disponibilité que vous créez dans l'agenda. Le prix sera alors ajusté en conséquence."
extended_prices: "De plus, vous pouvez définir des prix étendus qui prévaudront sur le tarif horaire ci-dessous. Les prix étendus vous permettent, par exemple, de fixer un prix favorable pour une réservation de plusieurs heures."
spaces: "Espaces"
price_updated: "Le prix a bien été mis à jour"
machines_pricing:
prices_match_machine_hours_rates_html: "Les tarifs ci-dessous correspondent à une heure d'utilisation machine, <strong>sans abonnement</strong>."
prices_calculated_on_hourly_rate_html: "Tous les prix seront automatiquement calculés par rapport au tarif horaire défini ici.<br/><em>Par exemple</em>, si vous définissez un tarif horaire à {RATE} : un créneau de {DURATION} minutes, sera facturé <strong>{PRICE}</strong>."
@ -378,6 +386,13 @@ fr:
packs: "Packs prépayés"
no_packs: "Aucun pack pour le moment"
pack_DURATION: "{DURATION} heures"
configure_extended_prices_button:
extended_prices: "Prix étendus"
no_extended_prices: "Aucun prix étendu pour l'instant"
extended_price_DURATION: "{DURATION} minutes"
extended_price_form:
duration: "Durée (minutes)"
amount: "Prix"
pack_form:
hours: "Heures"
amount: "Prix"
@ -404,6 +419,21 @@ fr:
edit_pack: "Modifier le pack"
confirm_changes: "Valider les modifications"
pack_successfully_updated: "Le pack prépayé a bien été mis à jour."
create_extended_price:
new_extended_price: "Nouveau prix étendu"
new_extended_price_info: "Les prix étendus vous permettent de définir des prix basés sur des durées personnalisées, au lieu du tarif horaire par défaut."
create_extended_price: "Créer un prix étendu"
extended_price_successfully_created: "Le nouveau prix étendu a bien été créé."
delete_extended_price:
extended_price_deleted: "Le prix étendu a bien été supprimé."
unable_to_delete: "Impossible de supprimer le prix étendu : "
delete_extended_price: "Supprimer le prix étendu"
confirm_delete: "Supprimer"
delete_confirmation: "Êtes-vous sûr de vouloir supprimer ce prix étendu ?"
edit_extended_price:
edit_extended_price: "Modifier le prix étendu"
confirm_changes: "Valider les modifications"
extended_price_successfully_updated: "Le prix étendu a bien été mis à jour."
#ajouter un code promotionnel
coupons_new:
add_a_coupon: "Ajouter un code promotionnel"
@ -466,11 +496,12 @@ fr:
details: "Détails"
amount: "Montant"
machine_booking-3D_printer: "Réservation Machine - Imprimante 3D"
training_booking-3D_print: "Réservation de formation - initiation à l'impression 3D"
total_amount: "Montant total"
total_including_all_taxes: "Total TTC"
VAT_disabled: "TVA désactivée"
VAT_enabled: "TVA activée"
including_VAT: "Dont TVA"
including_VAT: "Dont TVA {RATE} % de {AMOUNT}"
including_total_excluding_taxes: "Dont total HT"
including_amount_payed_on_ordering: "Dont montant payé à la commande"
settlement_by_debit_card_on_DATE_at_TIME_for_an_amount_of_AMOUNT: "Règlement effectué par carte bancaire le {DATE} à {TIME}, pour un montant de {AMOUNT}"
@ -522,6 +553,15 @@ fr:
enable_VAT: "Activer la TVA"
VAT_rate: "Taux de TVA"
VAT_history: "Historique des taux de TVA"
VAT_notice: "Ce paramètre configure le cas général du taux de TVA et s'applique à tout ce qui est vendu par le Fablab. Il est possible de surcharger ce paramètre en définissant un taux de TVA spécifique pour chaque objet."
edit_multi_VAT_button: "Plus d'options"
multiVAT: "TVA avancée"
multi_VAT_notice: "<strong>Veuillez noter</strong> : Le taux général actuel est de {RATE} %. Ici, vous pouvez définir des taux de TVA différents pour chaque catégorie.</br></br>Par exemple, vous pouvez surcharger cette valeur, uniquement pour les réservations de machines, en remplissant le champ correspondant ci-dessous. Si aucune valeur n'est remplie, le tarif général s'appliquera."
VAT_rate_machine: "Réservation de machines"
VAT_rate_space: "Réservation d'espaces"
VAT_rate_training: "Réservation de formations"
VAT_rate_event: "Réservation d'événements"
VAT_rate_subscription: "Abonnements"
changed_at: "Changé le"
changed_by: "Par"
deleted_user: "Utilisateur supprimé"
@ -640,9 +680,10 @@ fr:
codes_customization_success: "La personnalisation des codes comptables a bien été enregistrée."
unexpected_error_occurred: "Une erreur inattendue est survenue lors de lenregistrement des codes. Veuillez réessayer plus tard."
export_accounting_data: "Exporter les données comptables"
export_to: "Exporter vers le logiciel comptable"
export_what: "Que voulez-vous exporter ?"
export_VAT: "Exporter la TVA collectée"
export_to_ACD: "Exporter toutes les données vers le logiciel de comptabilité ACD"
export_is_running: "L'export est en cours. Vous serez notifié lorsqu'il sera prêt."
acd: "ACD"
export_form_date: "Exporter depuis le"
export_to_date: "Exporter jusqu'au"
format: "Format de fichier"
@ -665,6 +706,10 @@ fr:
debit_euro: "Débit euro"
credit_euro: "Crédit euro"
lettering: "Lettrage"
start_date: "Date de début"
end_date: "Date de fin"
vat_rate: "Taux de TVA"
amount: "Montant total"
payment:
payment_settings: "Paramètres de paiement"
online_payment: "Paiement en ligne"
@ -1235,6 +1280,10 @@ fr:
pack_only_for_subscription_info_html: "Si cette option est activée, l'achat et l'utilisation d'un pack prépayé n'est possible que pour l'utilisateur possédant un abonnement en cours de validité."
pack_only_for_subscription: "Abonnement valide pour l'achat et l'utilisation d'un pack prépayé"
pack_only_for_subscription_info: "Rendre l'abonnement obligatoire pour les packs prépayés"
extended_prices: "Prix étendus"
extended_prices_info_html: "Les espaces peuvent avoir des prix différents selon la durée cumulée de la réservation. Vous pouvez choisir si cela s'applique à toutes les réservations ou seulement à celles qui commencent dans la même journée."
extended_prices_in_same_day: "Prix étendus le même jour"
public_registrations: "Inscriptions publiques"
overlapping_options:
training_reservations: "Formations"
machine_reservations: "Machines"
@ -1261,6 +1310,9 @@ fr:
name: "Nom"
created_at: "Date de création"
updated_at: "Date de mise à jour"
public_registrations: "Inscriptions publiques"
public_registrations_info: "Permettre à tout le monde de créer un nouveau compte sur la plateforme. Si désactivé, seuls les administrateurs et les gestionnaires peuvent créer de nouveaux comptes."
public_registrations_allowed: "Inscriptions publiques autorisées"
help: "Aide"
feature_tour: "Visite guidée des fonctionnalités"
feature_tour_info_html: "<p>Lorsque un administrateur ou un gestionnaire est connecté, une visite guidée des fonctionnalités se déclenchera lors de la première visite de chaque section de l'application. Vous pouvez modifier ce comportement pour une des valeur suivantes :</p><ul><li>« Une fois » pour garder le comportement par défaut.</li><li>« Par session » pour afficher les visites guidées chaque fois que l'application est ouverte de nouveau.</li><li>« Lancement manuel » pour éviter l'affichage automatique des visites guidées. Il sera toujours possible de les déclencher en appuyant sur F1 ou en cliquant sur « Aide » dans le menu utilisateur.</li></ul>"
@ -1399,8 +1451,10 @@ fr:
payment_method: "Moyen de paiement"
method_card: "Carte bancaire en ligne"
method_check: "Par chèques"
method_transfer: "Par virement bancaire"
card_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é."
transfer_collection_info: "<p>En validant, vous confirmez avoir mis en place {DEADLINES} prélèvements bancaires, vous permettant de percevoir toutes les mensualités.</p><p><strong>Veuillez noter :</strong> les prélèvements bancaires ne sont pas automatiquement gérés par Fab-manager.</p>"
online_payment_disabled: "Le paiement en ligne n'est pas disponible. Vous ne pouvez pas encaisser cet échéancier de paiement en utilisant la carte bancaire en ligne."
check_list_setting:
save: 'Enregistrer'

Some files were not shown because too many files have changed in this diff Show More