1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2024-11-29 10:24:20 +01:00

Merge branch 'dev' for release 5.1.11

This commit is contained in:
Sylvain 2021-10-22 17:54:37 +02:00
commit 8b9dbba33e
125 changed files with 10386 additions and 7388 deletions

View File

@ -1,9 +1,42 @@
# Changelog Fab-manager
## 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
- 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: 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: 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: 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
- Updated @rails/webpacker to 5.4.3
- Updated react-refresh-webpack-plugin to 0.5.1
- Updated react-refresh to 0.10.0
- Fix a security issue: updated tar to 6.1.11 to fix [CVE-2021-37712](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-37712), [CVE-2021-37701](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-37701) and [CVE-2021-37713](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-37713)
- Fix a security issue: updated immer to 9.0.6 to fix [CVE-2021-3757](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-3757) and [CVE-2021-23436](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-23436)
- Fix a security issue: updated url-parse to 1.5.3 to fix [CVE-2021-3664](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-3664)
- Fix a security issue: updated axios to 0.21.2 to fix [CVE-2021-3749](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-3749)
- Fix a security issue: updated nokogiri to 1.12.5 to fix [CVE-2021-41098](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-41098)
- Fix a security issue: updated puma to 4.3.9 to fix [CVE-2021-41136](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-41136)
- Fix a security issue: updated sidekiq to 6.2.1 to fix [CVE-2021-30151](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-30151)
- [TODO DEPLOY] `rails db:seed`
## 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: 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
## v5.1.9 2021 September 21

View File

@ -7,7 +7,7 @@ gem 'rails', '~> 5.2.4'
# Used by rails 5.2 to reduce the app boot time by over 50%
gem 'bootsnap'
# Use Puma as web server
gem 'puma', '4.3.8'
gem 'puma', '4.3.9'
gem 'webpacker', '~> 5.x'
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder

View File

@ -89,7 +89,7 @@ GEM
coercible (1.0.0)
descendants_tracker (~> 0.0.1)
concurrent-ruby (1.1.8)
connection_pool (2.2.3)
connection_pool (2.2.5)
coveralls_reborn (0.18.0)
simplecov (>= 0.18.1, < 0.20.0)
term-ansicolor (~> 1.6)
@ -205,7 +205,7 @@ GEM
rake
mini_magick (4.10.1)
mini_mime (1.1.0)
mini_portile2 (2.5.1)
mini_portile2 (2.6.1)
minitest (5.14.4)
minitest-reporters (1.4.2)
ansi
@ -216,9 +216,9 @@ GEM
multi_json (1.14.1)
multi_xml (0.6.0)
multipart-post (2.1.1)
nio4r (2.5.7)
nokogiri (1.11.4)
mini_portile2 (~> 2.5.0)
nio4r (2.5.8)
nokogiri (1.12.5)
mini_portile2 (~> 2.6.1)
racc (~> 1.4)
notify_with (0.0.2)
jbuilder (~> 2.0)
@ -264,15 +264,13 @@ GEM
prawn-table (0.2.2)
prawn (>= 1.3.0, < 3.0.0)
public_suffix (4.0.6)
puma (4.3.8)
puma (4.3.9)
nio4r (~> 2.0)
pundit (2.1.0)
activesupport (>= 3.0.0)
raabro (1.1.6)
racc (1.5.2)
rack (2.2.3)
rack-protection (2.0.8.1)
rack
rack-proxy (0.6.5)
rack
rack-test (1.1.0)
@ -318,7 +316,7 @@ GEM
recurrence (1.3.0)
activesupport
i18n
redis (4.1.4)
redis (4.4.0)
repost (0.3.2)
responders (2.4.1)
actionpack (>= 4.2.0, < 6.0)
@ -348,11 +346,10 @@ GEM
activesupport (>= 4)
semantic_range (2.3.0)
sha3 (1.0.1)
sidekiq (6.0.7)
sidekiq (6.2.1)
connection_pool (>= 2.2.2)
rack (~> 2.0)
rack-protection (>= 2.0.0)
redis (>= 4.1.0)
redis (>= 4.2.0)
sidekiq-cron (1.1.0)
fugit (~> 1.1)
sidekiq (>= 4.2.1)
@ -470,7 +467,7 @@ DEPENDENCIES
pg_search
prawn
prawn-table
puma (= 4.3.8)
puma (= 4.3.9)
pundit
railroady
rails (~> 5.2.4)

View File

