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:
commit
8b9dbba33e
33
CHANGELOG.md
33
CHANGELOG.md
@ -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
|
||||
|
||||
|
2
Gemfile
2
Gemfile
@ -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
|
||||
|
23
Gemfile.lock
23
Gemfile.lock
@ -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)
|
||||
|
52
README.md
52
README.md
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
params[:payment_method_id],
|
||||
customer: 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
|
||||
cart.items.each do |item|
|
||||
raise InvalidSubscriptionError unless item.valid?(cart.items)
|
||||
raise InvalidSubscriptionError unless item.to_object.errors.empty?
|
||||
end
|
||||
|
||||
def confirm_payment_schedule
|
||||
service = Stripe::Service.new
|
||||
method = service.attach_method_as_default(
|
||||
params[:payment_method_id],
|
||||
cart.customer.payment_gateway_object.gateway_object_id
|
||||
)
|
||||
|
||||
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_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'
|
||||
|
@ -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
|
||||
|
@ -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?
|
||||
|
@ -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
|
||||
});
|
||||
|
20
app/frontend/src/javascript/api/subscription.ts
Normal file
20
app/frontend/src/javascript/api/subscription.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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 (
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>}
|
||||
|
@ -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 => {
|
||||
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">
|
||||
|
@ -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>
|
||||
|
@ -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']));
|
||||
|
@ -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}
|
||||
|
@ -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 {
|
||||
|
@ -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} />
|
||||
);
|
||||
|
@ -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,6 +69,7 @@ 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) {
|
||||
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) {
|
||||
@ -108,6 +84,21 @@ export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
|
||||
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) {
|
||||
onSuccess(response);
|
||||
} else {
|
||||
|
@ -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} />
|
||||
);
|
||||
|
@ -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")
|
||||
|
@ -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>}
|
||||
|
@ -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>
|
||||
|
@ -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']));
|
@ -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']));
|
@ -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']));
|
@ -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']));
|
@ -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; });
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
$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;
|
||||
|
||||
// available plans for the selected user
|
||||
$scope.plans = plans;
|
||||
|
||||
// default parameters for the new subscription
|
||||
$scope.subscription = {
|
||||
payment_schedule: false,
|
||||
payment_method: 'check'
|
||||
};
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
$scope.toggleFreeExtendModal = () => {
|
||||
setTimeout(() => {
|
||||
$scope.subscription.payment_schedule = checked;
|
||||
$scope.isOpenFreeExtendModal = !$scope.isOpenFreeExtendModal;
|
||||
$scope.$apply();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
/**
|
||||
* Modal dialog validation callback
|
||||
* Opens/closes the modal dialog to renew the subscription (with payment)
|
||||
*/
|
||||
$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);
|
||||
});
|
||||
$scope.toggleRenewModal = () => {
|
||||
setTimeout(() => {
|
||||
$scope.isOpenRenewModal = !$scope.isOpenRenewModal;
|
||||
$scope.$apply();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
/**
|
||||
* Modal dialog cancellation callback
|
||||
* Opens/closes the modal dialog to renew the subscription (with payment)
|
||||
*/
|
||||
$scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
|
||||
}]
|
||||
});
|
||||
// once the form was validated successfully ...
|
||||
return modalInstance.result.then(function (subscription) { $scope.subscription = subscription; });
|
||||
$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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback triggered if a new subscription was successfully taken
|
||||
*/
|
||||
$scope.onSubscribeSuccess = (message, newSubscription) => {
|
||||
growl.success(message);
|
||||
$scope.subscription = newSubscription;
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback triggered in case of error
|
||||
*/
|
||||
$scope.onError = (message) => {
|
||||
growl.error(message);
|
||||
};
|
||||
|
||||
$scope.createWalletCreditModal = function (user, wallet) {
|
||||
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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)) ||
|
||||
|
@ -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 €")
|
||||
*/
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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; }],
|
||||
|
@ -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'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -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";
|
||||
|
@ -0,0 +1,14 @@
|
||||
.check-list-setting {
|
||||
.check-list-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.save {
|
||||
background-color: #999;
|
||||
border-color: #999;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
<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">
|
||||
|
@ -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>
|
@ -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>
|
@ -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"/>
|
||||
|
@ -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
|
||||
|
38
app/models/cart_item/free_extension.rb
Normal file
38
app/models/cart_item/free_extension.rb
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
##
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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>}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -0,0 +1,4 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.payment_schedule !@subscription.original_payment_schedule.nil?
|
||||
json.card @subscription.original_invoice&.paid_by_card?
|
@ -1 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.partial! 'api/subscriptions/subscription', subscription: @subscription
|
||||
|
@ -1 +0,0 @@
|
||||
json.partial! 'api/subscriptions/subscription', subscription: @subscription
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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:"
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user