@ -9,13 +9,6 @@ Fab-manager is the Fab Lab management solution. It provides a comprehensive, web
Please visit [fab-manager.com](https://www.fab-manager.com/) for more information about this software and its features.
##### Table of Contents
1. [Contributing](#contributing)
2. [Documentation](#documentation)
3. [Open Projects](#open-projects)
4. [Plugins](#plugins)
5. [Single Sign-On](#sso)
<a name="contributing"></a>
## Contributing
@ -26,45 +19,8 @@ Contributions are welcome. Please read [the contribution guidelines](CONTRIBUTIN
The full documentation is available at [doc.fab.mn](http://doc.fab.mn).
<a name="open-projects"></a>
## Open Projects
<a name="Copyright"></a>
## Copyright
**This configuration is optional.**
You can configure your Fab-manager to synchronize every project with the [Open Projects platform](https://github.com/sleede/openlab-projects).
It's very simple and straightforward and in return, your users will be able to search over projects from all Fab-manager instances from within your platform.
The deal is fair, you share your projects and as reward you benefits from projects of the whole community.
If you want to try it, you can visit [this Fab-manager](https://fablab.lacasemate.fr/#!/projects) and see projects from different Fab-managers.
To start using this awesome feature, there are a few steps:
- send a mail to **contact@fab-manager.com** asking for your Open Projects client's credentials and giving them the name and the URL of your Fab-manager, they will give you an `App ID` and a `secret`
- fill in the value of the keys in Admin > Projects > Settings > Projects sharing
- export your projects to open-projects (if you already have projects created on your Fab-manager, unless you can skip that part) executing this command: `bundle exec rails fablab:openlab:bulk_export`
**IMPORTANT: please run your server in production mode.**
Go to your projects gallery and enjoy seeing your projects available from everywhere ! That's all.
<a name="plugins"></a>
## Plugins
Fab-manager has a system of plugins mainly inspired by [Discourse](https://github.com/discourse/discourse) architecture.
It enables you to write plugins which can:
- have its proper models and database tables
- have its proper assets (js & css)
- override existing behaviours of Fab-manager
- add features by adding views, controllers, ect...
To install a plugin, you just have to copy the plugin folder which contains its code into the folder `plugins` of Fab-manager.
You can see an example on the [repo of navinum gamification plugin](https://github.com/sleede/navinum-gamification)
<a name="sso"></a>
## Single Sign-On
Fab-manager can be connected to a [Single Sign-On](https://en.wikipedia.org/wiki/Single_sign-on) server which will provide its own authentication for the platform's users.
Currently, OAuth 2 is the only supported protocol for SSO authentication.
For an example of how to use configure an SSO in Fab-manager, please read [sso_with_github.md](doc/sso_with_github.md).
This free software is available under the terms of the [GNU Affero General Public License](LICENSE.md).
Fab-manager is developed by [sleede](https://www.sleede.com/) and the [open-open contributors](https://github.com/sleede/fab-manager/graphs/contributors) of the community.

View File

@ -39,7 +39,7 @@ class API::PaymentsController < API::ApiController
post_save(gateway_item_id, gateway_item_type, res[:payment])
res[:payment].render_resource.merge(status: :created)
else
{ json: res[:errors], status: :unprocessable_entity }
{ json: res[:errors].drop_while(&:empty?), status: :unprocessable_entity }
end
end
end

View File

@ -7,6 +7,7 @@ class API::PayzenController < API::PaymentsController
require 'pay_zen/token'
require 'pay_zen/transaction'
require 'pay_zen/helper'
require 'pay_zen/service'
def sdk_test
str = 'fab-manager'
@ -25,7 +26,7 @@ class API::PayzenController < API::PaymentsController
@id = PayZen::Helper.generate_ref(params[:cart_items], params[:customer_id])
client = PayZen::Charge.new
@result = client.create_payment(amount: amount[:amount],
@result = client.create_payment(amount: PayZen::Service.new.payzen_amount(amount[:amount]),
order_id: @id,
customer: PayZen::Helper.generate_customer(params[:customer_id], current_user.id, params[:cart_items]))
rescue PayzenError => e

View File

@ -47,7 +47,7 @@ class API::StripeController < API::PaymentsController
res = on_payment_success(intent, cart) if intent&.status == 'succeeded'
render generate_payment_response(intent, res)
render generate_payment_response(intent, 'payment', res)
end
def online_payment_status
@ -69,30 +69,38 @@ class API::StripeController < API::PaymentsController
render json: { id: @intent.id, client_secret: @intent.client_secret }
end
def payment_schedule
def setup_subscription
cart = shopping_cart
Stripe.api_key = Setting.get('stripe_secret_key')
@intent = Stripe::PaymentMethod.attach(
cart.items.each do |item|
raise InvalidSubscriptionError unless item.valid?(cart.items)
raise InvalidSubscriptionError unless item.to_object.errors.empty?
end
service = Stripe::Service.new
method = service.attach_method_as_default(
params[:payment_method_id],
customer: cart.customer.payment_gateway_object.gateway_object_id
cart.customer.payment_gateway_object.gateway_object_id
)
# Set the default payment method on the customer
Stripe::Customer.update(
cart.customer.payment_gateway_object.gateway_object_id,
invoice_settings: { default_payment_method: params[:payment_method_id] }
)
@res = cart.pay_schedule(@intent.id, @intent.class.name)
render json: @res.to_json
stp_subscription = service.subscribe(method.id, cart)
res = on_payment_success(stp_subscription, cart) if %w[active not_started].include?(stp_subscription&.status)
render generate_payment_response(stp_subscription.try(:latest_invoice)&.payment_intent, 'subscription', res, stp_subscription.id)
end
def confirm_payment_schedule
def confirm_subscription
key = Setting.get('stripe_secret_key')
subscription = Stripe::Subscription.retrieve(params[:subscription_id], api_key: key)
subscription = Stripe::Subscription.retrieve(
{ id: params[:subscription_id], expand: %w[latest_invoice.payment_intent] },
{ api_key: key }
)
cart = shopping_cart
if subscription&.status == 'active'
res = on_payment_success(subscription, cart)
render generate_payment_response(subscription, res)
render generate_payment_response(subscription.latest_invoice.payment_intent, 'subscription', res)
else
render generate_payment_response(subscription.latest_invoice.payment_intent, 'subscription', nil, subscription.id)
end
rescue Stripe::InvalidRequestError => e
render json: e, status: :unprocessable_entity
@ -130,7 +138,7 @@ class API::StripeController < API::PaymentsController
super(intent.id, intent.class.name, cart)
end
def generate_payment_response(intent, res = nil)
def generate_payment_response(intent, type, res = nil, stp_subscription_id = nil)
return res unless res.nil?
if intent.status == 'requires_action' && intent.next_action.type == 'use_stripe_sdk'
@ -139,7 +147,9 @@ class API::StripeController < API::PaymentsController
status: 200,
json: {
requires_action: true,
payment_intent_client_secret: intent.client_secret
payment_intent_client_secret: intent.client_secret,
type: type,
subscription_id: stp_subscription_id
}
}
elsif intent.status == 'succeeded'

View File

@ -2,28 +2,15 @@
# API Controller for resources of type Subscription
class API::SubscriptionsController < API::ApiController
before_action :set_subscription, only: %i[show edit update destroy]
before_action :set_subscription, only: %i[show payment_details]
before_action :authenticate_user!
def show
authorize @subscription
end
def update
def payment_details
authorize @subscription
free_days = params[:subscription][:free] || false
res = Subscriptions::Subscribe.new(current_user.invoicing_profile.id)
.extend_subscription(@subscription, subscription_update_params[:expired_at], free_days)
if res.is_a?(Subscription)
@subscription = res
render status: :created
elsif res
render status: :ok
else
render status: :unprocessable_entity
end
end
private
@ -32,8 +19,4 @@ class API::SubscriptionsController < API::ApiController
def set_subscription
@subscription = Subscription.find(params[:id])
end
def subscription_update_params
params.require(:subscription).permit(:expired_at)
end
end

View File

@ -20,7 +20,7 @@ class RegistrationsController < Devise::RegistrationsController
# Allows sending the confirmation email without blocking the access to the dashboard
resource.send_confirmation_instructions
sign_up(resource_name, resource)
sign_up(resource_name, resource) unless Setting.get('confirmation_required')
respond_with resource, location: after_sign_up_path_for(resource)
else
set_flash_message :notice, :"signed_up_but_#{resource.inactive_message}" if is_flashing_format?

View File

@ -1,6 +1,6 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { ShoppingCart, IntentConfirmation, PaymentConfirmation, UpdateCardResponse, StripeSubscription } from '../models/payment';
import { ShoppingCart, IntentConfirmation, PaymentConfirmation, UpdateCardResponse } from '../models/payment';
import { PaymentSchedule } from '../models/payment-schedule';
import { Invoice } from '../models/invoice';
@ -21,8 +21,8 @@ export default class StripeAPI {
return res?.data;
}
static async paymentSchedule (paymentMethodId: string, cartItems: ShoppingCart): Promise<StripeSubscription> {
const res: AxiosResponse = await apiClient.post('/api/stripe/payment_schedule', {
static async setupSubscription (paymentMethodId: string, cartItems: ShoppingCart): Promise<PaymentConfirmation|PaymentSchedule> {
const res: AxiosResponse = await apiClient.post('/api/stripe/setup_subscription', {
payment_method_id: paymentMethodId,
cart_items: cartItems
});
@ -34,8 +34,8 @@ export default class StripeAPI {
return res?.data;
}
static async confirmPaymentSchedule (subscriptionId: string, cartItems: ShoppingCart): Promise<PaymentSchedule> {
const res: AxiosResponse<PaymentSchedule> = await apiClient.post('/api/stripe/confirm_payment_schedule', {
static async confirmSubscription (subscriptionId: string, cartItems: ShoppingCart): Promise<PaymentSchedule> {
const res: AxiosResponse<PaymentSchedule> = await apiClient.post('/api/stripe/confirm_subscription', {
subscription_id: subscriptionId,
cart_items: cartItems
});

View File

@ -0,0 +1,20 @@
import apiClient from './clients/api-client';
import { Subscription, SubscriptionPaymentDetails, UpdateSubscriptionRequest } from '../models/subscription';
import { AxiosResponse } from 'axios';
export default class SubscriptionAPI {
static async update (request: UpdateSubscriptionRequest): Promise<Subscription> {
const res: AxiosResponse<Subscription> = await apiClient.patch(`/api/subscriptions/${request.id}`, { subscription: request });
return res?.data;
}
static async get (id: number): Promise<Subscription> {
const res: AxiosResponse<Subscription> = await apiClient.get(`/api/subscriptions/${id}`);
return res?.data;
}
static async paymentsDetails (id: number): Promise<SubscriptionPaymentDetails> {
const res: AxiosResponse<SubscriptionPaymentDetails> = await apiClient.get(`/api/subscriptions/${id}/payment_details`);
return res?.data;
}
}

View File

@ -1,11 +1,8 @@
import React from 'react';
import moment from 'moment';
import { FabModal } from '../base/fab-modal';
import { useTranslation } from 'react-i18next';
import { HtmlTranslate } from '../base/html-translate';
import { IFablab } from '../../models/fablab';
declare let Fablab: IFablab;
import FormatLib from '../../lib/format';
interface PendingTrainingModalProps {
isOpen: boolean,
@ -24,9 +21,7 @@ export const PendingTrainingModal: React.FC<PendingTrainingModalProps> = ({ isOp
* Return the formatted localized date for the given date
*/
const formatDateTime = (date: Date): string => {
const day = Intl.DateTimeFormat().format(moment(date).toDate());
const time = Intl.DateTimeFormat(Fablab.intl_locale, { hour: 'numeric', minute: 'numeric' }).format(moment(date).toDate());
return t('app.logged.pending_training_modal.DATE_TIME', { DATE: day, TIME: time });
return t('app.logged.pending_training_modal.DATE_TIME', { DATE: FormatLib.date(date), TIME: FormatLib.time(date) });
};
return (

View File

@ -17,7 +17,7 @@ interface PaymentScheduleSummaryProps {
/**
* This component displays a summary of the monthly payment schedule for the current cart, with a subscription
*/
const PaymentScheduleSummary: React.FC<PaymentScheduleSummaryProps> = ({ schedule }) => {
export const PaymentScheduleSummary: React.FC<PaymentScheduleSummaryProps> = ({ schedule }) => {
const { t } = useTranslation('shared');
// is open, the modal dialog showing the full details of the payment schedule?
@ -77,6 +77,7 @@ const PaymentScheduleSummary: React.FC<PaymentScheduleSummaryProps> = ({ schedul
</div>
);
};
const PaymentScheduleSummaryWrapper: React.FC<PaymentScheduleSummaryProps> = ({ schedule }) => {
return (
<Loader>

View File

@ -353,7 +353,7 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
<tr>
<td className="w-35 row-header" onClick={togglePaymentScheduleDetails(p.id)}>{expandCollapseIcon(p.id)}</td>
<td className="w-200">{p.reference}</td>
<td className="w-200">{FormatLib.date(p.created_at)}</td>
<td className="w-200">{FormatLib.date(_.minBy(p.items, 'due_date').due_date)}</td>
<td className="w-120">{FormatLib.price(p.total)}</td>
{showCustomer && <td className="w-200">{p.user.name}</td>}
<td className="w-200">{downloadButton(TargetType.PaymentSchedule, p.id)}</td>

View File

@ -12,19 +12,19 @@ interface SelectScheduleProps {
show: boolean,
selected: boolean,
onChange: (selected: boolean) => void,
className: string,
className?: string,
}
/**
* This component is a switch enabling the users to choose if they want to pay by monthly schedule
* or with a one time payment
*/
const SelectSchedule: React.FC<SelectScheduleProps> = ({ show, selected, onChange, className }) => {
export const SelectSchedule: React.FC<SelectScheduleProps> = ({ show, selected, onChange, className }) => {
const { t } = useTranslation('shared');
return (
<div className="select-schedule">
{show && <div className={className}>
{show && <div className={className || ''}>
<label htmlFor="payment_schedule">{ t('app.shared.cart.monthly_payment') }</label>
<Switch checked={selected} id="payment_schedule" onChange={onChange} className="schedule-switch" />
</div>}

View File

@ -1,4 +1,4 @@
import React, { FunctionComponent, ReactNode, useEffect, useState } from 'react';
import React, { FunctionComponent, ReactNode, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import WalletLib from '../../lib/wallet';
import { WalletInfo } from '../wallet-info';
@ -27,6 +27,7 @@ export interface GatewayFormProps {
className?: string,
paymentSchedule?: PaymentSchedule,
cart?: ShoppingCart,
updateCart?: (cart: ShoppingCart) => void,
formId: string,
}
@ -34,7 +35,9 @@ interface AbstractPaymentModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: (result: Invoice|PaymentSchedule) => void,
onError: (message: string) => void,
cart: ShoppingCart,
updateCart?: (cart: ShoppingCart) => void,
currentUser: User,
schedule?: PaymentSchedule,
customer: User,
@ -55,7 +58,7 @@ interface AbstractPaymentModalProps {
* This component must not be called directly but must be extended for each implemented payment gateway
* @see https://reactjs.org/docs/composition-vs-inheritance.html
*/
export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, cart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName, title, preventCgv, preventScheduleInfo, modalSize }) => {
export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName, title, preventCgv, preventScheduleInfo, modalSize }) => {
// customer's wallet
const [wallet, setWallet] = useState<Wallet>(null);
// server-computed price with all details
@ -74,6 +77,8 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
const [gateway, setGateway] = useState<string>(null);
// the sales conditions
const [cgv, setCgv] = useState<CustomAsset>(null);
// is the component mounted
const mounted = useRef(false);
const { t } = useTranslation('shared');
@ -81,11 +86,14 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
* When the component loads first, get the name of the currently active payment modal
*/
useEffect(() => {
mounted.current = true;
CustomAssetAPI.get(CustomAssetName.CgvFile).then(asset => setCgv(asset));
SettingAPI.get(SettingName.PaymentGateway).then((setting) => {
// we capitalize the first letter of the name
setGateway(setting.value.replace(/^\w/, (c) => c.toUpperCase()));
});
return () => { mounted.current = false; };
}, []);
/**
@ -153,8 +161,12 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
* When the payment form raises an error, it is handled by this callback which display it in the modal.
*/
const handleFormError = (message: string): void => {
setSubmitState(false);
setErrors(message);
if (mounted.current) {
setSubmitState(false);
setErrors(message);
} else {
onError(message);
}
};
/**
@ -195,13 +207,14 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
className={`gateway-form ${formClassName || ''}`}
formId={formId}
cart={cart}
updateCart={updateCart}
customer={customer}
paymentSchedule={schedule}>
{hasErrors() && <div className="payment-errors">
{errors}
</div>}
{hasPaymentScheduleInfo() && <div className="payment-schedule-info">
<HtmlTranslate trKey="app.shared.payment.payment_schedule_html" options={{ DEADLINES: schedule.items.length, GATEWAY: gateway }} />
<HtmlTranslate trKey="app.shared.payment.payment_schedule_html" options={{ DEADLINES: `${schedule.items.length}`, GATEWAY: gateway }} />
</div>}
{hasCgv() && <div className="terms-of-sales">
<input type="checkbox" id="acceptToS" name="acceptCondition" checked={tos} onChange={toggleTos} required />
@ -216,7 +229,8 @@ export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOp
disabled={!canSubmit()}
form={formId}
className="validate-btn">
{t('app.shared.payment.confirm_payment_of_', { AMOUNT: FormatLib.price(remainingPrice) })}
{remainingPrice > 0 && t('app.shared.payment.confirm_payment_of_', { AMOUNT: FormatLib.price(remainingPrice) })}
{remainingPrice === 0 && t('app.shared.payment.validate')}
</button>}
{submitState && <div className="payment-pending">
<div className="fa-2x">

View File

@ -1,4 +1,4 @@
import React, { FormEvent, useState } from 'react';
import React, { FormEvent, useEffect, useState } from 'react';
import Select from 'react-select';
import { useTranslation } from 'react-i18next';
import { GatewayFormProps } from '../abstract-payment-modal';
@ -24,12 +24,20 @@ type selectOption = { value: scheduleMethod, label: string };
* This is intended for use by privileged users.
* The form validation button must be created elsewhere, using the attribute form={formId}.
*/
export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, cart, customer, operator, formId }) => {
export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule, cart, updateCart, customer, operator, formId }) => {
const { t } = useTranslation('admin');
const [method, setMethod] = useState<scheduleMethod>('check');
const [onlinePaymentModal, setOnlinePaymentModal] = useState<boolean>(false);
useEffect(() => {
if (cart.payment_method === PaymentMethod.Card) {
setMethod('card');
} else {
setMethod('check');
}
}, [cart]);
/**
* Open/closes the online payment modal, used to collect card credentials when paying the payment schedule by card.
*/
@ -58,9 +66,9 @@ export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSucce
*/
const handleUpdateMethod = (option: selectOption) => {
if (option.value === 'card') {
cart.payment_method = PaymentMethod.Card;
updateCart(Object.assign({}, cart, { payment_method: PaymentMethod.Card }));
} else {
cart.payment_method = PaymentMethod.Other;
updateCart(Object.assign({}, cart, { payment_method: PaymentMethod.Other }));
}
setMethod(option.value);
};
@ -101,9 +109,26 @@ export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSucce
onSuccess(document);
};
/**
* Generally, this form component is only shown to admins or to managers when they book for someone else.
* If this is not the case, then it is shown to validate a free (or prepaid by wallet) cart.
* This function will return `true` in the later case.
*/
const isFreeOfCharge = (): boolean => {
return (customer.id === operator.id);
};
/**
* Get the type of the main item in the cart compile
*/
const mainItemType = (): string => {
return Object.keys(cart.items[0])[0];
};
return (
<form onSubmit={handleSubmit} id={formId} className={className || ''}>
{!paymentSchedule && <p className="payment">{t('app.admin.local_payment.about_to_cash')}</p>}
{!paymentSchedule && !isFreeOfCharge() && <p className="payment">{t('app.admin.local_payment.about_to_cash')}</p>}
{!paymentSchedule && isFreeOfCharge() && <p className="payment">{t('app.admin.local_payment.about_to_confirm', { ITEM: mainItemType() })}</p>}
{paymentSchedule && <div className="payment-schedule">
<div className="schedule-method">
<label htmlFor="payment-method">{t('app.admin.local_payment.payment_method')}</label>
@ -112,7 +137,7 @@ export const LocalPaymentForm: React.FC<GatewayFormProps> = ({ onSubmit, onSucce
className="method-select"
onChange={handleUpdateMethod}
options={buildMethodOptions()}
defaultValue={methodToOption(method)} />
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>}
</div>

View File

@ -3,7 +3,7 @@ import { AbstractPaymentModal, GatewayFormProps } from '../abstract-payment-moda
import { LocalPaymentForm } from './local-payment-form';
import { ShoppingCart } from '../../../models/payment';
import { PaymentSchedule } from '../../../models/payment-schedule';
import { User } from '../../../models/user';
import { User, UserRole } from '../../../models/user';
import { Invoice } from '../../../models/invoice';
import { useTranslation } from 'react-i18next';
import { ModalSize } from '../../base/fab-modal';
@ -17,7 +17,9 @@ interface LocalPaymentModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: (result: Invoice|PaymentSchedule) => void,
onError: (message: string) => void,
cart: ShoppingCart,
updateCart: (cart: ShoppingCart) => void,
currentUser: User,
schedule?: PaymentSchedule,
customer: User
@ -26,7 +28,7 @@ interface LocalPaymentModalProps {
/**
* This component enables a privileged user to confirm a local payments.
*/
const LocalPaymentModalComponent: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, cart, currentUser, schedule, customer }) => {
const LocalPaymentModalComponent: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, updateCart, currentUser, schedule, customer }) => {
const { t } = useTranslation('admin');
/**
@ -40,10 +42,19 @@ const LocalPaymentModalComponent: React.FC<LocalPaymentModalProps> = ({ isOpen,
);
};
/**
* Generally, this modal dialog is only shown to admins or to managers when they book for someone else.
* If this is not the case, then it is shown to validate a free (or prepaid by wallet) cart.
* This function will return `true` in the later case.
*/
const isFreeOfCharge = (): boolean => {
return (customer.id === currentUser.id);
};
/**
* Integrates the LocalPaymentForm into the parent AbstractPaymentModal
*/
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children }) => {
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, updateCart, customer, paymentSchedule, children }) => {
return (
<LocalPaymentForm onSubmit={onSubmit}
onSuccess={onSuccess}
@ -52,6 +63,7 @@ const LocalPaymentModalComponent: React.FC<LocalPaymentModalProps> = ({ isOpen,
className={className}
formId={formId}
cart={cart}
updateCart={updateCart}
customer={customer}
paymentSchedule={paymentSchedule}>
{children}
@ -64,13 +76,15 @@ const LocalPaymentModalComponent: React.FC<LocalPaymentModalProps> = ({ isOpen,
isOpen={isOpen}
toggleModal={toggleModal}
logoFooter={logoFooter()}
title={t('app.admin.local_payment.offline_payment')}
title={isFreeOfCharge() ? t('app.admin.local_payment.validate_cart') : t('app.admin.local_payment.offline_payment')}
formId="local-payment-form"
formClassName="local-payment-form"
currentUser={currentUser}
cart={cart}
updateCart={updateCart}
customer={customer}
afterSuccess={afterSuccess}
onError={onError}
schedule={schedule}
GatewayForm={renderForm}
modalSize={schedule ? ModalSize.large : ModalSize.medium}
@ -79,12 +93,12 @@ const LocalPaymentModalComponent: React.FC<LocalPaymentModalProps> = ({ isOpen,
);
};
export const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, currentUser, schedule, cart, customer }) => {
export const LocalPaymentModal: React.FC<LocalPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule, cart, updateCart, customer }) => {
return (
<Loader>
<LocalPaymentModalComponent isOpen={isOpen} toggleModal={toggleModal} afterSuccess={afterSuccess} currentUser={currentUser} schedule={schedule} cart={cart} customer={customer} />
<LocalPaymentModalComponent isOpen={isOpen} toggleModal={toggleModal} afterSuccess={afterSuccess} onError={onError} currentUser={currentUser} schedule={schedule} cart={cart} updateCart={updateCart} customer={customer} />
</Loader>
);
};
Application.Components.component('localPaymentModal', react2angular(LocalPaymentModal, ['isOpen', 'toggleModal', 'afterSuccess', 'currentUser', 'schedule', 'cart', 'customer']));
Application.Components.component('localPaymentModal', react2angular(LocalPaymentModal, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'updateCart', 'customer']));

View File

@ -47,6 +47,7 @@ const PaymentModalComponent: React.FC<PaymentModalProps> = ({ isOpen, toggleModa
return <StripeModal isOpen={isOpen}
toggleModal={toggleModal}
afterSuccess={afterSuccess}
onError={onError}
cart={cart}
currentUser={currentUser}
schedule={schedule}
@ -60,6 +61,7 @@ const PaymentModalComponent: React.FC<PaymentModalProps> = ({ isOpen, toggleModa
return <PayZenModal isOpen={isOpen}
toggleModal={toggleModal}
afterSuccess={afterSuccess}
onError={onError}
cart={cart}
currentUser={currentUser}
schedule={schedule}

View File

@ -40,7 +40,8 @@ export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onE
.then(({ KR, result }) => KR.showForm(result.formId))
.then(({ KR }) => KR.onFormReady(handleFormReady))
.then(({ KR }) => KR.onFormCreated(handleFormCreated))
.then(({ KR }) => { PayZenKR.current = KR; });
.then(({ KR }) => { PayZenKR.current = KR; })
.catch(error => onError(error));
}).catch(error => onError(error));
});
}, [cart, paymentSchedule, customer]);
@ -125,6 +126,7 @@ export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onE
*/
const handleSubmit = async (event: FormEvent): Promise<void> => {
event.preventDefault();
event.stopPropagation();
onSubmit();
try {

View File

@ -14,6 +14,7 @@ interface PayZenModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: (result: Invoice|PaymentSchedule) => void,
onError: (message: string) => void,
cart: ShoppingCart,
currentUser: User,
schedule?: PaymentSchedule,
@ -27,7 +28,7 @@ interface PayZenModalProps {
* This component should not be called directly. Prefer using <PaymentModal> which can handle the configuration
* of a different payment gateway.
*/
export const PayZenModal: React.FC<PayZenModalProps> = ({ isOpen, toggleModal, afterSuccess, cart, currentUser, schedule, customer }) => {
export const PayZenModal: React.FC<PayZenModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer }) => {
/**
* Return the logos, shown in the modal footer.
*/
@ -71,6 +72,7 @@ export const PayZenModal: React.FC<PayZenModalProps> = ({ isOpen, toggleModal, a
cart={cart}
customer={customer}
afterSuccess={afterSuccess}
onError={onError}
schedule={schedule}
GatewayForm={renderForm} />
);

View File

@ -2,9 +2,10 @@ import React, { FormEvent } from 'react';
import { CardElement, useElements, useStripe } from '@stripe/react-stripe-js';
import { useTranslation } from 'react-i18next';
import { GatewayFormProps } from '../abstract-payment-modal';
import { PaymentConfirmation, StripeSubscription } from '../../../models/payment';
import { PaymentConfirmation } from '../../../models/payment';
import StripeAPI from '../../../api/stripe';
import { Invoice } from '../../../models/invoice';
import { PaymentSchedule } from '../../../models/payment-schedule';
/**
* A form component to collect the credit card details and to create the payment method on Stripe.
@ -22,6 +23,7 @@ export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
*/
const handleSubmit = async (event: FormEvent): Promise<void> => {
event.preventDefault();
event.stopPropagation();
onSubmit();
// Stripe.js has not loaded yet
@ -43,36 +45,8 @@ export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
const res = await StripeAPI.confirmMethod(paymentMethod.id, cart);
await handleServerConfirmation(res);
} else {
const paymentMethodId = paymentMethod.id;
const subscription: StripeSubscription = await StripeAPI.paymentSchedule(paymentMethod.id, cart);
if (subscription && subscription.status === 'active') {
// Subscription is active, no customer actions required.
const res = await StripeAPI.confirmPaymentSchedule(subscription.id, cart);
onSuccess(res);
}
const paymentIntent = subscription.latest_invoice.payment_intent;
if (paymentIntent.status === 'requires_action') {
return stripe
.confirmCardPayment(paymentIntent.client_secret, {
payment_method: paymentMethodId
})
.then(async (result) => {
if (result.error) {
throw result.error;
} else {
if (result.paymentIntent.status === 'succeeded') {
const res = await StripeAPI.confirmPaymentSchedule(subscription.id, cart);
onSuccess(res);
}
}
})
.catch((error) => {
onError(error.message);
});
} else if (paymentIntent.status === 'requires_payment_method') {
onError(t('app.shared.messages.payment_card_declined'));
}
const res = await StripeAPI.setupSubscription(paymentMethod.id, cart);
await handleServerConfirmation(res, paymentMethod.id);
}
} catch (err) {
// catch api errors
@ -83,10 +57,11 @@ export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
/**
* Process the server response about the Strong-customer authentication (SCA)
* @param response can be a PaymentConfirmation, or an Invoice (if the payment succeeded)
* @param response can be a PaymentConfirmation, or an Invoice/PaymentSchedule (if the payment succeeded)
* @param paymentMethodId ID of the payment method, required only when confirming a payment schedule
* @see app/controllers/api/stripe_controller.rb#confirm_payment
*/
const handleServerConfirmation = async (response: PaymentConfirmation|Invoice) => {
const handleServerConfirmation = async (response: PaymentConfirmation|Invoice|PaymentSchedule, paymentMethodId?: string) => {
if ('error' in response) {
if (response.error.statusText) {
onError(response.error.statusText);
@ -94,18 +69,34 @@ export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
onError(`${t('app.shared.messages.payment_card_error')} ${response.error}`);
}
} else if ('requires_action' in response) {
// Use Stripe.js to handle required card action
const result = await stripe.handleCardAction(response.payment_intent_client_secret);
if (result.error) {
onError(result.error.message);
} else {
// The card action has been handled
// The PaymentIntent can be confirmed again on the server
try {
const confirmation = await StripeAPI.confirmIntent(result.paymentIntent.id, cart);
await handleServerConfirmation(confirmation);
} catch (e) {
onError(e);
if (response.type === 'payment') {
// Use Stripe.js to handle required card action
const result = await stripe.handleCardAction(response.payment_intent_client_secret);
if (result.error) {
onError(result.error.message);
} else {
// The card action has been handled
// The PaymentIntent can be confirmed again on the server
try {
const confirmation = await StripeAPI.confirmIntent(result.paymentIntent.id, cart);
await handleServerConfirmation(confirmation);
} catch (e) {
onError(e);
}
}
} else if (response.type === 'subscription') {
const result = await stripe.confirmCardPayment(response.payment_intent_client_secret, {
payment_method: paymentMethodId
});
if (result.error) {
onError(result.error.message);
} else {
try {
const confirmation = await StripeAPI.confirmSubscription(response.subscription_id, cart);
await handleServerConfirmation(confirmation);
} catch (e) {
onError(e);
}
}
}
} else if ('id' in response) {

View File

@ -15,6 +15,7 @@ interface StripeModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: (result: Invoice|PaymentSchedule) => void,
onError: (message: string) => void,
cart: ShoppingCart,
currentUser: User,
schedule?: PaymentSchedule,
@ -28,7 +29,7 @@ interface StripeModalProps {
* This component should not be called directly. Prefer using <PaymentModal> which can handle the configuration
* of a different payment gateway.
*/
export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuccess, cart, currentUser, schedule, customer }) => {
export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, cart, currentUser, schedule, customer }) => {
/**
* Return the logos, shown in the modal footer.
*/
@ -75,6 +76,7 @@ export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, a
cart={cart}
customer={customer}
afterSuccess={afterSuccess}
onError={onError}
schedule={schedule}
GatewayForm={renderForm} />
);

View File

@ -2,13 +2,11 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import moment from 'moment';
import _ from 'lodash';
import { IFablab } from '../../models/fablab';
import { Plan } from '../../models/plan';
import { User, UserRole } from '../../models/user';
import { Loader } from '../base/loader';
import '../../lib/i18n';
declare let Fablab: IFablab;
import FormatLib from '../../lib/format';
interface PlanCardProps {
plan: Plan,
@ -29,14 +27,14 @@ const PlanCardComponent: React.FC<PlanCardProps> = ({ plan, userId, subscribedPl
* Return the formatted localized amount of the given plan (eg. 20.5 => "20,50 €")
*/
const amount = () : string => {
return new Intl.NumberFormat(Fablab.intl_locale, { style: 'currency', currency: Fablab.intl_currency }).format(plan.amount);
return FormatLib.price(plan.amount);
};
/**
* Return the formatted localized amount, divided by the number of months (eg. 120 => "10,00 € / month")
*/
const monthlyAmount = (): string => {
const monthly = plan.amount / moment.duration(plan.interval_count, plan.interval).asMonths();
return new Intl.NumberFormat(Fablab.intl_locale, { style: 'currency', currency: Fablab.intl_currency }).format(monthly);
return FormatLib.price(monthly);
};
/**
* Return the formatted localized duration of te given plan (eg. Month/3 => "3 mois")

View File

@ -162,7 +162,9 @@ export const ProposePacksModal: React.FC<ProposePacksModalProps> = ({ isOpen, to
<LocalPaymentModal isOpen={localPaymentModal}
toggleModal={toggleLocalPaymentModal}
afterSuccess={handlePackBought}
onError={onError}
cart={cart}
updateCart={setCart}
currentUser={operator}
customer={customer} />
</div>}

View File

@ -17,6 +17,7 @@ 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';
declare let Fablab: IFablab;
declare const Application: IApplication;
@ -63,11 +64,11 @@ const MachinesPricing: React.FC<MachinesPricingProps> = ({ onError, onSuccess })
const hourlyRate = 10;
if (type === 'hourly_rate') {
return new Intl.NumberFormat(Fablab.intl_locale, { style: 'currency', currency: Fablab.intl_currency }).format(hourlyRate);
return FormatLib.price(hourlyRate);
}
const price = (hourlyRate / 60) * EXEMPLE_DURATION;
return new Intl.NumberFormat(Fablab.intl_locale, { style: 'currency', currency: Fablab.intl_currency }).format(price);
return FormatLib.price(price);
};
/**
@ -111,7 +112,7 @@ const MachinesPricing: React.FC<MachinesPricingProps> = ({ onError, onSuccess })
<div className="machines-pricing">
<FabAlert level="warning">
<p><HtmlTranslate trKey="app.admin.machines_pricing.prices_match_machine_hours_rates_html"/></p>
<p><HtmlTranslate trKey="app.admin.machines_pricing.prices_calculated_on_hourly_rate_html" options={{ DURATION: EXEMPLE_DURATION, RATE: examplePrice('hourly_rate'), PRICE: examplePrice('final_price') }} /></p>
<p><HtmlTranslate trKey="app.admin.machines_pricing.prices_calculated_on_hourly_rate_html" options={{ DURATION: `${EXEMPLE_DURATION}`, RATE: examplePrice('hourly_rate'), PRICE: examplePrice('final_price') }} /></p>
<p>{t('app.admin.machines_pricing.you_can_override')}</p>
</FabAlert>
<table>

View File

@ -0,0 +1,92 @@
import React, { BaseSyntheticEvent, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SettingName } from '../../models/setting';
import { IApplication } from '../../models/application';
import { react2angular } from 'react2angular';
import SettingAPI from '../../api/setting';
import { Loader } from '../base/loader';
import { FabButton } from '../base/fab-button';
declare const Application: IApplication;
interface CheckListSettingProps {
name: SettingName,
label: string,
className?: string,
// availableOptions must be like this [['option1', 'label 1'], ['option2', 'label 2']]
availableOptions: Array<Array<string>>,
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
/**
* This component allows to configure multiples values for a setting, like a check list.
* The result is stored as a string, composed of the checked values, e.g. 'option1,option2'
*/
const CheckListSetting: React.FC<CheckListSettingProps> = ({ name, label, className, availableOptions, onSuccess, onError }) => {
const { t } = useTranslation('admin');
const [value, setValue] = useState<string>(null);
// on component load, we retrieve the current value of the list from the API
useEffect(() => {
SettingAPI.get(name)
.then(res => setValue(res.value))
.catch(err => onError(err));
}, []);
/**
* Callback triggered when a checkbox is ticked or unticked.
* This function construct the resulting string, by adding or deleting the provided option identifier.
*/
const toggleCheckbox = (option: string) => {
return (event: BaseSyntheticEvent) => {
if (event.target.checked) {
let newValue = value ? `${value},` : '';
newValue += option;
setValue(newValue);
} else {
const regex = new RegExp(`,?${option}`, 'g');
setValue(value.replace(regex, ''));
}
};
};
/**
* Callback triggered when the 'save' button is clicked.
* Save the built string to the Setting API
*/
const handleSave = () => {
SettingAPI.update(name, value)
.then(() => onSuccess(t('app.admin.check_list_setting.customization_of_SETTING_successfully_saved', { SETTING: t(`app.admin.settings.${name}`) })))
.catch(err => onError(err));
};
/**
* Verify if the provided option is currently ticked (i.e. included in the value string)
*/
const isChecked = (option) => {
return value?.includes(option);
};
return (
<div className={`check-list-setting ${className || ''}`}>
<h4 className="check-list-title">{label}</h4>
{availableOptions.map(option => <div key={option[0]}>
<input id={`setting-${name}-${option[0]}`} type="checkbox" checked={isChecked(option[0])} onChange={toggleCheckbox(option[0])} />
<label htmlFor={`setting-${name}-${option[0]}`}>{option[1]}</label>
</div>)}
<FabButton className="save" onClick={handleSave}>{t('app.admin.check_list_setting.save')}</FabButton>
</div>
);
};
const CheckListSettingWrapper: React.FC<CheckListSettingProps> = ({ availableOptions, onSuccess, onError, label, className, name }) => {
return (
<Loader>
<CheckListSetting availableOptions={availableOptions} label={label} name={name} onError={onError} onSuccess={onSuccess} className={className} />
</Loader>
);
};
Application.Components.component('checkListSetting', react2angular(CheckListSettingWrapper, ['className', 'name', 'label', 'availableOptions', 'onSuccess', 'onError']));

View File

@ -0,0 +1,126 @@
import React, { useEffect, useState } from 'react';
import { Subscription } from '../../models/subscription';
import { FabModal, ModalSize } from '../base/fab-modal';
import { useTranslation } from 'react-i18next';
import { FabAlert } from '../base/fab-alert';
import { FabInput } from '../base/fab-input';
import FormatLib from '../../lib/format';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import { IApplication } from '../../models/application';
import LocalPaymentAPI from '../../api/local-payment';
import { PaymentMethod } from '../../models/payment';
declare const Application: IApplication;
interface FreeExtendModalProps {
isOpen: boolean,
toggleModal: () => void,
subscription: Subscription,
customerId: number,
onSuccess: (message: string, newExpirationDate: Date) => void,
onError: (message: string) => void,
}
/**
* Modal dialog shown to extend the current subscription of a customer, for free
*/
const FreeExtendModal: React.FC<FreeExtendModalProps> = ({ isOpen, toggleModal, subscription, customerId, onError, onSuccess }) => {
// we do not render the modal if the subscription was not provided
if (!subscription) return null;
const { t } = useTranslation('admin');
const [expirationDate, setExpirationDate] = useState<Date>(new Date(subscription.expired_at));
const [freeDays, setFreeDays] = useState<number>(0);
// we update the number of free days when the new expiration date is updated
useEffect(() => {
if (!expirationDate || !subscription.expired_at) {
setFreeDays(0);
}
// 86400000 = 1000 * 3600 * 24 = number of ms per day
setFreeDays(Math.ceil((expirationDate.getTime() - new Date(subscription.expired_at).getTime()) / 86400000));
}, [expirationDate]);
/**
* Return the formatted localized date for the given date
*/
const formatDateTime = (date: Date): string => {
return t('app.admin.free_extend_modal.DATE_TIME', { DATE: FormatLib.date(date), TIME: FormatLib.time(date) });
};
/**
* Return the given date formatted for the HTML input-date
*/
const formatDefaultDate = (date: Date): string => {
return date.toISOString().substr(0, 10);
};
/**
* Parse the given date and record it as the new expiration date of the subscription
*/
const handleDateUpdate = (date: string): void => {
setExpirationDate(new Date(Date.parse(date)));
};
/**
* Callback triggered when the user validates the free extent of the subscription
*/
const handleConfirmExtend = (): void => {
LocalPaymentAPI.confirmPayment({
customer_id: customerId,
payment_method: PaymentMethod.Other,
items: [
{
free_extension: {
end_at: expirationDate
}
}
]
}).then(() => {
onSuccess(t('app.admin.free_extend_modal.extend_success'), expirationDate);
toggleModal();
}).catch(err => onError(err));
};
return (
<FabModal isOpen={isOpen}
toggleModal={toggleModal}
width={ModalSize.large}
className="free-extend-modal"
title={t('app.admin.free_extend_modal.extend_subscription')}
confirmButton={t('app.admin.free_extend_modal.extend')}
onConfirm={handleConfirmExtend}
closeButton>
<FabAlert level="danger" className="conditions">
<p>{t('app.admin.free_extend_modal.offer_free_days_infos')}</p>
<p>{t('app.admin.free_extend_modal.credits_will_remain_unchanged')}</p>
</FabAlert>
<form className="configuration-form">
<label htmlFor="current_expiration">{t('app.admin.free_extend_modal.current_expiration')}</label>
<FabInput id="current_expiration"
defaultValue={formatDateTime(subscription.expired_at)}
readOnly />
<label htmlFor="new_expiration">{t('app.admin.free_extend_modal.new_expiration_date')}</label>
<FabInput id="new_expiration"
type="date"
defaultValue={formatDefaultDate(expirationDate)}
onChange={handleDateUpdate} />
<label htmlFor="free_days">{t('app.admin.free_extend_modal.number_of_free_days')}</label>
<input id="free_days" className="free-days" value={freeDays} readOnly />
</form>
</FabModal>
);
};
const FreeExtendModalWrapper: React.FC<FreeExtendModalProps> = ({ toggleModal, subscription, customerId, isOpen, onSuccess, onError }) => {
return (
<Loader>
<FreeExtendModal toggleModal={toggleModal} subscription={subscription} customerId={customerId} isOpen={isOpen} onError={onError} onSuccess={onSuccess} />
</Loader>
);
};
Application.Components.component('freeExtendModal', react2angular(FreeExtendModalWrapper, ['toggleModal', 'subscription', 'customerId', 'isOpen', 'onError', 'onSuccess']));

View File

@ -0,0 +1,164 @@
import React, { useEffect, useState } from 'react';
import { Subscription } from '../../models/subscription';
import { FabModal, ModalSize } from '../base/fab-modal';
import { useTranslation } from 'react-i18next';
import { FabAlert } from '../base/fab-alert';
import { FabInput } from '../base/fab-input';
import FormatLib from '../../lib/format';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import { IApplication } from '../../models/application';
import { PaymentMethod, ShoppingCart } from '../../models/payment';
import moment from 'moment';
import { SelectSchedule } from '../payment-schedule/select-schedule';
import SubscriptionAPI from '../../api/subscription';
import PriceAPI from '../../api/price';
import { ComputePriceResult } from '../../models/price';
import { PaymentScheduleSummary } from '../payment-schedule/payment-schedule-summary';
import { PaymentSchedule } from '../../models/payment-schedule';
import { LocalPaymentModal } from '../payment/local-payment/local-payment-modal';
import { User } from '../../models/user';
declare const Application: IApplication;
interface RenewModalProps {
isOpen: boolean,
toggleModal: () => void,
subscription?: Subscription,
customer: User,
operator: User,
onSuccess: (message: string, newExpirationDate: Date) => void,
onError: (message: string) => void,
}
/**
* Modal dialog shown to renew the current subscription of a customer, for free
*/
const RenewModal: React.FC<RenewModalProps> = ({ isOpen, toggleModal, subscription, customer, operator, onError, onSuccess }) => {
// we do not render the modal if the subscription was not provided
if (!subscription) return null;
const { t } = useTranslation('admin');
const [expirationDate, setExpirationDate] = useState<Date>(new Date());
const [localPaymentModal, setLocalPaymentModal] = useState<boolean>(false);
const [cart, setCart] = useState<ShoppingCart>(null);
const [price, setPrice] = useState<ComputePriceResult>(null);
const [scheduleRequired, setScheduleRequired] = useState<boolean>(false);
// on init, we compute the new expiration date
useEffect(() => {
setExpirationDate(moment(subscription.expired_at)
.add(subscription.plan.interval_count, subscription.plan.interval)
.toDate());
SubscriptionAPI.paymentsDetails(subscription.id)
.then(res => setScheduleRequired(res.payment_schedule))
.catch(err => onError(err));
}, []);
// when the payment schedule is toggled (requested/ignored), we update the cart accordingly
useEffect(() => {
setCart({
customer_id: customer.id,
items: [{
subscription: {
plan_id: subscription.plan.id,
start_at: subscription.expired_at
}
}],
payment_method: PaymentMethod.Other,
payment_schedule: scheduleRequired
});
}, [scheduleRequired]);
// when the cart is updated, re-compute the price and the payment schedule
useEffect(() => {
if (!cart) return;
PriceAPI.compute(cart)
.then(res => setPrice(res))
.catch(err => onError(err));
}, [cart]);
/**
* Return the formatted localized date for the given date
*/
const formatDateTime = (date: Date): string => {
return t('app.admin.free_extend_modal.DATE_TIME', { DATE: FormatLib.date(date), TIME: FormatLib.time(date) });
};
/**
* Callback triggered when the payment of the subscription renewal was successful
*/
const onPaymentSuccess = (): void => {
onSuccess(t('app.admin.renew_subscription_modal.renew_success'), expirationDate);
toggleModal();
};
/**
* Open/closes the local payment modal
*/
const toggleLocalPaymentModal = (): void => {
setLocalPaymentModal(!localPaymentModal);
};
return (
<FabModal isOpen={isOpen}
toggleModal={toggleModal}
width={ModalSize.large}
className="renew-modal"
title={t('app.admin.renew_subscription_modal.renew_subscription')}
confirmButton={t('app.admin.renew_subscription_modal.renew')}
onConfirm={toggleLocalPaymentModal}
closeButton>
<FabAlert level="danger" className="conditions">
<p>{t('app.admin.renew_subscription_modal.renew_subscription_info')}</p>
<p>{t('app.admin.renew_subscription_modal.credits_will_be_reset')}</p>
</FabAlert>
<div className="form-and-payment">
<form className="configuration-form">
<label htmlFor="current_expiration">{t('app.admin.renew_subscription_modal.current_expiration')}</label>
<FabInput id="current_expiration"
defaultValue={formatDateTime(subscription.expired_at)}
readOnly />
<label htmlFor="new_start">{t('app.admin.renew_subscription_modal.new_start')}</label>
<FabInput id="new_start"
defaultValue={formatDateTime(subscription.expired_at)}
readOnly />
<label htmlFor="new_expiration">{t('app.admin.renew_subscription_modal.new_expiration_date')}</label>
<FabInput id="new_expiration"
defaultValue={formatDateTime(expirationDate)}
readOnly/>
</form>
<div className="payment">
{subscription.plan.monthly_payment && <SelectSchedule show selected={scheduleRequired} onChange={setScheduleRequired} />}
{price?.schedule && <PaymentScheduleSummary schedule={price.schedule as PaymentSchedule} />}
{price && !price?.schedule && <div className="one-go-payment">
<h4>{t('app.admin.renew_subscription_modal.pay_in_one_go')}</h4>
<span>{FormatLib.price(price.price)}</span>
</div>}
</div>
</div>
<LocalPaymentModal isOpen={localPaymentModal}
toggleModal={toggleLocalPaymentModal}
afterSuccess={onPaymentSuccess}
onError={onError}
cart={cart}
updateCart={setCart}
currentUser={operator}
customer={customer}
schedule={price?.schedule as PaymentSchedule} />
</FabModal>
);
};
const RenewModalWrapper: React.FC<RenewModalProps> = ({ toggleModal, subscription, customer, operator, isOpen, onSuccess, onError }) => {
return (
<Loader>
<RenewModal toggleModal={toggleModal} subscription={subscription} customer={customer} operator={operator} isOpen={isOpen} onError={onError} onSuccess={onSuccess} />
</Loader>
);
};
Application.Components.component('renewModal', react2angular(RenewModalWrapper, ['toggleModal', 'subscription', 'customer', 'operator', 'isOpen', 'onError', 'onSuccess']));

View File

@ -0,0 +1,171 @@
import React, { useEffect, useState } from 'react';
import Select from 'react-select';
import { useTranslation } from 'react-i18next';
import { Subscription } from '../../models/subscription';
import { User } from '../../models/user';
import { PaymentMethod, ShoppingCart } from '../../models/payment';
import { FabModal } from '../base/fab-modal';
import SubscriptionAPI from '../../api/subscription';
import { Plan } from '../../models/plan';
import PlanAPI from '../../api/plan';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import { IApplication } from '../../models/application';
import FormatLib from '../../lib/format';
import { SelectSchedule } from '../payment-schedule/select-schedule';
import { ComputePriceResult } from '../../models/price';
import { PaymentScheduleSummary } from '../payment-schedule/payment-schedule-summary';
import { PaymentSchedule } from '../../models/payment-schedule';
import PriceAPI from '../../api/price';
import { LocalPaymentModal } from '../payment/local-payment/local-payment-modal';
declare const Application: IApplication;
interface SubscribeModalProps {
isOpen: boolean,
toggleModal: () => void,
customer: User,
operator: User,
onSuccess: (message: string, subscription: Subscription) => void,
onError: (message: string) => void,
}
/**
* Option format, expected by react-select
* @see https://github.com/JedWatson/react-select
*/
type selectOption = { value: number, label: string };
/**
* Modal dialog shown to create a subscription for the given customer
*/
const SubscribeModal: React.FC<SubscribeModalProps> = ({ isOpen, toggleModal, customer, operator, onError, onSuccess }) => {
const { t } = useTranslation('admin');
const [selectedPlan, setSelectedPlan] = useState<Plan>(null);
const [selectedSchedule, setSelectedSchedule] = useState<boolean>(false);
const [allPlans, setAllPlans] = useState<Array<Plan>>(null);
const [price, setPrice] = useState<ComputePriceResult>(null);
const [cart, setCart] = useState<ShoppingCart>(null);
const [localPaymentModal, setLocalPaymentModal] = useState<boolean>(false);
// fetch all plans from the API on component mount
useEffect(() => {
PlanAPI.index()
.then(plans => setAllPlans(plans))
.catch(error => onError(error));
}, []);
// when the plan is updated, update the default value for the payment schedule requirement
useEffect(() => {
if (!selectedPlan) return;
setSelectedSchedule(selectedPlan.monthly_payment);
}, [selectedPlan]);
// when the plan or the requirement for a payment schedule are updated, update the cart accordingly
useEffect(() => {
if (!selectedPlan) return;
setCart({
customer_id: customer.id,
items: [{
subscription: {
plan_id: selectedPlan.id
}
}],
payment_method: PaymentMethod.Other,
payment_schedule: selectedSchedule
});
}, [selectedSchedule, selectedPlan]);
// when the cart is updated, update the price accordingly
useEffect(() => {
if (!cart) return;
PriceAPI.compute(cart)
.then(res => setPrice(res))
.catch(err => onError(err));
}, [cart]);
/**
* Callback triggered when the user selects a group in the dropdown list
*/
const handlePlanSelect = (option: selectOption): void => {
const plan = allPlans.find(p => p.id === option.value);
setSelectedPlan(plan);
};
/**
* Callback triggered when the payment of the subscription was successful
*/
const onPaymentSuccess = (res): void => {
SubscriptionAPI.get(res.main_object.id).then(subscription => {
onSuccess(t('app.admin.subscribe_modal.subscription_success'), subscription);
toggleModal();
}).catch(error => onError(error));
};
/**
* Open/closes the local payment modal
*/
const toggleLocalPaymentModal = (): void => {
setLocalPaymentModal(!localPaymentModal);
};
/**
* Convert all groups to the react-select format
*/
const buildOptions = (): Array<selectOption> => {
if (!allPlans) return [];
return allPlans.filter(p => !p.disabled && p.group_id === customer.group_id).map(p => {
return { value: p.id, label: `${p.base_name} (${FormatLib.duration(p.interval, p.interval_count)})` };
});
};
return (
<FabModal isOpen={isOpen}
toggleModal={toggleModal}
className="subscribe-modal"
title={t('app.admin.subscribe_modal.subscribe_USER', { USER: customer.name })}
confirmButton={t('app.admin.subscribe_modal.subscribe')}
onConfirm={toggleLocalPaymentModal}
closeButton>
<div className="options">
<label htmlFor="select-plan">{t('app.admin.subscribe_modal.select_plan')}</label>
<Select id="select-plan"
onChange={handlePlanSelect}
options={buildOptions()} />
<SelectSchedule show={selectedPlan?.monthly_payment} selected={selectedSchedule} onChange={setSelectedSchedule} />
</div>
<div className="summary">
{price?.schedule && <PaymentScheduleSummary schedule={price.schedule as PaymentSchedule} />}
{price && !price.schedule && <div className="one-go-payment">
<h4>{t('app.admin.subscribe_modal.pay_in_one_go')}</h4>
<span>{FormatLib.price(price.price)}</span>
</div>}
</div>
<LocalPaymentModal isOpen={localPaymentModal}
toggleModal={toggleLocalPaymentModal}
afterSuccess={onPaymentSuccess}
onError={onError}
cart={cart}
updateCart={setCart}
currentUser={operator}
customer={customer}
schedule={price?.schedule as PaymentSchedule} />
</FabModal>
);
};
const SubscribeModalWrapper: React.FC<SubscribeModalProps> = ({ isOpen, toggleModal, customer, operator, onError, onSuccess }) => {
return (
<Loader>
<SubscribeModal isOpen={isOpen} toggleModal={toggleModal} customer={customer} operator={operator} onSuccess={onSuccess} onError={onError} />
</Loader>
);
};
Application.Components.component('subscribeModal', react2angular(SubscribeModalWrapper, ['toggleModal', 'isOpen', 'customer', 'operator', 'onError', 'onSuccess']));

View File

@ -705,6 +705,15 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
// current active authentication provider
$scope.activeProvider = activeProviderPromise;
// modal dialog to extend the current subscription for free
$scope.isOpenFreeExtendModal = false;
// modal dialog to renew the current subscription
$scope.isOpenRenewModal = false;
// modal dialog to take a new subscription
$scope.isOpenSubscribeModal = false;
/**
* Open a modal dialog asking for confirmation to change the role of the given user
* @returns {*}
@ -753,135 +762,55 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
};
/**
* Open a modal dialog, allowing the admin to extend the current user's subscription (freely or not)
* @param subscription {Object} User's subscription object
* @param free {boolean} True if the extent is offered, false otherwise
* Opens/closes the modal dialog to freely extend the subscription
*/
$scope.updateSubscriptionModal = function (subscription, free) {
const modalInstance = $uibModal.open({
animation: true,
templateUrl: '/admin/subscriptions/expired_at_modal.html',
size: 'lg',
controller: ['$scope', '$uibModalInstance', 'Subscription', function ($scope, $uibModalInstance, Subscription) {
$scope.new_expired_at = angular.copy(subscription.expired_at);
$scope.free = free;
$scope.datePicker = {
opened: false,
format: Fablab.uibDateFormat,
options: {
startingDay: Fablab.weekStartingDay
},
minDate: new Date()
};
$scope.openDatePicker = function (ev) {
ev.preventDefault();
ev.stopPropagation();
return $scope.datePicker.opened = true;
};
$scope.ok = function () {
Subscription.update(
{ id: subscription.id },
{ subscription: { expired_at: $scope.new_expired_at, free } },
function (_subscription) {
growl.success(_t('app.admin.members_edit.you_successfully_changed_the_expiration_date_of_the_user_s_subscription'));
return $uibModalInstance.close(_subscription);
},
function (error) {
growl.error(_t('app.admin.members_edit.a_problem_occurred_while_saving_the_date'));
console.error(error);
}
);
};
$scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
}]
});
// once the form was validated successfully ...
return modalInstance.result.then(function (subscription) { $scope.subscription.expired_at = subscription.expired_at; });
$scope.toggleFreeExtendModal = () => {
setTimeout(() => {
$scope.isOpenFreeExtendModal = !$scope.isOpenFreeExtendModal;
$scope.$apply();
}, 50);
};
/**
* Open a modal dialog allowing the admin to set a subscription for the given user.
* @param user {Object} User object, user currently reviewed, as recovered from GET /api/members/:id
* @param plans {Array} List of plans, available for the currently reviewed user, as recovered from GET /api/plans
* Opens/closes the modal dialog to renew the subscription (with payment)
*/
$scope.createSubscriptionModal = function (user, plans) {
const modalInstance = $uibModal.open({
animation: true,
templateUrl: '/admin/subscriptions/create_modal.html',
size: 'lg',
controller: ['$scope', '$uibModalInstance', 'Subscription', function ($scope, $uibModalInstance, Subscription) {
// selected user
$scope.user = user;
$scope.toggleRenewModal = () => {
setTimeout(() => {
$scope.isOpenRenewModal = !$scope.isOpenRenewModal;
$scope.$apply();
}, 50);
};
// available plans for the selected user
$scope.plans = plans;
/**
* Opens/closes the modal dialog to renew the subscription (with payment)
*/
$scope.toggleSubscribeModal = () => {
setTimeout(() => {
$scope.isOpenSubscribeModal = !$scope.isOpenSubscribeModal;
$scope.$apply();
}, 50);
};
/**
* Callback triggered if the subscription was successfully extended
*/
$scope.onExtendSuccess = (message, newExpirationDate) => {
growl.success(message);
$scope.subscription.expired_at = newExpirationDate;
};
// default parameters for the new subscription
$scope.subscription = {
payment_schedule: false,
payment_method: 'check'
};
/**
* Callback triggered if a new subscription was successfully taken
*/
$scope.onSubscribeSuccess = (message, newSubscription) => {
growl.success(message);
$scope.subscription = newSubscription;
};
/**
* Generate a string identifying the given plan by literal human-readable name
* @param plan {Object} Plan object, as recovered from GET /api/plan/:id
* @param groups {Array} List of Groups objects, as recovered from GET /api/groups
* @param short {boolean} If true, the generated name will contain the group slug, otherwise the group full name
* will be included.
* @returns {String}
*/
$scope.humanReadablePlanName = function (plan, groups, short) { return `${$filter('humanReadablePlanName')(plan, groups, short)}`; };
/**
* Check if the currently selected plan can be paid with a payment schedule or not
* @return {boolean}
*/
$scope.allowMonthlySchedule = function () {
if (!$scope.subscription) return false;
const plan = plans.find(p => p.id === $scope.subscription.plan_id);
return plan && plan.monthly_payment;
};
/**
* Triggered by the <switch> component.
* We must use a setTimeout to workaround the react integration.
* @param checked {Boolean}
*/
$scope.toggleSchedule = function (checked) {
setTimeout(() => {
$scope.subscription.payment_schedule = checked;
$scope.$apply();
}, 50);
};
/**
* Modal dialog validation callback
*/
$scope.ok = function () {
$scope.subscription.user_id = user.id;
return Subscription.save({ }, { subscription: $scope.subscription }, function (_subscription) {
growl.success(_t('app.admin.members_edit.subscription_successfully_purchased'));
$uibModalInstance.close(_subscription);
return $state.reload();
}
, function (error) {
growl.error(_t('app.admin.members_edit.a_problem_occurred_while_taking_the_subscription'));
console.error(error);
});
};
/**
* Modal dialog cancellation callback
*/
$scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
}]
});
// once the form was validated successfully ...
return modalInstance.result.then(function (subscription) { $scope.subscription = subscription; });
/**
* Callback triggered in case of error
*/
$scope.onError = (message) => {
growl.error(message);
};
$scope.createWalletCreditModal = function (user, wallet) {

View File

@ -314,6 +314,30 @@ Application.Controllers.controller('SettingsController', ['$scope', '$rootScope'
$scope.codeMirrorEditor = editor;
};
/**
* Shows a success message forwarded from a child react component
*/
$scope.onSuccess = function (message) {
growl.success(message);
};
/**
* Callback triggered by react components
*/
$scope.onError = function (message) {
growl.error(message);
};
/**
* Options for allow/prevent book overlapping slots: which kind of slots are used in the overlapping computation
*/
$scope.availableOverlappingOptions = [
['training_reservations', _t('app.admin.settings.overlapping_options.training_reservations')],
['machine_reservations', _t('app.admin.settings.overlapping_options.machine_reservations')],
['space_reservations', _t('app.admin.settings.overlapping_options.space_reservations')],
['events_reservations', _t('app.admin.settings.overlapping_options.events_reservations')]
];
/**
* Setup the feature-tour for the admin/settings page.
* This is intended as a contextual help (when pressing F1)

View File

@ -100,7 +100,8 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
format: Fablab.uibDateFormat,
opened: false,
options: {
startingDay: Fablab.weekStartingDay
startingDay: Fablab.weekStartingDay,
maxDate: new Date()
}
};
@ -179,7 +180,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
}]
}).result['finally'](null).then(function (res) {
// when the account was created successfully, set the session to the newly created account
if(res.settings.confirmation_required) {
if(res.settings.confirmation_required === 'true') {
Auth._currentUser = null;
growl.info(_t('app.public.common.you_will_receive_confirmation_instructions_by_email_detailed'));
} else {

View File

@ -362,6 +362,14 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
afterPayment(invoice);
};
/**
* Invoked when something wrong occurred after the payment dialog has been closed
* @param message {string}
*/
$scope.onLocalPaymentError = (message) => {
growl.error(message);
};
/**
* Invoked when something wrong occurred during the payment dialog initialization
* @param message {string}
@ -370,6 +378,17 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
growl.error(message);
};
/**
* Callback triggered when a child component (LocalPaymentModal) requires to update the cart content
* @param cart {ShoppingCart}
*/
$scope.updateCart = (cart) => {
setTimeout(() => {
$scope.localPayment.cartItems = cart;
$scope.$apply();
}, 50);
};
/* PRIVATE SCOPE */
/**
@ -437,12 +456,7 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
* @param callback {function}
*/
const validateSameTimeReservations = function (slot, callback) {
let sameTimeReservations = [
'training_reservations',
'machine_reservations',
'space_reservations',
'events_reservations'
].map(function (k) {
let sameTimeReservations = $scope.settings.overlapping_categories.split(',').map(function (k) {
return _.filter($scope.user[k], function (r) {
return slot.start.isSame(r.start_at) ||
(slot.end.isAfter(r.start_at) && slot.end.isBefore(r.end_at)) ||

View File

@ -1,4 +1,4 @@
import moment from 'moment';
import moment, { unitOfTime } from 'moment';
import { IFablab } from '../models/fablab';
declare let Fablab: IFablab;
@ -11,6 +11,20 @@ export default class FormatLib {
return Intl.DateTimeFormat().format(moment(date).toDate());
}
/**
* Return the formatted localized time for the given date
*/
static time = (date: Date): string => {
return Intl.DateTimeFormat(Fablab.intl_locale, { hour: 'numeric', minute: 'numeric' }).format(moment(date).toDate());
};
/**
* Return the formatted localized duration
*/
static duration = (interval: unitOfTime.DurationConstructor, intervalCount: number): string => {
return moment.duration(intervalCount, interval).locale(Fablab.moment_locale).humanize();
}
/**
* Return the formatted localized amount for the given price (eg. 20.5 => "20,50 €")
*/

View File

@ -4,6 +4,8 @@ import { SubscriptionRequest } from './subscription';
export interface PaymentConfirmation {
requires_action?: boolean,
payment_intent_client_secret?: string,
type?: 'payment' | 'subscription',
subscription_id?: string,
success?: boolean,
error?: {
statusText: string
@ -19,7 +21,10 @@ export enum PaymentMethod {
Other = ''
}
export type CartItem = { reservation: Reservation }|{ subscription: SubscriptionRequest }|{ prepaid_pack: { id: number } };
export type CartItem = { reservation: Reservation }|
{ subscription: SubscriptionRequest }|
{ prepaid_pack: { id: number } }|
{ free_extension: { end_at: Date } };
export interface ShoppingCart {
customer_id: number,
@ -34,14 +39,3 @@ export interface UpdateCardResponse {
updated: boolean,
error?: string
}
export interface StripeSubscription {
id: string,
status: string,
latest_invoice: {
payment_intent: {
status: string,
client_secret: string
}
}
}

View File

@ -111,6 +111,7 @@ export enum SettingName {
PublicAgendaModule = 'public_agenda_module',
RenewPackThreshold = 'renew_pack_threshold',
PackOnlyForSubscription = 'pack_only_for_subscription',
OverlappingCategories = 'overlapping_categories'
}
export type SettingValue = string|boolean|number;

View File

@ -1,3 +1,6 @@
import { PaymentIntent } from '@stripe/stripe-js';
// https://stripe.com/docs/api/tokens/object
export interface PIIToken {
id: string,
object: 'token',
@ -8,6 +11,7 @@ export interface PIIToken {
used: boolean
}
// https://stripe.com/docs/api/charges/object
export interface Charge {
id: string,
object: 'charge',
@ -50,3 +54,118 @@ export interface ListCharges {
has_more: boolean,
data: Array<Charge>
}
// https://stripe.com/docs/api/prices/object
export interface Price {
id: string,
object: 'price',
active: boolean,
billing_scheme: 'per_unit' | 'tiered',
created: Date,
currency: string,
livemode: boolean,
lookup_key: string,
metadata: Record<string, unknown>,
nickname: string,
product: string,
recurring: {
aggregate_usage: 'sum' | 'last_during_period' | 'last_ever' | 'max',
interval: 'day' | 'week' | 'month' | 'year',
interval_count: number,
usage_type: 'metered' | 'licensed'
},
tax_behavior: 'inclusive' | 'exclusive' | 'unspecified',
tiers: [
{
flat_amount: number,
flat_amount_decimal: string,
unit_amount: number,
unit_amount_decimal: string,
up_to: number
}
],
tiers_mode: 'graduated' | 'volume',
transform_quantity: {
divide_by: number,
round: 'up' | 'down'
},
type: 'one_time' | 'recurring'
unit_amount: number,
unit_amount_decimal: string
}
// https://stripe.com/docs/api/tax_rates/object
export interface TaxRate {
id: string,
object: 'tax_rate',
active: boolean,
country: string,
description: string,
display_name: string,
inclusive: boolean,
jurisdiction: string,
metadata: Record<string, unknown>,
percentage: number,
state: string,
created: Date,
livemode: boolean,
tax_type: 'vat' | 'sales_tax' | string
}
// https://stripe.com/docs/api/subscription_items/object
export interface SubscriptionItem {
id: string,
object: 'subscription_item',
billing_thresholds: {
usage_gte: number,
},
created: Date,
metadata: Record<string, unknown>,
price: Price,
quantity: number,
subscription: string;
tax_rates: Array<TaxRate>
}
// https://stripe.com/docs/api/invoices/object
export interface Invoice {
id: string,
object: 'invoice',
auto_advance: boolean,
charge: string,
collection_method: 'charge_automatically' | 'send_invoice',
currency: string,
customer: string,
description: string,
hosted_invoice_url: string,
lines: [],
metadata: Record<string, unknown>,
payment_intent: PaymentIntent,
period_end: Date,
period_start: Date,
status: 'draft' | 'open' | 'paid' | 'uncollectible' | 'void',
subscription: string,
total: number
}
// https://stripe.com/docs/api/subscriptions/object
export interface Subscription {
id: string,
object: 'subscription',
cancel_at_period_end: boolean,
current_period_end: Date,
current_period_start: Date,
customer: string,
default_payment_method: string,
items: [
{
object: 'list',
data: Array<SubscriptionItem>,
has_more: boolean,
url: string
}
]
status: 'incomplete' | 'incomplete_expired' | 'trialing' | 'active' | 'past_due' | 'canceled' | 'unpaid',
latest_invoice: Invoice
}

View File

@ -5,10 +5,21 @@ export interface Subscription {
plan_id: number,
expired_at: Date,
canceled_at?: Date,
stripe: boolean,
plan: Plan
}
export interface SubscriptionRequest {
plan_id: number
plan_id: number,
start_at?: Date
}
export interface UpdateSubscriptionRequest {
id: number,
expired_at: Date,
free: boolean
}
export interface SubscriptionPaymentDetails {
payment_schedule: boolean,
card: boolean
}

View File

@ -365,7 +365,7 @@ angular.module('application.router', ['ui.router'])
return Setting.query({
names: "['machine_explications_alert', 'booking_window_start', 'booking_window_end', 'booking_move_enable', " +
"'booking_move_delay', 'booking_cancel_enable', 'booking_cancel_delay', 'subscription_explications_alert', " +
"'online_payment_module', 'payment_gateway']"
"'online_payment_module', 'payment_gateway', 'overlapping_categories']"
}).$promise;
}]
}
@ -451,7 +451,7 @@ angular.module('application.router', ['ui.router'])
return Setting.query({
names: "['booking_window_start', 'booking_window_end', 'booking_move_enable', 'booking_move_delay', " +
"'booking_cancel_enable', 'booking_cancel_delay', 'subscription_explications_alert', " +
"'space_explications_alert', 'online_payment_module', 'payment_gateway']"
"'space_explications_alert', 'online_payment_module', 'payment_gateway', 'overlapping_categories']"
}).$promise;
}]
}
@ -505,7 +505,7 @@ angular.module('application.router', ['ui.router'])
names: "['booking_window_start', 'booking_window_end', 'booking_move_enable', 'booking_move_delay', " +
"'booking_cancel_enable', 'booking_cancel_delay', 'subscription_explications_alert', " +
"'training_explications_alert', 'training_information_message', 'online_payment_module', " +
"'payment_gateway']"
"'payment_gateway', 'overlapping_categories']"
}).$promise;
}]
}
@ -534,7 +534,7 @@ angular.module('application.router', ['ui.router'])
resolve: {
subscriptionExplicationsPromise: ['Setting', function (Setting) { return Setting.get({ name: 'subscription_explications_alert' }).$promise; }],
groupsPromise: ['Group', function (Group) { return Group.query().$promise; }],
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['online_payment_module', 'payment_gateway']" }).$promise; }]
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['online_payment_module', 'payment_gateway', 'overlapping_categories']" }).$promise; }]
}
})
@ -1080,7 +1080,7 @@ 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']"
"'renew_pack_threshold', 'pack_only_for_subscription', 'overlapping_categories']"
}).$promise;
}],
privacyDraftsPromise: ['Setting', function (Setting) { return Setting.get({ name: 'privacy_draft', history: true }).$promise; }],

View File

@ -3,8 +3,9 @@
Application.Services.factory('Subscription', ['$resource', function ($resource) {
return $resource('/api/subscriptions/:id',
{ id: '@id' }, {
update: {
method: 'PUT'
payment_details: {
url: '/api/subscriptions/:id/payment_details',
method: 'GET'
}
}
);

View File

@ -63,7 +63,10 @@
@import "modules/pricing/pack-form";
@import "modules/pricing/delete-pack";
@import "modules/pricing/edit-pack";
@import "modules/settings/check-list-setting";
@import "modules/prepaid-packs/propose-packs-modal";
@import "modules/prepaid-packs/packs-summary";
@import "modules/subscriptions/free-extend-modal";
@import "modules/subscriptions/renew-modal";
@import "app.responsive";

View File

@ -0,0 +1,14 @@
.check-list-setting {
.check-list-title {
font-weight: 600;
}
label {
margin-left: 1em;
}
.save {
background-color: #999;
border-color: #999;
}
}

View File

@ -0,0 +1,18 @@
.free-extend-modal {
.fab-modal-content {
padding: 30px;
.configuration-form {
padding: 15px;
.input-wrapper {
display: block;
}
.free-days {
display: block;
@extend .fab-input--input;
}
}
}
}

View File

@ -0,0 +1,28 @@
.renew-modal {
.fab-modal-content {
padding: 30px;
.form-and-payment {
display: flex;
.configuration-form {
padding-right: 15px;
.input-wrapper {
display: block;
}
}
.payment {
border: 1px solid #ccc;
border-radius: 2px;
padding: 10px;
width: 50%;
.one-go-payment {
text-align: center;
}
}
}
}
}

View File

@ -77,8 +77,23 @@
{{ 'app.admin.members_edit.price_' | translate }} {{ subscription.plan.amount | currency}}
</p>
<div ng-hide="user.id === currentUser.id">
<button class="btn btn-default" ng-click="updateSubscriptionModal(subscription, true)" translate>{{ 'app.admin.members_edit.offer_free_days' }}</button>
<button class="btn btn-default" ng-click="updateSubscriptionModal(subscription, false)" translate>{{ 'app.admin.members_edit.extend_subscription' }}</button>
<button class="btn btn-default" ng-click="toggleFreeExtendModal()" translate>{{ 'app.admin.members_edit.offer_free_days' }}</button>
<button class="btn btn-default" ng-click="toggleRenewModal()" translate>{{ 'app.admin.members_edit.renew_subscription' }}</button>
<free-extend-modal is-open="isOpenFreeExtendModal"
toggle-modal="toggleFreeExtendModal"
subscription="subscription"
customer-id="user.id"
on-error="onError"
on-success="onExtendSuccess">
</free-extend-modal>
<renew-modal is-open="isOpenRenewModal"
toggle-modal="toggleRenewModal"
subscription="subscription"
customer="user"
operator="currentUser"
on-error="onError"
on-success="onExtendSuccess">
</renew-modal>
</div>
<p class="alert alert-info" ng-show="user.id === currentUser.id" translate>
{{ 'app.admin.members_edit.cannot_extend_own_subscription' }}
@ -90,7 +105,14 @@
<p translate>
{{ 'app.admin.members_edit.user_has_no_current_subscription' }}
</p>
<button class="btn btn-default" ng-click="createSubscriptionModal(user, plans.filter(filterDisabledPlans))" translate>{{ 'app.admin.members_edit.subscribe_to_a_plan' }}</button>
<button class="btn btn-default" ng-click="toggleSubscribeModal()" translate>{{ 'app.admin.members_edit.subscribe_to_a_plan' }}</button>
<subscribe-modal is-open="isOpenSubscribeModal"
toggle-modal="toggleSubscribeModal"
customer="user"
operator="currentUser"
on-error="onError"
on-success="onSubscribeSuccess">
</subscribe-modal>
</div>
</div>
@ -106,7 +128,7 @@
</div>
<div class="widget-content bg-light wrapper r-b">
<ul class="list-unstyled" ng-if="user.training_reservations.length > 0">
<li class="m-b" ng-repeat="r in user.training_reservations | trainingReservationsFilter:'future'">
<li class="m-b" ng-repeat="r in user.training_reservations | trainingReservationsFilter:'future'" ng-class="{'reservation-canceled':r.canceled_at}">
<span class="font-sbold">{{r.reservable.name}}</span> - <span class="label label-warning wrapper-sm">{{ r.start_at | amDateFormat:'LLL' }} - {{ r.end_at | amDateFormat:'LT' }}</span>
</li>
</ul>

View File

@ -85,11 +85,24 @@
<div class="section-separator"></div>
<div class="row">
<h3 class="m-l m-t-lg" translate>{{ 'app.admin.settings.book_overlapping_slots_info' }}</h3>
<boolean-setting name="book_overlapping_slots"
settings="allSettings"
label="app.admin.settings.allow_booking"
classes="m-l">
</boolean-setting>
<div class="col-md-6">
<boolean-setting name="book_overlapping_slots"
settings="allSettings"
label="app.admin.settings.allow_booking"
classes="m-l">
</boolean-setting>
<div class="alert alert-warning" ng-show="allSettings.book_overlapping_slots !== 'true'" translate>
{{ 'app.admin.settings.overlapping_categories_info' }}
</div>
</div>
<div class="col-md-6" ng-show="allSettings.book_overlapping_slots !== 'true'">
<check-list-setting name="'overlapping_categories'"
label="'app.admin.settings.overlapping_categories' | translate"
available-options="availableOverlappingOptions"
on-success="onSuccess"
on-error="onError">
</check-list-setting>
</div>
</div>
<div class="section-separator"></div>
<div class="row">

View File

@ -1,24 +0,0 @@
<div class="modal-header">
<h3 class="modal-title m-l" translate>{{ 'app.admin.members_edit.new_subscription' }}</h3>
</div>
<div class="modal-body m-lg">
<div class="alert alert-danger">
<p translate translate-values="{NAME: user.name}">
{{ 'app.admin.members_edit.you_are_about_to_purchase_a_subscription_to_NAME' }}
</p>
</div>
<form role="form" name="subscriptionForm" class="form-horizontal" novalidate>
<div class="form-group">
<select ng-model="subscription.plan_id" ng-options="plan.id as humanReadablePlanName(plan) for plan in plans" class="form-control" required>
</select>
</div>
<div class="form-group" ng-show="allowMonthlySchedule()">
<label for="schedule" class="control-label m-r-md">{{ 'app.admin.members_edit.with_schedule' | translate }}</label>
<switch id="schedule" checked="subscription.payment_schedule" on-change="toggleSchedule"></switch>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-warning" ng-click="ok()" ng-disabled="subscriptionForm.$invalid" translate>{{ 'app.shared.buttons.confirm' }}</button>
<button class="btn btn-primary" ng-click="cancel()" translate>{{ 'app.shared.buttons.cancel' }}</button>
</div>

View File

@ -1,36 +0,0 @@
<div class="modal-header">
<h3 class="modal-title" translate>{{ 'app.admin.members_edit.expiration_date' }}</h3>
</div>
<div class="modal-body m-lg">
<div class="alert alert-danger">
<div ng-show="free">
<p translate>{{ 'app.admin.members_edit.you_intentionally_decide_to_extend_the_user_s_subscription_by_offering_him_free_days' }}</p>
<p translate>{{ 'app.admin.members_edit.credits_will_remain_unchanged' }}</p>
</div>
<div ng-hide="free">
<p translate>{{ 'app.admin.members_edit.you_intentionally_decide_to_extend_the_user_s_subscription_by_charging_him_again_for_his_current_subscription' }}</p>
<p translate>{{ 'app.admin.members_edit.credits_will_be_reset' }}</p>
<p translate>{{ 'app.admin.members_edit.payment_scheduled' }}</p>
</div>
</div>
<form role="form" name="subscriptionForm" novalidate>
<div class="form-group">
<label translate>{{ 'app.admin.members_edit.until_expiration_date' }}</label>
<input type="text"
class="form-control"
name="subscription[expired_at]"
ng-model="new_expired_at"
uib-datepicker-popup="{{datePicker.format}}"
datepicker-options="datePicker.options"
is-open="datePicker.opened"
ng-click="openDatePicker($event)"
min-date="datePicker.minDate"
placeholder=""
required/>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-warning" ng-click="ok()" ng-disabled="subscriptionForm.$invalid" translate>{{ 'app.shared.buttons.confirm' }}</button>
<button class="btn btn-primary" ng-click="cancel()" translate>{{ 'app.shared.buttons.cancel' }}</button>
</div>

View File

@ -214,7 +214,9 @@
<local-payment-modal is-open="localPayment.showModal"
toggle-modal="toggleLocalPaymentModal"
after-success="afterLocalPaymentSuccess"
on-error="onLocalPaymentError"
cart="localPayment.cartItems"
update-cart="updateCart"
current-user="currentUser"
customer="user"
schedule="schedule.payment_schedule"/>

View File

@ -24,6 +24,8 @@ class NotificationsMailer < NotifyWith::NotificationsMailer
end
send(notification.notification_type)
rescue StandardError => e
STDERR.puts "[NotificationsMailer] notification cannot be sent: #{e}"
end
def helpers

View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
# A subscription extended for free, added to the shopping cart
class CartItem::FreeExtension < CartItem::BaseItem
def initialize(customer, subscription, new_expiration_date)
raise TypeError unless subscription.is_a? Subscription
@customer = customer
@new_expiration_date = new_expiration_date
@subscription = subscription
super
end
def start_at
raise InvalidSubscriptionError if @subscription.nil?
raise InvalidSubscriptionError if @new_expiration_date <= @subscription.expired_at
@subscription.expired_at
end
def price
elements = { OfferDay: 0 }
{ elements: elements, amount: 0 }
end
def name
I18n.t('cart_items.free_extension', DATE: I18n.l(@new_expiration_date))
end
def to_object
::OfferDay.new(
subscription_id: @subscription.id,
start_at: start_at,
end_at: @new_expiration_date
)
end
end

View File

@ -4,17 +4,19 @@
class CartItem::PaymentSchedule
attr_reader :requested
def initialize(plan, coupon, requested)
def initialize(plan, coupon, requested, customer, start_at = nil)
raise TypeError unless coupon.is_a? CartItem::Coupon
@plan = plan
@coupon = coupon
@requested = requested
@customer = customer
@start_at = start_at
end
def schedule(total, total_without_coupon)
schedule = if @requested && @plan&.monthly_payment
PaymentScheduleService.new.compute(@plan, total_without_coupon, coupon: @coupon.coupon)
PaymentScheduleService.new.compute(@plan, total_without_coupon, @customer, coupon: @coupon.coupon, start_at: @start_at)
else
nil
end

View File

@ -2,11 +2,14 @@
# A subscription added to the shopping cart
class CartItem::Subscription < CartItem::BaseItem
def initialize(plan, customer)
attr_reader :start_at
def initialize(plan, customer, start_at = nil)
raise TypeError unless plan.is_a? Plan
@plan = plan
@customer = customer
@start_at = start_at
super
end
@ -30,7 +33,8 @@ class CartItem::Subscription < CartItem::BaseItem
def to_object
::Subscription.new(
plan_id: @plan.id,
statistic_profile_id: StatisticProfile.find_by(user: @customer).id
statistic_profile_id: StatisticProfile.find_by(user: @customer).id,
start_at: @start_at
)
end
end

View File

@ -129,7 +129,6 @@ class Invoice < PaymentDocument
def prevent_refund?
return true if user.nil?
# workaround for reservation saved after invoice
if main_item.object_type == 'Reservation' && main_item.object&.reservable_type == 'Training'
user.trainings.include?(main_item.object.reservable_id)
else

View File

@ -7,6 +7,8 @@ class OfferDay < ApplicationRecord
has_many :invoice_items, as: :object, dependent: :destroy
belongs_to :subscription
after_create :notify_subscription_extended
# buying invoice
def original_invoice
invoice_items.select(:invoice_id)
@ -15,4 +17,20 @@ class OfferDay < ApplicationRecord
.map { |id| Invoice.find_by(id: id, type: nil) }
.first
end
private
def notify_subscription_extended
meta_data = { free_days: true }
NotificationCenter.call type: :notify_member_subscription_extended,
receiver: subscription.user,
attached_object: subscription,
meta_data: meta_data
NotificationCenter.call type: :notify_admin_subscription_extended,
receiver: User.admins_and_managers,
attached_object: subscription,
meta_data: meta_data
end
end

View File

@ -22,7 +22,7 @@ class PaymentDocument < Footprintable
self.wallet_transaction_id = transaction_id
end
def post_save(arg); end
def post_save(*args); end
def render_resource; end
end

View File

@ -75,14 +75,10 @@ class PaymentSchedule < PaymentDocument
payment_schedule_items
end
def post_save(gateway_method_id)
def post_save(*args)
return unless payment_method == 'card'
PaymentGatewayService.new.create_subscription(self, gateway_method_id)
end
def pay(gateway_method_id)
PaymentGatewayService.new.pay_subscription(self, gateway_method_id)
PaymentGatewayService.new.create_subscription(self, *args)
end
def render_resource

View File

@ -120,8 +120,13 @@ class Setting < ApplicationRecord
payzen_currency
public_agenda_module
renew_pack_threshold
pack_only_for_subscription] }
# WARNING: when adding a new key, you may also want to add it in app/policies/setting_policy.rb#public_whitelist
pack_only_for_subscription
overlapping_categories] }
# 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
# - db/seeds.rb (to set the default value)
# - app/policies/setting_policy.rb#public_whitelist (if the setting can be read by anyone)
def value
last_value = history_values.order(HistoryValue.arel_table['created_at'].desc).limit(1).first

View File

@ -61,29 +61,15 @@ class ShoppingCart
payment = create_payment_document(price, objects, payment_id, payment_type)
WalletService.debit_user_wallet(payment, @customer)
payment.save
payment.post_save(payment_id)
payment.post_save(payment_id, payment_type)
end
success = objects.map(&:errors).flatten.map(&:empty?).all? && items.map(&:errors).map(&:empty?).all?
success = !payment.nil? && objects.map(&:errors).flatten.map(&:empty?).all? && items.map(&:errors).map(&:empty?).all?
errors = objects.map(&:errors).flatten.concat(items.map(&:errors))
errors.push('Unable to create the PaymentDocument') if payment.nil?
{ success: success, payment: payment, errors: errors }
end
def pay_schedule(payment_id, payment_type)
price = total
objects = []
items.each do |item|
raise InvalidSubscriptionError unless item.valid?(@items)
object = item.to_object
objects.push(object)
raise InvalidSubscriptionError unless object.errors.empty?
end
payment = create_payment_document(price, objects, payment_id, payment_type)
WalletService.debit_user_wallet(payment, @customer, transaction: false)
payment.pay(payment_id)
end
private
# Save the object associated with the provided item or raise and Rollback if something wrong append.
@ -103,10 +89,10 @@ class ShoppingCart
PaymentScheduleService.new.create(
objects,
price[:before_coupon],
@customer,
coupon: @coupon.coupon,
operator: @operator,
payment_method: @payment_method,
user: @customer,
payment_id: payment_id,
payment_type: payment_type
)

View File

@ -28,6 +28,8 @@ class StatisticProfile < ApplicationRecord
# Projects that the current user is the author
has_many :my_projects, foreign_key: :author_statistic_profile_id, class_name: 'Project', dependent: :destroy
validate :check_birthday_in_past
def str_gender
gender ? 'male' : 'female'
end
@ -40,4 +42,10 @@ class StatisticProfile < ApplicationRecord
''
end
end
private
def check_birthday_in_past
errors.add(:birthday, I18n.t('statistic_profile.birthday_in_past')) if birthday.present? && birthday > DateTime.current
end
end

View File

@ -47,26 +47,6 @@ class Subscription < ApplicationRecord
expiration_date
end
def free_extend(expiration, operator_profile_id)
return false if expiration <= expired_at
od = offer_days.create(start_at: expired_at, end_at: expiration)
invoice = Invoice.new(
invoicing_profile: user.invoicing_profile,
statistic_profile: user.statistic_profile,
operator_profile_id: operator_profile_id,
total: 0
)
invoice.invoice_items.push InvoiceItem.new(amount: 0, description: plan.base_name, object: od)
invoice.save
if save
notify_subscription_extended(true)
return true
end
false
end
def user
statistic_profile.user
end
@ -116,9 +96,8 @@ class Subscription < ApplicationRecord
attached_object: self
end
def notify_subscription_extended(free_days)
meta_data = {}
meta_data[:free_days] = true if free_days
def notify_subscription_extended
meta_data = { free_days: false }
NotificationCenter.call type: :notify_member_subscription_extended,
receiver: user,
attached_object: self,
@ -131,7 +110,7 @@ class Subscription < ApplicationRecord
end
def set_expiration_date
start_at = DateTime.current.in_time_zone
start_at = self.start_at || DateTime.current.in_time_zone
self.expiration_date = start_at + plan.duration
end

View File

@ -3,6 +3,9 @@
# Check the access policies for API::LocalPaymentsController
class LocalPaymentPolicy < ApplicationPolicy
def confirm_payment?
user.admin? || (user.manager? && record.shopping_cart.customer.id != user.id) || record.price.zero?
# only admins and managers can offer free extensions of a subscription
has_free_days = record.shopping_cart.items.any? { |item| item.is_a? CartItem::FreeExtension }
user.admin? || (user.manager? && record.shopping_cart.customer.id != user.id) || (record.price.zero? && !has_free_days)
end
end

View File

@ -40,7 +40,7 @@ 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]
pack_only_for_subscription overlapping_categories]
end
##

View File

@ -2,15 +2,11 @@
# Check the access policies for API::SubscriptionsController
class SubscriptionPolicy < ApplicationPolicy
def create?
Setting.get('plans_module') && (user.admin? || (user.manager? && record.user_id != user.id) || record.price.zero?)
end
def show?
user.admin? or record.user_id == user.id
end
def update?
user.admin? || (user.manager? && record.user.id != user.id)
def payment_details?
user.admin? || user.manager?
end
end

View File

@ -17,16 +17,18 @@ class CartService
items = []
cart_items[:items].each do |item|
if ['subscription', :subscription].include?(item.keys.first)
items.push(CartItem::Subscription.new(plan_info[:plan], @customer)) if plan_info[:new_subscription]
items.push(CartItem::Subscription.new(plan_info[:plan], @customer, item[:subscription][:start_at])) if plan_info[:new_subscription]
elsif ['reservation', :reservation].include?(item.keys.first)
items.push(reservable_from_hash(item[:reservation], plan_info))
elsif ['prepaid_pack', :prepaid_pack].include?(item.keys.first)
items.push(CartItem::PrepaidPack.new(PrepaidPack.find(item[:prepaid_pack][:id]), @customer))
elsif ['free_extension', :free_extension].include?(item.keys.first)
items.push(CartItem::FreeExtension.new(@customer, plan_info[:subscription], item[:free_extension][:end_at]))
end
end
coupon = CartItem::Coupon.new(@customer, @operator, cart_items[:coupon_code])
schedule = CartItem::PaymentSchedule.new(plan_info[:plan], coupon, cart_items[:payment_schedule])
schedule = CartItem::PaymentSchedule.new(plan_info[:plan], coupon, cart_items[:payment_schedule], @customer, plan_info[:subscription]&.start_at)
ShoppingCart.new(
@customer,
@ -40,19 +42,22 @@ class CartService
def from_payment_schedule(payment_schedule)
@customer = payment_schedule.user
plan = payment_schedule.payment_schedule_objects.find { |pso| pso.object_type == Subscription.name }&.subscription&.plan
subscription = payment_schedule.payment_schedule_objects.find { |pso| pso.object_type == Subscription.name }&.subscription
plan = subscription&.plan
coupon = CartItem::Coupon.new(@customer, @operator, payment_schedule.coupon&.code)
schedule = CartItem::PaymentSchedule.new(plan, coupon, true)
schedule = CartItem::PaymentSchedule.new(plan, coupon, true, @customer, subscription.start_at)
items = []
payment_schedule.payment_schedule_objects.each do |object|
if object.object_type == Subscription.name
items.push(CartItem::Subscription.new(object.subscription.plan, @customer))
items.push(CartItem::Subscription.new(object.subscription.plan, @customer, object.subscription.start_at))
elsif object.object_type == Reservation.name
items.push(reservable_from_payment_schedule_object(object, plan))
elsif object.object_type == PrepaidPack.name
items.push(CartItem::PrepaidPack.new(object.statistic_profile_prepaid_pack.prepaid_pack_id, @customer))
elsif object.object_type == OfferDay.name
items.push(CartItem::FreeExtension.new(@customer, object.offer_day.subscription, object.offer_day.end_date))
end
end
@ -70,18 +75,22 @@ class CartService
def plan(cart_items)
new_plan_being_bought = false
subscription = nil
plan = if cart_items[:items].any? { |item| ['subscription', :subscription].include?(item.keys.first) }
index = cart_items[:items].index { |item| ['subscription', :subscription].include?(item.keys.first) }
if cart_items[:items][index][:subscription][:plan_id]
new_plan_being_bought = true
Plan.find(cart_items[:items][index][:subscription][:plan_id])
plan = Plan.find(cart_items[:items][index][:subscription][:plan_id])
subscription = CartItem::Subscription.new(plan, @customer, cart_items[:items][index][:subscription][:start_at]).to_object
plan
end
elsif @customer.subscribed_plan
subscription = @customer.subscription unless @customer.subscription.expired_at < DateTime.current
@customer.subscribed_plan
else
nil
end
{ plan: plan, new_subscription: new_plan_being_bought }
{ plan: plan, subscription: subscription, new_subscription: new_plan_being_bought }
end
def customer(cart_items)

View File

@ -93,7 +93,7 @@ class InvoicesService
end
##
# Generate an array of {InvoiceItem} with the elements in provided reservation, price included.
# Generate an array of {InvoiceItem} with the provided elements, price included.
# @param invoice {Invoice} the parent invoice
# @param payment_details {Hash} as generated by ShoppingCart.total
# @param objects {Array<Reservation|Subscription|StatisticProfilePrepaidPack>}

View File

@ -19,12 +19,8 @@ class PaymentGatewayService
@gateway = service.new
end
def create_subscription(payment_schedule, gateway_object_id)
@gateway.create_subscription(payment_schedule, gateway_object_id)
end
def pay_subscription(payment_schedule, gateway_object_id)
@gateway.pay_subscription(payment_schedule, gateway_object_id)
def create_subscription(payment_schedule, *args)
@gateway.create_subscription(payment_schedule, *args)
end
def create_user(user_id)

View File

@ -6,9 +6,11 @@ class PaymentScheduleService
# Compute a payment schedule for a new subscription to the provided plan
# @param plan {Plan}
# @param total {Number} Total amount of the current shopping cart (which includes this plan) - without coupon
# @param customer {User} the customer
# @param coupon {Coupon} apply this coupon, if any
# @param start_at {DateTime} schedule the PaymentSchedule to start in the future
##
def compute(plan, total, coupon: nil)
def compute(plan, total, customer, coupon: nil, start_at: nil)
other_items = total - plan.amount
# base monthly price of the plan
price = plan.amount
@ -22,7 +24,7 @@ class PaymentScheduleService
end
items = []
(0..deadlines - 1).each do |i|
date = DateTime.current + i.months
date = (start_at || DateTime.current) + i.months
details = { recurring: per_month }
amount = if i.zero?
details[:adjustment] = adjustment.truncate
@ -45,15 +47,18 @@ class PaymentScheduleService
details: details
)
end
ps.start_at = start_at
ps.total = items.map(&:amount).reduce(:+)
ps.invoicing_profile = customer.invoicing_profile
ps.statistic_profile = customer.statistic_profile
{ payment_schedule: ps, items: items }
end
def create(objects, total, coupon: nil, operator: nil, payment_method: nil, user: nil,
def create(objects, total, customer, coupon: nil, operator: nil, payment_method: nil,
payment_id: nil, payment_type: nil)
subscription = objects.find { |item| item.class == Subscription }
schedule = compute(subscription.plan, total, coupon: coupon)
schedule = compute(subscription.plan, total, customer, coupon: coupon, start_at: subscription.start_at)
ps = schedule[:payment_schedule]
items = schedule[:items]
@ -68,8 +73,6 @@ class PaymentScheduleService
ps.payment_gateway_objects.push(pgo)
end
ps.operator_profile = operator.invoicing_profile
ps.invoicing_profile = user.invoicing_profile
ps.statistic_profile = user.statistic_profile
ps.payment_schedule_items = items
ps
end

View File

@ -1,63 +0,0 @@
# frozen_string_literal: true
# Provides helper methods for Subscription actions
class Subscriptions::Subscribe
attr_accessor :user_id, :operator_profile_id
def initialize(operator_profile_id, user_id = nil)
@user_id = user_id
@operator_profile_id = operator_profile_id
end
def extend_subscription(subscription, new_expiration_date, free_days)
return subscription.free_extend(new_expiration_date, @operator_profile_id) if free_days
new_sub = Subscription.create(
plan_id: subscription.plan_id,
statistic_profile_id: subscription.statistic_profile_id,
)
new_sub.expiration_date = new_expiration_date
if new_sub.save
schedule = subscription.original_payment_schedule
operator = InvoicingProfile.find(@operator_profile_id).user
cs = CartService.new(operator)
cart = cs.from_hash(customer_id: subscription.user.id,
items: [
{
subscription: {
plan_id: subscription.plan_id
}
}
],
payment_schedule: !schedule.nil?)
details = cart.total
payment = if schedule
operator = InvoicingProfile.find(operator_profile_id)&.user
PaymentScheduleService.new.create(
[new_sub],
details[:before_coupon],
operator: operator,
payment_method: schedule.payment_method,
user: new_sub.user,
payment_id: schedule.gateway_payment_mean&.id,
payment_type: schedule.gateway_payment_mean&.class
)
else
InvoicesService.create(
details,
operator_profile_id,
[new_sub],
new_sub.user
)
end
payment.save
payment.post_save(schedule&.gateway_payment_mean&.id)
UsersCredits::Manager.new(user: new_sub.user).reset_credits
return new_sub
end
false
end
end

View File

@ -94,6 +94,7 @@ class WalletService
##
# Subtract the amount of the payment document (Invoice|PaymentSchedule) from the customer's wallet
# @param transaction, if false: the wallet is not debited, the transaction is only simulated on the payment document
##
def self.debit_user_wallet(payment, user, transaction: true)
wallet_amount = WalletService.wallet_amount_debit(payment, user)

View File

@ -72,6 +72,7 @@ if member.subscription
json.interval member.subscription.plan.interval
json.interval_count member.subscription.plan.interval_count
json.amount member.subscription.plan.amount ? (member.subscription.plan.amount / 100.0) : 0
json.monthly_payment member.subscription.plan.monthly_payment
end
end
end

View File

@ -0,0 +1,4 @@
# frozen_string_literal: true
json.payment_schedule !@subscription.original_payment_schedule.nil?
json.card @subscription.original_invoice&.paid_by_card?

View File

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

View File

@ -1 +0,0 @@
json.partial! 'api/subscriptions/subscription', subscription: @subscription

View File

@ -866,7 +866,7 @@ de:
expires_at: "Läuft ab am:"
price_: "Preis:"
offer_free_days: "Kostenlose Tage anbieten"
extend_subscription: "Abonnement verlängern"
renew_subscription: "Renew the subscription"
user_has_no_current_subscription: "Benutzer hat kein aktuelles Abonnement."
subscribe_to_a_plan: "Plan abonnieren"
trainings: "Schulungen"
@ -888,13 +888,6 @@ de:
download_the_invoice: "Rechnung herunterladen"
download_the_refund_invoice: "Rückerstattungsrechnung herunterladen"
no_invoices_for_now: "Momentan keine Rechnungen."
expiration_date: "Ablaufdatum"
you_intentionally_decide_to_extend_the_user_s_subscription_by_offering_him_free_days: "Sie entscheiden sich absichtlich dafür, das Abonnement des Benutzers zu verlängern, indem Sie ihm kostenlose Tage anbieten."
credits_will_remain_unchanged: "Der Saldo der freien Gutschriften (Schulungen / Maschinen / Räume) des Nutzers bleibt unverändert."
you_intentionally_decide_to_extend_the_user_s_subscription_by_charging_him_again_for_his_current_subscription: "Sie entscheiden sich absichtlich dafür, das Abonnement des Benutzers zu verlängern, indem Sie ihn für sein aktuelles Abonnement erneut belasten."
credits_will_be_reset: "Das Inklusiv-Guthaben (für Schulungen / Maschinen / Räume) des Benutzers wird zurückgesetzt, nicht genutztes Guthaben geht verloren."
payment_scheduled: "If the previous subscription was charged through a payment schedule, this one will be charged the same way, the first deadline being charged right now, then each following month."
until_expiration_date: "Bis (Ablaufdatum):"
you_successfully_changed_the_expiration_date_of_the_user_s_subscription: "Sie haben das Ablaufdatum des Abonnements erfolgreich geändert"
a_problem_occurred_while_saving_the_date: "Beim Speichern des Datums ist ein Problem aufgetreten."
new_subscription: "Neues Abonnement"
@ -906,6 +899,35 @@ de:
to_credit: 'Guthaben'
cannot_credit_own_wallet: "Sie können keine Gutschrift auf Ihr eigenes Guthaben einbuchen. Bitten Sie einen anderen Manager oder einen Administrator um die Gutschreibung."
cannot_extend_own_subscription: "Sie können Ihr eigenes Abonnement nicht erweitern. Bitte fragen Sie einen anderen Manager oder einen Administrator."
#extend a subscription for free
free_extend_modal:
extend_subscription: "Extend the subscription"
offer_free_days_infos: "You are about to extend the user's subscription by offering him free additional days."
credits_will_remain_unchanged: "The balance of free credits (training / machines / spaces) of the user will remain unchanged."
current_expiration: "Current subscription will expire at:"
DATE_TIME: "{DATE} {TIME}"
new_expiration_date: "New expiration date:"
number_of_free_days: "Number of free days:"
extend: "Extend"
extend_success: "The subscription was successfully extended for free"
#renew a subscription
renew_subscription_modal:
renew_subscription: "Renew the subscription"
renew_subscription_info: "You are about to renew the user's subscription by charging him again for his current subscription."
credits_will_be_reset: "The balance of free credits (training / machines / spaces) of the user will be reset, unused credits will be lost."
current_expiration: "Current subscription will expire at:"
new_start: "The new subscription will start at:"
new_expiration_date: "The new subscription will expire at:"
pay_in_one_go: "Pay in one go"
renew: "Renew"
renew_success: "The subscription was successfully renewed"
#take a new subscription
subscribe_modal:
subscribe_USER: "Subscribe {USER}"
subscribe: "Subscribe"
select_plan: "Please select a plan"
pay_in_one_go: "Pay in one go"
subscription_success: ""
#add a new administrator to the platform
admins_new:
add_an_administrator: "Administrator hinzufügen"
@ -1154,8 +1176,9 @@ de:
error_SETTING_locked: "Die Einstellung konnte nicht aktualisiert werden: {SETTING} ist gesperrt. Bitte kontaktieren Sie Ihren Systemadministrator."
an_error_occurred_saving_the_setting: "Beim Speichern der Einstellung ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut."
book_overlapping_slots_info: "Erlauben / Verhindern der Reservierung von überlappenden Slots"
prevent_booking: "Buchungen verhindern"
allow_booking: "Allow booking"
overlapping_categories: "Overlapping categories"
overlapping_categories_info: "Preventing booking on overlapping slots will be done by comparing the date and time of the following categories of reservations."
default_slot_duration: "Standarddauer für Slots"
duration_minutes: "Dauer (in Minuten)"
default_slot_duration_info: "Die Verfügbarkeit von Maschinen und Räumen ist in mehrere Slots dieser Dauer aufgeteilt. Dieser Wert kann je Verfügbarkeit überschrieben werden."
@ -1212,6 +1235,11 @@ 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"
overlapping_options:
training_reservations: "Trainings"
machine_reservations: "Machines"
space_reservations: "Spaces"
events_reservations: "Events"
general:
general: "Allgemein"
title: "Titel"
@ -1364,14 +1392,19 @@ de:
category_deleted: "The category was successfully deleted"
unable_to_delete: "Unable to delete the category: "
local_payment:
validate_cart: "Validate my cart"
offline_payment: "Payment on site"
about_to_cash: "You're about to confirm the cashing by an external payment mean. Please do not click on the button below until you have fully cashed the requested payment."
about_to_confirm: "You're about to confirm your {ITEM, select, subscription{subscription} other{reservation}}."
payment_method: "Payment method"
method_card: "Online by card"
method_check: "By check"
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."
online_payment_disabled: "Online payment is not available. You cannot collect this payment schedule by online card."
check_list_setting:
save: 'Save'
customization_of_SETTING_successfully_saved: "Customization of the {SETTING} successfully saved."
#feature tour
tour:
conclusion:

View File

@ -866,7 +866,7 @@ en:
expires_at: "Expires at:"
price_: "Price:"
offer_free_days: "Offer free days"
extend_subscription: "Extend subscription"
renew_subscription: "Renew the subscription"
user_has_no_current_subscription: "User has no current subscription."
subscribe_to_a_plan: "Subscribe to a plan"
trainings: "Trainings"
@ -888,13 +888,6 @@ en:
download_the_invoice: "Download the invoice"
download_the_refund_invoice: "Download the refund invoice"
no_invoices_for_now: "No invoices for now."
expiration_date: "Expiration date"
you_intentionally_decide_to_extend_the_user_s_subscription_by_offering_him_free_days: "You intentionally decide to extend the user's subscription by offering him free days."
credits_will_remain_unchanged: "The balance of free credits (training / machines / spaces) of the user will remain unchanged."
you_intentionally_decide_to_extend_the_user_s_subscription_by_charging_him_again_for_his_current_subscription: "You intentionally decide to extend the user's subscription by charging him again for his current subscription."
credits_will_be_reset: "The balance of free credits (training / machines / spaces) of the user will be reset, unused credits will be lost."
payment_scheduled: "If the previous subscription was charged through a payment schedule, this one will be charged the same way, the first deadline being charged right now, then each following month."
until_expiration_date: "Until (expiration date):"
you_successfully_changed_the_expiration_date_of_the_user_s_subscription: "You successfully changed the expiration date of the user's subscription"
a_problem_occurred_while_saving_the_date: "A problem occurred while saving the date."
new_subscription: "New subscription"
@ -906,6 +899,35 @@ en:
to_credit: 'Credit'
cannot_credit_own_wallet: "You cannot credit your own wallet. Please ask another manager or an administrator to credit your wallet."
cannot_extend_own_subscription: "You cannot extend your own subscription. Please ask another manager or an administrator to extend your subscription."
# extend a subscription for free
free_extend_modal:
extend_subscription: "Extend the subscription"
offer_free_days_infos: "You are about to extend the user's subscription by offering him free additional days."
credits_will_remain_unchanged: "The balance of free credits (training / machines / spaces) of the user will remain unchanged."
current_expiration: "Current subscription will expire at:"
DATE_TIME: "{DATE} {TIME}"
new_expiration_date: "New expiration date:"
number_of_free_days: "Number of free days:"
extend: "Extend"
extend_success: "The subscription was successfully extended for free"
# renew a subscription
renew_subscription_modal:
renew_subscription: "Renew the subscription"
renew_subscription_info: "You are about to renew the user's subscription by charging him again for his current subscription."
credits_will_be_reset: "The balance of free credits (training / machines / spaces) of the user will be reset, unused credits will be lost."
current_expiration: "Current subscription will expire at:"
new_start: "The new subscription will start at:"
new_expiration_date: "The new subscription will expire at:"
pay_in_one_go: "Pay in one go"
renew: "Renew"
renew_success: "The subscription was successfully renewed"
# take a new subscription
subscribe_modal:
subscribe_USER: "Subscribe {USER}"
subscribe: "Subscribe"
select_plan: "Please select a plan"
pay_in_one_go: "Pay in one go"
subscription_success: ""
#add a new administrator to the platform
admins_new:
add_an_administrator: "Add an administrator"
@ -1155,6 +1177,8 @@ en:
an_error_occurred_saving_the_setting: "An error occurred while saving the setting. Please try again later."
book_overlapping_slots_info: "Allow / prevent the reservation of overlapping slots"
allow_booking: "Allow booking"
overlapping_categories: "Overlapping categories"
overlapping_categories_info: "Preventing booking on overlapping slots will be done by comparing the date and time of the following categories of reservations."
default_slot_duration: "Default duration for slots"
duration_minutes: "Duration (in minutes)"
default_slot_duration_info: "Machine and space availabilities are divided in multiple slots of this duration. This value can be overridden per availability."
@ -1211,6 +1235,11 @@ 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"
overlapping_options:
training_reservations: "Trainings"
machine_reservations: "Machines"
space_reservations: "Spaces"
events_reservations: "Events"
general:
general: "General"
title: "Title"
@ -1363,14 +1392,19 @@ en:
category_deleted: "The category was successfully deleted"
unable_to_delete: "Unable to delete the category: "
local_payment:
validate_cart: "Validate my cart"
offline_payment: "Payment on site"
about_to_cash: "You're about to confirm the cashing by an external payment mean. Please do not click on the button below until you have fully cashed the requested payment."
about_to_confirm: "You're about to confirm your {ITEM, select, subscription{subscription} other{reservation}}."
payment_method: "Payment method"
method_card: "Online by card"
method_check: "By check"
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."
online_payment_disabled: "Online payment is not available. You cannot collect this payment schedule by online card."
check_list_setting:
save: 'Save'
customization_of_SETTING_successfully_saved: "Customization of the {SETTING} successfully saved."
#feature tour
tour:
conclusion:

View File

@ -866,7 +866,7 @@ es:
expires_at: "Caduca en:"
price_: "Precio:"
offer_free_days: "Ofrecer días gratis"
extend_subscription: "Ampliar suscripción"
renew_subscription: "Renew the subscription"
user_has_no_current_subscription: "El usuario no tiene una suscripción actual."
subscribe_to_a_plan: "Suscribirse a un plan"
trainings: "Trainings"
@ -888,13 +888,6 @@ es:
download_the_invoice: "Download the invoice"
download_the_refund_invoice: "Descargar la factura de reembolso"
no_invoices_for_now: "No invoices for now."
expiration_date: "Fecha de caducidad"
you_intentionally_decide_to_extend_the_user_s_subscription_by_offering_him_free_days: "Usted intencionalmente decide extender la suscripción del usuario ofreciéndole días libres."
credits_will_remain_unchanged: "El saldo de créditos gratuitos (entrenamiento / máquinas / espacios) del usuario permanecerá sin cambios."
you_intentionally_decide_to_extend_the_user_s_subscription_by_charging_him_again_for_his_current_subscription: "Usted intencionalmente decide extender la suscripción del usuario al cobrarle de nuevo por su suscripción actual."
credits_will_be_reset: "Se restablecerá el saldo de créditos gratuitos (entrenamiento / máquinas / espacios) del usuario, se perderán los créditos no utilizados."
payment_scheduled: "If the previous subscription was charged through a payment schedule, this one will be charged the same way, the first deadline being charged right now, then each following month."
until_expiration_date: "Until (expiration date):"
you_successfully_changed_the_expiration_date_of_the_user_s_subscription: "Ha cambiado correctamente la fecha de caducidad de la suscripción del usuario"
a_problem_occurred_while_saving_the_date: "Se ha producido un problema al guardar la fecha."
new_subscription: "Nueva suscripción"
@ -906,6 +899,35 @@ es:
to_credit: 'Credit'
cannot_credit_own_wallet: "You cannot credit your own wallet. Please ask another manager or an administrator to credit your wallet."
cannot_extend_own_subscription: "You cannot extend your own subscription. Please ask another manager or an administrator to extend your subscription."
#extend a subscription for free
free_extend_modal:
extend_subscription: "Extend the subscription"
offer_free_days_infos: "You are about to extend the user's subscription by offering him free additional days."
credits_will_remain_unchanged: "The balance of free credits (training / machines / spaces) of the user will remain unchanged."
current_expiration: "Current subscription will expire at:"
DATE_TIME: "{DATE} {TIME}"
new_expiration_date: "New expiration date:"
number_of_free_days: "Number of free days:"
extend: "Extend"
extend_success: "The subscription was successfully extended for free"
#renew a subscription
renew_subscription_modal:
renew_subscription: "Renew the subscription"
renew_subscription_info: "You are about to renew the user's subscription by charging him again for his current subscription."
credits_will_be_reset: "The balance of free credits (training / machines / spaces) of the user will be reset, unused credits will be lost."
current_expiration: "Current subscription will expire at:"
new_start: "The new subscription will start at:"
new_expiration_date: "The new subscription will expire at:"
pay_in_one_go: "Pay in one go"
renew: "Renew"
renew_success: "The subscription was successfully renewed"
#take a new subscription
subscribe_modal:
subscribe_USER: "Subscribe {USER}"
subscribe: "Subscribe"
select_plan: "Please select a plan"
pay_in_one_go: "Pay in one go"
subscription_success: ""
#add a new administrator to the platform
admins_new:
add_an_administrator: "Agregar un administrador"
@ -1154,8 +1176,9 @@ es:
error_SETTING_locked: "Unable to update the setting: {SETTING} is locked. Please contact your system administrator."
an_error_occurred_saving_the_setting: "An error occurred while saving the setting. Please try again later."
book_overlapping_slots_info: "Allow / prevent the reservation of overlapping slots"
prevent_booking: "Impedir reservas"
allow_booking: "Allow booking"
overlapping_categories: "Overlapping categories"
overlapping_categories_info: "Preventing booking on overlapping slots will be done by comparing the date and time of the following categories of reservations."
default_slot_duration: "Default duration for slots"
duration_minutes: "Duration (in minutes)"
default_slot_duration_info: "Machine and space availabilities are divided in multiple slots of this duration. This value can be overridden per availability."
@ -1212,6 +1235,11 @@ 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"
overlapping_options:
training_reservations: "Trainings"
machine_reservations: "Machines"
space_reservations: "Spaces"
events_reservations: "Events"
general:
general: "General"
title: "Title"
@ -1364,14 +1392,19 @@ es:
category_deleted: "The category was successfully deleted"
unable_to_delete: "Unable to delete the category: "
local_payment:
validate_cart: "Validate my cart"
offline_payment: "Payment on site"
about_to_cash: "You're about to confirm the cashing by an external payment mean. Please do not click on the button below until you have fully cashed the requested payment."
about_to_confirm: "You're about to confirm your {ITEM, select, subscription{subscription} other{reservation}}."
payment_method: "Payment method"
method_card: "Online by card"
method_check: "By check"
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."
online_payment_disabled: "Online payment is not available. You cannot collect this payment schedule by online card."
check_list_setting:
save: 'Save'
customization_of_SETTING_successfully_saved: "Customization of the {SETTING} successfully saved."
#feature tour
tour:
conclusion:

View File

@ -866,7 +866,7 @@ fr:
expires_at: "Expire le :"
price_: "Prix :"
offer_free_days: "Offrir des jours gratuits"
extend_subscription: "Prolonger l'abonnement"
renew_subscription: "Renouveler l'abonnement"
user_has_no_current_subscription: "L'utilisateur n'a pas d'abonnement en cours."
subscribe_to_a_plan: "Souscrire à un abonnement"
trainings: "Formations"
@ -888,13 +888,6 @@ fr:
download_the_invoice: "Télécharger la facture"
download_the_refund_invoice: "Télécharger l'avoir"
no_invoices_for_now: "Aucune facture pour le moment."
expiration_date: "Date d'expiration"
you_intentionally_decide_to_extend_the_user_s_subscription_by_offering_him_free_days: "Vous décidez délibérément d'étendre l'abonnement de l'utilisateur en lui offrant des jours gratuits."
credits_will_remain_unchanged: "Le solde de crédits gratuits (formations/machines/espaces) de l'utilisateur restera inchangé."
you_intentionally_decide_to_extend_the_user_s_subscription_by_charging_him_again_for_his_current_subscription: "Vous décidez délibérément d'étendre l'abonnement de l'utilisateur en lui faisant repayer le prix de l'abonnement qu'il possède actuellement."
credits_will_be_reset: "Le solde de crédits gratuits (formations/machines/espaces) de l'utilisateur sera remis à zéro, ses crédits non utilisés seront perdu."
payment_scheduled: "Si l'abonnement précédent a été facturé via un échéancier de paiement mensualisé, celui-ci sera facturé de la même façon, la première échéance étant facturée immédiatement, puis chaque mois suivant."
until_expiration_date: "Jusqu'à (date d'expiration) :"
you_successfully_changed_the_expiration_date_of_the_user_s_subscription: "Vous avez bien modifié la date d'expiration de l'abonnement de l'utilisateur"
a_problem_occurred_while_saving_the_date: "Il y a eu un problème lors de l'enregistrement de la date."
new_subscription: "Nouvelle souscription"
@ -906,6 +899,35 @@ fr:
to_credit: 'Créditer'
cannot_credit_own_wallet: "Vous ne pouvez pas créditer votre propre porte-monnaie. Veuillez demander à un autre gestionnaire ou à un administrateur de créditer votre porte-monnaie."
cannot_extend_own_subscription: "Vous ne pouvez pas prolonger votre propre abonnement. Veuillez demander à un autre gestionnaire ou à un administrateur de prolonger votre abonnement."
#extend a subscription for free
free_extend_modal:
extend_subscription: "Prolonger l'abonnement"
offer_free_days_infos: "Vous êtes sur le point de prolonger l'abonnement de l'utilisateur en lui offrant des jours supplémentaires gratuits."
credits_will_remain_unchanged: "Le solde de crédits gratuits (formations / machines / espaces) de l'utilisateur restera inchangé."
current_expiration: "L'abonnement actuel expirera le :"
DATE_TIME: "{DATE} à {TIME}"
new_expiration_date: "Nouvelle date d'expiration :"
number_of_free_days: "Nombre de jours gratuits :"
extend: "Prolonger"
extend_success: "L'abonnement a été prolongé gratuitement"
#renew a subscription
renew_subscription_modal:
renew_subscription: "Renouveler l'abonnement"
renew_subscription_info: "Vous êtes sur le point de renouveler l'abonnement de l'utilisateur en lui refacturant son abonnement actuel."
credits_will_be_reset: "Le solde de crédits gratuits (formations / machines / espaces) de l'utilisateur sera remis à zéro, ses crédits non utilisés seront perdu."
current_expiration: "L'abonnement actuel expirera le :"
new_start: "La nouvelle souscription commencera le :"
new_expiration_date: "Le nouvel abonnement expirera le :"
pay_in_one_go: "Payer en une fois"
renew: "Renouveler"
renew_success: "L'abonnement a bien été renouvelé"
#take a new subscription
subscribe_modal:
subscribe_USER: "Abonner {USER}"
subscribe: "Abonner"
select_plan: "Veuillez choisir une formule d'abonnement"
pay_in_one_go: "Payer en une fois"
subscription_success: ""
#add a new administrator to the platform
admins_new:
add_an_administrator: "Ajouter un administrateur"
@ -1154,8 +1176,9 @@ fr:
error_SETTING_locked: "Impossible de mettre à jour le paramètre : {SETTING} est verrouillé. Veuillez contacter votre administrateur système."
an_error_occurred_saving_the_setting: "Une erreur est survenue pendant l'enregistrement du paramètre. Veuillez réessayer plus tard."
book_overlapping_slots_info: "Autoriser / empêcher la réservation de créneaux qui se chevauchent"
prevent_booking: "Empêcher la réservation"
allow_booking: "Autoriser la réservation"
overlapping_categories: "Catégories des chevauchements"
overlapping_categories_info: "Éviter la réservation de créneaux qui se chevauchent sera effectué en comparant la date et l'heure des catégories de réservations suivantes."
default_slot_duration: "Durée par défaut pour les créneaux"
duration_minutes: "Durée (en minutes)"
default_slot_duration_info: "Les disponibilités des machines et des espaces sont divisées en plusieurs créneaux de cette durée. Cette valeur peur être changée pour chaque disponibilité."
@ -1209,9 +1232,14 @@ fr:
display_invite_to_renew_pack: "Afficher l'invitation à renouveler les packs prépayés"
packs_threshold_info_html: "Vous pouvez définir le nombre d'heures en dessous duquel l'utilisateur sera invité à acheter un nouveau pack prépayé, si son stock d'heures prépayées est inférieur à ce seuil.<br/>Vous pouvez définir un <strong>nombre d'heures</strong> (<em>par exemple. 5</em>) ou un <strong>pourcentage</strong> de son pack actuel (<em>par exemple 0,05 signifie 5%</em>)."
renew_pack_threshold: "seuil de renouvellement des packs"
pack_only_for_subscription_info_html: "Si cette option est activée, l'achat et l'utilisation d'un pack prépayé est seulement possible pour l'utilisateur possédant un abonnement en cours de validité."
pack_only_for_subscription: "Abonnement valide pour achat et utilisation d'un pack prépayé"
pack_only_for_subscription_info: "Rendre obligatoire l'abonnement pour les packs prépayés"
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"
overlapping_options:
training_reservations: "Formations"
machine_reservations: "Machines"
space_reservations: "Espaces"
events_reservations: "Événements"
general:
general: "Général"
title: "Titre"
@ -1364,14 +1392,19 @@ fr:
category_deleted: "La catégorie a bien été supprimée"
unable_to_delete: "Impossible de supprimer la catégorie : "
local_payment:
validate_cart: "Valider mon panier"
offline_payment: "Paiement sur place"
about_to_cash: "Vous êtes sur le point de confirmer l'encaissement par un moyen de paiement externe. Veuillez ne pas cliquer sur le bouton ci-dessous tant que vous n'avez pas encaissé le paiement demandé."
about_to_confirm: "Vous êtes sur le point de confirmer votre {ITEM, select, subscription{abonnement} other{réservation}}."
payment_method: "Moyen de paiement"
method_card: "Carte bancaire en ligne"
method_check: "Par chèques"
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é."
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'
customization_of_SETTING_successfully_saved: "La personnalisation de {SETTING} a bien été enregistrée."
#feature tour
tour:
conclusion:

View File

@ -866,7 +866,7 @@
expires_at: "Utløper:"
price_: "Pris:"
offer_free_days: "Tilby gratis dager"
extend_subscription: "Forleng medlemskap"
renew_subscription: "Renew the subscription"
user_has_no_current_subscription: "Brukeren har ikke noe gjeldende medlemskap."
subscribe_to_a_plan: "Abonner på et medlemskap"
trainings: "Opplæringer/kurs"
@ -888,13 +888,6 @@
download_the_invoice: "Last ned fakturaen"
download_the_refund_invoice: "Last ned refusjonsfakturaen"
no_invoices_for_now: "Ingen fakturaer for øyeblikket."
expiration_date: "Utløpsdato"
you_intentionally_decide_to_extend_the_user_s_subscription_by_offering_him_free_days: "Du ønsker å forlenge brukerens abonnement ved å tilby gratisdager."
credits_will_remain_unchanged: "Brukernes gjenværende kreditter (opplæring / maskiner/lokaler) vil ikke være endret."
you_intentionally_decide_to_extend_the_user_s_subscription_by_charging_him_again_for_his_current_subscription: "Du vil forlenge brukerens abonnement ved å kreve betaling for gjeldende abonnement."
credits_will_be_reset: "Brukernes gjenværende kreditter (opplæring / maskiner/lokaler) vil annulleres."
payment_scheduled: "Dersom det forrige abonnementet ble betalt gjennom en betalingsplan, vil denne bli belastet på samme måte. Første betalingsfrist er nå, deretter samme dato hver måned fremover."
until_expiration_date: "Til (utløpsdato):"
you_successfully_changed_the_expiration_date_of_the_user_s_subscription: "Du har endret utløpsdato på brukerens abonnement"
a_problem_occurred_while_saving_the_date: "Det oppstod et problem under lagring av dato."
new_subscription: "Nytt abonnement/medlemskap"
@ -906,6 +899,35 @@
to_credit: 'Kreditt'
cannot_credit_own_wallet: "Du kan ikke kreditere din egen lommebok. Vennligst spør en annen leder eller administrator om å kreditere din lommebok."
cannot_extend_own_subscription: "Du kan ikke utvide ditt eget medlemskap. Be en annen leder eller en administrator om å utvide abonnementet."
#extend a subscription for free
free_extend_modal:
extend_subscription: "Extend the subscription"
offer_free_days_infos: "You are about to extend the user's subscription by offering him free additional days."
credits_will_remain_unchanged: "The balance of free credits (training / machines / spaces) of the user will remain unchanged."
current_expiration: "Current subscription will expire at:"
DATE_TIME: "{DATE} {TIME}"
new_expiration_date: "New expiration date:"
number_of_free_days: "Number of free days:"
extend: "Extend"
extend_success: "The subscription was successfully extended for free"
#renew a subscription
renew_subscription_modal:
renew_subscription: "Renew the subscription"
renew_subscription_info: "You are about to renew the user's subscription by charging him again for his current subscription."
credits_will_be_reset: "The balance of free credits (training / machines / spaces) of the user will be reset, unused credits will be lost."
current_expiration: "Current subscription will expire at:"
new_start: "The new subscription will start at:"
new_expiration_date: "The new subscription will expire at:"
pay_in_one_go: "Pay in one go"
renew: "Renew"
renew_success: "The subscription was successfully renewed"
#take a new subscription
subscribe_modal:
subscribe_USER: "Subscribe {USER}"
subscribe: "Subscribe"
select_plan: "Please select a plan"
pay_in_one_go: "Pay in one go"
subscription_success: ""
#add a new administrator to the platform
admins_new:
add_an_administrator: "Legg til administrator"
@ -1154,7 +1176,9 @@
error_SETTING_locked: "Unable to update the setting: {SETTING} is locked. Please contact your system administrator."
an_error_occurred_saving_the_setting: "An error occurred while saving the setting. Please try again later."
book_overlapping_slots_info: "Allow / prevent the reservation of overlapping slots"
prevent_booking: "Prevent booking"
allow_booking: "Allow booking"
overlapping_categories: "Overlapping categories"
overlapping_categories_info: "Preventing booking on overlapping slots will be done by comparing the date and time of the following categories of reservations."
default_slot_duration: "Default duration for slots"
duration_minutes: "Duration (in minutes)"
default_slot_duration_info: "Machine and space availabilities are divided in multiple slots of this duration. This value can be overridden per availability."
@ -1211,6 +1235,11 @@
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"
overlapping_options:
training_reservations: "Trainings"
machine_reservations: "Machines"
space_reservations: "Spaces"
events_reservations: "Events"
general:
general: "Generelt"
title: "Tittel"
@ -1363,14 +1392,19 @@
category_deleted: "The category was successfully deleted"
unable_to_delete: "Unable to delete the category: "
local_payment:
validate_cart: "Validate my cart"
offline_payment: "Payment on site"
about_to_cash: "You're about to confirm the cashing by an external payment mean. Please do not click on the button below until you have fully cashed the requested payment."
about_to_confirm: "You're about to confirm your {ITEM, select, subscription{subscription} other{reservation}}."
payment_method: "Payment method"
method_card: "Online by card"
method_check: "By check"
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."
online_payment_disabled: "Online payment is not available. You cannot collect this payment schedule by online card."
check_list_setting:
save: 'Save'
customization_of_SETTING_successfully_saved: "Customization of the {SETTING} successfully saved."
#feature tour
tour:
conclusion:

View File

@ -866,7 +866,7 @@ pt:
expires_at: "Experia em:"
price_: "Preço:"
offer_free_days: "Oferecer dias grátis"
extend_subscription: "Estender inscrição"
renew_subscription: "Renew the subscription"
user_has_no_current_subscription: "O usuário não possui inscrição."
subscribe_to_a_plan: "Plano de inscrição"
trainings: "Treinamentos"
@ -888,13 +888,6 @@ pt:
download_the_invoice: "Baixar a fatura"
download_the_refund_invoice: "Baixar fatura de reembolso"
no_invoices_for_now: "Nenhuma fatura."
expiration_date: "Data de expiração"
you_intentionally_decide_to_extend_the_user_s_subscription_by_offering_him_free_days: "Você intencionalmente decidir estender a inscrição do usuário, oferecendo-lhe dias livres."
credits_will_remain_unchanged: "O saldo de créditos gratuitos (treinamento / máquinas / espaços) do usuário permanecerá inalterado."
you_intentionally_decide_to_extend_the_user_s_subscription_by_charging_him_again_for_his_current_subscription: "Você decide intencionalmente estender a assinatura do usuário cobrando-o novamente por sua assinatura atual."
credits_will_be_reset: "O saldo de créditos gratuitos (treinamento / máquinas / espaços) do usuário será redefinido, os créditos não utilizados serão perdidos."
payment_scheduled: "If the previous subscription was charged through a payment schedule, this one will be charged the same way, the first deadline being charged right now, then each following month."
until_expiration_date: "Até (data de expiração):"
you_successfully_changed_the_expiration_date_of_the_user_s_subscription: "Você alterou com êxito a data de expiração da assinatura do usuário"
a_problem_occurred_while_saving_the_date: "Um erro ocorreu ao salvar a data."
new_subscription: "Nova inscrição"
@ -906,6 +899,35 @@ pt:
to_credit: 'Crédito'
cannot_credit_own_wallet: "Você não pode creditar sua própria carteira. Por favor, peça a outro gerente ou a um administrador para creditar sua carteira."
cannot_extend_own_subscription: "Você não pode estender sua própria assinatura. Por favor, peça a outro gerente ou administrador para estender sua assinatura."
#extend a subscription for free
free_extend_modal:
extend_subscription: "Extend the subscription"
offer_free_days_infos: "You are about to extend the user's subscription by offering him free additional days."
credits_will_remain_unchanged: "The balance of free credits (training / machines / spaces) of the user will remain unchanged."
current_expiration: "Current subscription will expire at:"
DATE_TIME: "{DATE} {TIME}"
new_expiration_date: "New expiration date:"
number_of_free_days: "Number of free days:"
extend: "Extend"
extend_success: "The subscription was successfully extended for free"
#renew a subscription
renew_subscription_modal:
renew_subscription: "Renew the subscription"
renew_subscription_info: "You are about to renew the user's subscription by charging him again for his current subscription."
credits_will_be_reset: "The balance of free credits (training / machines / spaces) of the user will be reset, unused credits will be lost."
current_expiration: "Current subscription will expire at:"
new_start: "The new subscription will start at:"
new_expiration_date: "The new subscription will expire at:"
pay_in_one_go: "Pay in one go"
renew: "Renew"
renew_success: "The subscription was successfully renewed"
#take a new subscription
subscribe_modal:
subscribe_USER: "Subscribe {USER}"
subscribe: "Subscribe"
select_plan: "Please select a plan"
pay_in_one_go: "Pay in one go"
subscription_success: ""
#add a new administrator to the platform
admins_new:
add_an_administrator: "Adicionar administrador"
@ -1154,8 +1176,9 @@ pt:
error_SETTING_locked: "Não foi possível atualizar a configuração: {SETTING} está bloqueado. Por favor contate o administrador do sistema."
an_error_occurred_saving_the_setting: "Ocorreu um erro ao salvar a configuração. Por favor, tente novamente mais tarde."
book_overlapping_slots_info: "Permitir / impedir a reserva de slots sobrepostos"
prevent_booking: "Prevent booking"
allow_booking: "Allow booking"
overlapping_categories: "Overlapping categories"
overlapping_categories_info: "Preventing booking on overlapping slots will be done by comparing the date and time of the following categories of reservations."
default_slot_duration: "Duração padrão para slots"
duration_minutes: "Duração (em minutos)"
default_slot_duration_info: "Máquina e espaço disponíveis são divididos em vários slots desta duração. Esse valor pode ser substituído por disponibilidade."
@ -1212,6 +1235,11 @@ pt:
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"
overlapping_options:
training_reservations: "Trainings"
machine_reservations: "Machines"
space_reservations: "Spaces"
events_reservations: "Events"
general:
general: "Geral"
title: "Título"
@ -1364,14 +1392,19 @@ pt:
category_deleted: "The category was successfully deleted"
unable_to_delete: "Unable to delete the category: "
local_payment:
validate_cart: "Validate my cart"
offline_payment: "Payment on site"
about_to_cash: "You're about to confirm the cashing by an external payment mean. Please do not click on the button below until you have fully cashed the requested payment."
about_to_confirm: "You're about to confirm your {ITEM, select, subscription{subscription} other{reservation}}."
payment_method: "Payment method"
method_card: "Online by card"
method_check: "By check"
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."
online_payment_disabled: "Online payment is not available. You cannot collect this payment schedule by online card."
check_list_setting:
save: 'Save'
customization_of_SETTING_successfully_saved: "Customization of the {SETTING} successfully saved."
#feature tour
tour:
conclusion:

View File

@ -866,7 +866,7 @@ zu:
expires_at: "crwdns7949:0crwdne7949:0"
price_: "crwdns7951:0crwdne7951:0"
offer_free_days: "crwdns7953:0crwdne7953:0"
extend_subscription: "crwdns7955:0crwdne7955:0"
renew_subscription: "crwdns22045:0crwdne22045:0"
user_has_no_current_subscription: "crwdns7957:0crwdne7957:0"
subscribe_to_a_plan: "crwdns7959:0crwdne7959:0"
trainings: "crwdns7961:0crwdne7961:0"
@ -888,13 +888,6 @@ zu:
download_the_invoice: "crwdns7993:0crwdne7993:0"
download_the_refund_invoice: "crwdns7995:0crwdne7995:0"
no_invoices_for_now: "crwdns7997:0crwdne7997:0"
expiration_date: "crwdns7999:0crwdne7999:0"
you_intentionally_decide_to_extend_the_user_s_subscription_by_offering_him_free_days: "crwdns8001:0crwdne8001:0"
credits_will_remain_unchanged: "crwdns8003:0crwdne8003:0"
you_intentionally_decide_to_extend_the_user_s_subscription_by_charging_him_again_for_his_current_subscription: "crwdns8005:0crwdne8005:0"
credits_will_be_reset: "crwdns8007:0crwdne8007:0"
payment_scheduled: "crwdns21084:0crwdne21084:0"
until_expiration_date: "crwdns8009:0crwdne8009:0"
you_successfully_changed_the_expiration_date_of_the_user_s_subscription: "crwdns8011:0crwdne8011:0"
a_problem_occurred_while_saving_the_date: "crwdns8013:0crwdne8013:0"
new_subscription: "crwdns8015:0crwdne8015:0"
@ -906,6 +899,35 @@ zu:
to_credit: 'crwdns8025:0crwdne8025:0'
cannot_credit_own_wallet: "crwdns20344:0crwdne20344:0"
cannot_extend_own_subscription: "crwdns20346:0crwdne20346:0"
#extend a subscription for free
free_extend_modal:
extend_subscription: "crwdns22047:0crwdne22047:0"
offer_free_days_infos: "crwdns22049:0crwdne22049:0"
credits_will_remain_unchanged: "crwdns22051:0crwdne22051:0"
current_expiration: "crwdns22053:0crwdne22053:0"
DATE_TIME: "crwdns22055:0{DATE}crwdnd22055:0{TIME}crwdne22055:0"
new_expiration_date: "crwdns22057:0crwdne22057:0"
number_of_free_days: "crwdns22059:0crwdne22059:0"
extend: "crwdns22061:0crwdne22061:0"
extend_success: "crwdns22063:0crwdne22063:0"
#renew a subscription
renew_subscription_modal:
renew_subscription: "crwdns22065:0crwdne22065:0"
renew_subscription_info: "crwdns22067:0crwdne22067:0"
credits_will_be_reset: "crwdns22069:0crwdne22069:0"
current_expiration: "crwdns22071:0crwdne22071:0"
new_start: "crwdns22073:0crwdne22073:0"
new_expiration_date: "crwdns22075:0crwdne22075:0"
pay_in_one_go: "crwdns22085:0crwdne22085:0"
renew: "crwdns22087:0crwdne22087:0"
renew_success: "crwdns22089:0crwdne22089:0"
#take a new subscription
subscribe_modal:
subscribe_USER: "crwdns22133:0{USER}crwdne22133:0"
subscribe: "crwdns22099:0crwdne22099:0"
select_plan: "crwdns22101:0crwdne22101:0"
pay_in_one_go: "crwdns22103:0crwdne22103:0"
subscription_success: "crwdns22105:0crwdne22105:0"
#add a new administrator to the platform
admins_new:
add_an_administrator: "crwdns8027:0crwdne8027:0"
@ -1154,8 +1176,9 @@ zu:
error_SETTING_locked: "crwdns20640:0{SETTING}crwdne20640:0"
an_error_occurred_saving_the_setting: "crwdns20380:0crwdne20380:0"
book_overlapping_slots_info: "crwdns20642:0crwdne20642:0"
prevent_booking: "crwdns21478:0crwdne21478:0"
allow_booking: "Allow booking"
allow_booking: "crwdns22035:0crwdne22035:0"
overlapping_categories: "crwdns22107:0crwdne22107:0"
overlapping_categories_info: "crwdns22109:0crwdne22109:0"
default_slot_duration: "crwdns20646:0crwdne20646:0"
duration_minutes: "crwdns20648:0crwdne20648:0"
default_slot_duration_info: "crwdns20650:0crwdne20650:0"
@ -1209,9 +1232,14 @@ zu:
display_invite_to_renew_pack: "crwdns22000:0crwdne22000:0"
packs_threshold_info_html: "crwdns22030:0crwdne22030:0"
renew_pack_threshold: "crwdns22004:0crwdne22004:0"
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"
pack_only_for_subscription_info_html: "crwdns22037:0crwdne22037:0"
pack_only_for_subscription: "crwdns22039:0crwdne22039:0"
pack_only_for_subscription_info: "crwdns22041:0crwdne22041:0"
overlapping_options:
training_reservations: "crwdns22111:0crwdne22111:0"
machine_reservations: "crwdns22113:0crwdne22113:0"
space_reservations: "crwdns22115:0crwdne22115:0"
events_reservations: "crwdns22117:0crwdne22117:0"
general:
general: "crwdns20726:0crwdne20726:0"
title: "crwdns20728:0crwdne20728:0"
@ -1364,14 +1392,19 @@ zu:
category_deleted: "crwdns21618:0crwdne21618:0"
unable_to_delete: "crwdns21620:0crwdne21620:0"
local_payment:
validate_cart: "crwdns22119:0crwdne22119:0"
offline_payment: "crwdns22006:0crwdne22006:0"
about_to_cash: "crwdns22008:0crwdne22008:0"
about_to_confirm: "crwdns22121:0ITEM={ITEM}crwdne22121:0"
payment_method: "crwdns22010:0crwdne22010:0"
method_card: "crwdns22012:0crwdne22012:0"
method_check: "crwdns22014:0crwdne22014:0"
card_collection_info: "crwdns22016:0crwdne22016:0"
check_collection_info: "crwdns22018:0{DEADLINES}crwdne22018:0"
online_payment_disabled: "crwdns22020:0crwdne22020:0"
check_list_setting:
save: 'crwdns22123:0crwdne22123:0'
customization_of_SETTING_successfully_saved: "crwdns22125:0{SETTING}crwdne22125:0"
#feature tour
tour:
conclusion:

View File

@ -196,7 +196,7 @@ fr:
remaining_HOURS: "Il vous reste {HOURS} heures prépayées pour {ITEM, select, Machine{cette machine} Space{cet espace} other{}}."
no_hours: "Vous n'avez aucune heure prépayée pour {ITEM, select, Machine{cette machine} Space{cet espace} other{}}."
buy_a_new_pack: "Acheter un nouveau pack"
unable_to_use_pack_for_subsription_is_expired: "Vous devez avoir un abonnement en cours de validité pour consommer vos heures restantes."
unable_to_use_pack_for_subsription_is_expired: "Vous devez avoir un abonnement en cours de validité pour utiliser vos heures restantes."
#book a training
trainings_reserve:
trainings_planning: "Planning formations"

View File

@ -196,7 +196,7 @@ zu:
remaining_HOURS: "crwdns21934:0HOURS={HOURS}crwdnd21934:0ITEM={ITEM}crwdne21934:0"
no_hours: "crwdns21936:0ITEM={ITEM}crwdne21936:0"
buy_a_new_pack: "crwdns21938:0crwdne21938:0"
unable_to_use_pack_for_subsription_is_expired: "You must have a valid subscription to use your remaining hours."
unable_to_use_pack_for_subsription_is_expired: "crwdns22043:0crwdne22043:0"
#book a training
trainings_reserve:
trainings_planning: "crwdns8767:0crwdne8767:0"

View File

@ -125,6 +125,7 @@ de:
_the_general_terms_and_conditions: "die allgemeinen Nutzungs- und Geschäftsbedingungen."
payment_schedule_html: "<p>You're about to subscribe to a payment schedule of {DEADLINES} months.</p><p>By paying this bill, you agree to send instructions to the financial institution that issue your card, to take payments from your card account, for the whole duration of this subscription. This imply that your card data are saved by {GATEWAY} and a series of payments will be initiated on your behalf, conforming to the payment schedule previously shown.</p>"
confirm_payment_of_: "Bezahlen: {AMOUNT}"
validate: "Validate"
#dialog of on site payment for reservations
valid_reservation_modal:
booking_confirmation: "Buchungsbestätigung"
@ -417,7 +418,7 @@ de:
NUMBER_monthly_payment_of_AMOUNT: "{NUMBER} monthly {NUMBER, plural, =1{payment} other{payments}} of {AMOUNT}"
first_debit: "First debit on the day of the order."
debit: "Debit on the day of the order."
view_full_schedule: "View the complete payement schedule"
view_full_schedule: "View the complete payment schedule"
confirm_and_pay: "Bestätigen und bezahlen"
you_have_settled_the_following_TYPE: "Sie haben die folgenden {TYPE, select, Machine{Maschinenslots} Training{Schulungen} other{Elemente}} beglichen:"
you_have_settled_a_: "Sie haben beglichen"

View File

@ -125,6 +125,7 @@ en:
_the_general_terms_and_conditions: "the general terms and conditions."
payment_schedule_html: "<p>You're about to subscribe to a payment schedule of {DEADLINES} months.</p><p>By paying this bill, you agree to send instructions to the financial institution that issue your card, to take payments from your card account, for the whole duration of this subscription. This imply that your card data are saved by {GATEWAY} and a series of payments will be initiated on your behalf, conforming to the payment schedule previously shown.</p>"
confirm_payment_of_: "Pay: {AMOUNT}"
validate: "Validate"
#dialog of on site payment for reservations
valid_reservation_modal:
booking_confirmation: "Booking confirmation"
@ -417,7 +418,7 @@ en:
NUMBER_monthly_payment_of_AMOUNT: "{NUMBER} monthly {NUMBER, plural, =1{payment} other{payments}} of {AMOUNT}"
first_debit: "First debit on the day of the order."
debit: "Debit on the day of the order."
view_full_schedule: "View the complete payement schedule"
view_full_schedule: "View the complete payment schedule"
confirm_and_pay: "Confirm and pay"
you_have_settled_the_following_TYPE: "You have settled the following {TYPE, select, Machine{machine slots} Training{training} other{elements}}:"
you_have_settled_a_: "You have settled a"

View File

@ -125,6 +125,7 @@ es:
_the_general_terms_and_conditions: "los términos y condiciones."
payment_schedule_html: "<p>You're about to subscribe to a payment schedule of {DEADLINES} months.</p><p>By paying this bill, you agree to send instructions to the financial institution that issue your card, to take payments from your card account, for the whole duration of this subscription. This imply that your card data are saved by {GATEWAY} and a series of payments will be initiated on your behalf, conforming to the payment schedule previously shown.</p>"
confirm_payment_of_: "Pay: {AMOUNT}"
validate: "Validate"
#dialog of on site payment for reservations
valid_reservation_modal:
booking_confirmation: "Confirmar reserva"
@ -417,7 +418,7 @@ es:
NUMBER_monthly_payment_of_AMOUNT: "{NUMBER} monthly {NUMBER, plural, =1{payment} other{payments}} of {AMOUNT}"
first_debit: "First debit on the day of the order."
debit: "Debit on the day of the order."
view_full_schedule: "View the complete payement schedule"
view_full_schedule: "View the complete payment schedule"
confirm_and_pay: "Confirmar y pagar"
you_have_settled_the_following_TYPE: "Acaba de seleccionar {TYPE, select, Machine{machine slots} Training{training} other{elements}}:"
you_have_settled_a_: "Ha establecido una"

View File

@ -125,6 +125,7 @@ fr:
_the_general_terms_and_conditions: "les conditions générales de vente."
payment_schedule_html: "<p>Vous êtes sur le point de souscrire à un échéancier de paiement de {DEADLINES} mois.</p><p>En payant cette facture, vous vous engagez à l'envoi d'instructions vers l'institution financière émettrice de votre carte, afin de prélever des paiements sur votre compte, pendant toute la durée de cet abonnement. Cela implique que les données de votre carte soient enregistrées par {GATEWAY} et qu'une série de paiements sera initiée en votre nom, conformément à l'échéancier de paiement précédemment affiché.</p>"
confirm_payment_of_: "Payer : {AMOUNT}"
validate: "Valider"
#dialog of on site payment for reservations
valid_reservation_modal:
booking_confirmation: "Validation réservation"

View File

@ -125,6 +125,7 @@
_the_general_terms_and_conditions: "generelle vilkår og betingelser."
payment_schedule_html: "<p>Du er i ferd med å abonnere på en betalingsplan på {DEADLINES} måneder.</p><p>Ved å betale denne regningen godtar du å sende instrukser til den finansielle institusjonen som utsteder kortet, for å ta betalinger fra din kortkonto, så lenge det varer i dette abonnementet. Dette antyder at kortdataene dine lagres av {GATEWAY} og en rekke betalinger vil bli satt i gang på dine vegne, i samsvar med betalingsplanen som tidligere er vist</p>"
confirm_payment_of_: "Betal: {AMOUNT}"
validate: "Validate"
#dialog of on site payment for reservations
valid_reservation_modal:
booking_confirmation: "Bestillingsbekreftelse"
@ -417,7 +418,7 @@
NUMBER_monthly_payment_of_AMOUNT: "{NUMBER} månedlig {NUMBER, plural, one {} =1{betaling} other{betalinger}} på {AMOUNT}"
first_debit: "Første debet på bestillingsdato."
debit: "Første trekk på bestillingsdato."
view_full_schedule: "Vis fullstendig betalingsplan"
view_full_schedule: "View the complete payment schedule"
confirm_and_pay: "Bekreft og betal"
you_have_settled_the_following_TYPE: "Du har betalt for følgende {TYPE, select, Machine{maskinplasser} Training{opplæring/kurs} other{elementer}}:"
you_have_settled_a_: "Du har gjort opp en"

View File

@ -125,6 +125,7 @@ pt:
_the_general_terms_and_conditions: "os termos e condições."
payment_schedule_html: "<p>You're about to subscribe to a payment schedule of {DEADLINES} months.</p><p>By paying this bill, you agree to send instructions to the financial institution that issue your card, to take payments from your card account, for the whole duration of this subscription. This imply that your card data are saved by {GATEWAY} and a series of payments will be initiated on your behalf, conforming to the payment schedule previously shown.</p>"
confirm_payment_of_: "Pay: {AMOUNT}"
validate: "Validate"
#dialog of on site payment for reservations
valid_reservation_modal:
booking_confirmation: "Confirmação de reserva"
@ -417,7 +418,7 @@ pt:
NUMBER_monthly_payment_of_AMOUNT: "{NUMBER} monthly {NUMBER, plural, =1{payment} other{payments}} of {AMOUNT}"
first_debit: "First debit on the day of the order."
debit: "Debit on the day of the order."
view_full_schedule: "View the complete payement schedule"
view_full_schedule: "View the complete payment schedule"
confirm_and_pay: "Confirmar e pagar"
you_have_settled_the_following_TYPE: "Você liquidou o seguinte {TYPE, select, Machine{slots de máquina} Training{training} other{elements}}:"
you_have_settled_a_: "Você tem liquidado:"

View File

@ -22,7 +22,7 @@ zu:
you_will_lose_any_unsaved_modification_if_you_quit_this_page: "crwdns9405:0crwdne9405:0"
you_will_lose_any_unsaved_modification_if_you_reload_this_page: "crwdns9407:0crwdne9407:0"
payment_card_error: "crwdns9409:0crwdne9409:0"
payment_card_declined: "Your card was declined."
payment_card_declined: "crwdns22033:0crwdne22033:0"
#user edition form
user:
man: "crwdns9411:0crwdne9411:0"
@ -125,6 +125,7 @@ zu:
_the_general_terms_and_conditions: "crwdns21488:0crwdne21488:0"
payment_schedule_html: "crwdns21490:0{DEADLINES}crwdnd21490:0{GATEWAY}crwdne21490:0"
confirm_payment_of_: "crwdns21492:0{AMOUNT}crwdne21492:0"
validate: "crwdns22131:0crwdne22131:0"
#dialog of on site payment for reservations
valid_reservation_modal:
booking_confirmation: "crwdns9587:0crwdne9587:0"
@ -417,7 +418,7 @@ zu:
NUMBER_monthly_payment_of_AMOUNT: "crwdns20980:0NUMBER={NUMBER}crwdnd20980:0NUMBER={NUMBER}crwdnd20980:0AMOUNT={AMOUNT}crwdne20980:0"
first_debit: "crwdns20982:0crwdne20982:0"
debit: "crwdns20984:0crwdne20984:0"
view_full_schedule: "crwdns20986:0crwdne20986:0"
view_full_schedule: "crwdns22095:0crwdne22095:0"
confirm_and_pay: "crwdns10057:0crwdne10057:0"
you_have_settled_the_following_TYPE: "crwdns10059:0TYPE={TYPE}crwdne10059:0"
you_have_settled_a_: "crwdns10061:0crwdne10061:0"

View File

@ -416,6 +416,10 @@ de:
group:
#name of the user's group for administrators
admins: 'Administratoren'
cart_items:
free_extension: "Free extension of a subscription, until %{DATE}"
statistic_profile:
birthday_in_past: "The date of birth must be in the past"
settings:
locked_setting: "the setting is locked."
about_title: "\"About\" page title"
@ -529,3 +533,5 @@ de:
payzen_currency: "PayZen currency"
public_agenda_module: "Public agenda module"
renew_pack_threshold: "Threshold for packs renewal"
pack_only_for_subscription: "Restrict packs for subscribers"
overlapping_categories: "Categories for overlapping booking prevention"

View File

@ -416,6 +416,10 @@ en:
group:
#name of the user's group for administrators
admins: 'Administrators'
cart_items:
free_extension: "Free extension of a subscription, until %{DATE}"
statistic_profile:
birthday_in_past: "The date of birth must be in the past"
settings:
locked_setting: "the setting is locked."
about_title: "\"About\" page title"
@ -529,3 +533,5 @@ en:
payzen_currency: "PayZen currency"
public_agenda_module: "Public agenda module"
renew_pack_threshold: "Threshold for packs renewal"
pack_only_for_subscription: "Restrict packs for subscribers"
overlapping_categories: "Categories for overlapping booking prevention"

View File

@ -416,6 +416,10 @@ es:
group:
#name of the user's group for administrators
admins: 'Administradores'
cart_items:
free_extension: "Free extension of a subscription, until %{DATE}"
statistic_profile:
birthday_in_past: "The date of birth must be in the past"
settings:
locked_setting: "the setting is locked."
about_title: "\"About\" page title"
@ -529,3 +533,5 @@ es:
payzen_currency: "PayZen currency"
public_agenda_module: "Public agenda module"
renew_pack_threshold: "Threshold for packs renewal"
pack_only_for_subscription: "Restrict packs for subscribers"
overlapping_categories: "Categories for overlapping booking prevention"

View File

@ -416,6 +416,10 @@ fr:
group:
#name of the user's group for administrators
admins: 'Administrateurs'
cart_items:
free_extension: "Extension gratuite d'un abonnement, jusqu'au %{DATE}"
statistic_profile:
birthday_in_past: "La date de naissance doit être dans le passé"
settings:
locked_setting: "le paramètre est verrouillé."
about_title: "Le titre de la page \"À propos\""
@ -529,3 +533,5 @@ fr:
payzen_currency: "Devise PayZen"
public_agenda_module: "Module d'agenda public"
renew_pack_threshold: "Seuil de renouvellement des packs"
pack_only_for_subscription: "Restreindre les packs pour les abonnés"
overlapping_categories: "Catégories pour la prévention du chevauchement des réservations"

View File

@ -416,6 +416,10 @@
group:
#name of the user's group for administrators
admins: 'Administratorer'
cart_items:
free_extension: "Free extension of a subscription, until %{DATE}"
statistic_profile:
birthday_in_past: "The date of birth must be in the past"
settings:
locked_setting: "innstillingen er låst."
about_title: "\"Om\" sidetittel"
@ -529,3 +533,5 @@
payzen_currency: "PayZen currency"
public_agenda_module: "Public agenda module"
renew_pack_threshold: "Threshold for packs renewal"
pack_only_for_subscription: "Restrict packs for subscribers"
overlapping_categories: "Categories for overlapping booking prevention"

View File

@ -416,6 +416,10 @@ pt:
group:
#name of the user's group for administrators
admins: 'Administradores'
cart_items:
free_extension: "Free extension of a subscription, until %{DATE}"
statistic_profile:
birthday_in_past: "The date of birth must be in the past"
settings:
locked_setting: "the setting is locked."
about_title: "\"About\" page title"
@ -529,3 +533,5 @@ pt:
payzen_currency: "PayZen currency"
public_agenda_module: "Public agenda module"
renew_pack_threshold: "Threshold for packs renewal"
pack_only_for_subscription: "Restrict packs for subscribers"
overlapping_categories: "Categories for overlapping booking prevention"

View File

@ -416,6 +416,10 @@ zu:
group:
#name of the user's group for administrators
admins: 'crwdns3769:0crwdne3769:0'
cart_items:
free_extension: "crwdns22091:0%{DATE}crwdne22091:0"
statistic_profile:
birthday_in_past: "crwdns22127:0crwdne22127:0"
settings:
locked_setting: "crwdns21632:0crwdne21632:0"
about_title: "crwdns21634:0crwdne21634:0"
@ -529,3 +533,5 @@ zu:
payzen_currency: "crwdns21850:0crwdne21850:0"
public_agenda_module: "crwdns21874:0crwdne21874:0"
renew_pack_threshold: "crwdns22032:0crwdne22032:0"
pack_only_for_subscription: "crwdns22093:0crwdne22093:0"
overlapping_categories: "crwdns22129:0crwdne22129:0"

View File

@ -98,7 +98,9 @@ Rails.application.routes.draw do
end
resources :groups, only: %i[index create update destroy]
resources :subscriptions, only: %i[show update]
resources :subscriptions, only: %i[show] do
get 'payment_details', action: 'payment_details', on: :member
end
resources :plan_categories
resources :plans do
get 'durations', on: :collection
@ -180,10 +182,10 @@ Rails.application.routes.draw do
# card payments handling
## Stripe gateway
post 'stripe/confirm_payment' => 'stripe/confirm_payment'
post 'stripe/payment_schedule' => 'stripe/payment_schedule'
get 'stripe/online_payment_status' => 'stripe/online_payment_status'
get 'stripe/setup_intent/:user_id' => 'stripe#setup_intent'
post 'stripe/confirm_payment_schedule' => 'stripe#confirm_payment_schedule'
post 'stripe/setup_subscription' => 'stripe/setup_subscription'
post 'stripe/confirm_subscription' => 'stripe#confirm_subscription'
post 'stripe/update_card' => 'stripe#update_card'
## PayZen gateway

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
# From this migration we save again the start_at field to subscriptions (was removed in 20140703100457_change_start_at_to_expired_at_from_subscription.rb).
# This is used to schedule subscriptions start at a future date
class AddStartAtAgainToSubscription < ActiveRecord::Migration[5.2]
def change
add_column :subscriptions, :start_at, :datetime
end
end

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